feat(m4): SvelteKit frontend - today, week, history, settings views
This commit is contained in:
3
.gitignore
vendored
3
.gitignore
vendored
@@ -1 +1,4 @@
|
|||||||
wotra.db
|
wotra.db
|
||||||
|
web/build/
|
||||||
|
web/.svelte-kit/
|
||||||
|
web/node_modules/
|
||||||
|
|||||||
9
cmd/wotra/embed_dev.go
Normal file
9
cmd/wotra/embed_dev.go
Normal 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
20
cmd/wotra/embed_prod.go
Normal 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
|
||||||
|
}()
|
||||||
@@ -2,6 +2,7 @@ package main
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"io/fs"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
@@ -53,7 +54,13 @@ func main() {
|
|||||||
defer cancel()
|
defer cancel()
|
||||||
go runMidnightGuard(ctx, entrySvc)
|
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{
|
srv := &http.Server{
|
||||||
Addr: ":" + cfg.Port,
|
Addr: ":" + cfg.Port,
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package handler
|
package handler
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"io/fs"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
|
||||||
"github.com/go-chi/chi/v5"
|
"github.com/go-chi/chi/v5"
|
||||||
@@ -9,7 +10,14 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
// NewRouter builds the full HTTP router.
|
// 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 := chi.NewRouter()
|
||||||
r.Use(middleware.RequestID)
|
r.Use(middleware.RequestID)
|
||||||
r.Use(middleware.RealIP)
|
r.Use(middleware.RealIP)
|
||||||
@@ -38,5 +46,23 @@ func NewRouter(authToken string, entrySvc *service.EntryService, daySvc *service
|
|||||||
weekH.Routes(r)
|
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
|
return r
|
||||||
}
|
}
|
||||||
|
|||||||
23
web/.gitignore
vendored
Normal file
23
web/.gitignore
vendored
Normal 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
1
web/.npmrc
Normal file
@@ -0,0 +1 @@
|
|||||||
|
engine-strict=true
|
||||||
3
web/.vscode/extensions.json
vendored
Normal file
3
web/.vscode/extensions.json
vendored
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
{
|
||||||
|
"recommendations": ["svelte.svelte-vscode"]
|
||||||
|
}
|
||||||
42
web/README.md
Normal file
42
web/README.md
Normal 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
1308
web/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
26
web/package.json
Normal file
26
web/package.json
Normal 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
13
web/src/app.d.ts
vendored
Normal 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
12
web/src/app.html
Normal 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
144
web/src/lib/api/client.ts
Normal 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);
|
||||||
1
web/src/lib/assets/favicon.svg
Normal file
1
web/src/lib/assets/favicon.svg
Normal 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
1
web/src/lib/index.ts
Normal 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
88
web/src/lib/utils.ts
Normal 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;
|
||||||
|
}
|
||||||
78
web/src/routes/+layout.svelte
Normal file
78
web/src/routes/+layout.svelte
Normal 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>
|
||||||
3
web/src/routes/+layout.ts
Normal file
3
web/src/routes/+layout.ts
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
// +layout.ts — disable SSR for SPA mode
|
||||||
|
export const ssr = false;
|
||||||
|
export const prerender = false;
|
||||||
5
web/src/routes/+page.svelte
Normal file
5
web/src/routes/+page.svelte
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { goto } from '$app/navigation';
|
||||||
|
import { onMount } from 'svelte';
|
||||||
|
onMount(() => goto('/today'));
|
||||||
|
</script>
|
||||||
116
web/src/routes/history/+page.svelte
Normal file
116
web/src/routes/history/+page.svelte
Normal 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>
|
||||||
188
web/src/routes/settings/+page.svelte
Normal file
188
web/src/routes/settings/+page.svelte
Normal 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>
|
||||||
234
web/src/routes/today/+page.svelte
Normal file
234
web/src/routes/today/+page.svelte
Normal 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>
|
||||||
171
web/src/routes/week/+page.svelte
Normal file
171
web/src/routes/week/+page.svelte
Normal 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
3
web/static/robots.txt
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
# allow crawling everything by default
|
||||||
|
User-agent: *
|
||||||
|
Disallow:
|
||||||
18
web/svelte.config.js
Normal file
18
web/svelte.config.js
Normal 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
20
web/tsconfig.json
Normal 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
12
web/vite.config.ts
Normal 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'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user