Compare commits
6
Commits
e58e4bc13e
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
aee8d9ead1 | ||
|
|
228871648d | ||
|
|
c56d1f7f8f | ||
|
|
7d90e1853d | ||
|
|
6722497469 | ||
|
|
5450b6a82c |
@@ -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.
|
||||
|
||||

|
||||
|
||||
## 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.
|
||||
|
||||
|
||||
@@ -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}}
|
||||
@@ -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"`
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 486 KiB |
@@ -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)
|
||||
|
||||
@@ -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
@@ -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")
|
||||
|
||||
+99
-66
@@ -1,31 +1,38 @@
|
||||
/* zeitkort-web — styled to match fló.fo. Light + tokyo (dark), follows system. */
|
||||
@import url('https://fonts.googleapis.com/css2?family=Noto+Sans:wght@300;400;500;700&display=swap');
|
||||
|
||||
*, *::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;
|
||||
--border: rgba(26, 29, 34, 0.12);
|
||||
|
||||
--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(26, 29, 34, 0.03);
|
||||
|
||||
--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(44, 90, 122, 0.06);
|
||||
--link: #2c5a7a;
|
||||
--accent: #2c5a7a;
|
||||
--accent-bg: rgba(44, 90, 122, 0.07);
|
||||
--accent-border: #2c5a7a;
|
||||
--nav-bg: rgba(244, 241, 236, 0.6);
|
||||
--bg-panel: rgba(255, 253, 250, 0.72);
|
||||
--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);
|
||||
@@ -36,58 +43,65 @@
|
||||
--backdrop: rgba(0, 0, 0, 0.3);
|
||||
--shadow: rgba(0, 0, 0, 0.12);
|
||||
|
||||
/* Theme toggle pill */
|
||||
--toggle-bg: linear-gradient(135deg, #07070d 0%, #64dcff 100%);
|
||||
--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);
|
||||
}
|
||||
|
||||
[data-theme="tokyo"] {
|
||||
color-scheme: dark;
|
||||
--bg: #07070d;
|
||||
--fg: #e8f4ff;
|
||||
--muted: #a3b3cc;
|
||||
--dim: #7886a0;
|
||||
--border: rgba(100, 220, 255, 0.12);
|
||||
--border-soft: rgba(100, 220, 255, 0.06);
|
||||
--dotted: rgba(100, 220, 255, 0.1);
|
||||
--field-bg: rgba(100, 220, 255, 0.04);
|
||||
--field-border: rgba(100, 220, 255, 0.15);
|
||||
--btn-bg: rgba(100, 220, 255, 0.06);
|
||||
--btn-bg-hover: rgba(100, 220, 255, 0.1);
|
||||
--btn-border: rgba(100, 220, 255, 0.18);
|
||||
--header-border: rgba(100, 220, 255, 0.12);
|
||||
--th-bg: rgba(100, 220, 255, 0.04);
|
||||
--row-active: rgba(100, 220, 255, 0.06);
|
||||
--link: #64dcff;
|
||||
--accent: #64dcff;
|
||||
--accent-bg: rgba(100, 220, 255, 0.06);
|
||||
--accent-border: #64dcff;
|
||||
--nav-bg: rgba(7, 7, 13, 0.65);
|
||||
--bg-panel: rgba(15, 16, 28, 0.72);
|
||||
[data-theme="macchiato"] {
|
||||
color-scheme: dark;
|
||||
|
||||
--notice-fg: #8fe08f; --notice-bg: rgba(143, 224, 143, 0.06); --notice-border: rgba(143, 224, 143, 0.25);
|
||||
--error-fg: #ff9b9b; --error-bg: rgba(255, 155, 155, 0.06); --error-border: rgba(255, 155, 155, 0.2);
|
||||
--go-bg: rgba(80, 200, 80, 0.08); --go-border: rgba(80, 200, 80, 0.3); --go-bg-hover: rgba(80, 200, 80, 0.14);
|
||||
--stop-bg: rgba(200, 80, 80, 0.08); --stop-border: rgba(200, 80, 80, 0.3); --stop-bg-hover: rgba(200, 80, 80, 0.14);
|
||||
--card-bg: #0f1018; --card-border: rgba(200, 80, 80, 0.4);
|
||||
--danger-btn-bg: rgba(200, 80, 80, 0.1); --danger-btn-bg-hover: rgba(200, 80, 80, 0.18);
|
||||
--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: #f4f1ec;
|
||||
--toggle-border: rgba(100, 220, 255, 0.4);
|
||||
--toggle-shadow: 0 0 10px rgba(100, 220, 255, 0.5), 0 0 25px rgba(100, 220, 255, 0.3);
|
||||
--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="tokyo"] select,
|
||||
[data-theme="tokyo"] option {
|
||||
background: #0f1018;
|
||||
color: #e8f4ff;
|
||||
|
||||
[data-theme="macchiato"] select,
|
||||
[data-theme="macchiato"] option {
|
||||
background: #24273a;
|
||||
color: #cad3f5;
|
||||
}
|
||||
|
||||
html, body { max-width: 100%; overflow-x: hidden; }
|
||||
body {
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-family: 'Noto Sans', sans-serif;
|
||||
font-size: 0.88rem;
|
||||
font-weight: 300;
|
||||
line-height: 1.55;
|
||||
@@ -120,11 +134,11 @@ header {
|
||||
z-index: 20;
|
||||
transition: background 0.3s ease;
|
||||
}
|
||||
.brand { justify-self: center; }
|
||||
nav { justify-self: end; }
|
||||
.brand { justify-self: center; }
|
||||
nav { justify-self: end; }
|
||||
|
||||
.back-link {
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-family: 'Noto Sans', sans-serif;
|
||||
font-weight: 400;
|
||||
font-size: 0.7rem;
|
||||
letter-spacing: 0.15em;
|
||||
@@ -168,9 +182,9 @@ nav { justify-self: end; }
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
[data-theme="tokyo"] .brand {
|
||||
color: #e8f4ff;
|
||||
text-shadow: 0 0 8px rgba(100, 220, 255, 0.3);
|
||||
[data-theme="macchiato"] .brand {
|
||||
color: #cad3f5;
|
||||
text-shadow: 0 0 8px rgba(138, 173, 244, 0.25);
|
||||
}
|
||||
|
||||
nav a {
|
||||
@@ -212,7 +226,7 @@ footer {
|
||||
}
|
||||
|
||||
.footer-icon {
|
||||
color: var(--ink-muted);
|
||||
color: var(--muted);
|
||||
text-decoration: none;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -226,14 +240,14 @@ footer {
|
||||
|
||||
h2 {
|
||||
margin: 0 0 0.9rem;
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-family: 'Noto Sans', sans-serif;
|
||||
font-weight: 700;
|
||||
font-size: 1.1rem;
|
||||
letter-spacing: -0.02em;
|
||||
}
|
||||
|
||||
[data-theme="tokyo"] h2 {
|
||||
text-shadow: 0 0 10px rgba(100, 220, 255, 0.2);
|
||||
[data-theme="macchiato"] h2 {
|
||||
text-shadow: 0 0 10px rgba(138, 173, 244, 0.2);
|
||||
}
|
||||
|
||||
/* ---- Forms ---- */
|
||||
@@ -308,12 +322,11 @@ button.go[disabled] { opacity: .45; cursor: not-allowed; }
|
||||
fieldset > .inline { margin-top: 10px; }
|
||||
.add-toggle { margin-top: 10px; }
|
||||
|
||||
.clock { font-family: 'JetBrains Mono', monospace; font-size: 1.5rem; font-weight: 700; margin: 0; letter-spacing: -0.02em; }
|
||||
.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: 52px; /* below the header */
|
||||
top: 52px;
|
||||
z-index: 15;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -433,7 +446,7 @@ details.confirm[open]::before {
|
||||
font-size: 0.72rem;
|
||||
}
|
||||
.reset-countdown {
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-family: 'Noto Sans', sans-serif;
|
||||
font-size: clamp(1.5rem, 6vw, 2.5rem);
|
||||
font-weight: 700;
|
||||
line-height: 1;
|
||||
@@ -490,9 +503,28 @@ details.confirm[open]::before {
|
||||
}
|
||||
.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; /* shown only at ≤640px */
|
||||
display: none;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
background: none;
|
||||
@@ -545,7 +577,7 @@ details.confirm[open]::before {
|
||||
background: none; border: none;
|
||||
font-size: 1.3rem; color: var(--muted);
|
||||
cursor: pointer; padding: 0.5rem;
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-family: 'Noto Sans', sans-serif;
|
||||
}
|
||||
.nav-close:hover { color: var(--fg); }
|
||||
|
||||
@@ -554,7 +586,7 @@ details.confirm[open]::before {
|
||||
gap: 0.15rem; width: 100%;
|
||||
}
|
||||
.offcanvas-links a {
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-family: 'Noto Sans', sans-serif;
|
||||
font-size: 1.05rem;
|
||||
letter-spacing: 0.12em;
|
||||
text-transform: uppercase;
|
||||
@@ -607,3 +639,4 @@ details.confirm[open]::before {
|
||||
.reset-note {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
+20
-15
@@ -8,14 +8,15 @@
|
||||
<script>
|
||||
(function () {
|
||||
var stored = localStorage.getItem('poc-theme');
|
||||
var theme = stored || (window.matchMedia('(prefers-color-scheme: dark)').matches ? 'tokyo' : 'light');
|
||||
// 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=JetBrains+Mono:wght@300;400;500;700&display=swap" rel="stylesheet">
|
||||
<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>
|
||||
@@ -36,7 +37,7 @@
|
||||
<!-- Off-canvas mobile menu -->
|
||||
<div id="nav-overlay" class="nav-overlay"></div>
|
||||
<div id="nav-offcanvas" class="nav-offcanvas">
|
||||
<div class="offcanvas-header">I
|
||||
<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">✕</button>
|
||||
</div>
|
||||
@@ -57,12 +58,18 @@
|
||||
{{template "main" .}}
|
||||
</main>
|
||||
|
||||
<footer>
|
||||
<span>© <script>document.write(new Date().getFullYear())</script> FLÓ.FO | ØLL RÆTTINDI TILSKILAÐ</span>
|
||||
<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>
|
||||
<footer>
|
||||
<span>© <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>
|
||||
|
||||
@@ -70,25 +77,24 @@
|
||||
<script src="/static/timer.js"></script>
|
||||
<script>
|
||||
(function () {
|
||||
function currentSystemTheme() {
|
||||
return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'tokyo' : 'light';
|
||||
}
|
||||
var btn = document.getElementById('theme-toggle');
|
||||
if (btn) {
|
||||
btn.addEventListener('click', function () {
|
||||
var current = document.documentElement.getAttribute('data-theme') || 'light';
|
||||
var next = current === 'light' ? 'tokyo' : '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', currentSystemTheme());
|
||||
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');
|
||||
@@ -101,7 +107,6 @@
|
||||
if (overlay) overlay.addEventListener('click', close);
|
||||
panel.querySelectorAll('a').forEach(function(a) { a.addEventListener('click', close); });
|
||||
|
||||
// Mark active link on click
|
||||
document.querySelectorAll('nav a, .offcanvas-links a').forEach(function(a) {
|
||||
a.addEventListener('click', function() {
|
||||
document.querySelectorAll('nav a, .offcanvas-links a').forEach(function(el) {
|
||||
|
||||
Reference in New Issue
Block a user