Compare commits

...
16 Commits
Author SHA1 Message Date
bartal-lsn aee8d9ead1 add demo 2026-08-26 23:40:28 +01:00
bartal-lsn 228871648d seed data anew every 20 mins 2026-08-26 20:32:51 +01:00
bartal-lsn c56d1f7f8f 20 minutes instead of 10 2026-08-24 15:32:43 +01:00
bartal-lsn 7d90e1853d correct footer 2026-08-24 15:27:36 +01:00
bartal-lsn 6722497469 add deployment and corrected themes 2026-08-23 22:30:08 +01:00
bartal-lsn 5450b6a82c change fonts 2026-08-13 21:00:14 +01:00
bartal-lsn e58e4bc13e remove banner to bottom 2026-06-22 14:06:10 +01:00
bartal-lsn 738a02ca1d remove banner to bottom 2026-06-22 13:37:44 +01:00
bartal-lsn 3f10dd4c07 footer 2026-06-22 13:29:26 +01:00
bartal-lsn 74c28c4f35 add sandwich nav 2026-06-22 13:29:26 +01:00
bartal-lsn 9323d6e9c3 dyn year 2026-06-19 21:23:19 +01:00
bartal-lsn 423c5b99e2 dyn year 2026-06-19 21:16:12 +01:00
bartal-lsn 71becb2eb0 add footer correct 2026-06-19 21:03:07 +01:00
bartal-lsn bf1a5a4b6a fix dropdown styling 2026-06-19 13:21:15 +01:00
bartal-lsn 264caa911a makefile 2026-06-19 13:04:42 +01:00
bartal-lsn 7e4c508329 layout fixes 2026-06-19 13:01:29 +01:00
14 changed files with 783 additions and 133 deletions
+1
View File
@@ -0,0 +1 @@
bin/
+3 -2
View File
@@ -1,8 +1,9 @@
BINARY = zeitkort-web
BINARY = poc-zeitkort-web
.PHONY: build run clean
build:
build: clean
go clean -cache
CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -o ./bin/$(BINARY) .
run: build
+5 -3
View File
@@ -11,7 +11,7 @@ records in memory.
- Companies, assignments, tasks, work sessions, and invoice-number records are
held in process memory only.
- Settings edited from the UI are also in-memory only.
- Everything resets automatically every **10 minutes** after the process starts.
- Everything resets automatically every **20 minutes** after the process starts.
- A sticky, high-visibility countdown banner shows exactly when the next reset will happen.
- Restarting the process also clears everything immediately.
- `config.json` is read only at startup for the listen address and initial
@@ -23,9 +23,11 @@ records in memory.
- Add / edit / delete companies, assignments, tasks, and work sessions.
- Add manual time entries.
- Generate the Faroese invoice PDF directly from the in-memory data.
- Edit sender/company invoice defaults for the current 10-minute in-memory run.
- Edit sender/company invoice defaults for the current 20-minute in-memory run.
- Open the **About** menu item for a concise explanation of the POC features and implementation.
![Demo](./gif/zeitkort.gif)
## Build
Requires Go 1.26+ as declared by the original project.
@@ -59,7 +61,7 @@ There are no API URL, username, or password fields in this version.
./zeitkort-web -config config.json
```
The app logs a reset message every 10 minutes when the in-memory POC state is
The app logs a reset message every 20 minutes when the in-memory POC state is
cleared. The browser also shows a large countdown and reloads shortly after the
reset so the cleared state is visible.
+55
View File
@@ -0,0 +1,55 @@
version: '3'
vars:
REMOTE_MACHINE: bl@flo-homeserver
BINARY_DIR: ./bin
DEV_BINARY_DIR: ./local
BINARY: "{{.BINARY_DIR}}/zeitkort-web-poc"
DEV_BINARY: "{{.DEV_BINARY_DIR}}/"
tasks:
build:
desc: runs vet, fmt, and build
deps: [vet, fmt]
cmds:
- rm -rf {{.BINARY_DIR}}/*
- CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -o {{.BINARY}} .
dev-build:
desc: runs vet, fmt, and builds dev binary
deps: [vet, fmt]
cmds:
- CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -o {{.DEV_BINARY}}/zeitkort-web-poc .
deploy:
desc: build, stage binary, and deploy to remote server (preserves config.json)
deps: [build]
cmds:
- ssh {{.REMOTE_MACHINE}} "mkdir -p /tmp/zeitkort-web-poc-deploy"
- rsync -avz --delete {{.BINARY}} {{.REMOTE_MACHINE}}:/tmp/zeitkort-web-poc-deploy/
- ssh -t {{.REMOTE_MACHINE}} "sudo mkdir -p /opt/zeitkort-web-poc && sudo rsync -a /tmp/zeitkort-web-poc-deploy/zeitkort-web-poc /opt/zeitkort-web-poc/zeitkort-web-poc && sudo chown zeitkort-web-poc:zeitkort-web-poc /opt/zeitkort-web-poc/zeitkort-web-poc && sudo systemctl restart zeitkort-web-poc"
- ssh {{.REMOTE_MACHINE}} "rm -rf /tmp/zeitkort-web-poc-deploy"
dev-start:
desc: build dev binary, copy assets, and start locally
dir: "{{ .DEV_BINARY }}"
cmds:
- "rm -rf *"
- go tool task dev-build
- "cp ../config.json ."
- "./zeitkort-web-poc -config config.json"
vet:
cmds:
- go vet ./...
fmt:
cmds:
- go fmt ./...
clean:
cmds:
- rm -f {{.BINARY}}
BIN
View File
Binary file not shown.
+1 -1
View File
@@ -8,7 +8,7 @@ import (
// Config holds the web-only proof-of-concept server settings plus invoice
// defaults. It is read at startup only; edits made on the Settings page are
// kept in memory and are reset with the rest of the POC data every 10 minutes.
// kept in memory and are reset with the rest of the POC data every 20 minutes.
type Config struct {
// Where the web server listens. Caddy can reverse-proxy to this.
ListenAddr string `json:"listen_addr"`
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 486 KiB

+7 -1
View File
@@ -14,7 +14,7 @@ import (
// Version is set at build time via -ldflags.
var Version = "dev"
const resetInterval = 10 * time.Minute
const resetInterval = 20 * time.Minute
// Server holds shared dependencies.
type Server struct {
@@ -44,6 +44,8 @@ func main() {
errl.Fatalf("repository: %v", err)
}
seedDemoData(repo)
s := &Server{
Cfg: cfg,
initialCfg: cfg,
@@ -79,6 +81,10 @@ func (s *Server) resetLoop(interval time.Duration) {
if resetter, ok := s.repo.(interface{ Reset() }); ok {
resetter.Reset()
}
// Re-seed demo data after reset
seedDemoData(s.repo)
s.mu.Lock()
s.Cfg = s.initialCfg
s.resetAt = time.Now().Add(interval)
+133
View File
@@ -0,0 +1,133 @@
package main
import (
"fmt"
"time"
"zeitkort-web/repository"
)
func seedDemoData(repo repository.Repository) {
if resetter, ok := repo.(interface{ Reset() }); ok {
resetter.Reset()
}
now := time.Now()
loc := now.Location()
mukki, _ := repo.InsertCompany(repository.Company{
Name: "Mukki",
Address: "Snoppiesgøta 1, FO-100 Tórshavn",
ContactEmail: "kontakt@mukki.fo",
ContactPhone: "+298 210000",
HourlyRate: 850,
})
teltlar, _ := repo.InsertCompany(repository.Company{
Name: "Teldlar og Teldur",
Address: "Teldutrøð 5, FO-100 Tórshavn",
ContactEmail: "info@teltlar.fo",
ContactPhone: "+298 310000",
HourlyRate: 750,
})
sjogvur, _ := repo.InsertCompany(repository.Company{
Name: "Sjógvur.fo",
Address: "Vesturgøta 22, FO-100 Tórshavn",
ContactEmail: "heiðri@sjogvur.fo",
ContactPhone: "+298 410000",
HourlyRate: 650,
})
mukkiAPI, _ := repo.InsertAssignment(repository.Assignment{
CompanyID: mukki.ID,
Name: "Microservices Backend Migration",
Description: "Refactoring legacy monolith into Go microservices.",
HourlyRate: 850,
Status: "active",
})
mukkiInfra, _ := repo.InsertAssignment(repository.Assignment{
CompanyID: mukki.ID,
Name: "Kubernetes Cluster Setup",
Description: "Deploying K3s cluster.",
HourlyRate: 850,
Status: "active",
})
teltlarERP, _ := repo.InsertAssignment(repository.Assignment{
CompanyID: teltlar.ID,
Name: "ERP Integration Module",
Description: "REST API bridge between SQL Server and frontend.",
HourlyRate: 750,
Status: "active",
})
sjogvurWeb, _ := repo.InsertAssignment(repository.Assignment{
CompanyID: sjogvur.ID,
Name: "Corporate Website Redesign",
Description: "Modernizing frontend with Tailwind and static generation.",
HourlyRate: 650,
Status: "active",
})
tMukkiAPI, _ := repo.InsertTask(repository.Task{AssignmentID: mukkiAPI.ID, Name: "Design gRPC Protobufs"})
tMukkiAPI2, _ := repo.InsertTask(repository.Task{AssignmentID: mukkiAPI.ID, Name: "Implement Auth Middleware"})
tMukkiInfra, _ := repo.InsertTask(repository.Task{AssignmentID: mukkiInfra.ID, Name: "Ansible Playbooks"})
tTeltlar, _ := repo.InsertTask(repository.Task{AssignmentID: teltlarERP.ID, Name: "SQL Stored Procedures"})
tTeltlar2, _ := repo.InsertTask(repository.Task{AssignmentID: teltlarERP.ID, Name: "API Endpoints"})
tSjogvur, _ := repo.InsertTask(repository.Task{AssignmentID: sjogvurWeb.ID, Name: "Design System"})
tSjogvur2, _ := repo.InsertTask(repository.Task{AssignmentID: sjogvurWeb.ID, Name: "SEO Optimization"})
addSession := func(assignID, taskID int64, daysAgo int, hours float64) {
start := time.Date(now.Year(), now.Month(), now.Day()-daysAgo, 9, 0, 0, 0, loc)
end := start.Add(time.Duration(hours * float64(time.Hour)))
task, _ := repo.GetTaskByID(taskID)
desc := fmt.Sprintf("%d - %s", task.Number, task.Name)
repo.InsertManualWorkSession(repository.WorkSession{
AssignmentID: assignID,
TaskID: taskID,
TaskDescription: desc,
StartTime: start,
EndTime: &end,
})
}
addSession(mukkiAPI.ID, tMukkiAPI.ID, 5, 4.5)
addSession(mukkiAPI.ID, tMukkiAPI2.ID, 3, 2.0)
addSession(mukkiInfra.ID, tMukkiInfra.ID, 1, 6.0)
addSession(teltlarERP.ID, tTeltlar.ID, 4, 3.5)
addSession(teltlarERP.ID, tTeltlar2.ID, 2, 5.0)
addSession(sjogvurWeb.ID, tSjogvur.ID, 6, 2.5)
addSession(sjogvurWeb.ID, tSjogvur2.ID, 0, 1.5)
runningStart := now.Add(-45 * time.Minute)
task, _ := repo.GetTaskByID(tSjogvur2.ID)
desc := fmt.Sprintf("%d - %s", task.Number, task.Name)
runningSession, _ := repo.InsertWorkSession(repository.WorkSession{
AssignmentID: sjogvurWeb.ID,
TaskID: tSjogvur2.ID,
TaskDescription: desc,
StartTime: runningStart,
EndTime: nil,
})
if runningSession != nil {
updated := repository.WorkSession{
ID: runningSession.ID,
AssignmentID: runningSession.AssignmentID,
TaskID: runningSession.TaskID,
TaskDescription: desc,
StartTime: runningSession.StartTime,
EndTime: runningSession.EndTime,
DurationMinutes: runningSession.DurationMinutes,
}
repo.UpdateWorkSession(runningSession.ID, updated)
}
}
+1 -1
View File
@@ -27,7 +27,7 @@ func (s *Server) handleSettingsSave(w http.ResponseWriter, r *http.Request) {
s.mu.Lock()
// Web-only POC: settings edits stay in memory only and are reset with the
// rest of the POC state every 10 minutes. config.json is never rewritten.
// rest of the POC state every 20 minutes. config.json is never rewritten.
s.Cfg.CompanyName = r.FormValue("company_name")
s.Cfg.CompanyAddress = r.FormValue("company_address")
s.Cfg.CompanyPhone = r.FormValue("company_phone")
+477 -114
View File
@@ -1,129 +1,317 @@
/* zeitkort-web — deliberately plain. Light + dark (follows the browser). */
@import url('https://fonts.googleapis.com/css2?family=Noto+Sans:wght@300;400;500;700&display=swap');
:root {
color-scheme: light dark;
--bg: #fff;
--fg: #000;
--muted: #777;
--border: #999;
--border-soft: #ccc;
--dotted: #ccc;
--field-bg: #fff;
--field-border: #888;
--btn-bg: #eee;
--btn-bg-hover: #ddd;
--btn-border: #555;
--header-border: #000;
--th-bg: #f0f0f0;
--row-active: #ffd;
--link: #003;
--accent-bg: #e3ecff;
--accent-border: #357;
--notice-fg: #060; --notice-bg: #efe; --notice-border: #060;
--error-fg: #a00; --error-bg: #fee; --error-border: #a00;
--go-bg: #e7f5e7; --go-border: #2a8a2a; --go-bg-hover: #d6eed6;
--stop-bg: #fbe4e4; --stop-border: #a00; --stop-bg-hover: #f6d2d2;
--card-bg: #fff; --card-border: #a00;
--danger-btn-bg: #fee; --danger-btn-bg-hover: #fdd;
--backdrop: rgba(0, 0, 0, .35);
--shadow: rgba(0, 0, 0, .4);
*, *::before, *::after { box-sizing: border-box; }
:root,
[data-theme="light"] {
color-scheme: light;
--bg: #f4f1ec;
--bg-panel: rgba(255, 255, 255, 0.85);
--nav-bg: rgba(255, 253, 250, 0.92);
--fg: #1a1d22;
--muted: #595f68;
--dim: #9299a2;
--accent: #164f75;
--link: #0f527d;
--accent-bg: rgba(22, 79, 117, 0.07);
--accent-border: #164f75;
--border: rgba(26, 29, 34, 0.1);
--border-soft: rgba(26, 29, 34, 0.06);
--dotted: rgba(26, 29, 34, 0.12);
--field-bg: rgba(255, 253, 250, 0.6);
--field-border: rgba(26, 29, 34, 0.18);
--btn-bg: rgba(26, 29, 34, 0.05);
--btn-bg-hover: rgba(26, 29, 34, 0.09);
--btn-border: rgba(26, 29, 34, 0.18);
--header-border: rgba(26, 29, 34, 0.12);
--th-bg: rgba(26, 29, 34, 0.04);
--row-active: rgba(22, 79, 117, 0.06);
--notice-fg: #2a6e2a; --notice-bg: rgba(42, 110, 42, 0.06); --notice-border: rgba(42, 110, 42, 0.25);
--error-fg: #a03030; --error-bg: rgba(160, 48, 48, 0.06); --error-border: rgba(160, 48, 48, 0.25);
--go-bg: rgba(42, 110, 42, 0.06); --go-border: rgba(42, 110, 42, 0.3); --go-bg-hover: rgba(42, 110, 42, 0.1);
--stop-bg: rgba(160, 48, 48, 0.06); --stop-border: rgba(160, 48, 48, 0.3); --stop-bg-hover: rgba(160, 48, 48, 0.1);
--card-bg: #fff; --card-border: rgba(160, 48, 48, 0.3);
--danger-btn-bg: rgba(160, 48, 48, 0.06); --danger-btn-bg-hover: rgba(160, 48, 48, 0.12);
--backdrop: rgba(0, 0, 0, 0.3);
--shadow: rgba(0, 0, 0, 0.12);
--toggle-bg: linear-gradient(135deg, #24273a 0%, #8aadf4 100%);
--toggle-border: rgba(26, 29, 34, 0.2);
--toggle-shadow: 0 4px 16px rgba(0, 0, 0, 0.15);
}
@media (prefers-color-scheme: dark) {
:root {
--bg: #1d1f21;
--fg: #e6e6e6;
--muted: #9aa0a6;
--border: #555;
--border-soft: #3a3d40;
--dotted: #3a3d40;
--field-bg: #2a2d30;
--field-border: #5a5e62;
--btn-bg: #34383c;
--btn-bg-hover: #3f4348;
--btn-border: #6a6e72;
--header-border: #6a6e72;
--th-bg: #2a2d30;
--row-active: #3a3a20;
--link: #9bbcff;
--accent-bg: #21344f;
--accent-border: #5a86c0;
--notice-fg: #8fe08f; --notice-bg: #142a14; --notice-border: #3f7a3f;
--error-fg: #ff9b9b; --error-bg: #3a1616; --error-border: #a85050;
--go-bg: #1f3a1f; --go-border: #4caf4c; --go-bg-hover: #264826;
--stop-bg: #3a1a1a; --stop-border: #cc5555; --stop-bg-hover: #4a2222;
--card-bg: #26292c; --card-border: #cc5555;
--danger-btn-bg: #3a1a1a; --danger-btn-bg-hover: #4a2222;
--backdrop: rgba(0, 0, 0, .6);
--shadow: rgba(0, 0, 0, .75);
[data-theme="macchiato"] {
color-scheme: dark;
--bg: #181926;
--bg-panel: #24273a;
--nav-bg: rgba(36, 39, 58, 0.65);
--fg: #cad3f5;
--muted: #a5adcb;
--dim: #8087a2;
--accent: #8aadf4;
--link: #8aadf4;
--accent-bg: rgba(138, 173, 244, 0.06);
--accent-border: #8aadf4;
--border: rgba(165, 173, 203, 0.15);
--border-soft: rgba(165, 173, 203, 0.08);
--dotted: rgba(165, 173, 203, 0.12);
--field-bg: rgba(138, 173, 244, 0.04);
--field-border: rgba(165, 173, 203, 0.18);
--btn-bg: rgba(138, 173, 244, 0.06);
--btn-bg-hover: rgba(138, 173, 244, 0.1);
--btn-border: rgba(165, 173, 203, 0.18);
--header-border: rgba(165, 173, 203, 0.15);
--th-bg: rgba(138, 173, 244, 0.04);
--row-active: rgba(138, 173, 244, 0.06);
--notice-fg: #a6da95; --notice-bg: rgba(166, 218, 149, 0.06); --notice-border: rgba(166, 218, 149, 0.25);
--error-fg: #f38ba8; --error-bg: rgba(243, 139, 168, 0.06); --error-border: rgba(243, 139, 168, 0.2);
--go-bg: rgba(166, 218, 149, 0.08); --go-border: rgba(166, 218, 149, 0.3); --go-bg-hover: rgba(166, 218, 149, 0.14);
--stop-bg: rgba(243, 139, 168, 0.08); --stop-border: rgba(243, 139, 168, 0.3); --stop-bg-hover: rgba(243, 139, 168, 0.14);
--card-bg: #24273a; --card-border: rgba(243, 139, 168, 0.4);
--danger-btn-bg: rgba(243, 139, 168, 0.1); --danger-btn-bg-hover: rgba(243, 139, 168, 0.18);
--backdrop: rgba(0, 0, 0, 0.55);
--shadow: rgba(0, 0, 0, 0.5);
--toggle-bg: #24273a;
--toggle-border: rgba(138, 173, 244, 0.4);
--toggle-shadow: 0 0 10px rgba(138, 173, 244, 0.5), 0 0 25px rgba(138, 173, 244, 0.3);
}
[data-theme="macchiato"] select,
[data-theme="macchiato"] option {
background: #24273a;
color: #cad3f5;
}
* { box-sizing: border-box; }
html, body { max-width: 100%; overflow-x: hidden; }
body {
font-family: -apple-system, Helvetica, Arial, sans-serif;
font-size: 15px;
line-height: 1.4;
font-family: 'Noto Sans', sans-serif;
font-size: 0.88rem;
font-weight: 300;
line-height: 1.55;
color: var(--fg);
background: var(--bg);
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
min-height: 100vh;
transition: background 0.3s ease, color 0.3s ease;
}
/* Long, unbreakable names must never widen the page on mobile. */
main { overflow-wrap: break-word; padding: 16px; max-width: 1040px; margin: 0 auto; }
main { overflow-wrap: break-word; padding: 16px; max-width: 1040px; margin: 0 auto; width: 100%; flex: 1; }
input, select, textarea { max-width: 100%; }
.table-wrap { overflow-x: auto; -webkit-overflow-scrolling: touch; }
/* ---- Header / nav ---- */
header {
border-bottom: 2px solid var(--header-border);
padding: 8px 16px;
display: flex;
align-items: baseline;
gap: 20px;
border-bottom: 1px solid var(--header-border);
padding: 1rem 1.5rem;
display: grid;
grid-template-columns: auto 1fr auto;
align-items: center;
background: var(--nav-bg);
backdrop-filter: blur(12px) saturate(120%);
-webkit-backdrop-filter: blur(12px) saturate(120%);
position: sticky;
top: 0;
z-index: 20;
transition: background 0.3s ease;
}
.brand { font-weight: bold; font-size: 18px; }
nav a { margin-right: 14px; text-decoration: none; color: var(--link); }
nav a:hover { text-decoration: underline; }
footer { padding: 12px 16px; color: var(--muted); font-size: 12px; border-top: 1px solid var(--border-soft); margin-top: 24px; }
.brand { justify-self: center; }
nav { justify-self: end; }
h2 { margin: 0 0 12px; }
.back-link {
font-family: 'Noto Sans', sans-serif;
font-weight: 400;
font-size: 0.7rem;
letter-spacing: 0.15em;
text-transform: uppercase;
white-space: nowrap;
color: var(--muted);
text-decoration: none;
padding: 0.35rem 0.6rem;
border: 1px solid var(--border);
border-radius: 2px;
transition: color 0.2s ease, border-color 0.2s ease, background 0.2s ease;
}
.back-link:hover {
color: var(--fg);
border-color: var(--muted);
background: var(--bg-panel);
}
fieldset { border: 1px solid var(--border); margin: 0 0 14px; padding: 10px 12px; min-width: 0; }
legend { font-weight: bold; padding: 0 4px; }
.brand {
font-weight: 700;
font-size: 1rem;
letter-spacing: -0.02em;
color: var(--fg);
text-decoration: none;
display: inline-flex;
align-items: baseline;
gap: 0.35rem;
white-space: nowrap;
}
.brand:hover { color: var(--accent); }
.brand-sep {
font-weight: 300;
color: var(--dim);
}
.brand-sub {
font-weight: 400;
font-size: 0.78rem;
letter-spacing: 0.18em;
text-transform: uppercase;
color: var(--muted);
}
[data-theme="macchiato"] .brand {
color: #cad3f5;
text-shadow: 0 0 8px rgba(138, 173, 244, 0.25);
}
nav a {
margin-right: 0.9rem;
text-decoration: none;
color: var(--muted);
font-size: 0.72rem;
font-weight: 400;
letter-spacing: 0.15em;
text-transform: uppercase;
padding: 0.35rem 0;
border-bottom: 1px solid transparent;
transition: color 0.2s ease, border-color 0.2s ease;
}
nav a:hover { color: var(--fg); border-bottom-color: transparent; }
nav a.active {
color: var(--fg);
border-bottom-color: var(--fg);
}
.offcanvas-links a.active {
color: var(--fg);
border-bottom-color: var(--fg);
}
footer {
padding: 0.9rem 1.5rem;
color: var(--muted);
font-size: 0.68rem;
letter-spacing: 0.05em;
border-top: 1px solid var(--border-soft);
background: var(--nav-bg);
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
display: flex;
justify-content: center;
align-items: center;
gap: 1.5rem;
}
.footer-icon {
color: var(--muted);
text-decoration: none;
display: flex;
align-items: center;
border-bottom: none;
transition: color 0.25s ease;
}
.footer-icon:hover {
color: var(--accent);
border-bottom: none;
}
h2 {
margin: 0 0 0.9rem;
font-family: 'Noto Sans', sans-serif;
font-weight: 700;
font-size: 1.1rem;
letter-spacing: -0.02em;
}
[data-theme="macchiato"] h2 {
text-shadow: 0 0 10px rgba(138, 173, 244, 0.2);
}
/* ---- Forms ---- */
fieldset {
border: 1px solid var(--border);
margin: 0 0 14px;
padding: 10px 12px;
min-width: 0;
border-radius: 3px;
background: var(--field-bg);
transition: border-color 0.3s ease, background 0.3s ease;
}
legend {
font-weight: 500;
padding: 0 6px;
font-size: 0.78rem;
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--muted);
}
select, input[type=text], input[type=number], input:not([type]), textarea {
font: inherit;
padding: 3px 5px;
font-size: 0.85rem;
padding: 0.35rem 0.55rem;
border: 1px solid var(--field-border);
border-radius: 3px;
background: var(--field-bg);
color: var(--fg);
transition: border-color 0.2s;
outline: none;
}
select:focus, input[type=text]:focus, input[type=number]:focus, input:not([type]):focus, textarea:focus {
border-color: var(--accent);
}
textarea { width: 100%; max-width: 460px; }
label { display: inline-block; }
label { display: inline-block; font-size: 0.85rem; }
button {
font: inherit;
padding: 3px 10px;
font-size: 0.82rem;
font-weight: 500;
padding: 0.3rem 0.7rem;
border: 1px solid var(--btn-border);
border-radius: 3px;
background: var(--btn-bg);
color: var(--fg);
cursor: pointer;
letter-spacing: 0.02em;
transition: background 0.15s, border-color 0.15s;
}
button:hover { background: var(--btn-bg-hover); }
.muted { color: var(--muted); font-size: 13px; }
.error { color: var(--error-fg); border: 1px solid var(--error-border); padding: 6px 10px; background: var(--error-bg); }
.notice { color: var(--notice-fg); border: 1px solid var(--notice-border); padding: 6px 10px; background: var(--notice-bg); }
.muted { color: var(--muted); font-size: 0.78rem; }
.error { color: var(--error-fg); border: 1px solid var(--error-border); padding: 6px 10px; background: var(--error-bg); border-radius: 3px; }
.notice { color: var(--notice-fg); border: 1px solid var(--notice-border); padding: 6px 10px; background: var(--notice-bg); border-radius: 3px; }
.columns { display: flex; gap: 16px; flex-wrap: wrap; }
.col { flex: 1 1 340px; min-width: 0; }
.task { padding: 4px 0; border-bottom: 1px dotted var(--dotted); display: flex; flex-wrap: wrap; gap: 8px; align-items: center; }
.task { padding: 5px 0; border-bottom: 1px dotted var(--dotted); display: flex; flex-wrap: wrap; gap: 8px; align-items: center; }
.task .pick { flex: 1 1 140px; min-width: 0; }
button.go { font-weight: bold; min-width: 84px; }
button.go { font-weight: 700; min-width: 84px; letter-spacing: 0.04em; }
button.go:not(.stop):not([disabled]) { background: var(--go-bg); border-color: var(--go-border); }
button.go:not(.stop):not([disabled]):hover { background: var(--go-bg-hover); }
button.go.stop { background: var(--stop-bg); border-color: var(--stop-border); }
@@ -134,21 +322,23 @@ button.go[disabled] { opacity: .45; cursor: not-allowed; }
fieldset > .inline { margin-top: 10px; }
.add-toggle { margin-top: 10px; }
.clock { font-family: monospace; font-size: 26px; font-weight: bold; margin: 0; }
.clock { font-family: 'Noto Sans', sans-serif; font-size: 1.5rem; font-weight: 700; margin: 0; letter-spacing: -0.02em; }
/* Sticky bar shown only while a timer runs. */
.running-bar {
position: sticky;
top: 0;
z-index: 50;
top: 52px;
z-index: 15;
display: flex;
align-items: center;
gap: 14px;
flex-wrap: wrap;
background: var(--go-bg);
border: 1px solid var(--go-border);
border-radius: 3px;
padding: 8px 12px;
margin: 0 0 14px;
backdrop-filter: blur(8px);
-webkit-backdrop-filter: blur(8px);
}
.rb-task { flex: 1 1 auto; }
.running-bar .go { margin-left: auto; }
@@ -157,33 +347,39 @@ fieldset > .inline { margin-top: 10px; }
display: inline-block;
min-width: 84px;
text-align: center;
font-weight: bold;
font-weight: 700;
padding: 4px 10px;
border: 1px solid var(--go-border);
border-radius: 3px;
background: var(--go-bg);
color: var(--fg);
font-size: 0.78rem;
letter-spacing: 0.04em;
}
/* ---- Tables ---- */
table { border-collapse: collapse; width: 100%; }
th, td { border: 1px solid var(--border-soft); padding: 3px 6px; text-align: left; font-size: 13px; vertical-align: top; }
th { background: var(--th-bg); }
th, td { border: 1px solid var(--border-soft); padding: 0.3rem 0.5rem; text-align: left; font-size: 0.78rem; vertical-align: top; }
th { background: var(--th-bg); font-weight: 500; letter-spacing: 0.04em; text-transform: uppercase; font-size: 0.7rem; color: var(--muted); }
tr.active { background: var(--row-active); }
.row-actions { white-space: nowrap; }
.row-actions button { padding: 1px 6px; font-size: 12px; }
.row-actions button { padding: 1px 6px; font-size: 0.72rem; }
.line { display: flex; gap: 6px; margin: 4px 0; flex-wrap: wrap; }
.line input { width: 110px; }
.line input[name=extra_desc] { width: 220px; }
/* Two-tap delete confirm — a centered card, always fully on-screen (mobile-safe).
No native dialog and no JS, so it works in Brave/Chrome/Safari alike. */
/* ---- Two-tap delete confirm ---- */
details.confirm { display: inline-block; }
details.confirm > summary {
list-style: none;
cursor: pointer;
font: inherit;
padding: 3px 10px;
font-size: 0.82rem;
font-weight: 500;
padding: 0.3rem 0.7rem;
border: 1px solid var(--btn-border);
border-radius: 3px;
background: var(--btn-bg);
color: var(--fg);
display: inline-block;
@@ -193,7 +389,6 @@ details.confirm > summary::marker { content: ""; }
details.confirm > summary:hover { background: var(--btn-bg-hover); }
details.confirm[open] > summary { background: var(--stop-bg); border-color: var(--stop-border); }
/* Dim the page behind the open confirm card. */
details.confirm[open]::before {
content: "";
position: fixed;
@@ -209,71 +404,239 @@ details.confirm[open]::before {
transform: translate(-50%, -50%);
z-index: 1000;
width: min(360px, 92vw);
padding: 16px 18px;
padding: 1.2rem 1.4rem;
border: 1px solid var(--card-border);
border-radius: 3px;
background: var(--card-bg);
color: var(--fg);
white-space: normal;
box-shadow: 0 6px 30px var(--shadow);
font-size: 14px;
line-height: 1.45;
box-shadow: 0 8px 30px var(--shadow);
font-size: 0.85rem;
line-height: 1.55;
text-align: left;
}
.confirm-pop button { margin-top: 12px; margin-right: 6px; }
.confirm-pop button:not(.cancel) { border-color: var(--stop-border); background: var(--danger-btn-bg); }
.confirm-pop button:not(.cancel):hover { background: var(--danger-btn-bg-hover); }
.row-actions details.confirm > summary { padding: 1px 6px; font-size: 12px; }
.row-actions details.confirm > summary { padding: 1px 6px; font-size: 0.72rem; }
/* Very visible countdown for the proof-of-concept reset. */
/* ---- POC reset banner ---- */
.reset-banner {
position: sticky;
top: 0;
z-index: 80;
top: 52px;
z-index: 18;
display: flex;
align-items: center;
justify-content: center;
gap: 10px;
flex-wrap: wrap;
padding: 10px 16px;
border-bottom: 3px solid var(--error-border);
padding: 8px 16px;
border-bottom: 1px solid var(--error-border);
background: var(--error-bg);
color: var(--error-fg);
text-align: center;
backdrop-filter: blur(8px);
-webkit-backdrop-filter: blur(8px);
}
.reset-label {
font-weight: bold;
font-weight: 500;
text-transform: uppercase;
letter-spacing: .04em;
letter-spacing: 0.12em;
font-size: 0.72rem;
}
.reset-countdown {
font-family: monospace;
font-size: clamp(34px, 8vw, 64px);
font-weight: 900;
font-family: 'Noto Sans', sans-serif;
font-size: clamp(1.5rem, 6vw, 2.5rem);
font-weight: 700;
line-height: 1;
padding: 2px 10px;
border: 2px solid currentColor;
border: 1px solid currentColor;
border-radius: 3px;
background: var(--bg);
}
.reset-note {
flex-basis: 100%;
font-size: 13px;
font-weight: bold;
font-size: 0.72rem;
font-weight: 500;
letter-spacing: 0.04em;
}
.reset-imminent .reset-banner {
outline: 4px solid var(--error-border);
outline-offset: -4px;
outline: 2px solid var(--error-border);
outline-offset: -2px;
}
.about-card {
max-width: 760px;
border: 1px solid var(--border);
border-radius: 3px;
background: var(--field-bg);
padding: 12px 14px;
padding: 1.2rem 1.4rem;
line-height: 1.75;
}
.about-card p { margin-bottom: 0.6rem; }
.poc-warning {
border: 1px solid var(--error-border);
border-radius: 3px;
background: var(--error-bg);
color: var(--error-fg);
padding: 8px 10px;
margin-top: 0.8rem;
}
/* ---- Theme toggle ---- */
.theme-toggle {
position: fixed;
bottom: 1.5rem;
right: 1.5rem;
width: 28px;
height: 28px;
border-radius: 50%;
border: 1px solid var(--toggle-border);
cursor: pointer;
padding: 0;
z-index: 30;
transition: all 0.3s ease;
box-shadow: var(--toggle-shadow);
background: var(--toggle-bg);
}
.theme-toggle:hover { transform: scale(1.08); }
[data-theme="macchiato"] .theme-toggle {
animation: neon-breathe-toggle 4s ease-in-out infinite;
}
@keyframes neon-breathe-toggle {
0%, 100% {
box-shadow:
0 0 10px rgba(138, 173, 244, 0.5),
0 0 25px rgba(138, 173, 244, 0.3),
0 0 50px rgba(116, 150, 230, 0.2);
}
50% {
box-shadow:
0 0 15px rgba(138, 173, 244, 0.6),
0 0 35px rgba(138, 173, 244, 0.4),
0 0 70px rgba(116, 150, 230, 0.25);
}
}
/* ── Hamburger (hidden on desktop) ── */
.nav-hamburger {
display: none;
flex-direction: column;
gap: 4px;
background: none;
border: none;
cursor: pointer;
padding: 6px;
justify-self: end;
}
.nav-hamburger span {
display: block;
width: 20px;
height: 1.5px;
background: var(--muted);
transition: background 0.2s;
}
.nav-hamburger:hover span { background: var(--fg); }
/* ── Off-canvas overlay ── */
.nav-overlay {
position: fixed; inset: 0;
background: rgba(0,0,0,0.4);
z-index: 49;
opacity: 0; pointer-events: none;
transition: opacity 0.3s;
}
.nav-overlay.open { opacity: 1; pointer-events: auto; }
/* ── Off-canvas panel ── */
.nav-offcanvas {
position: fixed; top: 0; right: 0;
width: 100vw; height: 100vh;
background: var(--bg);
z-index: 50;
transform: translateX(100%);
transition: transform 0.35s cubic-bezier(0.16, 1, 0.3, 1);
display: flex; flex-direction: column;
padding: calc(60px + 2rem) 2rem 2rem;
overflow-y: auto;
}
.nav-offcanvas.open { transform: translateX(0); }
.offcanvas-header {
position: absolute; top: 0; left: 0; right: 0;
height: 60px;
display: flex; align-items: center; justify-content: space-between;
padding: 0 1.25rem;
border-bottom: 1px solid var(--border);
}
.nav-close {
background: none; border: none;
font-size: 1.3rem; color: var(--muted);
cursor: pointer; padding: 0.5rem;
font-family: 'Noto Sans', sans-serif;
}
.nav-close:hover { color: var(--fg); }
.offcanvas-links {
display: flex; flex-direction: column;
gap: 0.15rem; width: 100%;
}
.offcanvas-links a {
font-family: 'Noto Sans', sans-serif;
font-size: 1.05rem;
letter-spacing: 0.12em;
text-transform: uppercase;
color: var(--muted);
text-decoration: none;
padding: 0.75rem 0;
border-bottom: 1px solid var(--border);
transition: color 0.2s;
}
.offcanvas-links a:first-child { border-top: 1px solid var(--border); }
.offcanvas-links a:hover { color: var(--fg); padding-left: 0.5rem; }
@media (max-width: 640px) {
header { padding: 0.7rem 0.9rem; gap: 0.6rem; }
.back-link { font-size: 0.58rem; padding: 0.25rem 0.4rem; letter-spacing: 0.08em; }
.brand { font-size: 0.9rem; }
nav a { font-size: 0.62rem; margin-right: 0.6rem; letter-spacing: 0.08em; }
footer { font-size: 0.62rem; padding: 0.7rem 0.9rem; }
main { padding: 12px; }
.theme-toggle { width: 20px; height: 20px; bottom: 1rem; right: 0.9rem; }
.reset-banner { top: 44px; }
.running-bar { top: 44px; }
.nav-desktop { display: none; }
.nav-hamburger { display: flex; }
}
@media (min-width: 641px) {
.clock {
font-size: 0.85rem;
}
.reset-banner {
position: fixed;
left: 1rem;
bottom: 1rem;
top: auto;
justify-content: flex-start;
gap: 8px;
max-width: calc(100vw - 2rem);
padding: 6px 10px;
border: 1px solid var(--error-border);
border-radius: 999px;
font-size: 0.7rem;
text-align: left;
}
.reset-countdown {
font-size: 0.9rem;
padding: 1px 8px;
border-radius: 999px;
}
.reset-note {
display: none;
}
}
-2
View File
@@ -1,5 +1,3 @@
// The only JavaScript in the app: tick the live work timer and the visible POC reset countdown.
// Everything else is plain HTML + htmx.
(function () {
var workInterval = null;
var resetInterval = null;
+1 -1
View File
@@ -10,7 +10,7 @@
<p>From here on you can either start a timer for a certain task, or add tasks manually. Everything can be edited or deleted, but not cascading deletes upwards in the hierarchy.</p>
<p>When you are done with an assignment for the company, create the invoice. When pressing the button, you can change the basic info about the company, and you can add lines that are not time, but quantity or other measures. For example, 2 pcs. routers at 200 kr a piece. Or whatever you know what I mean. Or like perhaps it is not an hourly rate, but just a one time price for the whole assignment.</p>
<br>
<p>This dummy version runs in-memory and is reset every 10 minutes (When I run it I have an API to my Nixos home server which uses sqlite as data store).</p>
<p>This dummy version runs in-memory and is reset every 20 minutes (When I run it I have an API to my Nixos home server which uses sqlite as data store).</p>
<p>It is served via Caddy (I love Caddy) to my FLÓ subdomain.</p>
<p>It is one static Go binary, it uses the golang built-in server-rendered HTML templating language, htmx for page updates (Javascript for the dummy timer), embedded static files.</p>
<p>I mean, come on, this things ships <strong>as a single binary</strong>, like all of this right? And when I use the API, it is all still a single binary. Could not be easier (I love Go).</p>
+98 -7
View File
@@ -3,19 +3,51 @@
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>zeitkort</title>
<title>zeitkort — FLÓ</title>
<script>
(function () {
var stored = localStorage.getItem('poc-theme');
// Changed default from 'tokyo' to 'macchiato'
var theme = stored || (window.matchMedia('(prefers-color-scheme: dark)').matches ? 'macchiato' : 'light');
document.documentElement.setAttribute('data-theme', theme);
})();
</script>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Noto+Sans:wght@300;400;500;700&display=swap" rel="stylesheet">
<link rel="stylesheet" href="/static/app.css">
</head>
<body>
<header>
<span class="brand">zeitkort</span>
<nav>
<a href="/">Timer</a>
<a hx-get="/settings" hx-target="#main" href="javascript:void(0)">Settings</a>
<a hx-get="/about" hx-target="#main" href="javascript:void(0)">About</a>
<a href="https://fló.fo" class="back-link">← FLÓ.FO</a>
<span class="brand">FLÓ <span class="brand-sep">/</span> <span class="brand-sub">ZEITKORT</span></span>
<nav class="nav-desktop">
<a href="/">[TIMER]</a>
<a hx-get="/settings" hx-target="#main" href="javascript:void(0)">[SETTINGS]</a>
<a hx-get="/about" hx-target="#main" href="javascript:void(0)">[ABOUT]</a>
</nav>
<button id="nav-hamburger" class="nav-hamburger" aria-label="Open menu">
<span></span><span></span><span></span>
</button>
</header>
<!-- Off-canvas mobile menu -->
<div id="nav-overlay" class="nav-overlay"></div>
<div id="nav-offcanvas" class="nav-offcanvas">
<div class="offcanvas-header">
<span class="brand">FLÓ <span class="brand-sep">/</span> <span class="brand-sub">ZEITKORT</span></span>
<button id="nav-close" class="nav-close" aria-label="Close menu">&#x2715;</button>
</div>
<div class="offcanvas-links">
<a href="/">[TIMER]</a>
<a hx-get="/settings" hx-target="#main" href="javascript:void(0)">[SETTINGS]</a>
<a hx-get="/about" hx-target="#main" href="javascript:void(0)">[ABOUT]</a>
</div>
</div>
<div class="reset-banner" role="status" aria-live="polite">
<span class="reset-label">POC reset in</span>
<span id="reset-countdown" class="reset-countdown" data-reset-at="{{rfc3339 .ResetAt}}">--:--</span>
@@ -26,9 +58,68 @@
{{template "main" .}}
</main>
<footer>zeitkort-web {{.Version}} · web-only POC · resets every 10 minutes</footer>
<footer>
<span>&copy; <script>document.write(new Date().getFullYear())</script> FLÓ.FO | ALL RIGHTS RESERVED</span>
<a href="https://www.facebook.com/people/FL%C3%93/61593006613799/" target="_blank" rel="noopener" class="footer-icon" aria-label="Facebook">
<svg width="18" height="18" viewBox="0 0 24 24" fill="currentColor"><path d="M24 12.073c0-6.627-5.373-12-12-12s-12 5.373-12 12c0 5.99 4.388 10.954 10.125 11.854v-8.385H7.078v-3.47h3.047V9.43c0-3.007 1.792-4.669 4.533-4.669 1.312 0 2.686.235 2.686.235v2.953H15.83c-1.491 0-1.956.925-1.956 1.874v2.25h3.328l-.532 3.47h-2.796v8.385C19.612 23.027 24 18.062 24 12.073z"/></svg>
</a>
<a href="https://git.flo.fo/FLO" target="_blank" rel="noopener" class="footer-icon" aria-label="Gitea">
<svg width="18" height="18" viewBox="0 0 24 24" fill="currentColor"><path d="M12 .297c-6.63 0-12 5.373-12 12 0 5.303 3.438 9.8 8.205 11.385.6.113.82-.258.82-.577 0-.285-.01-1.04-.015-2.04-3.338.724-4.042-1.61-4.042-1.61C4.422 18.07 3.633 17.7 3.633 17.7c-1.087-.744.084-.729.084-.729 1.205.084 1.838 1.236 1.838 1.236 1.07 1.835 2.809 1.305 3.495.998.108-.776.417-1.305.76-1.605-2.665-.3-5.466-1.332-5.466-5.93 0-1.31.465-2.38 1.235-3.22-.135-.303-.54-1.523.105-3.176 0 0 1.005-.322 3.3 1.23.96-.267 1.98-.399 3-.405 1.02.006 2.04.138 3 .405 2.28-1.552 3.285-1.23 3.285-1.23.645 1.653.24 2.873.12 3.176.765.84 1.23 1.91 1.23 3.22 0 4.61-2.805 5.625-5.475 5.92.42.36.81 1.096.81 2.22 0 1.606-.015 2.896-.015 3.286 0 .315.21.69.825.57C20.565 22.092 24 17.592 24 12.297c0-6.627-5.373-12-12-12"/></svg>
</a>
<a href="https://www.linkedin.com/in/bartal-l%C3%A6arsson-63793895/" target="_blank" rel="noopener" class="footer-icon" aria-label="LinkedIn">
<svg width="18" height="18" viewBox="0 0 24 24" fill="currentColor"><path d="M20.447 20.452h-3.554v-5.569c0-1.328-.027-3.037-1.852-3.037-1.853 0-2.136 1.445-2.136 2.939v5.667H9.351V9h3.414v1.561h.046c.477-.9 1.637-1.85 3.37-1.85 3.601 0 4.267 2.37 4.267 5.455v6.286zM5.337 7.433a2.062 2.062 0 01-2.063-2.065 2.064 2.064 0 112.063 2.065zm1.782 13.019H3.555V9h3.564v11.452zM22.225 0H1.771C.792 0 0 .774 0 1.729v20.542C0 23.227.792 24 1.771 24h20.451C23.2 24 24 23.227 24 22.271V1.729C24 .774 23.2 0 22.222 0h.003z"/></svg>
</a>
</footer>
<button class="theme-toggle" id="theme-toggle" aria-label="Toggle theme"></button>
<script src="/static/htmx.min.js"></script>
<script src="/static/timer.js"></script>
<script>
(function () {
var btn = document.getElementById('theme-toggle');
if (btn) {
btn.addEventListener('click', function () {
var current = document.documentElement.getAttribute('data-theme') || 'light';
var next = current === 'light' ? 'macchiato' : 'light';
document.documentElement.setAttribute('data-theme', next);
localStorage.setItem('poc-theme', next);
});
}
window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', function () {
if (!localStorage.getItem('poc-theme')) {
document.documentElement.setAttribute('data-theme', window.matchMedia('(prefers-color-scheme: dark)').matches ? 'macchiato' : 'light');
}
});
})();
</script>
<script>
(function () {
var overlay = document.getElementById('nav-overlay');
var panel = document.getElementById('nav-offcanvas');
var openBtn = document.getElementById('nav-hamburger');
var closeBtn = document.getElementById('nav-close');
function open() { panel.classList.add('open'); overlay.classList.add('open'); }
function close() { panel.classList.remove('open'); overlay.classList.remove('open'); }
if (openBtn) openBtn.addEventListener('click', open);
if (closeBtn) closeBtn.addEventListener('click', close);
if (overlay) overlay.addEventListener('click', close);
panel.querySelectorAll('a').forEach(function(a) { a.addEventListener('click', close); });
document.querySelectorAll('nav a, .offcanvas-links a').forEach(function(a) {
a.addEventListener('click', function() {
document.querySelectorAll('nav a, .offcanvas-links a').forEach(function(el) {
el.classList.remove('active');
});
var href = a.getAttribute('hx-get') || a.getAttribute('href');
document.querySelectorAll('nav a, .offcanvas-links a').forEach(function(el) {
var elHref = el.getAttribute('hx-get') || el.getAttribute('href');
if (elHref === href) el.classList.add('active');
});
});
});
})();
</script>
</body>
</html>{{end}}