feat(m4): SvelteKit frontend - today, week, history, settings views

This commit is contained in:
2026-04-30 16:45:00 +02:00
parent d0ef0387f2
commit df04d9d7a9
28 changed files with 2577 additions and 2 deletions

3
.gitignore vendored
View File

@@ -1 +1,4 @@
wotra.db
web/build/
web/.svelte-kit/
web/node_modules/

9
cmd/wotra/embed_dev.go Normal file
View File

@@ -0,0 +1,9 @@
// embed_dev.go — used in non-production builds (no web/build directory required)
//go:build !production
package main
import "io/fs"
// webFS is nil in dev mode; the router will skip static file serving.
var webFS fs.FS

20
cmd/wotra/embed_prod.go Normal file
View File

@@ -0,0 +1,20 @@
// embed_prod.go — used in production builds (requires web/build to exist)
//go:build production
package main
import (
"embed"
"io/fs"
)
//go:embed all:../../web/build
var embeddedWeb embed.FS
var webFS fs.FS = func() fs.FS {
sub, err := fs.Sub(embeddedWeb, "web/build")
if err != nil {
panic(err)
}
return sub
}()

View File

@@ -2,6 +2,7 @@ package main
import (
"context"
"io/fs"
"log/slog"
"net/http"
"os"
@@ -53,7 +54,13 @@ func main() {
defer cancel()
go runMidnightGuard(ctx, entrySvc)
router := handler.NewRouter(cfg.AuthToken, entrySvc, daySvc, settingsSvc, weekSvc)
// Static SPA files (embedded or from disk for dev)
var staticFS fs.FS
if webFS != nil {
staticFS = webFS
}
router := handler.NewRouter(cfg.AuthToken, entrySvc, daySvc, settingsSvc, weekSvc, staticFS)
srv := &http.Server{
Addr: ":" + cfg.Port,

View File

@@ -1,6 +1,7 @@
package handler
import (
"io/fs"
"net/http"
"github.com/go-chi/chi/v5"
@@ -9,7 +10,14 @@ import (
)
// NewRouter builds the full HTTP router.
func NewRouter(authToken string, entrySvc *service.EntryService, daySvc *service.DayService, settingsSvc *service.SettingsService, weekSvc *service.WeekService) http.Handler {
func NewRouter(
authToken string,
entrySvc *service.EntryService,
daySvc *service.DayService,
settingsSvc *service.SettingsService,
weekSvc *service.WeekService,
staticFiles fs.FS,
) http.Handler {
r := chi.NewRouter()
r.Use(middleware.RequestID)
r.Use(middleware.RealIP)
@@ -38,5 +46,23 @@ func NewRouter(authToken string, entrySvc *service.EntryService, daySvc *service
weekH.Routes(r)
})
// Serve embedded SPA if available (production build)
if staticFiles != nil {
fileServer := http.FileServer(http.FS(staticFiles))
r.Get("/*", func(w http.ResponseWriter, r *http.Request) {
// Try to open the requested path; fall back to index.html for SPA routing
path := r.URL.Path
if path == "/" || path == "" {
path = "index.html"
} else {
path = path[1:] // strip leading slash
}
if _, err := staticFiles.Open(path); err != nil {
r.URL.Path = "/"
}
fileServer.ServeHTTP(w, r)
})
}
return r
}

23
web/.gitignore vendored Normal file
View File

@@ -0,0 +1,23 @@
node_modules
# Output
.output
.vercel
.netlify
.wrangler
/.svelte-kit
/build
# OS
.DS_Store
Thumbs.db
# Env
.env
.env.*
!.env.example
!.env.test
# Vite
vite.config.js.timestamp-*
vite.config.ts.timestamp-*

1
web/.npmrc Normal file
View File

@@ -0,0 +1 @@
engine-strict=true

3
web/.vscode/extensions.json vendored Normal file
View File

@@ -0,0 +1,3 @@
{
"recommendations": ["svelte.svelte-vscode"]
}

42
web/README.md Normal file
View File

@@ -0,0 +1,42 @@
# sv
Everything you need to build a Svelte project, powered by [`sv`](https://github.com/sveltejs/cli).
## Creating a project
If you're seeing this, you've probably already done this step. Congrats!
```sh
# create a new project
npx sv create my-app
```
To recreate this project with the same configuration:
```sh
# recreate this project
npx sv@0.15.2 create --template minimal --types ts --install npm web
```
## Developing
Once you've created a project and installed dependencies with `npm install` (or `pnpm install` or `yarn`), start a development server:
```sh
npm run dev
# or start the server and open the app in a new browser tab
npm run dev -- --open
```
## Building
To create a production version of your app:
```sh
npm run build
```
You can preview the production build with `npm run preview`.
> To deploy your app, you may need to install an [adapter](https://svelte.dev/docs/kit/adapters) for your target environment.

1308
web/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

26
web/package.json Normal file
View File

@@ -0,0 +1,26 @@
{
"name": "web",
"private": true,
"version": "0.0.1",
"type": "module",
"scripts": {
"dev": "vite dev",
"build": "vite build",
"preview": "vite preview",
"prepare": "svelte-kit sync || echo ''",
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch"
},
"devDependencies": {
"@sveltejs/adapter-auto": "^7.0.1",
"@sveltejs/kit": "^2.57.0",
"@sveltejs/vite-plugin-svelte": "^7.0.0",
"svelte": "^5.55.2",
"svelte-check": "^4.4.6",
"typescript": "^6.0.2",
"vite": "^8.0.7"
},
"dependencies": {
"@sveltejs/adapter-static": "^3.0.10"
}
}

13
web/src/app.d.ts vendored Normal file
View File

@@ -0,0 +1,13 @@
// See https://svelte.dev/docs/kit/types#app.d.ts
// for information about these interfaces
declare global {
namespace App {
// interface Error {}
// interface Locals {}
// interface PageData {}
// interface PageState {}
// interface Platform {}
}
}
export {};

12
web/src/app.html Normal file
View File

@@ -0,0 +1,12 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="text-scale" content="scale" />
%sveltekit.head%
</head>
<body data-sveltekit-preload-data="hover">
<div style="display: contents">%sveltekit.body%</div>
</body>
</html>

144
web/src/lib/api/client.ts Normal file
View File

@@ -0,0 +1,144 @@
// API client for Wotra backend.
// Base URL: /api (relative, works both in dev proxy and production)
const API_BASE = '/api';
function getToken(): string {
return localStorage.getItem('auth_token') ?? '';
}
export function setToken(token: string) {
localStorage.setItem('auth_token', token);
}
export function hasToken(): boolean {
return !!localStorage.getItem('auth_token');
}
async function request<T>(method: string, path: string, body?: unknown): Promise<T> {
const res = await fetch(`${API_BASE}${path}`, {
method,
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${getToken()}`
},
body: body !== undefined ? JSON.stringify(body) : undefined
});
if (!res.ok) {
const err = await res.json().catch(() => ({ error: res.statusText }));
throw new ApiError(res.status, err.error ?? res.statusText);
}
if (res.status === 204) return undefined as T;
return res.json();
}
export class ApiError extends Error {
constructor(
public status: number,
message: string
) {
super(message);
}
}
// ─── Types ──────────────────────────────────────────────────────────────────
export interface Entry {
id: string;
start_time: number; // unix ms UTC
end_time: number | null;
auto_stopped: boolean;
note: string;
day_key: string;
updated_at: number;
}
export interface ClosedDay {
day_key: string;
start_time: number | null;
end_time: number | null;
worked_ms: number;
kind: 'work' | 'holiday' | 'vacation' | 'sick';
closed_at: number;
updated_at: number;
}
export interface ClosedWeek {
week_key: string;
expected_ms: number;
worked_ms: number;
delta_ms: number;
closed_at: number;
updated_at: number;
}
export interface Settings {
id: number;
effective_from: string;
hours_per_week: number;
workdays_mask: number;
timezone: string;
created_at: number;
}
// ─── Entries ─────────────────────────────────────────────────────────────────
export const entries = {
start: (note = '') => request<Entry>('POST', '/entries/start', { note }),
stop: (id: string) => request<Entry>('POST', `/entries/${id}/stop`),
list: (from?: string, to?: string) => {
const params = new URLSearchParams();
if (from) params.set('from', from);
if (to) params.set('to', to);
return request<Entry[]>('GET', `/entries?${params}`);
},
update: (id: string, body: { start_time?: number; end_time?: number; note?: string }) =>
request<Entry>('PUT', `/entries/${id}`, body),
delete: (id: string) => request<void>('DELETE', `/entries/${id}`)
};
// ─── Days ────────────────────────────────────────────────────────────────────
export const days = {
list: (from?: string, to?: string) => {
const params = new URLSearchParams();
if (from) params.set('from', from);
if (to) params.set('to', to);
return request<ClosedDay[]>('GET', `/days?${params}`);
},
close: (dayKey: string) => request<ClosedDay>('POST', `/days/${dayKey}/close`),
mark: (dayKey: string, kind: 'holiday' | 'vacation' | 'sick') =>
request<ClosedDay>('POST', `/days/${dayKey}/mark`, { kind }),
reopen: (dayKey: string) => request<void>('DELETE', `/days/${dayKey}/close`)
};
// ─── Weeks ───────────────────────────────────────────────────────────────────
export const weeks = {
list: (from?: string, to?: string) => {
const params = new URLSearchParams();
if (from) params.set('from', from);
if (to) params.set('to', to);
return request<ClosedWeek[]>('GET', `/weeks?${params}`);
},
close: (weekKey: string) => request<ClosedWeek>('POST', `/weeks/${weekKey}/close`),
reopen: (weekKey: string) => request<void>('DELETE', `/weeks/${weekKey}/close`)
};
// ─── Settings ────────────────────────────────────────────────────────────────
export const settings = {
current: () => request<Settings>('GET', '/settings'),
history: () => request<Settings[]>('GET', '/settings/history'),
upsert: (body: {
effective_from: string;
hours_per_week: number;
workdays_mask: number;
timezone: string;
}) => request<Settings>('PUT', '/settings', body)
};
// ─── Health ──────────────────────────────────────────────────────────────────
export const healthz = () =>
fetch('/healthz').then((r) => r.ok);

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="107" height="128" viewBox="0 0 107 128"><title>svelte-logo</title><path d="M94.157 22.819c-10.4-14.885-30.94-19.297-45.792-9.835L22.282 29.608A29.92 29.92 0 0 0 8.764 49.65a31.5 31.5 0 0 0 3.108 20.231 30 30 0 0 0-4.477 11.183 31.9 31.9 0 0 0 5.448 24.116c10.402 14.887 30.942 19.297 45.791 9.835l26.083-16.624A29.92 29.92 0 0 0 98.235 78.35a31.53 31.53 0 0 0-3.105-20.232 30 30 0 0 0 4.474-11.182 31.88 31.88 0 0 0-5.447-24.116" style="fill:#ff3e00"/><path d="M45.817 106.582a20.72 20.72 0 0 1-22.237-8.243 19.17 19.17 0 0 1-3.277-14.503 18 18 0 0 1 .624-2.435l.49-1.498 1.337.981a33.6 33.6 0 0 0 10.203 5.098l.97.294-.09.968a5.85 5.85 0 0 0 1.052 3.878 6.24 6.24 0 0 0 6.695 2.485 5.8 5.8 0 0 0 1.603-.704L69.27 76.28a5.43 5.43 0 0 0 2.45-3.631 5.8 5.8 0 0 0-.987-4.371 6.24 6.24 0 0 0-6.698-2.487 5.7 5.7 0 0 0-1.6.704l-9.953 6.345a19 19 0 0 1-5.296 2.326 20.72 20.72 0 0 1-22.237-8.243 19.17 19.17 0 0 1-3.277-14.502 17.99 17.99 0 0 1 8.13-12.052l26.081-16.623a19 19 0 0 1 5.3-2.329 20.72 20.72 0 0 1 22.237 8.243 19.17 19.17 0 0 1 3.277 14.503 18 18 0 0 1-.624 2.435l-.49 1.498-1.337-.98a33.6 33.6 0 0 0-10.203-5.1l-.97-.294.09-.968a5.86 5.86 0 0 0-1.052-3.878 6.24 6.24 0 0 0-6.696-2.485 5.8 5.8 0 0 0-1.602.704L37.73 51.72a5.42 5.42 0 0 0-2.449 3.63 5.79 5.79 0 0 0 .986 4.372 6.24 6.24 0 0 0 6.698 2.486 5.8 5.8 0 0 0 1.602-.704l9.952-6.342a19 19 0 0 1 5.295-2.328 20.72 20.72 0 0 1 22.237 8.242 19.17 19.17 0 0 1 3.277 14.503 18 18 0 0 1-8.13 12.053l-26.081 16.622a19 19 0 0 1-5.3 2.328" style="fill:#fff"/></svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

1
web/src/lib/index.ts Normal file
View File

@@ -0,0 +1 @@
// place files you want to import through the `$lib` alias in this folder.

88
web/src/lib/utils.ts Normal file
View File

@@ -0,0 +1,88 @@
// Utility functions shared across the UI.
/** Format unix milliseconds as HH:MM:SS */
export function formatDuration(ms: number): string {
const totalSec = Math.floor(ms / 1000);
const h = Math.floor(totalSec / 3600);
const m = Math.floor((totalSec % 3600) / 60);
const s = totalSec % 60;
return `${pad(h)}:${pad(m)}:${pad(s)}`;
}
/** Format unix milliseconds as HH:MM */
export function formatDurationShort(ms: number): string {
const totalSec = Math.floor(ms / 1000);
const h = Math.floor(totalSec / 3600);
const m = Math.floor((totalSec % 3600) / 60);
return `${pad(h)}:${pad(m)}`;
}
function pad(n: number) {
return String(n).padStart(2, '0');
}
/** Format unix ms as locale time string (HH:MM) */
export function formatTime(ms: number): string {
return new Date(ms).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
}
/** Format unix ms as locale date string */
export function formatDate(ms: number): string {
return new Date(ms).toLocaleDateString([], { weekday: 'short', month: 'short', day: 'numeric' });
}
/** Today's date key YYYY-MM-DD */
export function todayKey(): string {
const now = new Date();
return `${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())}`;
}
/** Current ISO week key e.g. "2024-W03" */
export function currentWeekKey(): string {
const now = new Date();
// ISO week: Thursday's year
const thu = new Date(now);
thu.setDate(now.getDate() + 4 - (now.getDay() || 7));
const yearStart = new Date(thu.getFullYear(), 0, 1);
const week = Math.ceil(((thu.getTime() - yearStart.getTime()) / 86400000 + 1) / 7);
return `${thu.getFullYear()}-W${String(week).padStart(2, '0')}`;
}
/** Return the 7 YYYY-MM-DD keys for the ISO week (Mon=0..Sun=6) */
export function weekDayKeys(weekKey: string): string[] {
const [yearStr, weekStr] = weekKey.split('-W');
const year = parseInt(yearStr);
const week = parseInt(weekStr);
// Jan 4 is always in week 1
const jan4 = new Date(year, 0, 4);
const jan4Day = jan4.getDay() || 7; // Mon=1..Sun=7
const monday = new Date(jan4);
monday.setDate(jan4.getDate() - jan4Day + 1 + (week - 1) * 7);
return Array.from({ length: 7 }, (_, i) => {
const d = new Date(monday);
d.setDate(monday.getDate() + i);
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`;
});
}
/** Format a signed duration with +/- prefix */
export function formatDelta(ms: number): string {
const sign = ms >= 0 ? '+' : '-';
return `${sign}${formatDurationShort(Math.abs(ms))}`;
}
/** Workday bit mask: Mon=1, Tue=2, Wed=4, Thu=8, Fri=16, Sat=32, Sun=64 */
export const WEEKDAY_BITS = [1, 2, 4, 8, 16, 32, 64]; // Mon..Sun
/** JS getDay() → our bit (Sun=0 in JS → bit 64) */
export function weekdayBit(jsDay: number): number {
// JS: 0=Sun, 1=Mon, ..., 6=Sat
// Our: Mon=1..Sat=32, Sun=64
const map: Record<number, number> = { 1: 1, 2: 2, 3: 4, 4: 8, 5: 16, 6: 32, 0: 64 };
return map[jsDay] ?? 0;
}
export function isWorkday(dayKey: string, mask: number): boolean {
const d = new Date(dayKey + 'T12:00:00Z');
return (weekdayBit(d.getUTCDay()) & mask) !== 0;
}

View File

@@ -0,0 +1,78 @@
<script lang="ts">
import { page } from '$app/state';
import { hasToken } from '$lib/api/client';
import { onMount } from 'svelte';
import { goto } from '$app/navigation';
let { children } = $props();
onMount(() => {
if (!hasToken() && page.url.pathname !== '/settings') {
goto('/settings');
}
});
const navItems = [
{ href: '/today', label: 'Today' },
{ href: '/week', label: 'Week' },
{ href: '/history', label: 'History' },
{ href: '/settings', label: 'Settings' }
];
</script>
<nav>
{#each navItems as item}
<a href={item.href} class:active={page.url.pathname === item.href}>{item.label}</a>
{/each}
</nav>
<main>
{@render children()}
</main>
<style>
:global(*, *::before, *::after) {
box-sizing: border-box;
}
:global(body) {
margin: 0;
font-family: system-ui, sans-serif;
background: #f8f9fa;
color: #212529;
}
:global(button) {
cursor: pointer;
}
nav {
display: flex;
gap: 0.5rem;
padding: 0.75rem 1rem;
background: #1a1a2e;
position: sticky;
top: 0;
z-index: 10;
}
nav a {
color: #adb5bd;
text-decoration: none;
padding: 0.4rem 0.8rem;
border-radius: 6px;
font-size: 0.9rem;
font-weight: 500;
transition: background 0.15s, color 0.15s;
}
nav a:hover {
background: #16213e;
color: #e9ecef;
}
nav a.active {
background: #0f3460;
color: #e9ecef;
}
main {
max-width: 800px;
margin: 0 auto;
padding: 1.5rem 1rem;
}
</style>

View File

@@ -0,0 +1,3 @@
// +layout.ts — disable SSR for SPA mode
export const ssr = false;
export const prerender = false;

View File

@@ -0,0 +1,5 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { onMount } from 'svelte';
onMount(() => goto('/today'));
</script>

View File

@@ -0,0 +1,116 @@
<script lang="ts">
import { onMount } from 'svelte';
import { days, weeks, type ClosedDay, type ClosedWeek, ApiError } from '$lib/api/client';
import { formatDurationShort, formatDelta } from '$lib/utils';
// Show last 12 weeks
function pastWeekKeys(n: number): string[] {
const result: string[] = [];
const now = new Date();
for (let i = 0; i < n; i++) {
const d = new Date(now);
d.setDate(now.getDate() - i * 7);
const thu = new Date(d);
thu.setDate(d.getDate() + 4 - (d.getDay() || 7));
const ys = new Date(thu.getFullYear(), 0, 1);
const w = Math.ceil(((thu.getTime() - ys.getTime()) / 86400000 + 1) / 7);
result.push(`${thu.getFullYear()}-W${String(w).padStart(2, '0')}`);
}
return result.reverse();
}
let weekKeys = pastWeekKeys(12);
let closedWeeksMap: Record<string, ClosedWeek> = $state({});
let closedDaysMap: Record<string, ClosedDay> = $state({});
let error = $state('');
let loading = $state(false);
onMount(async () => {
loading = true;
try {
const from = weekKeys[0];
const to = weekKeys[weekKeys.length - 1];
// Estimate day range
const dayFrom = from.replace('-W', '') + '-01-01'; // rough
const [ws, ds] = await Promise.all([
weeks.list(from, to),
days.list('2000-01-01', '2100-01-01')
]);
closedWeeksMap = Object.fromEntries((ws ?? []).map((w) => [w.week_key, w]));
closedDaysMap = Object.fromEntries((ds ?? []).map((d) => [d.day_key, d]));
} catch (e) {
error = e instanceof ApiError ? e.message : String(e);
} finally {
loading = false;
}
});
</script>
<div class="history">
<h1>History</h1>
{#if error}<p class="error">{error}</p>{/if}
{#if loading}<p>Loading…</p>{/if}
<table>
<thead>
<tr>
<th>Week</th>
<th>Worked</th>
<th>Expected</th>
<th>Delta</th>
<th>Status</th>
</tr>
</thead>
<tbody>
{#each weekKeys as wk (wk)}
{@const cw = closedWeeksMap[wk]}
<tr class:closed={!!cw}>
<td>{wk}</td>
<td>{cw ? formatDurationShort(cw.worked_ms) : '—'}</td>
<td>{cw ? formatDurationShort(cw.expected_ms) : '—'}</td>
<td class:positive={cw && cw.delta_ms >= 0} class:negative={cw && cw.delta_ms < 0}>
{cw ? formatDelta(cw.delta_ms) : '—'}
</td>
<td>{cw ? 'closed' : 'open'}</td>
</tr>
{/each}
</tbody>
</table>
<h2>Recent Days</h2>
<table>
<thead>
<tr>
<th>Day</th>
<th>Kind</th>
<th>Worked</th>
</tr>
</thead>
<tbody>
{#each Object.entries(closedDaysMap).sort((a, b) => b[0].localeCompare(a[0])).slice(0, 30) as [dk, cd]}
<tr>
<td>{dk}</td>
<td><span class="badge" data-kind={cd.kind}>{cd.kind}</span></td>
<td>{formatDurationShort(cd.worked_ms)}</td>
</tr>
{/each}
</tbody>
</table>
</div>
<style>
h1 { margin: 0 0 1rem; }
h2 { margin: 2rem 0 0.5rem; }
.error { color: #c0392b; background: #fde8e4; padding: 0.5rem; border-radius: 6px; }
table { width: 100%; border-collapse: collapse; background: #fff; border-radius: 8px; overflow: hidden; box-shadow: 0 1px 3px rgba(0,0,0,0.08); margin-bottom: 1rem; }
th { background: #f8f9fa; padding: 0.6rem 1rem; text-align: left; font-size: 0.85rem; color: #6c757d; border-bottom: 1px solid #dee2e6; }
td { padding: 0.5rem 1rem; border-bottom: 1px solid #f1f3f5; font-size: 0.9rem; }
tr.closed td { color: #495057; }
.positive { color: #27ae60; font-weight: 600; }
.negative { color: #c0392b; font-weight: 600; }
.badge { font-size: 0.75rem; padding: 0.15rem 0.4rem; border-radius: 4px; }
.badge[data-kind="work"] { background: #d4edda; color: #155724; }
.badge[data-kind="holiday"] { background: #fff3cd; color: #856404; }
.badge[data-kind="vacation"] { background: #cce5ff; color: #004085; }
.badge[data-kind="sick"] { background: #f8d7da; color: #721c24; }
</style>

View File

@@ -0,0 +1,188 @@
<script lang="ts">
import { onMount } from 'svelte';
import { settings, setToken, hasToken, type Settings, ApiError } from '$lib/api/client';
import { todayKey } from '$lib/utils';
let current: Settings | null = $state(null);
let history: Settings[] = $state([]);
let error = $state('');
let saved = $state('');
// Token setup
let tokenInput = $state('');
let tokenSaved = $state(hasToken());
// New settings form
let formEffectiveFrom = $state(todayKey());
let formHoursPerWeek = $state(40);
let formWorkdaysMask = $state(31);
let formTimezone = $state('UTC');
// Workday checkboxes: Mon=bit1..Sun=bit64
const DAYS = [
{ label: 'Mon', bit: 1 },
{ label: 'Tue', bit: 2 },
{ label: 'Wed', bit: 4 },
{ label: 'Thu', bit: 8 },
{ label: 'Fri', bit: 16 },
{ label: 'Sat', bit: 32 },
{ label: 'Sun', bit: 64 }
];
function toggleDay(bit: number) {
formWorkdaysMask ^= bit;
}
async function load() {
if (!hasToken()) return;
error = '';
try {
const [c, h] = await Promise.all([settings.current(), settings.history()]);
current = c;
history = h ?? [];
if (c) {
formHoursPerWeek = c.hours_per_week;
formWorkdaysMask = c.workdays_mask;
formTimezone = c.timezone;
}
} catch (e) {
error = e instanceof ApiError ? e.message : String(e);
}
}
function handleSaveToken() {
const t = tokenInput.trim();
if (!t) return;
setToken(t);
tokenSaved = true;
tokenInput = '';
load();
}
async function handleSaveSettings(ev: Event) {
ev.preventDefault();
error = '';
saved = '';
try {
const s = await settings.upsert({
effective_from: formEffectiveFrom,
hours_per_week: formHoursPerWeek,
workdays_mask: formWorkdaysMask,
timezone: formTimezone
});
current = s;
history = [s, ...history];
saved = 'Settings saved!';
} catch (e) {
error = e instanceof ApiError ? e.message : String(e);
}
}
onMount(load);
</script>
<div class="settings">
<h1>Settings</h1>
<!-- Token setup -->
<section>
<h2>API Token</h2>
{#if tokenSaved}
<p class="ok">Token configured. <button onclick={() => { tokenSaved = false; }}>Change</button></p>
{:else}
<div class="token-row">
<input type="password" bind:value={tokenInput} placeholder="Paste your AUTH_TOKEN" />
<button onclick={handleSaveToken}>Save token</button>
</div>
{/if}
</section>
{#if tokenSaved}
{#if error}<p class="error">{error}</p>{/if}
{#if saved}<p class="ok">{saved}</p>{/if}
<!-- Settings form -->
<section>
<h2>Working Hours</h2>
<form onsubmit={handleSaveSettings}>
<label>
Effective from
<input type="date" bind:value={formEffectiveFrom} required />
</label>
<label>
Hours per week
<input type="number" min="1" max="168" step="0.5" bind:value={formHoursPerWeek} required />
</label>
<label>
Timezone
<input type="text" bind:value={formTimezone} placeholder="e.g. Europe/Berlin" />
</label>
<fieldset>
<legend>Workdays</legend>
<div class="days-row">
{#each DAYS as d}
<label class="day-check">
<input type="checkbox" checked={(formWorkdaysMask & d.bit) !== 0} onchange={() => toggleDay(d.bit)} />
{d.label}
</label>
{/each}
</div>
</fieldset>
<button type="submit">Save settings</button>
</form>
</section>
<!-- Current effective settings -->
{#if current}
<section>
<h2>Current effective settings</h2>
<dl>
<dt>Hours/week</dt><dd>{current.hours_per_week}h</dd>
<dt>Timezone</dt><dd>{current.timezone}</dd>
<dt>Effective from</dt><dd>{current.effective_from}</dd>
</dl>
</section>
{/if}
<!-- History -->
{#if history.length > 0}
<section>
<h2>Settings history</h2>
<table>
<thead><tr><th>Effective from</th><th>Hours/week</th><th>Timezone</th></tr></thead>
<tbody>
{#each history as s (s.id)}
<tr><td>{s.effective_from}</td><td>{s.hours_per_week}h</td><td>{s.timezone}</td></tr>
{/each}
</tbody>
</table>
</section>
{/if}
{/if}
</div>
<style>
h1 { margin: 0 0 1rem; }
h2 { font-size: 1rem; margin: 1.5rem 0 0.5rem; color: #495057; border-bottom: 1px solid #dee2e6; padding-bottom: 0.25rem; }
section { margin-bottom: 2rem; }
.error { color: #c0392b; background: #fde8e4; padding: 0.5rem; border-radius: 6px; }
.ok { color: #155724; background: #d4edda; padding: 0.5rem; border-radius: 6px; }
.token-row { display: flex; gap: 0.5rem; }
.token-row input { flex: 1; padding: 0.5rem; border: 1px solid #ced4da; border-radius: 6px; font-size: 0.95rem; }
.token-row button, .ok button { padding: 0.5rem 1rem; background: #2c7be5; color: #fff; border: none; border-radius: 6px; }
form { display: flex; flex-direction: column; gap: 1rem; max-width: 400px; }
form label { display: flex; flex-direction: column; gap: 0.25rem; font-size: 0.9rem; font-weight: 500; }
form input[type="text"], form input[type="number"], form input[type="date"] {
padding: 0.45rem 0.75rem; border: 1px solid #ced4da; border-radius: 6px; font-size: 0.95rem;
}
fieldset { border: 1px solid #dee2e6; border-radius: 6px; padding: 0.5rem 1rem; }
legend { font-weight: 500; font-size: 0.9rem; }
.days-row { display: flex; gap: 0.75rem; flex-wrap: wrap; margin-top: 0.25rem; }
.day-check { display: flex; align-items: center; gap: 0.3rem; font-weight: 400; cursor: pointer; }
form button[type="submit"] { align-self: flex-start; padding: 0.55rem 1.4rem; background: #2c7be5; color: #fff; border: none; border-radius: 6px; font-weight: 600; }
dl { display: grid; grid-template-columns: auto 1fr; gap: 0.25rem 1rem; font-size: 0.9rem; }
dt { font-weight: 500; color: #6c757d; }
table { width: 100%; border-collapse: collapse; font-size: 0.9rem; }
th { background: #f8f9fa; padding: 0.4rem 0.75rem; text-align: left; }
td { padding: 0.4rem 0.75rem; border-bottom: 1px solid #f1f3f5; }
</style>

View File

@@ -0,0 +1,234 @@
<script lang="ts">
import { onMount, onDestroy } from 'svelte';
import { entries, days, type Entry, type ClosedDay, ApiError } from '$lib/api/client';
import { todayKey, formatTime, formatDuration, formatDurationShort } from '$lib/utils';
let today = todayKey();
let entryList: Entry[] = $state([]);
let closedDay: ClosedDay | null = $state(null);
let running: Entry | null = $state(null);
let elapsed = $state(0);
let note = $state('');
let error = $state('');
let loading = $state(false);
let timer: ReturnType<typeof setInterval> | null = null;
async function load() {
loading = true;
error = '';
try {
const [es, ds] = await Promise.all([
entries.list(today, today),
days.list(today, today)
]);
entryList = es ?? [];
closedDay = (ds ?? []).find((d) => d.day_key === today) ?? null;
running = entryList.find((e) => e.end_time === null) ?? null;
startTimer();
} catch (e) {
error = e instanceof ApiError ? e.message : String(e);
} finally {
loading = false;
}
}
function startTimer() {
if (timer) clearInterval(timer);
if (running) {
timer = setInterval(() => {
elapsed = Date.now() - running!.start_time;
}, 500);
elapsed = Date.now() - running.start_time;
} else {
elapsed = 0;
}
}
async function handleStart() {
error = '';
try {
const e = await entries.start(note);
running = e;
entryList = [...entryList, e];
note = '';
startTimer();
} catch (e) {
error = e instanceof ApiError ? e.message : String(e);
}
}
async function handleStop() {
if (!running) return;
error = '';
try {
const e = await entries.stop(running.id);
entryList = entryList.map((x) => (x.id === e.id ? e : x));
running = null;
if (timer) clearInterval(timer);
elapsed = 0;
} catch (e) {
error = e instanceof ApiError ? e.message : String(e);
}
}
async function handleCloseDay() {
error = '';
try {
const cd = await days.close(today);
closedDay = cd;
} catch (e) {
error = e instanceof ApiError ? e.message : String(e);
}
}
async function handleMark(kind: 'holiday' | 'vacation' | 'sick') {
error = '';
try {
const cd = await days.mark(today, kind);
closedDay = cd;
} catch (e) {
error = e instanceof ApiError ? e.message : String(e);
}
}
async function handleReopenDay() {
error = '';
try {
await days.reopen(today);
closedDay = null;
} catch (e) {
error = e instanceof ApiError ? e.message : String(e);
}
}
async function handleDeleteEntry(id: string) {
error = '';
try {
await entries.delete(id);
entryList = entryList.filter((e) => e.id !== id);
} catch (e) {
error = e instanceof ApiError ? e.message : String(e);
}
}
const totalWorkedMs = $derived(
entryList.reduce((sum, e) => {
if (e.end_time === null) return sum;
return sum + (e.end_time - e.start_time);
}, 0)
);
onMount(load);
onDestroy(() => { if (timer) clearInterval(timer); });
</script>
<div class="today">
<h1>Today — {today}</h1>
{#if error}
<p class="error">{error}</p>
{/if}
{#if closedDay}
<div class="closed-banner" data-kind={closedDay.kind}>
<span>Day closed ({closedDay.kind}) — {formatDurationShort(closedDay.worked_ms)} worked</span>
<button onclick={handleReopenDay}>Reopen</button>
</div>
{:else}
<!-- Timer -->
<div class="timer-section">
{#if running}
<div class="timer running">{formatDuration(elapsed)}</div>
<button class="btn stop" onclick={handleStop}> Stop</button>
{:else}
<div class="timer idle">{formatDuration(totalWorkedMs)}</div>
<div class="start-row">
<input bind:value={note} placeholder="Note (optional)" onkeydown={(e) => e.key === 'Enter' && handleStart()} />
<button class="btn start" onclick={handleStart}> Start</button>
</div>
{/if}
</div>
<!-- Quick mark -->
<div class="quick-mark">
<span>Mark day as:</span>
<button onclick={() => handleMark('holiday')}>🏖 Holiday</button>
<button onclick={() => handleMark('vacation')}> Vacation</button>
<button onclick={() => handleMark('sick')}>🤒 Sick</button>
</div>
<!-- Close day -->
{#if entryList.length > 0 && !running}
<button class="btn close-day" onclick={handleCloseDay}>Close day</button>
{/if}
{/if}
<!-- Entry list -->
<section class="entries">
<h2>Entries</h2>
{#if entryList.length === 0}
<p class="empty">No entries yet today.</p>
{:else}
{#each entryList as entry (entry.id)}
<div class="entry" class:running={entry.end_time === null}>
<span class="time">{formatTime(entry.start_time)}</span>
<span class="dash"></span>
<span class="time">{entry.end_time ? formatTime(entry.end_time) : '…'}</span>
{#if entry.end_time}
<span class="dur">{formatDuration(entry.end_time - entry.start_time)}</span>
{/if}
{#if entry.note}
<span class="note">{entry.note}</span>
{/if}
{#if entry.auto_stopped}
<span class="badge auto">auto-stopped</span>
{/if}
{#if !closedDay}
<button class="del" onclick={() => handleDeleteEntry(entry.id)} title="Delete"></button>
{/if}
</div>
{/each}
<div class="total">Total: {formatDurationShort(totalWorkedMs)}</div>
{/if}
</section>
</div>
<style>
h1 { margin: 0 0 1rem; font-size: 1.4rem; }
h2 { font-size: 1rem; margin: 1.5rem 0 0.5rem; color: #495057; }
.error { color: #c0392b; background: #fde8e4; padding: 0.5rem; border-radius: 6px; }
.closed-banner { display: flex; align-items: center; justify-content: space-between;
padding: 0.75rem 1rem; border-radius: 8px; margin-bottom: 1rem; font-weight: 500; }
.closed-banner[data-kind="work"] { background: #d4edda; color: #155724; }
.closed-banner[data-kind="holiday"] { background: #fff3cd; color: #856404; }
.closed-banner[data-kind="vacation"] { background: #cce5ff; color: #004085; }
.closed-banner[data-kind="sick"] { background: #f8d7da; color: #721c24; }
.timer-section { text-align: center; padding: 2rem 0 1rem; }
.timer { font-size: 3.5rem; font-variant-numeric: tabular-nums; letter-spacing: 0.05em; font-weight: 300; }
.timer.running { color: #2c7be5; }
.timer.idle { color: #6c757d; }
.start-row { display: flex; gap: 0.5rem; justify-content: center; margin-top: 1rem; }
.start-row input { padding: 0.5rem 0.75rem; border: 1px solid #ced4da; border-radius: 6px; font-size: 0.95rem; width: 220px; }
.btn { padding: 0.55rem 1.4rem; border: none; border-radius: 6px; font-size: 1rem; font-weight: 600; transition: opacity 0.15s; }
.btn:hover { opacity: 0.88; }
.btn.start { background: #2c7be5; color: #fff; margin-top: 1rem; }
.btn.stop { background: #e74c3c; color: #fff; margin-top: 1rem; }
.btn.close-day { background: #343a40; color: #fff; margin-top: 1rem; display: block; }
.quick-mark { display: flex; gap: 0.5rem; align-items: center; margin-top: 1rem; flex-wrap: wrap; }
.quick-mark span { color: #6c757d; font-size: 0.875rem; }
.quick-mark button { padding: 0.35rem 0.75rem; border: 1px solid #ced4da; background: #fff; border-radius: 6px; font-size: 0.875rem; }
.quick-mark button:hover { background: #e9ecef; }
.entries { border-top: 1px solid #dee2e6; margin-top: 1.5rem; padding-top: 0.5rem; }
.entry { display: flex; align-items: center; gap: 0.5rem; padding: 0.5rem 0; border-bottom: 1px solid #f1f3f5; font-size: 0.9rem; }
.entry.running { background: #eaf3ff; border-radius: 6px; padding: 0.5rem 0.5rem; }
.time { font-variant-numeric: tabular-nums; color: #343a40; }
.dash { color: #adb5bd; }
.dur { font-variant-numeric: tabular-nums; color: #6c757d; margin-left: auto; }
.note { color: #495057; font-style: italic; }
.badge.auto { font-size: 0.75rem; background: #fff3cd; color: #856404; padding: 0.1rem 0.4rem; border-radius: 4px; }
.del { margin-left: 0.5rem; background: none; border: none; color: #adb5bd; font-size: 1rem; padding: 0 0.25rem; }
.del:hover { color: #e74c3c; }
.total { text-align: right; padding: 0.5rem 0; color: #495057; font-weight: 600; }
.empty { color: #6c757d; font-style: italic; }
</style>

View File

@@ -0,0 +1,171 @@
<script lang="ts">
import { onMount } from 'svelte';
import { days, weeks, settings, type ClosedDay, type ClosedWeek, type Settings, ApiError } from '$lib/api/client';
import {
currentWeekKey, weekDayKeys, formatDurationShort, formatDelta, todayKey, isWorkday
} from '$lib/utils';
let weekKey = $state(currentWeekKey());
let dayKeys = $derived(weekDayKeys(weekKey));
let closedDaysMap: Record<string, ClosedDay> = $state({});
let closedWeek: ClosedWeek | null = $state(null);
let currentSettings: Settings | null = $state(null);
let error = $state('');
async function load() {
error = '';
const from = dayKeys[0];
const to = dayKeys[6];
try {
const [ds, ws, s] = await Promise.all([
days.list(from, to),
weeks.list(weekKey, weekKey),
settings.current()
]);
closedDaysMap = Object.fromEntries((ds ?? []).map((d) => [d.day_key, d]));
closedWeek = (ws ?? []).find((w) => w.week_key === weekKey) ?? null;
currentSettings = s;
} catch (e) {
error = e instanceof ApiError ? e.message : String(e);
}
}
$effect(() => { weekKey; load(); });
async function handleCloseWeek() {
error = '';
try {
const cw = await weeks.close(weekKey);
closedWeek = cw;
} catch (e) {
error = e instanceof ApiError ? e.message : String(e);
}
}
async function handleReopenWeek() {
error = '';
try {
await weeks.reopen(weekKey);
closedWeek = null;
} catch (e) {
error = e instanceof ApiError ? e.message : String(e);
}
}
const DAY_NAMES = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'];
const totalWorkedMs = $derived(
dayKeys.reduce((sum, dk) => sum + (closedDaysMap[dk]?.worked_ms ?? 0), 0)
);
const expectedMs = $derived(
currentSettings ? currentSettings.hours_per_week * 3_600_000 : 0
);
const allWorkdaysClosed = $derived(
currentSettings
? dayKeys.every((dk) => !isWorkday(dk, currentSettings!.workdays_mask) || !!closedDaysMap[dk])
: false
);
function prevWeek() { weekKey = offsetWeek(weekKey, -1); }
function nextWeek() { weekKey = offsetWeek(weekKey, 1); }
function offsetWeek(wk: string, offset: number): string {
const [y, w] = wk.split('-W').map(Number);
const date = new Date(y, 0, 4);
date.setDate(date.getDate() + (w - 1) * 7 + offset * 7);
const thu = new Date(date);
thu.setDate(date.getDate() + 4 - (date.getDay() || 7));
const ys = new Date(thu.getFullYear(), 0, 1);
const weekNum = Math.ceil(((thu.getTime() - ys.getTime()) / 86400000 + 1) / 7);
return `${thu.getFullYear()}-W${String(weekNum).padStart(2, '0')}`;
}
</script>
<div class="week-view">
<div class="header">
<button onclick={prevWeek}></button>
<h1>Week {weekKey}</h1>
<button onclick={nextWeek}></button>
</div>
{#if error}<p class="error">{error}</p>{/if}
<div class="days-grid">
{#each dayKeys as dk, i (dk)}
{@const cd = closedDaysMap[dk]}
{@const isToday = dk === todayKey()}
{@const workday = currentSettings ? isWorkday(dk, currentSettings.workdays_mask) : i < 5}
<div class="day" class:today={isToday} class:weekend={!workday} class:closed={!!cd}>
<div class="day-header">
<span class="day-name">{DAY_NAMES[i]}</span>
<span class="day-date">{dk.slice(5)}</span>
</div>
{#if cd}
<div class="day-status" data-kind={cd.kind}>{cd.kind}</div>
<div class="day-worked">{formatDurationShort(cd.worked_ms)}</div>
{:else if workday}
<div class="day-status open">open</div>
{:else}
<div class="day-status weekend-label"></div>
{/if}
</div>
{/each}
</div>
<div class="summary">
<div class="row">
<span>Worked</span>
<strong>{formatDurationShort(totalWorkedMs)}</strong>
</div>
<div class="row">
<span>Expected</span>
<strong>{formatDurationShort(expectedMs)}</strong>
</div>
<div class="row delta" class:positive={totalWorkedMs >= expectedMs}>
<span>Delta</span>
<strong>{formatDelta(totalWorkedMs - expectedMs)}</strong>
</div>
</div>
{#if closedWeek}
<div class="week-closed">
<p>Week closed — overtime: <strong>{formatDelta(closedWeek.delta_ms)}</strong></p>
<button onclick={handleReopenWeek}>Reopen week</button>
</div>
{:else if allWorkdaysClosed}
<button class="btn close-week" onclick={handleCloseWeek}>Close week</button>
{:else}
<p class="hint">Close all workdays to close the week.</p>
{/if}
</div>
<style>
.week-view h1 { margin: 0; font-size: 1.2rem; }
.header { display: flex; align-items: center; gap: 1rem; margin-bottom: 1.5rem; }
.header button { background: none; border: 1px solid #dee2e6; border-radius: 6px; padding: 0.25rem 0.6rem; font-size: 1.1rem; }
.error { color: #c0392b; background: #fde8e4; padding: 0.5rem; border-radius: 6px; }
.days-grid { display: grid; grid-template-columns: repeat(7, 1fr); gap: 0.5rem; margin-bottom: 1.5rem; }
.day { border: 1px solid #dee2e6; border-radius: 8px; padding: 0.5rem; background: #fff; text-align: center; }
.day.today { border-color: #2c7be5; box-shadow: 0 0 0 2px #cce5ff; }
.day.weekend { background: #f8f9fa; color: #adb5bd; }
.day.closed { background: #f0f9f0; }
.day-header { display: flex; flex-direction: column; font-size: 0.75rem; }
.day-name { font-weight: 600; }
.day-date { color: #6c757d; }
.day-status { font-size: 0.7rem; margin-top: 0.3rem; padding: 0.1rem 0.3rem; border-radius: 4px; }
.day-status[data-kind="work"] { background: #d4edda; color: #155724; }
.day-status[data-kind="holiday"] { background: #fff3cd; color: #856404; }
.day-status[data-kind="vacation"] { background: #cce5ff; color: #004085; }
.day-status[data-kind="sick"] { background: #f8d7da; color: #721c24; }
.day-status.open { background: #f8f9fa; color: #6c757d; }
.day-status.weekend-label { color: #adb5bd; }
.day-worked { font-size: 0.8rem; font-variant-numeric: tabular-nums; margin-top: 0.25rem; font-weight: 600; }
.summary { background: #fff; border: 1px solid #dee2e6; border-radius: 8px; padding: 1rem; margin-bottom: 1rem; }
.row { display: flex; justify-content: space-between; padding: 0.25rem 0; }
.delta strong { color: #c0392b; }
.delta.positive strong { color: #27ae60; }
.btn.close-week { background: #343a40; color: #fff; border: none; padding: 0.55rem 1.4rem; border-radius: 6px; font-size: 1rem; font-weight: 600; }
.week-closed { background: #d4edda; border-radius: 8px; padding: 0.75rem 1rem; display: flex; align-items: center; justify-content: space-between; }
.week-closed button { background: none; border: 1px solid #155724; color: #155724; border-radius: 6px; padding: 0.3rem 0.6rem; font-size: 0.875rem; }
.hint { color: #6c757d; font-size: 0.875rem; font-style: italic; }
</style>

3
web/static/robots.txt Normal file
View File

@@ -0,0 +1,3 @@
# allow crawling everything by default
User-agent: *
Disallow:

18
web/svelte.config.js Normal file
View File

@@ -0,0 +1,18 @@
import adapter from '@sveltejs/adapter-static';
/** @type {import('@sveltejs/kit').Config} */
const config = {
compilerOptions: {
runes: ({ filename }) => (filename.split(/[/\\]/).includes('node_modules') ? undefined : true)
},
kit: {
adapter: adapter({
pages: 'build',
assets: 'build',
fallback: 'index.html', // SPA fallback for client-side routing
precompress: false
})
}
};
export default config;

20
web/tsconfig.json Normal file
View File

@@ -0,0 +1,20 @@
{
"extends": "./.svelte-kit/tsconfig.json",
"compilerOptions": {
"rewriteRelativeImportExtensions": true,
"allowJs": true,
"checkJs": true,
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"skipLibCheck": true,
"sourceMap": true,
"strict": true,
"moduleResolution": "bundler"
}
// Path aliases are handled by https://svelte.dev/docs/kit/configuration#alias
// except $lib which is handled by https://svelte.dev/docs/kit/configuration#files
//
// To make changes to top-level options such as include and exclude, we recommend extending
// the generated config; see https://svelte.dev/docs/kit/configuration#typescript
}

12
web/vite.config.ts Normal file
View File

@@ -0,0 +1,12 @@
import { sveltekit } from '@sveltejs/kit/vite';
import { defineConfig } from 'vite';
export default defineConfig({
plugins: [sveltekit()],
server: {
proxy: {
'/api': 'http://localhost:8080',
'/healthz': 'http://localhost:8080'
}
}
});