Initial commit

This commit is contained in:
Aaro Koivukangas
2026-10-11 03:26:15 +03:00
commit b6af96cf6d
49 changed files with 9627 additions and 0 deletions
+15
View File
@@ -0,0 +1,15 @@
node_modules
dist
data.json
data/
data.json.bak
*.tmp
.git
k8s
timetrials
Dockerfile
.dockerignore
e2e/.data
test-results
playwright-report
e2e
+12
View File
@@ -0,0 +1,12 @@
node_modules
dist
data.json
data.json.migrated
data/
data.json.bak
*.tmp
.env
.env.*
e2e/.data
test-results
playwright-report
+6
View File
@@ -0,0 +1,6 @@
{
"$schema": "./node_modules/oxfmt/configuration_schema.json",
"printWidth": 120,
"singleQuote": true,
"ignorePatterns": ["dist", "playwright-report", "test-results", "e2e/.data", "package-lock.json", "timetrials"]
}
+26
View File
@@ -0,0 +1,26 @@
{
"$schema": "./node_modules/oxlint/configuration_schema.json",
"plugins": ["typescript", "react", "import", "vitest"],
"categories": {
"correctness": "error",
"suspicious": "warn"
},
"ignorePatterns": ["dist", "playwright-report", "test-results", "e2e/.data"],
"overrides": [
{
"files": ["e2e/**"],
"rules": {
"no-empty-pattern": "off"
}
}
],
"rules": {
"react/react-in-jsx-scope": "off",
"import/no-unassigned-import": [
"warn",
{
"allow": ["**/*.css", "@testing-library/jest-dom/vitest"]
}
]
}
}
+23
View File
@@ -0,0 +1,23 @@
# ---- build: type-check and bundle the React frontend ----
FROM node:22-alpine AS build
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
RUN npm test && npm run build
# ---- runtime: Express server + built frontend ----
FROM node:22-alpine
WORKDIR /app
ENV NODE_ENV=production
COPY package.json package-lock.json ./
RUN npm ci --omit=dev && npm cache clean --force
COPY --from=build /app/dist ./dist
COPY backend ./backend
COPY shared ./shared
RUN mkdir -p /app/data && chown node:node /app/data
USER node
VOLUME /app/data
EXPOSE 3001
# Run node directly (not via npm) so SIGTERM reaches the server.
CMD ["node", "--import", "tsx", "backend/server.ts"]
+97
View File
@@ -0,0 +1,97 @@
# workh — working hours tracker
Log your daily hours, see how much you are over/under your weekly target,
and keep a running balance of extra/minus hours.
- Enter each day as a **total** (`7.5`, `7:15`, `7h15`; 0–24 h) — the default — or as
**start–end + break**, or mark it **off** (vacation, sick, holiday → no target).
- Full-time target defaults to **36 h 15 min/week** (7:15 per weekday).
- **Workload periods** (Settings) scale the target from a given date, e.g. 50% or 25%.
- Anyone can **create an account** and **delete it** again under _Account_. Each
account sees only its own hours.
- Several tabs or devices can be open at once: a save never overwrites newer changes
made elsewhere — edits are merged day by day.
## Project layout
| Path | |
| ----------- | ----------------------------------- |
| `frontend/` | React app (Vite) |
| `backend/` | Express API, accounts, file storage |
| `shared/` | Types used by both |
| `tests/` | Unit and integration tests (Vitest) |
| `e2e/` | End-to-end tests (Playwright) |
| `k8s/` | Kubernetes manifests |
## Configuration
| Variable | Default | |
| ------------------- | -------- | ------------------------------------------------------------------------------------------ |
| `PORT` | `3001` | |
| `DATA_DIR` | `./data` | `users.json` + `users/<id>.json` |
| `SESSION_SECRET` | random | Signs login cookies; without it everyone is logged out on restart. `npm run -s gen-secret` |
| `REGISTRATION_CODE` | — | If set, sign-up requires this invite code |
Everything else is fixed in `backend/config.ts`: at most 100 accounts, 5 sign-ups per
hour per IP, 10 failed logins per 15 min per IP, 30-day sessions. Client IPs are taken
from `X-Forwarded-For` only when the connection comes from a private network (the
ingress proxy).
Session cookies are always `Secure`; browsers accept that on `http://localhost`, but
anywhere else the app must be served over HTTPS.
## Run locally
```sh
npm install
npm run dev # http://localhost:5173 (Vite + API on :3001) — create an account there
```
`npm run dev` loads `.env` if present (e.g. `npm run -s gen-secret > .env` to stay
logged in across restarts). If you used the single-user version before, the first
account created takes over the hours in `data.json`.
## Checks
```sh
npm run lint # oxlint
npm run format:check # oxfmt (npm run format to fix)
npm run typecheck
npm test # unit + API integration tests
npx playwright install chromium # once
npm run test:e2e # builds, starts two test servers, runs Playwright
```
## CI/CD
`.github/workflows/ci.yml` runs lint, format, type, unit and e2e checks on every push and
pull request. When they pass it builds the Docker image; pushes to `main` and `v*` tags
also publish it to `ghcr.io/<owner>/<repo>` (tags: `latest`, branch, version, commit SHA).
GitHub packages are private by default: make the package public, or give the cluster an
image pull secret for ghcr.io.
## Docker
```sh
docker build -t workh . # also runs the unit tests
npm run -s gen-secret > .env.docker
docker run -p 3001:3001 --env-file .env.docker -v workh-data:/app/data workh # http://localhost:3001
```
## Kubernetes
Manifests are in `k8s/`: Deployment (1 replica, PVC-backed), Service, and a Traefik
Ingress at `workh.pumppi.dev` with the shared fail2ban middleware.
1. Set `image:` in `k8s/deployment.yaml` to the image CI published.
2. Create the sealed secret (session secret, plus an optional invite code):
```sh
npm run -s gen-secret > .env.secret
echo "REGISTRATION_CODE=<invite code>" >> .env.secret # optional
kubectl create secret generic workh-secrets -n default \
--from-env-file=.env.secret --dry-run=client -o yaml \
| kubeseal --format yaml > k8s/sealed-secret.yaml
rm .env.secret
```
3. Apply: `kubectl apply -f k8s/`
+312
View File
@@ -0,0 +1,312 @@
import express from 'express';
import fs from 'node:fs';
import { isData, type Data } from '../shared/types';
import { createSessionToken, hashPassword, RateLimiter, verifyPassword, verifySessionToken } from './auth';
import * as config from './config';
import { UserStore, type User } from './store';
const COOKIE = 'workh_session';
const HOUR_MS = 3_600_000;
export const USERNAME_RULE = /^[a-z0-9][a-z0-9._-]{2,31}$/;
export const MIN_PASSWORD = 12;
const MAX_PASSWORD = 1024;
function readCookie(header: string | undefined, name: string): string | undefined {
for (const part of header?.split(';') ?? []) {
const i = part.indexOf('=');
if (i > 0 && part.slice(0, i).trim() === name) return part.slice(i + 1).trim();
}
return undefined;
}
type AuthedRequest = express.Request & { user: User };
/** Defaults come from config.ts; tests override individual values. */
export interface AppOptions {
dataDir: string;
distDir: string;
legacyDataFile: string;
sessionSecret: string;
sessionTtlMs: number;
registrationCode: string;
maxUsers: number;
registrationsPerIpPerHour: number;
loginMaxFailures: number;
loginWindowMs: number;
}
export function createApp(overrides: Partial<AppOptions> = {}) {
const o: AppOptions = {
dataDir: config.DATA_DIR,
distDir: config.DIST_DIR,
legacyDataFile: config.LEGACY_DATA_FILE,
sessionSecret: config.SESSION_SECRET,
sessionTtlMs: config.SESSION_TTL_MS,
registrationCode: config.REGISTRATION_CODE,
maxUsers: config.MAX_USERS,
registrationsPerIpPerHour: config.REGISTRATIONS_PER_IP_PER_HOUR,
loginMaxFailures: config.LOGIN_MAX_FAILURES,
loginWindowMs: config.LOGIN_WINDOW_MS,
...overrides,
};
const store = new UserStore(o.dataDir);
const app = express();
app.disable('x-powered-by');
app.set('trust proxy', config.TRUST_PROXY);
const loginLimiter = new RateLimiter(o.loginMaxFailures, o.loginWindowMs);
const signupLimiter = new RateLimiter(o.registrationsPerIpPerHour, HOUR_MS);
// Logins for unknown usernames still run scrypt against this, so they take as long as real ones.
const dummyHash = hashPassword('dummy password for timing');
app.use((_req, res, next) => {
res.set({
'Content-Security-Policy':
"default-src 'self'; script-src 'self'; style-src 'self'; img-src 'self' data:; connect-src 'self'; " +
"frame-ancestors 'none'; base-uri 'self'; form-action 'self'; object-src 'none'",
'X-Content-Type-Options': 'nosniff',
'X-Frame-Options': 'DENY',
'Referrer-Policy': 'no-referrer',
'Cross-Origin-Opener-Policy': 'same-origin',
'Strict-Transport-Security': 'max-age=31536000',
});
next();
});
app.use(express.json({ limit: '1mb' }));
// State-changing API calls must send JSON. Together with the SameSite=Strict cookie
// this stops cross-site form posts (CSRF): browsers can't send JSON cross-origin
// without a CORS preflight, which this server never approves.
app.use('/api', (req, res, next) => {
if (req.method !== 'GET' && req.method !== 'HEAD' && !req.is('application/json')) {
res.status(415).json({ error: 'Content-Type must be application/json' });
return;
}
next();
});
app.get('/api/health', (_req, res) => {
res.json({ ok: true });
});
const cookieOptions: express.CookieOptions = {
httpOnly: true,
sameSite: 'strict',
secure: true, // browsers also accept Secure cookies on http://localhost
path: '/',
};
const startSession = (res: express.Response, user: User) => {
const token = createSessionToken(user.id, o.sessionSecret, o.sessionTtlMs);
res.cookie(COOKIE, token, { ...cookieOptions, maxAge: o.sessionTtlMs });
};
/** The logged-in user, or undefined. A deleted account's cookie no longer matches anyone. */
const currentUser = (req: express.Request): User | undefined => {
const token = readCookie(req.headers.cookie, COOKIE);
const id = token ? verifySessionToken(token, o.sessionSecret) : null;
return id ? store.findById(id) : undefined;
};
const credentials = (body: unknown) => {
const { username, password } = (body ?? {}) as { username?: unknown; password?: unknown };
if (typeof username !== 'string' || typeof password !== 'string' || password.length > MAX_PASSWORD) return null;
return { username: username.trim().toLowerCase(), password };
};
const tooMany = (res: express.Response, limiter: RateLimiter, key: string, what: string) => {
const wait = limiter.retryAfter(key);
if (wait === 0) return false;
res
.set('Retry-After', String(wait))
.status(429)
.json({ error: `Too many ${what}. Try again later.` });
return true;
};
// ---------- Registration ----------
app.get('/api/registration', (_req, res) => {
res.json({ open: store.count() < o.maxUsers, inviteRequired: o.registrationCode !== '' });
});
app.post('/api/register', async (req, res) => {
const ip = req.ip ?? 'unknown';
if (tooMany(res, signupLimiter, ip, 'new accounts from your address')) return;
const creds = credentials(req.body);
if (!creds) {
res.status(400).json({ error: 'username and password are required' });
return;
}
if (!USERNAME_RULE.test(creds.username)) {
res.status(400).json({
error: 'Username must be 3–32 characters: letters, numbers, dots, dashes or underscores',
});
return;
}
if (creds.password.length < MIN_PASSWORD) {
res.status(400).json({ error: `Password must be at least ${MIN_PASSWORD} characters` });
return;
}
const { inviteCode } = (req.body ?? {}) as { inviteCode?: unknown };
if (o.registrationCode !== '' && inviteCode !== o.registrationCode) {
signupLimiter.hit(ip); // guessing invite codes counts against the sign-up limit
res.status(403).json({ error: 'Invalid invite code' });
return;
}
if (store.count() >= o.maxUsers) {
res.status(403).json({ error: 'No more accounts can be created' });
return;
}
if (store.findByName(creds.username)) {
res.status(409).json({ error: 'That username is taken' });
return;
}
const passwordHash = await hashPassword(creds.password);
if (store.count() >= o.maxUsers) {
res.status(403).json({ error: 'No more accounts can be created' }); // filled up while hashing
return;
}
// The very first account inherits hours logged before accounts existed.
const legacy = store.count() === 0 ? readLegacyData(o.legacyDataFile) : undefined;
const user = store.create(creds.username, passwordHash, legacy);
if (!user) {
res.status(409).json({ error: 'That username is taken' }); // lost a race with a parallel sign-up
return;
}
if (legacy) {
fs.renameSync(o.legacyDataFile, `${o.legacyDataFile}.migrated`);
console.log(`workh: moved ${o.legacyDataFile} into the new account "${user.username}"`);
}
signupLimiter.hit(ip);
startSession(res, user);
res.status(201).json({ username: user.username });
});
// ---------- Sessions ----------
app.post('/api/login', async (req, res) => {
const ip = req.ip ?? 'unknown';
if (tooMany(res, loginLimiter, ip, 'failed logins')) return;
const creds = credentials(req.body);
if (!creds) {
res.status(400).json({ error: 'username and password are required' });
return;
}
const user = store.findByName(creds.username);
const ok = await verifyPassword(creds.password, user?.passwordHash ?? (await dummyHash));
if (!user || !ok) {
loginLimiter.hit(ip);
res.status(401).json({ error: 'Wrong username or password' });
return;
}
loginLimiter.reset(ip);
startSession(res, user);
res.json({ username: user.username });
});
app.post('/api/logout', (_req, res) => {
res.clearCookie(COOKIE, cookieOptions);
res.status(204).end();
});
// Everything below requires a logged-in user.
const requireAuth: express.RequestHandler = (req, res, next) => {
const user = currentUser(req);
if (!user) {
res.status(401).json({ error: 'Not logged in' });
return;
}
(req as AuthedRequest).user = user;
next();
};
app.get('/api/session', requireAuth, (req, res) => {
res.json({ username: (req as AuthedRequest).user.username });
});
app.delete('/api/account', requireAuth, async (req, res) => {
const { user } = req as AuthedRequest;
const ip = req.ip ?? 'unknown';
if (tooMany(res, loginLimiter, ip, 'failed attempts')) return;
const { password } = (req.body ?? {}) as { password?: unknown };
if (typeof password !== 'string' || !(await verifyPassword(password, user.passwordHash))) {
loginLimiter.hit(ip);
res.status(403).json({ error: 'Wrong password' });
return;
}
store.delete(user.id);
console.log(`workh: deleted account "${user.username}"`);
res.clearCookie(COOKIE, cookieOptions);
res.status(204).end();
});
// ---------- Data ----------
// The data version travels as an ETag. Saves must send it back in If-Match; a stale
// version gets 412 with the current data so the client can merge and retry.
app.get('/api/data', requireAuth, (req, res) => {
res.set('Cache-Control', 'no-store');
try {
const { data, rev } = store.readData((req as AuthedRequest).user.id);
res.set('ETag', `"${rev}"`).json(data);
} catch (err) {
console.error('Could not read data file:', err);
res.status(500).json({ error: 'Could not read data file' });
}
});
app.put('/api/data', requireAuth, (req, res) => {
const ifMatch = req.get('If-Match')?.replace(/^"|"$/g, '');
if (!ifMatch) {
res.status(428).json({ error: 'If-Match header with the data version is required' });
return;
}
if (!isData(req.body)) {
res.status(400).json({ error: 'Invalid data shape' });
return;
}
const result = store.writeData((req as AuthedRequest).user.id, req.body, ifMatch);
if (!result.ok) {
res.status(412).set('ETag', `"${result.current.rev}"`).json(result.current.data);
return;
}
res.set('ETag', `"${result.rev}"`).json({ ok: true });
});
app.use('/api', (_req, res) => {
res.status(404).json({ error: 'Not found' });
});
// Malformed / oversized JSON bodies end up here; answer with JSON instead of an HTML error page.
app.use((err: unknown, _req: express.Request, res: express.Response, next: express.NextFunction) => {
if (err instanceof SyntaxError) {
res.status(400).json({ error: 'Malformed JSON' });
return;
}
if (typeof err === 'object' && err !== null && 'status' in err && err.status === 413) {
res.status(413).json({ error: 'Request too large' });
return;
}
next(err);
});
if (fs.existsSync(o.distDir)) app.use(express.static(o.distDir));
return app;
}
function readLegacyData(file: string): Data | undefined {
if (!fs.existsSync(file)) return undefined;
try {
const data: unknown = JSON.parse(fs.readFileSync(file, 'utf8'));
return isData(data) ? data : undefined;
} catch {
return undefined;
}
}
+116
View File
@@ -0,0 +1,116 @@
import { createHmac, randomBytes, scrypt as scryptCb, timingSafeEqual, type ScryptOptions } from 'node:crypto';
// ---------- Password hashing (scrypt) ----------
// Stored format: scrypt$N$r$p$<salt base64>$<hash base64>
const SCRYPT = { N: 2 ** 15, r: 8, p: 1, keylen: 64 };
// 128 * N * r = 32 MiB per hash; give scrypt a little headroom above that.
const MAXMEM = 64 * 1024 * 1024;
function scrypt(password: string, salt: Buffer, keylen: number, opts: ScryptOptions): Promise<Buffer> {
return new Promise((resolve, reject) =>
scryptCb(password, salt, keylen, opts, (err, key) => (err ? reject(err) : resolve(key))),
);
}
export async function hashPassword(password: string): Promise<string> {
const salt = randomBytes(16);
const { N, r, p, keylen } = SCRYPT;
const hash = await scrypt(password, salt, keylen, { N, r, p, maxmem: MAXMEM });
return ['scrypt', N, r, p, salt.toString('base64'), hash.toString('base64')].join('$');
}
export function isPasswordHash(stored: string): boolean {
return /^scrypt\$\d+\$\d+\$\d+\$[A-Za-z0-9+/=]+\$[A-Za-z0-9+/=]+$/.test(stored);
}
export async function verifyPassword(password: string, stored: string): Promise<boolean> {
if (!isPasswordHash(stored)) return false;
const [, n, r, p, saltB64, hashB64] = stored.split('$');
const expected = Buffer.from(hashB64, 'base64');
const actual = await scrypt(password, Buffer.from(saltB64, 'base64'), expected.length, {
N: Number(n),
r: Number(r),
p: Number(p),
maxmem: MAXMEM,
});
return timingSafeEqual(actual, expected);
}
// ---------- Session tokens ----------
// Stateless: base64url(JSON {u, exp}) + "." + base64url(HMAC-SHA256). Rotating
// SESSION_SECRET invalidates every session.
interface SessionPayload {
u: string;
exp: number;
}
const sign = (payload: string, secret: string) => createHmac('sha256', secret).update(payload).digest('base64url');
export function createSessionToken(userId: string, secret: string, ttlMs: number, now = Date.now()): string {
const payload = Buffer.from(JSON.stringify({ u: userId, exp: now + ttlMs } satisfies SessionPayload)).toString(
'base64url',
);
return `${payload}.${sign(payload, secret)}`;
}
/** Returns the user id for a valid, unexpired token, otherwise null. */
export function verifySessionToken(token: string, secret: string, now = Date.now()): string | null {
const parts = token.split('.');
if (parts.length !== 2) return null;
const [payload, sig] = parts;
const expected = Buffer.from(sign(payload, secret));
const actual = Buffer.from(sig);
if (actual.length !== expected.length || !timingSafeEqual(actual, expected)) return null;
try {
const data = JSON.parse(Buffer.from(payload, 'base64url').toString('utf8')) as Partial<SessionPayload>;
if (typeof data.u !== 'string' || typeof data.exp !== 'number' || data.exp <= now) return null;
return data.u;
} catch {
return null;
}
}
// ---------- Rate limiting ----------
/**
* Counts events (failed logins, sign-ups) per key (client IP) in a sliding window. After
* `maxHits` events the key is blocked until its oldest event leaves the window.
*/
export class RateLimiter {
private hits = new Map<string, number[]>();
constructor(
private maxHits: number,
private windowMs: number,
private now: () => number = Date.now,
) {}
private recent(key: string): number[] {
const cutoff = this.now() - this.windowMs;
const list = (this.hits.get(key) ?? []).filter((t) => t > cutoff);
if (list.length) this.hits.set(key, list);
else this.hits.delete(key);
return list;
}
/** Seconds until the key may try again; 0 if it is not blocked. */
retryAfter(key: string): number {
const list = this.recent(key);
if (list.length < this.maxHits) return 0;
return Math.max(1, Math.ceil((list[0] + this.windowMs - this.now()) / 1000));
}
hit(key: string): void {
this.hits.set(key, [...this.recent(key), this.now()]);
// Keep memory bounded if someone sprays many source IPs.
if (this.hits.size > 10_000) {
for (const k of this.hits.keys()) this.recent(k);
}
}
reset(key: string): void {
this.hits.delete(key);
}
}
+28
View File
@@ -0,0 +1,28 @@
import { randomBytes } from 'node:crypto';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
export const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
// ---------- From the environment ----------
export const PORT = Number(process.env.PORT ?? 3001);
/** Holds users.json and users/<id>.json. */
export const DATA_DIR = process.env.DATA_DIR ?? path.join(ROOT, 'data');
/** Signs session cookies. Without one, a random secret is used and everyone is logged out on restart. */
export const SESSION_SECRET = process.env.SESSION_SECRET ?? randomBytes(32).toString('base64url');
/** If set, creating an account requires this invite code. */
export const REGISTRATION_CODE = process.env.REGISTRATION_CODE ?? '';
// ---------- Fixed ----------
export const DIST_DIR = path.join(ROOT, 'dist');
/** Pre-accounts single-user data; adopted by the first account created, if present. */
export const LEGACY_DATA_FILE = path.join(ROOT, 'data.json');
export const SESSION_TTL_MS = 30 * 86_400_000;
export const MAX_USERS = 100;
export const REGISTRATIONS_PER_IP_PER_HOUR = 5;
export const LOGIN_MAX_FAILURES = 10;
export const LOGIN_WINDOW_MS = 15 * 60_000;
/** Trust X-Forwarded-For only from private-network peers, i.e. the ingress proxy. */
export const TRUST_PROXY = 'loopback, linklocal, uniquelocal';
+9
View File
@@ -0,0 +1,9 @@
import { createApp } from './app';
import { DATA_DIR, PORT } from './config';
const server = createApp().listen(PORT, () => {
console.log(`workh listening on http://localhost:${PORT} (data: ${DATA_DIR})`);
});
// Exit promptly when Kubernetes / Docker stops the container.
process.on('SIGTERM', () => server.close(() => process.exit(0)));
+118
View File
@@ -0,0 +1,118 @@
import { createHash, randomUUID } from 'node:crypto';
import fs from 'node:fs';
import path from 'node:path';
import { defaultData, type Data } from '../shared/types';
export interface User {
id: string;
/** Stored lowercase; usernames are case-insensitive. */
username: string;
passwordHash: string;
createdAt: string;
}
interface UsersFile {
users: User[];
}
/** Version tag of a user's stored data; changes whenever the file's content does. */
const revOf = (content: string | null) =>
content === null ? 'none' : createHash('sha256').update(content).digest('base64url').slice(0, 22);
export type WriteResult = { ok: true; rev: string } | { ok: false; current: { data: Data; rev: string } };
/** Writes via a temp file + rename so a crash never leaves a half-written file. */
function writeAtomic(file: string, content: string) {
const tmp = `${file}.tmp`;
fs.writeFileSync(tmp, content);
fs.renameSync(tmp, file);
}
/**
* Users live in <dataDir>/users.json; each user's hours in <dataDir>/users/<id>.json.
* All access goes through this one process, and writes are synchronous, so the
* in-memory copy of users.json is always the truth.
*/
export class UserStore {
private users: User[];
private readonly usersFile: string;
private readonly userDir: string;
constructor(dataDir: string) {
this.usersFile = path.join(dataDir, 'users.json');
this.userDir = path.join(dataDir, 'users');
fs.mkdirSync(this.userDir, { recursive: true });
this.users = fs.existsSync(this.usersFile)
? (JSON.parse(fs.readFileSync(this.usersFile, 'utf8')) as UsersFile).users
: [];
}
private persist() {
writeAtomic(this.usersFile, JSON.stringify({ users: this.users } satisfies UsersFile, null, 2) + '\n');
}
private dataFile(id: string) {
return path.join(this.userDir, `${id}.json`);
}
count(): number {
return this.users.length;
}
findById(id: string): User | undefined {
return this.users.find((u) => u.id === id);
}
findByName(username: string): User | undefined {
const name = username.toLowerCase();
return this.users.find((u) => u.username === name);
}
/** Returns null if the username is already taken. */
create(username: string, passwordHash: string, initialData?: Data): User | null {
if (this.findByName(username)) return null;
const user: User = {
id: randomUUID(),
username: username.toLowerCase(),
passwordHash,
createdAt: new Date().toISOString(),
};
if (initialData) this.writeData(user.id, initialData);
this.users.push(user);
this.persist();
return user;
}
/** Removes the user and every file holding their hours. */
delete(id: string): void {
this.users = this.users.filter((u) => u.id !== id);
this.persist();
for (const suffix of ['', '.bak', '.tmp']) fs.rmSync(`${this.dataFile(id)}${suffix}`, { force: true });
}
private readRaw(id: string): string | null {
const file = this.dataFile(id);
return fs.existsSync(file) ? fs.readFileSync(file, 'utf8') : null;
}
readData(id: string): { data: Data; rev: string } {
const raw = this.readRaw(id);
return { data: raw === null ? defaultData() : (JSON.parse(raw) as Data), rev: revOf(raw) };
}
/**
* Saves only if the stored data is still at `ifRev` — the version the client started
* from — so an outdated tab or device can't overwrite newer changes. Omit `ifRev` to
* write unconditionally (new accounts).
*/
writeData(id: string, data: Data, ifRev?: string): WriteResult {
if (ifRev !== undefined && revOf(this.readRaw(id)) !== ifRev) {
return { ok: false, current: this.readData(id) };
}
const file = this.dataFile(id);
const content = JSON.stringify(data, null, 2) + '\n';
if (fs.existsSync(file)) fs.copyFileSync(file, `${file}.bak`);
writeAtomic(file, content);
return { ok: true, rev: revOf(content) };
}
}
+100
View File
@@ -0,0 +1,100 @@
import type { Page } from '@playwright/test';
import {
baseData,
dayInput,
expect,
fetchData,
MONDAY,
newClient,
registerViaApi,
saved,
seed,
test,
} from './fixtures';
async function openDelete(page: Page) {
await page.goto('/');
await page.getByText('Account', { exact: true }).click();
await page.getByRole('button', { name: 'Delete account…' }).click();
}
test.describe('deleting an account', () => {
test('cancel leaves everything in place', async ({ page, user }) => {
await openDelete(page);
await page.getByRole('button', { name: 'Cancel' }).click();
await expect(page.getByRole('button', { name: 'Delete account…' })).toBeVisible();
expect((await page.request.get('/api/session')).status()).toBe(200);
void user;
});
test('the delete button stays disabled until a password is typed', async ({ page, user }) => {
await openDelete(page);
await expect(page.getByRole('button', { name: 'Delete my account' })).toBeDisabled();
void user;
});
test('a wrong password deletes nothing', async ({ page, user }) => {
await openDelete(page);
await page.getByLabel('Enter your password to confirm').fill('wrong password');
await page.getByRole('button', { name: 'Delete my account' }).click();
await expect(page.getByRole('alert')).toHaveText('Wrong password');
expect((await page.request.get('/api/session')).status()).toBe(200);
void user;
});
test('deletes the login and the hours; the name can be reused from scratch', async ({ page, user }) => {
await seed(page.request, baseData({ entries: { [MONDAY]: { mode: 'total', minutes: 435 } } }));
await openDelete(page);
await page.getByLabel('Enter your password to confirm').fill(user.password);
await page.getByRole('button', { name: 'Delete my account' }).click();
await expect(page.getByText('Your account and all its data were deleted.')).toBeVisible();
// The old password no longer logs in.
await page.getByLabel('Username').fill(user.username);
await page.getByLabel('Password').fill(user.password);
await page.getByRole('button', { name: 'Log in' }).click();
await expect(page.getByRole('alert')).toHaveText('Wrong username or password');
// Re-registering the name gives an empty account, not the old hours.
await registerViaApi(page.request, user);
expect((await fetchData(page.request)).entries).toEqual({});
});
test('another device logged into the deleted account is logged out', async ({ page, browser, user }) => {
const other = await newClient(browser);
await other.page.request.post('/api/login', { data: user });
await other.page.goto('/');
await expect(other.page.locator('.app-header')).toContainText(user.username);
await openDelete(page);
await page.getByLabel('Enter your password to confirm').fill(user.password);
await page.getByRole('button', { name: 'Delete my account' }).click();
await expect(page.getByText('Your account and all its data were deleted.')).toBeVisible();
// The other device notices on its next save…
await dayInput(other.page, MONDAY).fill('7:15');
await expect(other.page.getByText(/session expired/i)).toBeVisible();
// …and nothing was written for the deleted account.
await registerViaApi(page.request, user);
expect((await fetchData(page.request)).entries).toEqual({});
await other.context.close();
});
test('a change typed just before deleting is not saved afterwards', async ({ page, user }) => {
await openDelete(page);
await dayInput(page, MONDAY).fill('7:15');
await page.getByLabel('Enter your password to confirm').fill(user.password);
await page.getByRole('button', { name: 'Delete my account' }).click();
await expect(page.getByText('Your account and all its data were deleted.')).toBeVisible();
await page.waitForTimeout(800);
await registerViaApi(page.request, user);
expect((await fetchData(page.request)).entries).toEqual({});
});
});
test('saving shows progress and then "Saved ✓"', async ({ page, user }) => {
await page.goto('/');
await dayInput(page, MONDAY).fill('7:15');
await saved(page);
void user;
});
+191
View File
@@ -0,0 +1,191 @@
import {
baseData,
dayInput,
expect,
fetchData,
MONDAY,
newClient,
PASSWORD,
registerViaApi,
track,
saved,
seed,
test,
uniqueName,
} from './fixtures';
test.describe('sign up', () => {
test('creates an account through the form and lands in an empty tracker', async ({ page }) => {
const username = uniqueName();
track({ username, password: PASSWORD });
await page.goto('/');
await page.getByRole('button', { name: 'Create an account' }).click();
await page.getByLabel('Username').fill(username);
await page.getByLabel('Password', { exact: true }).fill(PASSWORD);
await page.getByLabel('Repeat password').fill(PASSWORD);
await page.getByRole('button', { name: 'Create account' }).click();
await expect(page.getByRole('heading', { name: /Week 41/ })).toBeVisible();
await expect(page.locator('.app-header')).toContainText(username);
await expect(page.locator('.week-total')).toContainText('0 h 00 min');
});
test('stores mixed-case usernames lowercase, and both spellings log in', async ({ page }) => {
const username = uniqueName('Mixed');
await registerViaApi(page.request, { username, password: PASSWORD });
await page.request.post('/api/logout', { data: {} });
await page.goto('/');
await page.getByLabel('Username').fill(username.toUpperCase());
await page.getByLabel('Password').fill(PASSWORD);
await page.getByRole('button', { name: 'Log in' }).click();
await expect(page.locator('.app-header')).toContainText(username.toLowerCase());
});
test('refuses a taken username without logging into the existing account', async ({ page, user }) => {
await page.request.post('/api/logout', { data: {} });
await page.goto('/');
await page.getByRole('button', { name: 'Create an account' }).click();
await page.getByLabel('Username').fill(user.username);
await page.getByLabel('Password', { exact: true }).fill('some other password');
await page.getByLabel('Repeat password').fill('some other password');
await page.getByRole('button', { name: 'Create account' }).click();
await expect(page.getByRole('alert')).toHaveText('That username is taken');
expect((await page.request.get('/api/session')).status()).toBe(401);
});
test('blocks a username with spaces in the browser before it reaches the server', async ({ page }) => {
const registerCalls: string[] = [];
page.on('request', (r) => r.url().endsWith('/api/register') && registerCalls.push(r.url()));
await page.goto('/');
await page.getByRole('button', { name: 'Create an account' }).click();
await page.getByLabel('Username').fill('has space');
await page.getByLabel('Password', { exact: true }).fill(PASSWORD);
await page.getByLabel('Repeat password').fill(PASSWORD);
await page.getByRole('button', { name: 'Create account' }).click();
expect(await page.getByLabel('Username').evaluate((el: HTMLInputElement) => el.validity.patternMismatch)).toBe(
true,
);
expect(registerCalls).toEqual([]);
});
test('a password typo in the repeat field is caught', async ({ page }) => {
await page.goto('/');
await page.getByRole('button', { name: 'Create an account' }).click();
await page.getByLabel('Username').fill(uniqueName());
await page.getByLabel('Password', { exact: true }).fill(PASSWORD);
await page.getByLabel('Repeat password').fill(PASSWORD + 'x');
await page.getByRole('button', { name: 'Create account' }).click();
await expect(page.getByRole('alert')).toHaveText('Passwords do not match');
});
});
test.describe('log in / log out', () => {
test('a session survives a reload; logging out survives a reload too', async ({ page, user }) => {
await page.goto('/');
await expect(page.locator('.app-header')).toContainText(user.username);
await page.reload();
await expect(page.locator('.app-header')).toContainText(user.username);
await page.getByRole('button', { name: 'Log out' }).click();
await expect(page.getByRole('button', { name: 'Log in' })).toBeVisible();
await page.reload();
await expect(page.getByRole('button', { name: 'Log in' })).toBeVisible();
expect((await page.request.get('/api/data')).status()).toBe(401);
});
test('a wrong password shows an error and clears only the password field', async ({ page, user }) => {
await page.request.post('/api/logout', { data: {} });
await page.goto('/');
await page.getByLabel('Username').fill(user.username);
await page.getByLabel('Password').fill('not my password');
await page.getByRole('button', { name: 'Log in' }).click();
await expect(page.getByRole('alert')).toHaveText('Wrong username or password');
await expect(page.getByLabel('Username')).toHaveValue(user.username);
await expect(page.getByLabel('Password')).toHaveValue('');
});
test('ten wrong passwords lock the client out, even with the right one', async ({ page, user }) => {
await page.request.post('/api/logout', { data: {} });
for (let i = 0; i < 10; i++) {
await page.request.post('/api/login', { data: { username: user.username, password: 'wrong guess' } });
}
await page.goto('/');
await page.getByLabel('Username').fill(user.username);
await page.getByLabel('Password').fill(user.password);
await page.getByRole('button', { name: 'Log in' }).click();
await expect(page.getByRole('alert')).toContainText('Too many failed logins');
});
test('logging out right after typing still saves the change', async ({ page, user }) => {
await page.goto('/');
await dayInput(page, MONDAY).fill('7:15');
// Within the 500 ms save debounce:
await page.getByRole('button', { name: 'Log out' }).click();
await expect(page.getByRole('button', { name: 'Log in' })).toBeVisible();
await page.request.post('/api/login', { data: user });
expect((await fetchData(page.request)).entries[MONDAY]).toEqual({ mode: 'total', minutes: 435 });
});
});
test.describe('expired session', () => {
test('asks to log in again and then saves the change made meanwhile', async ({ page, context, user }) => {
await page.goto('/');
await expect(page.getByRole('heading', { name: /Week 41/ })).toBeVisible();
await context.clearCookies(); // the session is gone, the page doesn't know yet
await dayInput(page, MONDAY).fill('6:00');
await expect(page.getByText(/session expired/i)).toBeVisible();
await page.getByLabel('Username').fill(user.username);
await page.getByLabel('Password').fill(user.password);
await page.getByRole('button', { name: 'Log in' }).click();
await expect(page.getByRole('heading', { name: /Week 41/ })).toBeVisible();
await saved(page);
expect((await fetchData(page.request)).entries[MONDAY]).toEqual({ mode: 'total', minutes: 360 });
});
});
test.describe('several users and devices', () => {
test('users never see each other’s hours', async ({ browser }) => {
const alice = await newClient(browser);
const bob = await newClient(browser);
await registerViaApi(alice.page.request, { username: uniqueName('alice'), password: PASSWORD });
await registerViaApi(bob.page.request, { username: uniqueName('bob'), password: PASSWORD });
await seed(alice.page.request, baseData({ entries: { [MONDAY]: { mode: 'total', minutes: 600 } } }));
await bob.page.goto('/');
await expect(dayInput(bob.page, MONDAY)).toHaveValue('');
await expect(bob.page.locator('.week-total')).toContainText('0 h 00 min');
await alice.page.goto('/');
await expect(dayInput(alice.page, MONDAY)).toHaveValue('10:00');
await alice.context.close();
await bob.context.close();
});
// Question: what happens with two devices open at once (phone + laptop)? Each save sends
// the whole document, so a device with an old copy must not wipe the other's changes.
test('two tabs editing different days keep both changes', async ({ page, context, user }) => {
const tabA = page;
const tabB = await context.newPage();
await tabB.clock.setFixedTime(new Date('2026-10-08T12:00:00'));
await tabA.goto('/');
await tabB.goto('/');
await expect(dayInput(tabB, MONDAY)).toBeVisible();
await dayInput(tabA, MONDAY).fill('8:00');
await saved(tabA);
await dayInput(tabB, '2026-10-06').fill('7:00'); // tab B still has the old copy
await saved(tabB);
const data = await fetchData(page.request);
expect(data.entries[MONDAY]).toEqual({ mode: 'total', minutes: 480 });
expect(data.entries['2026-10-06']).toEqual({ mode: 'total', minutes: 420 });
// Tab B now also shows tab A's Monday.
await expect(dayInput(tabB, MONDAY)).toHaveValue('8:00');
void user;
});
});
+124
View File
@@ -0,0 +1,124 @@
import { test as base, expect, type APIRequestContext, type Browser, type Page } from '@playwright/test';
import type { Data } from '../shared/types';
/** Thursday of ISO week 41 (Mon 5 Oct – Sun 11 Oct 2026), midday local time. */
export const NOW = new Date('2026-10-08T12:00:00');
export const MONDAY = '2026-10-05';
export const PASSWORD = 'correct horse battery';
export interface Creds {
username: string;
password: string;
}
let counter = 0;
/** Unique, valid username (3–32 chars, lowercase). Parallel workers never collide. */
export function uniqueName(prefix = 'u'): string {
return `${prefix}${process.pid.toString(36)}${(counter++).toString(36)}${Math.random().toString(36).slice(2, 7)}`;
}
/**
* A made-up client address. The server trusts X-Forwarded-For from loopback (like it trusts
* Traefik in the cluster), so each test is a separate client for the per-IP rate limits.
*/
export function randomIp(): string {
const b = () => Math.floor(Math.random() * 254) + 1;
return `10.${b()}.${b()}.${b()}`;
}
// Accounts created by the running test; deleted afterwards so the servers never reach
// their account limit (MAX_USERS), however often the suite runs.
const created: { creds: Creds; baseURL: string }[] = [];
/** Remember an account created through the UI so it gets cleaned up too. */
export function track(creds: Creds, baseURL = 'http://localhost:3100') {
created.push({ creds, baseURL });
}
export async function registerViaApi(request: APIRequestContext, creds: Creds) {
const r = await request.post('/api/register', { data: creds });
expect(r.status(), await r.text()).toBe(201);
track(creds, new URL(r.url()).origin);
}
// Plain fetch, not a Playwright request context: this runs during teardown, outside the
// test's trace recording.
async function deleteCreated() {
for (const { creds, baseURL } of created.splice(0)) {
const headers = { 'Content-Type': 'application/json', 'X-Forwarded-For': randomIp() };
const login = await fetch(`${baseURL}/api/login`, { method: 'POST', headers, body: JSON.stringify(creds) });
if (!login.ok) continue; // already deleted by the test, or never finished signing up
const cookie = login.headers.get('set-cookie')!.split(';')[0];
await fetch(`${baseURL}/api/account`, {
method: 'DELETE',
headers: { ...headers, Cookie: cookie },
body: JSON.stringify({ password: creds.password }),
});
}
}
/** Replace the logged-in user's data — test setup without clicking through every field. */
export async function seed(request: APIRequestContext, data: Data) {
const etag = (await request.get('/api/data')).headers()['etag'];
const r = await request.put('/api/data', { data, headers: { 'If-Match': etag } });
expect(r.status(), await r.text()).toBe(200);
}
export function baseData(overrides: Partial<Data> = {}): Data {
return {
settings: { fullTimeWeekMinutes: 2175, workdays: [1, 2, 3, 4, 5], trackingStart: MONDAY, openingBalanceMinutes: 0 },
workloadPeriods: [{ from: '2026-01-01', percent: 100 }],
entries: {},
...overrides,
};
}
/** New browser context = a different device/person, with its own IP and frozen clock. */
export async function newClient(browser: Browser, baseURL = 'http://localhost:3100') {
const context = await browser.newContext({ baseURL, extraHTTPHeaders: { 'X-Forwarded-For': randomIp() } });
const page = await context.newPage();
await page.clock.setFixedTime(NOW);
return { context, page };
}
// ---------- Page helpers ----------
export const dayInput = (page: Page, date: string) => page.getByLabel(`Hours worked on ${date}`);
export const dayRow = (page: Page, date: string) =>
page.locator('.day').filter({ has: page.getByRole('group', { name: `Input mode for ${date}` }) });
export const weekFooter = (page: Page) => page.locator('.week-total');
export const balanceCard = (page: Page) => page.locator('section.balance');
export const saved = (page: Page) => expect(page.getByText('Saved ✓')).toBeVisible();
export async function fetchData(request: APIRequestContext): Promise<Data> {
const r = await request.get('/api/data');
expect(r.status()).toBe(200);
return (await r.json()) as Data;
}
// ---------- Fixtures ----------
export const test = base.extend<{ user: Creds; cleanup: void }>({
cleanup: [
async ({}, use) => {
await use();
await deleteCreated();
},
{ auto: true },
],
extraHTTPHeaders: async ({}, use) => {
await use({ 'X-Forwarded-For': randomIp() });
},
page: async ({ page }, use) => {
await page.clock.setFixedTime(NOW);
await use(page);
},
/** A freshly registered user whose session cookie is already in the page's context. */
user: async ({ page }, use) => {
const creds = { username: uniqueName(), password: PASSWORD };
await registerViaApi(page.request, creds);
await use(creds);
},
});
export { expect };
+256
View File
@@ -0,0 +1,256 @@
import {
balanceCard,
baseData,
dayInput,
dayRow,
expect,
fetchData,
MONDAY,
saved,
seed,
test,
weekFooter,
} from './fixtures';
const TUE = '2026-10-06';
const WED = '2026-10-07';
const THU = '2026-10-08'; // "today"
const FRI = '2026-10-09';
const WEEKDAYS = [MONDAY, TUE, WED, THU, FRI];
test.describe('entering total hours', () => {
test('a full 7:15 week is exactly on target, and survives a reload', async ({ page, user }) => {
await page.goto('/');
for (const d of WEEKDAYS) await dayInput(page, d).fill('7:15');
await expect(weekFooter(page)).toContainText('Worked36 h 15 min');
await expect(weekFooter(page)).toContainText('Target36 h 15 min');
await expect(weekFooter(page)).toContainText('0 h 00 min — on target');
await saved(page);
await page.reload();
for (const d of WEEKDAYS) await expect(dayInput(page, d)).toHaveValue('7:15');
void user;
});
for (const [typed, minutes] of [
['7.5', 450],
['7,5', 450],
['7:30', 450],
['7h30', 450],
['7h 30min', 450],
['450m', 450],
['8', 480],
] as const) {
test(`understands "${typed}"`, async ({ page, user }) => {
await page.goto('/');
const input = dayInput(page, MONDAY);
await input.fill(typed);
await input.blur();
await expect(input).toHaveValue(`${Math.floor(minutes / 60)}:${String(minutes % 60).padStart(2, '0')}`);
await saved(page);
expect((await fetchData(page.request)).entries[MONDAY]).toEqual({ mode: 'total', minutes });
void user;
});
}
// Question: a single day can't be negative or longer than a day. Before this test the app
// accepted "-2" (subtracting hours) and "30" for one day.
for (const typed of ['-2', '25', '30:00', 'abc', '7:75']) {
test(`rejects "${typed}" for a day and saves nothing`, async ({ page, user }) => {
await page.goto('/');
const input = dayInput(page, MONDAY);
await input.fill(typed);
await expect(input).toHaveAttribute('aria-invalid', 'true');
await expect(weekFooter(page)).toContainText('Worked0 h 00 min');
await page.waitForTimeout(800); // longer than the save debounce
expect((await fetchData(page.request)).entries[MONDAY]).toBeUndefined();
void user;
});
}
test('clearing a day removes it', async ({ page, user }) => {
await seed(page.request, baseData({ entries: { [MONDAY]: { mode: 'total', minutes: 435 } } }));
await page.goto('/');
await dayInput(page, MONDAY).fill('');
await saved(page);
expect((await fetchData(page.request)).entries[MONDAY]).toBeUndefined();
void user;
});
// Question: the save waits 500 ms after typing. Is a change lost if the tab is closed sooner?
test('a change is saved even when the page is reloaded immediately', async ({ page, user }) => {
await page.goto('/');
await dayInput(page, MONDAY).fill('6:45');
await page.reload();
await expect(dayInput(page, MONDAY)).toHaveValue('6:45');
void user;
});
});
test.describe('start–end ranges', () => {
async function setRange(
page: import('@playwright/test').Page,
date: string,
start: string,
end: string,
br?: string,
) {
const row = dayRow(page, date);
await row.getByRole('button', { name: 'Start–End' }).click();
await row.getByLabel('Start').fill(start);
await row.getByLabel('End').fill(end);
if (br !== undefined) await row.getByRole('spinbutton').fill(br);
return row;
}
test('subtracts the default 30 min break', async ({ page, user }) => {
await page.goto('/');
const row = await setRange(page, MONDAY, '08:00', '16:00');
await expect(row.locator('.day-figures')).toContainText('7:30');
await saved(page);
expect((await fetchData(page.request)).entries[MONDAY]).toEqual({
mode: 'range',
start: '08:00',
end: '16:00',
breakMin: 30,
});
void user;
});
test('handles a shift past midnight', async ({ page, user }) => {
await page.goto('/');
const row = await setRange(page, MONDAY, '22:00', '06:00', '0');
await expect(row.locator('.day-figures')).toContainText('8:00');
void user;
});
test('a break longer than the shift gives zero, not negative hours', async ({ page, user }) => {
await page.goto('/');
const row = await setRange(page, MONDAY, '09:00', '10:00', '90');
await expect(row.locator('.day-figures')).toContainText('0:00');
void user;
});
test('only a start time counts as nothing yet', async ({ page, user }) => {
await page.goto('/');
const row = dayRow(page, MONDAY);
await row.getByRole('button', { name: 'Start–End' }).click();
await row.getByLabel('Start').fill('08:00');
await expect(row.locator('.day-figures')).toContainText('0:00');
void user;
});
});
test.describe('off days', () => {
test('an off day drops out of the target and keeps its note', async ({ page, user }) => {
await page.goto('/');
const row = dayRow(page, WED);
await row.getByRole('button', { name: 'Off' }).click();
await row.getByPlaceholder('Vacation, sick, holiday…').fill('Vacation');
await expect(weekFooter(page)).toContainText('Target29 h 00 min');
await saved(page);
await page.reload();
await expect(dayRow(page, WED).getByPlaceholder('Vacation, sick, holiday…')).toHaveValue('Vacation');
void user;
});
});
test.describe('week view and balance', () => {
test('the week number and dates are right, including around New Year', async ({ page, user }) => {
await page.goto('/');
await expect(page.getByRole('heading', { level: 2, name: /Week/ })).toHaveText('Week 41 5 Oct – 11 Oct 2026');
const next = page.getByRole('button', { name: 'Next week' });
for (let i = 0; i < 12; i++) await next.click();
// 2026 has 53 ISO weeks; this one contains 1 Jan 2027.
await expect(page.getByRole('heading', { level: 2, name: /Week/ })).toHaveText('Week 53 28 Dec – 3 Jan 2027');
await next.click();
await expect(page.getByRole('heading', { level: 2, name: /Week/ })).toHaveText('Week 1 4 Jan – 10 Jan 2027');
await page.getByRole('button', { name: 'This week' }).click();
await expect(page.getByRole('heading', { level: 2, name: /Week 41/ })).toBeVisible();
await expect(page.getByRole('button', { name: 'This week' })).toHaveCount(0);
void user;
});
test('today is highlighted', async ({ page, user }) => {
await page.goto('/');
await expect(page.locator('.day.today')).toHaveCount(1);
await expect(page.locator('.day.today')).toContainText('Thu');
void user;
});
test('the balance counts up to today only; future days in the week do not count yet', async ({ page, user }) => {
await seed(
page.request,
baseData({
entries: {
[MONDAY]: { mode: 'total', minutes: 435 },
[TUE]: { mode: 'total', minutes: 435 },
[WED]: { mode: 'total', minutes: 435 },
[THU]: { mode: 'total', minutes: 495 }, // +1 h today
[FRI]: { mode: 'total', minutes: 600 }, // tomorrow, already logged
},
}),
);
await page.goto('/');
await expect(balanceCard(page)).toContainText('+1 h 00 min');
// The week view, by contrast, shows the whole week.
await expect(weekFooter(page)).toContainText('+3 h 45 min over');
void user;
});
test('hours before the tracking start date are ignored by the balance', async ({ page, user }) => {
await seed(
page.request,
baseData({
settings: { ...baseData().settings, trackingStart: WED },
entries: { [MONDAY]: { mode: 'total', minutes: 600 }, [WED]: { mode: 'total', minutes: 435 } },
}),
);
await page.goto('/');
// Wed logged 7:15, Thu (today) nothing -> −7:15. Monday's 10 h must not count.
await expect(balanceCard(page)).toContainText('−7 h 15 min');
void user;
});
test('the history adds up week by week and links to each week', async ({ page, user }) => {
await seed(
page.request,
baseData({
settings: { ...baseData().settings, trackingStart: '2026-09-28' },
entries: Object.fromEntries(
['2026-09-28', '2026-09-29', '2026-09-30', '2026-10-01', '2026-10-02'].map((d) => [
d,
{ mode: 'total' as const, minutes: 495 },
]),
),
}),
);
await page.goto('/');
const rows = page.locator('table tbody tr');
await expect(rows).toHaveCount(2);
// Newest first. Week 40: +5 h. Week 41 Mon–Thu: nothing logged -> −29 h.
await expect(rows.nth(1)).toContainText('+5 h 00 min');
await expect(rows.nth(0)).toContainText('−29 h 00 min');
await expect(rows.nth(0)).toContainText('−24 h 00 min');
await expect(balanceCard(page)).toContainText('−24 h 00 min');
await rows.nth(1).click();
await expect(page.getByRole('heading', { level: 2, name: /Week 40/ })).toBeVisible();
await expect(dayInput(page, '2026-09-28')).toHaveValue('8:15');
void user;
});
});
test.describe('time zones', () => {
// Question: "today" comes from the browser clock. Just after midnight on a Monday, is it
// already the new week — in a time zone far from the server's?
test.use({ timezoneId: 'Pacific/Auckland' });
test('just after midnight on Monday is already the new week', async ({ page, user }) => {
await page.clock.setFixedTime(new Date('2026-10-12T00:30:00+13:00'));
await page.goto('/');
await expect(page.getByRole('heading', { level: 2, name: /Week/ })).toHaveText('Week 42 12 Oct – 18 Oct 2026');
await expect(page.locator('.day.today')).toContainText('Mon');
void user;
});
});
+33
View File
@@ -0,0 +1,33 @@
import { expect, PASSWORD, test, track, uniqueName } from './fixtures';
// This server runs with REGISTRATION_CODE=let-me-in-please.
test.use({ baseURL: 'http://localhost:3101' });
test.describe('invite-only sign-up', () => {
async function fillSignUp(page: import('@playwright/test').Page, code: string) {
const username = uniqueName();
track({ username, password: PASSWORD }, 'http://localhost:3101');
await page.goto('/');
await page.getByRole('button', { name: 'Create an account' }).click();
await page.getByLabel('Username').fill(username);
await page.getByLabel('Password', { exact: true }).fill(PASSWORD);
await page.getByLabel('Repeat password').fill(PASSWORD);
await page.getByLabel('Invite code').fill(code);
await page.getByRole('button', { name: 'Create account' }).click();
}
test('a wrong code is refused', async ({ page }) => {
await fillSignUp(page, 'guess-the-code');
await expect(page.getByRole('alert')).toHaveText('Invalid invite code');
});
test('the right code creates the account', async ({ page }) => {
await fillSignUp(page, 'let-me-in-please');
await expect(page.getByRole('heading', { name: /Week 41/ })).toBeVisible();
});
test('the API refuses sign-up without the code too', async ({ page }) => {
const r = await page.request.post('/api/register', { data: { username: uniqueName(), password: PASSWORD } });
expect(r.status()).toBe(403);
});
});
+26
View File
@@ -0,0 +1,26 @@
import type { Page } from '@playwright/test';
import { dayInput, expect, MONDAY, saved, test } from './fixtures';
async function expectNoSideways(page: Page) {
const overflow = await page.evaluate(() => document.documentElement.scrollWidth - window.innerWidth);
expect(overflow).toBeLessThanOrEqual(0);
}
test.describe('on a phone @mobile', () => {
test('the login screen fits the width', async ({ page }) => {
await page.goto('/');
await expect(page.getByRole('button', { name: 'Log in' })).toBeVisible();
await expectNoSideways(page);
});
test('the tracker fits the width and hours can be entered', async ({ page, user }) => {
await page.goto('/');
await expectNoSideways(page);
await dayInput(page, MONDAY).tap();
await dayInput(page, MONDAY).fill('7:15');
await saved(page);
await page.getByText('Settings', { exact: true }).tap();
await expectNoSideways(page);
void user;
});
});
+60
View File
@@ -0,0 +1,60 @@
import { request as apiRequest } from '@playwright/test';
import { baseData, dayRow, expect, MONDAY, seed, test } from './fixtures';
test.describe('session cookie', () => {
test('is HttpOnly, Secure and SameSite=Strict, and invisible to page scripts', async ({ page, context, user }) => {
await page.goto('/');
const cookie = (await context.cookies()).find((c) => c.name === 'workh_session');
expect(cookie).toMatchObject({ httpOnly: true, secure: true, sameSite: 'Strict' });
// Expires in ~30 days (a persistent cookie, not a browser-session one).
const days = (cookie!.expires * 1000 - Date.now()) / 86_400_000;
expect(days).toBeGreaterThan(29);
expect(days).toBeLessThan(31);
expect(await page.evaluate(() => document.cookie)).not.toContain('workh_session');
void user;
});
});
test.describe('API without a session', () => {
test('refuses every private endpoint', async ({ baseURL }) => {
const anon = await apiRequest.newContext({ baseURL });
expect((await anon.get('/api/data')).status()).toBe(401);
expect((await anon.put('/api/data', { data: baseData() })).status()).toBe(401);
expect((await anon.get('/api/session')).status()).toBe(401);
expect((await anon.delete('/api/account', { data: { password: 'x' } })).status()).toBe(401);
await anon.dispose();
});
});
test.describe('content', () => {
test('HTML typed into a note is shown as text, never run', async ({ page, user }) => {
const payload = '<img src=x onerror="window.xssRan=1">';
await seed(page.request, baseData({ entries: { [MONDAY]: { mode: 'off', note: payload } } }));
await page.goto('/');
await expect(dayRow(page, MONDAY).getByPlaceholder('Vacation, sick, holiday…')).toHaveValue(payload);
expect(await page.evaluate(() => (window as unknown as { xssRan?: number }).xssRan)).toBeUndefined();
void user;
});
test('the page runs without console errors or CSP violations', async ({ page, user }) => {
const problems: string[] = [];
page.on('console', (m) => m.type() === 'error' && problems.push(m.text()));
page.on('pageerror', (e) => problems.push(e.message));
await page.goto('/');
await page.getByText('Settings', { exact: true }).click();
await page.getByText('Account', { exact: true }).click();
await page.getByRole('button', { name: 'Next week' }).click();
await expect(page.getByRole('heading', { level: 2, name: /Week 42/ })).toBeVisible();
expect(problems).toEqual([]);
void user;
});
test('the login page also loads cleanly under the CSP', async ({ page }) => {
const problems: string[] = [];
page.on('console', (m) => m.type() === 'error' && !m.text().includes('401') && problems.push(m.text()));
page.on('pageerror', (e) => problems.push(e.message));
await page.goto('/');
await expect(page.getByRole('button', { name: 'Log in' })).toBeVisible();
expect(problems).toEqual([]);
});
});
+112
View File
@@ -0,0 +1,112 @@
import type { Page } from '@playwright/test';
import { balanceCard, baseData, dayRow, expect, fetchData, MONDAY, saved, seed, test, weekFooter } from './fixtures';
async function openSettings(page: Page) {
await page.getByText('Settings', { exact: true }).click();
}
test.describe('workload periods', () => {
test('a 50% period from Wednesday halves the target from that day on', async ({ page, user }) => {
await page.goto('/');
await openSettings(page);
await page.getByRole('button', { name: '+ Add period' }).click(); // starts today at 50%
const periods = page.locator('.periods li');
await expect(periods).toHaveCount(2);
await periods.nth(1).locator('input[type=date]').fill('2026-10-07');
// Mon, Tue 7:15; Wed–Fri 3:37.5
await expect(dayRow(page, MONDAY).locator('.day-figures')).toContainText('/ 7:15');
await expect(dayRow(page, '2026-10-07').locator('.day-figures')).toContainText('/ 3:38');
await expect(weekFooter(page)).toContainText('Target25 h 23 min'); // 14:30 + 10:52.5, rounded
await saved(page);
// A new account's first period starts the Monday it was created (server clock).
expect((await fetchData(page.request)).workloadPeriods).toEqual([
{ from: expect.any(String), percent: 100 },
{ from: '2026-10-07', percent: 50 },
]);
void user;
});
test('25% gives 9:04 a week and the period list shows it', async ({ page, user }) => {
await seed(page.request, baseData({ workloadPeriods: [{ from: '2026-01-01', percent: 25 }] }));
await page.goto('/');
await expect(weekFooter(page)).toContainText('Target9 h 04 min');
await openSettings(page);
await expect(page.locator('.periods li')).toContainText('= 9:04 h/week');
void user;
});
test('percentages outside 0–100 are clamped', async ({ page, user }) => {
await page.goto('/');
await openSettings(page);
const pct = page.locator('.periods li input[type=number]');
await pct.fill('250');
await expect(pct).toHaveValue('100');
await pct.fill('-5');
await expect(pct).toHaveValue('0');
void user;
});
test('with every period removed the target falls back to 100%', async ({ page, user }) => {
await page.goto('/');
await openSettings(page);
await page.locator('.periods li').getByRole('button', { name: 'Remove' }).click();
await expect(page.locator('.periods li')).toHaveCount(0);
await expect(weekFooter(page)).toContainText('Target36 h 15 min');
void user;
});
});
test.describe('other settings', () => {
test('a different full-time week changes every daily target', async ({ page, user }) => {
await page.goto('/');
await openSettings(page);
const input = page.locator('.settings-grid label').filter({ hasText: 'Full-time week' }).locator('input');
await input.fill('37:30');
await expect(weekFooter(page)).toContainText('Target37 h 30 min');
await expect(dayRow(page, MONDAY).locator('.day-figures')).toContainText('/ 7:30');
void user;
});
test('a four-day week spreads the same hours over four days', async ({ page, user }) => {
await page.goto('/');
await openSettings(page);
await page.getByRole('checkbox', { name: 'Fri' }).uncheck();
await expect(weekFooter(page)).toContainText('Target36 h 15 min');
await expect(dayRow(page, MONDAY).locator('.day-figures')).toContainText('/ 9:04');
await expect(dayRow(page, '2026-10-09').locator('.day-figures')).toContainText('/ 0:00');
void user;
});
test('the last working day cannot be unchecked', async ({ page, user }) => {
await seed(page.request, baseData({ settings: { ...baseData().settings, workdays: [1] } }));
await page.goto('/');
await openSettings(page);
await page.getByRole('checkbox', { name: 'Mon' }).click();
await expect(page.getByRole('checkbox', { name: 'Mon' })).toBeChecked();
void user;
});
test('a negative opening balance carries into the balance', async ({ page, user }) => {
await page.goto('/');
await openSettings(page);
await page.getByPlaceholder('e.g. -2:30').fill('-2:30');
// Mon–Thu nothing logged (−29 h) plus −2:30
await expect(balanceCard(page)).toContainText('−31 h 30 min');
void user;
});
test('moving the tracking start changes what the balance counts', async ({ page, user }) => {
await page.goto('/');
await openSettings(page);
await page
.locator('.settings-grid label')
.filter({ hasText: 'Tracking start' })
.locator('input')
.fill('2026-10-08');
await expect(balanceCard(page)).toContainText('−7 h 15 min'); // only today
await saved(page);
expect((await fetchData(page.request)).settings.trackingStart).toBe('2026-10-08');
void user;
});
});
+266
View File
@@ -0,0 +1,266 @@
import { useEffect, useRef, useState } from 'react';
import type { Data, Entry } from '../shared/types';
import { api, UnauthorizedError, type Versioned } from './api';
import { history, mondayOf, todayISO } from './calc';
import { mergeData } from './merge';
import { Account } from './components/Account';
import { AuthScreen } from './components/AuthScreen';
import { BalanceCard } from './components/BalanceCard';
import { HistoryTable } from './components/HistoryTable';
import { Settings } from './components/Settings';
import { WeekView } from './components/WeekView';
type SaveState = 'idle' | 'pending' | 'saving' | 'saved' | 'error';
const SAVE_DELAY_MS = 500;
type Auth =
| { state: 'checking' }
| { state: 'out'; notice?: string }
| { state: 'in'; username: string; recovered: Unsaved | null };
/** Edits that could not be saved, plus the server version they were made on. */
interface Unsaved {
base: Data;
mine: Data;
}
/** Login gate: shows the login form until the server confirms a session. */
export function App() {
const [auth, setAuth] = useState<Auth>({ state: 'checking' });
// Changes that could not be saved because the session expired; saved after the *same*
// user logs in again (never into someone else's account).
const unsaved = useRef<{ username: string; edits: Unsaved } | null>(null);
useEffect(() => {
api
.session()
.then(({ username }) => setAuth({ state: 'in', username, recovered: null }))
.catch(() => setAuth({ state: 'out' }));
}, []);
if (auth.state === 'checking') return <main className="muted">Loading…</main>;
if (auth.state === 'out') {
const logIn = (username: string) => {
const recovered = unsaved.current?.username === username ? unsaved.current.edits : null;
unsaved.current = null;
setAuth({ state: 'in', username, recovered });
};
return <AuthScreen notice={auth.notice} onLogin={logIn} />;
}
return (
<Tracker
key={auth.username}
username={auth.username}
recovered={auth.recovered}
onUnauthorized={(pending) => {
unsaved.current = pending ? { username: auth.username, edits: pending } : null;
setAuth({
state: 'out',
notice: pending
? 'Your session expired. Log in again — your latest change will be saved then.'
: 'Your session expired. Please log in again.',
});
}}
onLogout={async () => {
await api.logout().catch(() => undefined);
setAuth({ state: 'out' });
}}
onDeleted={() => setAuth({ state: 'out', notice: 'Your account and all its data were deleted.' })}
/>
);
}
interface TrackerProps {
username: string;
recovered: Unsaved | null;
onUnauthorized: (unsaved: Unsaved | null) => void;
onLogout: () => void;
onDeleted: () => void;
}
function Tracker({ username, recovered, onUnauthorized, onLogout, onDeleted }: TrackerProps) {
const [data, setData] = useState<Data | null>(null);
const [loadError, setLoadError] = useState<string | null>(null);
const [saveState, setSaveState] = useState<SaveState>('idle');
const today = todayISO();
const [monday, setMonday] = useState(mondayOf(today));
const timer = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
const pending = useRef<Data | null>(null);
/** The latest version known to be on the server — what our edits are based on. */
const server = useRef<Versioned | null>(null);
const saving = useRef(false);
async function save(next: Data, attempt = 0): Promise<void> {
if (!server.current) return;
pending.current = null;
saving.current = true;
setSaveState('saving');
try {
const result = await api.save(next, server.current.etag);
if ('etag' in result) {
server.current = { data: next, etag: result.etag };
setSaveState(pending.current ? 'pending' : 'saved');
return;
}
// Another tab or device saved first: put our edits on top of its version and retry.
const mine = pending.current ?? next;
const merged = mergeData(server.current.data, mine, result.conflict.data);
server.current = result.conflict;
clearTimeout(timer.current);
pending.current = null;
setData(merged);
if (attempt < 3) await save(merged, attempt + 1);
else setSaveState('error');
} catch (err) {
if (err instanceof UnauthorizedError)
onUnauthorized({ base: server.current.data, mine: pending.current ?? next });
else setSaveState('error');
} finally {
saving.current = false;
}
}
async function load() {
try {
const latest = await api.load();
server.current = latest;
return latest;
} catch (err) {
if (err instanceof UnauthorizedError) onUnauthorized(null);
else setLoadError((err as Error).message);
return null;
}
}
// Both effects run once per login: Tracker is re-created (keyed) for every user, and
// load/save only touch refs and state setters, so stale closures are not an issue.
/* oxlint-disable react-hooks/exhaustive-deps, react/set-state-in-effect */
useEffect(() => {
void load().then((latest) => {
if (!latest) return;
if (recovered) {
const merged = mergeData(recovered.base, recovered.mine, latest.data);
setData(merged);
void save(merged);
} else {
setData(latest.data);
}
});
}, []);
// Coming back to the tab (e.g. after editing on the phone): show the latest data.
useEffect(() => {
const refresh = () => {
if (document.visibilityState !== 'visible' || pending.current || saving.current) return;
const known = server.current?.etag;
void load().then((latest) => {
if (latest && latest.etag !== known && !pending.current) setData(latest.data);
});
};
document.addEventListener('visibilitychange', refresh);
return () => document.removeEventListener('visibilitychange', refresh);
}, []);
/* oxlint-enable react-hooks/exhaustive-deps, react/set-state-in-effect */
// If the tab is closed while a save is still debounced, send it right away.
useEffect(() => {
const flush = () => {
if (pending.current && server.current) {
clearTimeout(timer.current);
// If another device saved meanwhile this is refused (412) rather than overwriting it.
void fetch('/api/data', {
method: 'PUT',
headers: { 'Content-Type': 'application/json', 'If-Match': server.current.etag },
body: JSON.stringify(pending.current),
keepalive: true,
});
}
};
window.addEventListener('beforeunload', flush);
return () => {
window.removeEventListener('beforeunload', flush);
clearTimeout(timer.current);
};
}, []);
function update(next: Data) {
setData(next);
pending.current = next;
setSaveState('pending');
clearTimeout(timer.current);
timer.current = setTimeout(() => void save(next), SAVE_DELAY_MS);
}
function setEntry(date: string, entry: Entry | undefined) {
if (!data) return;
const entries = { ...data.entries };
if (entry) entries[date] = entry;
else delete entries[date];
update({ ...data, entries });
}
if (loadError) {
return (
<main>
<p className="neg">Could not load data from the server: {loadError}</p>
</main>
);
}
if (!data) return <main className="muted">Loading…</main>;
const { rows, balance } = history(data, today);
return (
<main>
<header className="app-header">
<h1>Working hours</h1>
<span className={`save-state ${saveState}`} aria-live="polite">
{saveState === 'pending' || saveState === 'saving'
? 'Saving…'
: saveState === 'saved'
? 'Saved ✓'
: saveState === 'error'
? 'Save failed!'
: ''}
</span>
<span className="user">
<span className="muted">{username}</span>
<button
type="button"
className="link"
onClick={async () => {
// Save a still-debounced change before the session goes away.
if (pending.current) {
clearTimeout(timer.current);
await save(pending.current);
}
onLogout();
}}
>
Log out
</button>
</span>
</header>
<div className="top">
<WeekView data={data} monday={monday} today={today} onNavigate={setMonday} onEntryChange={setEntry} />
<BalanceCard balance={balance} trackingStart={data.settings.trackingStart} today={today} />
</div>
<HistoryTable rows={rows} selected={monday} onSelect={setMonday} />
<Settings data={data} today={today} onChange={update} />
<Account
username={username}
onUnauthorized={() => onUnauthorized(null)}
onDeleted={() => {
// Nothing left to save: drop any debounced write so it can't hit the deleted account.
clearTimeout(timer.current);
pending.current = null;
onDeleted();
}}
/>
</main>
);
}
+69
View File
@@ -0,0 +1,69 @@
import type { Data } from '../shared/types';
export class UnauthorizedError extends Error {
constructor() {
super('Not logged in');
}
}
export class ApiError extends Error {
constructor(
public status: number,
message: string,
) {
super(message);
}
}
async function send(method: string, url: string, body?: unknown, headers: Record<string, string> = {}) {
return fetch(url, {
method,
credentials: 'same-origin',
headers: body === undefined ? headers : { 'Content-Type': 'application/json', ...headers },
body: body === undefined ? undefined : JSON.stringify(body),
});
}
async function request<T>(method: string, url: string, body?: unknown): Promise<T> {
const r = await send(method, url, body);
if (r.status === 401 && url !== '/api/login') throw new UnauthorizedError();
if (!r.ok) {
const err = (await r.json().catch(() => ({}))) as { error?: string };
throw new ApiError(r.status, err.error ?? `HTTP ${r.status}`);
}
return r.status === 204 ? (undefined as T) : ((await r.json()) as T);
}
export const api = {
session: () => request<{ username: string }>('GET', '/api/session'),
login: (username: string, password: string) =>
request<{ username: string }>('POST', '/api/login', { username, password }),
logout: () => request<void>('POST', '/api/logout', {}),
registration: () => request<{ open: boolean; inviteRequired: boolean }>('GET', '/api/registration'),
register: (username: string, password: string, inviteCode?: string) =>
request<{ username: string }>('POST', '/api/register', { username, password, inviteCode }),
deleteAccount: (password: string) => request<void>('DELETE', '/api/account', { password }),
/** The data plus its version tag, needed to save. */
load: async (): Promise<Versioned> => {
const r = await send('GET', '/api/data');
if (r.status === 401) throw new UnauthorizedError();
if (!r.ok) throw new ApiError(r.status, `HTTP ${r.status}`);
return { data: (await r.json()) as Data, etag: r.headers.get('ETag') ?? '' };
},
/**
* Saves `data` if the server still has version `etag`. Otherwise resolves to the
* server's newer version (`conflict`) so the caller can merge and retry.
*/
save: async (data: Data, etag: string): Promise<{ etag: string } | { conflict: Versioned }> => {
const r = await send('PUT', '/api/data', data, { 'If-Match': etag });
if (r.status === 401) throw new UnauthorizedError();
if (r.status === 412) return { conflict: { data: (await r.json()) as Data, etag: r.headers.get('ETag') ?? '' } };
if (!r.ok) throw new ApiError(r.status, `HTTP ${r.status}`);
return { etag: r.headers.get('ETag') ?? '' };
},
};
export interface Versioned {
data: Data;
etag: string;
}
+180
View File
@@ -0,0 +1,180 @@
import type { Data, Entry, WorkloadPeriod } from '../shared/types';
// ---------- Dates (ISO strings, computed in UTC so DST never shifts a day) ----------
const DAY_MS = 86_400_000;
const toDate = (iso: string) => new Date(`${iso}T00:00:00Z`);
const toISO = (d: Date) => d.toISOString().slice(0, 10);
export function todayISO(now = new Date()): string {
return toISO(new Date(Date.UTC(now.getFullYear(), now.getMonth(), now.getDate())));
}
export function addDays(iso: string, n: number): string {
return toISO(new Date(toDate(iso).getTime() + n * DAY_MS));
}
/** ISO weekday: 1 = Monday ... 7 = Sunday. */
export function isoWeekday(iso: string): number {
return toDate(iso).getUTCDay() || 7;
}
export function mondayOf(iso: string): string {
return addDays(iso, 1 - isoWeekday(iso));
}
export function isoWeekNumber(iso: string): number {
const thursday = toDate(addDays(iso, 4 - isoWeekday(iso)));
const yearStart = Date.UTC(thursday.getUTCFullYear(), 0, 1);
return Math.floor((thursday.getTime() - yearStart) / DAY_MS / 7) + 1;
}
export function weekDays(monday: string): string[] {
return Array.from({ length: 7 }, (_, i) => addDays(monday, i));
}
// ---------- Durations ----------
/**
* Parses "7.5", "7,5", "7:30", "7h30", "7h 30min", "45m" or "-1:30" into minutes.
* Returns null for anything it does not understand; "" also returns null.
*/
export function parseDuration(input: string): number | null {
const s = input.trim().toLowerCase().replace(/\s+/g, '');
if (s === '') return null;
const sign = s.startsWith('-') ? -1 : 1;
const body = s.replace(/^[+-]/, '');
let m: RegExpMatchArray | null;
if ((m = body.match(/^(\d+)(?:[.,](\d+))?h?$/))) {
return sign * Math.round(parseFloat(`${m[1]}.${m[2] ?? '0'}`) * 60);
}
if ((m = body.match(/^(\d+):(\d{1,2})$/)) && Number(m[2]) < 60) {
return sign * (Number(m[1]) * 60 + Number(m[2]));
}
if ((m = body.match(/^(\d+)h(\d{1,2})(?:m|min)?$/)) && Number(m[2]) < 60) {
return sign * (Number(m[1]) * 60 + Number(m[2]));
}
if ((m = body.match(/^(\d+)(?:m|min)$/))) {
return sign * Number(m[1]);
}
return null;
}
/** 435 -> "7:15", -90 -> "-1:30". Rounds to whole minutes. */
export function formatHM(minutes: number): string {
const r = Math.round(minutes);
const abs = Math.abs(r);
return `${r < 0 ? '-' : ''}${Math.floor(abs / 60)}:${String(abs % 60).padStart(2, '0')}`;
}
/** 65 -> "1 h 05 min"; with signed: "+1 h 05 min" / "−1 h 05 min". */
export function formatDuration(minutes: number, signed = false): string {
const r = Math.round(minutes);
const abs = Math.abs(r);
const text = `${Math.floor(abs / 60)} h ${String(abs % 60).padStart(2, '0')} min`;
if (!signed || r === 0) return r < 0 ? `−${text}` : text;
return `${r > 0 ? '+' : '−'}${text}`;
}
const clockMinutes = (hhmm: string) => {
const [h, m] = hhmm.split(':').map(Number);
return h * 60 + m;
};
/** Worked minutes for a start/end range; an end before the start means past midnight. */
export function rangeMinutes(start: string, end: string, breakMin: number): number {
if (!start || !end) return 0;
let span = clockMinutes(end) - clockMinutes(start);
if (span < 0) span += 24 * 60;
return Math.max(0, span - breakMin);
}
// ---------- Targets and balances ----------
export function dayWorked(entry: Entry | undefined): number {
if (!entry) return 0;
switch (entry.mode) {
case 'total':
return entry.minutes;
case 'range':
return rangeMinutes(entry.start, entry.end, entry.breakMin);
case 'off':
return 0;
}
}
/** Workload % in force on `date`: the latest period starting on or before it (100% if none). */
export function percentOn(date: string, periods: WorkloadPeriod[]): number {
let best: WorkloadPeriod | undefined;
for (const p of periods) {
if (p.from <= date && (!best || p.from > best.from)) best = p;
}
return best ? best.percent : 100;
}
export function dayTarget(date: string, data: Data): number {
const { workdays, fullTimeWeekMinutes } = data.settings;
if (!workdays.includes(isoWeekday(date))) return 0;
if (data.entries[date]?.mode === 'off') return 0;
return (fullTimeWeekMinutes / workdays.length) * (percentOn(date, data.workloadPeriods) / 100);
}
export interface DaySummary {
date: string;
worked: number;
target: number;
}
export interface WeekSummary {
monday: string;
days: DaySummary[];
worked: number;
target: number;
diff: number;
}
function summarize(monday: string, days: DaySummary[]): WeekSummary {
const worked = days.reduce((a, d) => a + d.worked, 0);
const target = days.reduce((a, d) => a + d.target, 0);
return { monday, days, worked, target, diff: worked - target };
}
/** Full Monday–Sunday summary, regardless of tracking start or today. */
export function weekSummary(monday: string, data: Data): WeekSummary {
return summarize(
monday,
weekDays(monday).map((date) => ({
date,
worked: dayWorked(data.entries[date]),
target: dayTarget(date, data),
})),
);
}
export interface HistoryRow extends WeekSummary {
/** Running balance at the end of this week (includes the opening balance). */
balance: number;
}
/**
* Week-by-week totals from the tracking start up to and including `today`.
* Days after today are left out, so an unfinished week never shows as missing hours.
*/
export function history(data: Data, today: string): { rows: HistoryRow[]; balance: number } {
const start = data.settings.trackingStart;
let balance = data.settings.openingBalanceMinutes;
const rows: HistoryRow[] = [];
if (start > today) return { rows, balance };
for (let monday = mondayOf(start); monday <= today; monday = addDays(monday, 7)) {
const days = weekDays(monday)
.filter((date) => date >= start && date <= today)
.map((date) => ({ date, worked: dayWorked(data.entries[date]), target: dayTarget(date, data) }));
const week = summarize(monday, days);
balance += week.diff;
rows.push({ ...week, balance });
}
return { rows, balance };
}
+84
View File
@@ -0,0 +1,84 @@
import { useState, type FormEvent } from 'react';
import { api, UnauthorizedError } from '../api';
interface Props {
username: string;
onDeleted: () => void;
onUnauthorized: () => void;
}
/** Account card with a two-step, password-confirmed "delete account". */
export function Account({ username, onDeleted, onUnauthorized }: Props) {
const [confirming, setConfirming] = useState(false);
const [password, setPassword] = useState('');
const [error, setError] = useState<string | null>(null);
const [busy, setBusy] = useState(false);
async function remove(e: FormEvent) {
e.preventDefault();
setBusy(true);
setError(null);
try {
await api.deleteAccount(password);
onDeleted();
} catch (err) {
if (err instanceof UnauthorizedError) return onUnauthorized();
setError((err as Error).message);
setPassword('');
} finally {
setBusy(false);
}
}
return (
<details className="card account">
<summary>
<h2>Account</h2>
<span className="muted">Logged in as {username}</span>
</summary>
{!confirming ? (
<button type="button" className="danger-outline" onClick={() => setConfirming(true)}>
Delete account…
</button>
) : (
<form className="danger-zone" onSubmit={remove}>
<p>
This permanently deletes the account <strong>{username}</strong> and all logged hours. It cannot be undone.
</p>
<label>
Enter your password to confirm
<input
type="password"
autoComplete="current-password"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
autoFocus
/>
</label>
{error && (
<p className="neg" role="alert">
{error}
</p>
)}
<div className="actions">
<button type="submit" className="danger" disabled={busy || !password}>
{busy ? 'Deleting…' : 'Delete my account'}
</button>
<button
type="button"
onClick={() => {
setConfirming(false);
setPassword('');
setError(null);
}}
>
Cancel
</button>
</div>
</form>
)}
</details>
);
}
+155
View File
@@ -0,0 +1,155 @@
import { useEffect, useState, type FormEvent } from 'react';
import { api } from '../api';
interface Props {
notice?: string;
onLogin: (username: string) => void;
}
const MIN_PASSWORD = 12;
/** Log in, or create a new account. */
export function AuthScreen({ notice, onLogin }: Props) {
const [mode, setMode] = useState<'login' | 'register'>('login');
const [registration, setRegistration] = useState<{ open: boolean; inviteRequired: boolean } | null>(null);
const [username, setUsername] = useState('');
const [password, setPassword] = useState('');
const [repeat, setRepeat] = useState('');
const [inviteCode, setInviteCode] = useState('');
const [error, setError] = useState<string | null>(null);
const [busy, setBusy] = useState(false);
useEffect(() => {
api
.registration()
.then(setRegistration)
.catch(() => setRegistration({ open: false, inviteRequired: false }));
}, []);
function switchMode(next: 'login' | 'register') {
setMode(next);
setError(null);
setPassword('');
setRepeat('');
}
async function submit(e: FormEvent) {
e.preventDefault();
setError(null);
if (mode === 'register') {
if (password.length < MIN_PASSWORD) return setError(`Password must be at least ${MIN_PASSWORD} characters`);
if (password !== repeat) return setError('Passwords do not match');
}
setBusy(true);
try {
const { username: user } =
mode === 'login'
? await api.login(username, password)
: await api.register(username, password, registration?.inviteRequired ? inviteCode : undefined);
onLogin(user);
} catch (err) {
setError((err as Error).message);
setPassword('');
setRepeat('');
} finally {
setBusy(false);
}
}
const registering = mode === 'register';
return (
<main className="login">
<form className="card" onSubmit={submit}>
<h1>Working hours</h1>
{notice && <p className="muted">{notice}</p>}
<h2>{registering ? 'Create account' : 'Log in'}</h2>
<label>
Username
<input
name="username"
autoComplete="username"
value={username}
onChange={(e) => setUsername(e.target.value)}
required
autoFocus
{...(registering
? {
minLength: 3,
maxLength: 32,
pattern: '[A-Za-z0-9][A-Za-z0-9._\\-]{2,31}',
'aria-describedby': 'username-hint',
}
: {})}
/>
</label>
{registering && (
<span id="username-hint" className="muted small hint">
3–32 letters, numbers, dots, dashes or underscores
</span>
)}
<label>
Password
<input
name="password"
type="password"
autoComplete={registering ? 'new-password' : 'current-password'}
value={password}
onChange={(e) => setPassword(e.target.value)}
required
aria-describedby={registering ? 'password-hint' : undefined}
/>
</label>
{registering && (
<span id="password-hint" className="muted small hint">
At least {MIN_PASSWORD} characters
</span>
)}
{registering && (
<label>
Repeat password
<input
name="repeat"
type="password"
autoComplete="new-password"
value={repeat}
onChange={(e) => setRepeat(e.target.value)}
required
/>
</label>
)}
{registering && registration?.inviteRequired && (
<label>
Invite code
<input name="invite" value={inviteCode} onChange={(e) => setInviteCode(e.target.value)} required />
</label>
)}
{error && (
<p className="neg" role="alert">
{error}
</p>
)}
<button type="submit" className="primary" disabled={busy}>
{registering ? (busy ? 'Creating account…' : 'Create account') : busy ? 'Logging in…' : 'Log in'}
</button>
{registering ? (
<p className="muted small">
Already have an account?{' '}
<button type="button" className="link" onClick={() => switchMode('login')}>
Log in
</button>
</p>
) : (
registration?.open && (
<p className="muted small">
New here?{' '}
<button type="button" className="link" onClick={() => switchMode('register')}>
Create an account
</button>
</p>
)
)}
</form>
</main>
);
}
+24
View File
@@ -0,0 +1,24 @@
import { formatDuration } from '../calc';
interface Props {
balance: number;
trackingStart: string;
today: string;
}
export function BalanceCard({ balance, trackingStart, today }: Props) {
const r = Math.round(balance);
const tone = r > 0 ? 'pos' : r < 0 ? 'neg' : '';
const label = r > 0 ? 'extra hours banked' : r < 0 ? 'hours short' : 'exactly even';
return (
<section className={`card balance ${tone}`}>
<span className="muted">Balance</span>
<div className="balance-value">{formatDuration(balance, true)}</div>
<div>{label}</div>
<div className="muted small">
{trackingStart} → {today}, including the opening balance
</div>
</section>
);
}
+114
View File
@@ -0,0 +1,114 @@
import type { Entry } from '../../shared/types';
import { formatHM } from '../calc';
import { DurationInput } from './DurationInput';
interface Props {
date: string;
entry: Entry | undefined;
worked: number;
target: number;
isToday: boolean;
onChange: (entry: Entry | undefined) => void;
}
const MODES: { mode: Entry['mode']; label: string }[] = [
{ mode: 'total', label: 'Total' },
{ mode: 'range', label: 'Start–End' },
{ mode: 'off', label: 'Off' },
];
function emptyEntry(mode: Entry['mode']): Entry | undefined {
switch (mode) {
case 'total':
return undefined;
case 'range':
return { mode: 'range', start: '', end: '', breakMin: 30 };
case 'off':
return { mode: 'off', note: '' };
}
}
export function DayRow({ date, entry, worked, target, isToday, onChange }: Props) {
const mode = entry?.mode ?? 'total';
const dayName = new Date(`${date}T00:00:00Z`).toLocaleDateString('en-GB', {
weekday: 'short',
day: 'numeric',
month: 'numeric',
timeZone: 'UTC',
});
return (
<div className={`day${isToday ? ' today' : ''}${target === 0 && mode !== 'off' ? ' weekend' : ''}`}>
<div className="day-name">{dayName}</div>
<div className="mode-toggle" role="group" aria-label={`Input mode for ${date}`}>
{MODES.map((m) => (
<button
key={m.mode}
type="button"
className={m.mode === mode ? 'active' : ''}
onClick={() => m.mode !== mode && onChange(emptyEntry(m.mode))}
>
{m.label}
</button>
))}
</div>
<div className="day-input">
{mode === 'total' && (
<DurationInput
value={entry?.mode === 'total' ? entry.minutes : null}
placeholder="e.g. 7:15"
min={0}
max={24 * 60}
ariaLabel={`Hours worked on ${date}`}
onChange={(minutes) => onChange(minutes === null ? undefined : { mode: 'total', minutes })}
/>
)}
{entry?.mode === 'range' && (
<span className="range">
<input
type="time"
aria-label="Start"
value={entry.start}
onChange={(e) => onChange({ ...entry, start: e.target.value })}
/>
–
<input
type="time"
aria-label="End"
value={entry.end}
onChange={(e) => onChange({ ...entry, end: e.target.value })}
/>
<label>
break
<input
type="number"
min={0}
step={5}
className="break"
value={entry.breakMin}
onChange={(e) => onChange({ ...entry, breakMin: Math.max(0, Number(e.target.value) || 0) })}
/>
min
</label>
</span>
)}
{entry?.mode === 'off' && (
<input
type="text"
className="note"
placeholder="Vacation, sick, holiday…"
value={entry.note ?? ''}
onChange={(e) => onChange({ mode: 'off', note: e.target.value })}
/>
)}
</div>
<div className="day-figures">
<span className="num">{formatHM(worked)}</span>
<span className="muted"> / {formatHM(target)}</span>
</div>
</div>
);
}
+53
View File
@@ -0,0 +1,53 @@
import { useEffect, useState } from 'react';
import { formatHM, parseDuration } from '../calc';
interface Props {
value: number | null;
onChange: (minutes: number | null) => void;
placeholder?: string;
ariaLabel?: string;
/** Allowed range in minutes; values outside it are marked invalid and not committed. */
min?: number;
max?: number;
}
/**
* Free-text duration field ("7.5", "7:15", "7h15"). Valid input is committed as you type;
* on blur the text is normalised to h:mm. Invalid text is kept and outlined until fixed.
*/
export function DurationInput({ value, onChange, placeholder, ariaLabel, min = -Infinity, max = Infinity }: Props) {
const [text, setText] = useState(value === null ? '' : formatHM(value));
const inRange = (m: number | null) => (m !== null && m >= min && m <= max ? m : null);
const parsed = inRange(parseDuration(text));
const invalid = text.trim() !== '' && parsed === null;
// Follow outside changes (another tab's edit merged in, data refreshed), but leave
// the text alone while it already means the current value — or is being typed.
useEffect(() => {
if (value !== parsed && !(value === null && text.trim() === '')) {
setText(value === null ? '' : formatHM(value));
}
// oxlint-disable-next-line react-hooks/exhaustive-deps -- only react to new values
}, [value]);
return (
<input
className={`duration${invalid ? ' invalid' : ''}`}
type="text"
inputMode="decimal"
value={text}
placeholder={placeholder}
aria-label={ariaLabel}
aria-invalid={invalid}
onChange={(e) => {
setText(e.target.value);
const p = inRange(parseDuration(e.target.value));
if (e.target.value.trim() === '') onChange(null);
else if (p !== null) onChange(p);
}}
onBlur={() => {
if (parsed !== null) setText(formatHM(parsed));
}}
/>
);
}
+58
View File
@@ -0,0 +1,58 @@
import type { HistoryRow } from '../calc';
import { formatDuration, isoWeekNumber } from '../calc';
interface Props {
rows: HistoryRow[];
selected: string;
onSelect: (monday: string) => void;
}
const tone = (m: number) => (Math.round(m) > 0 ? 'pos' : Math.round(m) < 0 ? 'neg' : '');
export function HistoryTable({ rows, selected, onSelect }: Props) {
if (rows.length === 0) {
return (
<section className="card">
<h2>History</h2>
<p className="muted">Nothing tracked yet — the tracking start date is in the future.</p>
</section>
);
}
return (
<section className="card">
<h2>History</h2>
<div className="table-wrap">
<table>
<thead>
<tr>
<th>Week</th>
<th>Starts</th>
<th className="num">Worked</th>
<th className="num">Target</th>
<th className="num">Difference</th>
<th className="num">Balance</th>
</tr>
</thead>
<tbody>
{[...rows].reverse().map((r) => (
<tr
key={r.monday}
className={r.monday === selected ? 'selected' : ''}
onClick={() => onSelect(r.monday)}
title="Show this week"
>
<td>{isoWeekNumber(r.monday)}</td>
<td>{r.monday}</td>
<td className="num">{formatDuration(r.worked)}</td>
<td className="num">{formatDuration(r.target)}</td>
<td className={`num ${tone(r.diff)}`}>{formatDuration(r.diff, true)}</td>
<td className={`num ${tone(r.balance)}`}>{formatDuration(r.balance, true)}</td>
</tr>
))}
</tbody>
</table>
</div>
</section>
);
}
+120
View File
@@ -0,0 +1,120 @@
import type { Data, Settings as SettingsT, WorkloadPeriod } from '../../shared/types';
import { formatHM } from '../calc';
import { DurationInput } from './DurationInput';
interface Props {
data: Data;
today: string;
onChange: (data: Data) => void;
}
const WEEKDAYS = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'];
export function Settings({ data, today, onChange }: Props) {
const s = data.settings;
const setSettings = (patch: Partial<SettingsT>) => onChange({ ...data, settings: { ...s, ...patch } });
const setPeriods = (workloadPeriods: WorkloadPeriod[]) => onChange({ ...data, workloadPeriods });
const toggleWorkday = (day: number) => {
const workdays = s.workdays.includes(day) ? s.workdays.filter((d) => d !== day) : [...s.workdays, day].sort();
if (workdays.length > 0) setSettings({ workdays });
};
return (
<details className="card settings">
<summary>
<h2>Settings</h2>
<span className="muted">
{formatHM(s.fullTimeWeekMinutes)} h/week at 100% · tracking since {s.trackingStart}
</span>
</summary>
<div className="settings-grid">
<label>
Full-time week (100%)
<DurationInput
value={s.fullTimeWeekMinutes}
onChange={(m) => m !== null && m > 0 && setSettings({ fullTimeWeekMinutes: m })}
/>
</label>
<label>
Tracking start
<input
type="date"
value={s.trackingStart}
onChange={(e) => e.target.value && setSettings({ trackingStart: e.target.value })}
/>
</label>
<label>
Opening balance
<DurationInput
value={s.openingBalanceMinutes}
placeholder="e.g. -2:30"
onChange={(m) => setSettings({ openingBalanceMinutes: m ?? 0 })}
/>
</label>
<fieldset>
<legend>Working days</legend>
{WEEKDAYS.map((name, i) => (
<label key={name} className="check">
<input type="checkbox" checked={s.workdays.includes(i + 1)} onChange={() => toggleWorkday(i + 1)} />
{name}
</label>
))}
</fieldset>
</div>
<h3>Workload periods</h3>
<p className="muted small">
Each period applies from its date until the next one starts. Daily target = full-time week ÷ working days ×
percentage.
</p>
<ul className="periods">
{data.workloadPeriods
.map((p, i) => ({ p, i }))
.sort((a, b) => a.p.from.localeCompare(b.p.from))
.map(({ p, i }) => (
<li key={i}>
from
<input
type="date"
value={p.from}
onChange={(e) =>
e.target.value &&
setPeriods(data.workloadPeriods.map((q, j) => (j === i ? { ...q, from: e.target.value } : q)))
}
/>
<input
type="number"
min={0}
max={100}
step={5}
value={p.percent}
onChange={(e) =>
setPeriods(
data.workloadPeriods.map((q, j) =>
j === i ? { ...q, percent: Math.min(100, Math.max(0, Number(e.target.value) || 0)) } : q,
),
)
}
/>
%<span className="muted">= {formatHM((s.fullTimeWeekMinutes * p.percent) / 100)} h/week</span>
<button
type="button"
className="link danger"
onClick={() => setPeriods(data.workloadPeriods.filter((_, j) => j !== i))}
>
Remove
</button>
</li>
))}
</ul>
<button type="button" onClick={() => setPeriods([...data.workloadPeriods, { from: today, percent: 50 }])}>
+ Add period
</button>
</details>
);
}
+78
View File
@@ -0,0 +1,78 @@
import type { Data, Entry } from '../../shared/types';
import { addDays, formatDuration, isoWeekNumber, mondayOf, weekSummary } from '../calc';
import { DayRow } from './DayRow';
interface Props {
data: Data;
monday: string;
today: string;
onNavigate: (monday: string) => void;
onEntryChange: (date: string, entry: Entry | undefined) => void;
}
const fmtDate = (iso: string, opts: Intl.DateTimeFormatOptions) =>
new Date(`${iso}T00:00:00Z`).toLocaleDateString('en-GB', { ...opts, timeZone: 'UTC' });
export function WeekView({ data, monday, today, onNavigate, onEntryChange }: Props) {
const week = weekSummary(monday, data);
const sunday = addDays(monday, 6);
const isCurrent = monday === mondayOf(today);
return (
<section className="card week">
<header className="week-nav">
<button type="button" onClick={() => onNavigate(addDays(monday, -7))} aria-label="Previous week">
◀
</button>
<h2>
Week {isoWeekNumber(monday)}
<span className="muted">
{' '}
{fmtDate(monday, { day: 'numeric', month: 'short' })} –{' '}
{fmtDate(sunday, { day: 'numeric', month: 'short', year: 'numeric' })}
</span>
</h2>
<button type="button" onClick={() => onNavigate(addDays(monday, 7))} aria-label="Next week">
▶
</button>
{!isCurrent && (
<button type="button" className="link" onClick={() => onNavigate(mondayOf(today))}>
This week
</button>
)}
</header>
<div className="days">
{week.days.map((d) => (
<DayRow
key={d.date}
date={d.date}
entry={data.entries[d.date]}
worked={d.worked}
target={d.target}
isToday={d.date === today}
onChange={(entry) => onEntryChange(d.date, entry)}
/>
))}
</div>
<footer className="week-total">
<div>
<span className="muted">Worked</span>
<strong>{formatDuration(week.worked)}</strong>
</div>
<div>
<span className="muted">Target</span>
<strong>{formatDuration(week.target)}</strong>
</div>
<div className={Math.round(week.diff) > 0 ? 'pos' : Math.round(week.diff) < 0 ? 'neg' : ''}>
<span className="muted">This week</span>
<strong>
{formatDuration(week.diff, true)}
{Math.round(week.diff) > 0 ? ' over' : Math.round(week.diff) < 0 ? ' under' : ' — on target'}
</strong>
</div>
</footer>
</section>
);
}
+10
View File
@@ -0,0 +1,10 @@
import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
import { App } from './App';
import './styles.css';
createRoot(document.getElementById('root')!).render(
<StrictMode>
<App />
</StrictMode>,
);
+32
View File
@@ -0,0 +1,32 @@
import type { Data } from '../shared/types';
const same = (a: unknown, b: unknown) => JSON.stringify(a) === JSON.stringify(b);
/**
* Three-way merge for when another tab or device saved first.
* `base` is the version this tab started from, `mine` is this tab's edited copy and
* `theirs` is what the server has now. Whatever this tab changed wins; everything it
* did not touch comes from the server. Granularity: one day entry, one setting, or the
* whole workload-period list.
*/
export function mergeData(base: Data, mine: Data, theirs: Data): Data {
const pick = <T>(b: T, m: T, t: T): T => (same(m, b) ? t : m);
const entries: Data['entries'] = {};
const dates = new Set([...Object.keys(base.entries), ...Object.keys(mine.entries), ...Object.keys(theirs.entries)]);
for (const date of dates) {
const entry = pick(base.entries[date], mine.entries[date], theirs.entries[date]);
if (entry !== undefined) entries[date] = entry;
}
const settings = { ...theirs.settings };
for (const key of Object.keys(mine.settings) as (keyof Data['settings'])[]) {
(settings as Record<string, unknown>)[key] = pick(base.settings[key], mine.settings[key], theirs.settings[key]);
}
return {
settings,
workloadPeriods: pick(base.workloadPeriods, mine.workloadPeriods, theirs.workloadPeriods),
entries,
};
}
+458
View File
@@ -0,0 +1,458 @@
:root {
--bg: #f6f7f9;
--card: #ffffff;
--text: #1c2024;
--muted: #6b7280;
--border: #e3e6ea;
--accent: #2563eb;
--pos: #15803d;
--neg: #c2410c;
--pos-bg: #ecfdf3;
--neg-bg: #fff4ed;
--today: #eff4ff;
color-scheme: light;
font-family: system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif;
font-size: 15px;
}
@media (prefers-color-scheme: dark) {
:root {
--bg: #111317;
--card: #1a1d22;
--text: #e6e8eb;
--muted: #9aa1ab;
--border: #2c3138;
--accent: #7aa2ff;
--pos: #4ade80;
--neg: #fb923c;
--pos-bg: #13261b;
--neg-bg: #2b1c12;
--today: #1b2338;
color-scheme: dark;
}
}
* {
box-sizing: border-box;
}
body {
margin: 0;
background: var(--bg);
color: var(--text);
}
main {
max-width: 1100px;
margin: 0 auto;
padding: 1.5rem 1rem 3rem;
}
h1 {
margin: 0;
font-size: 1.5rem;
}
h2 {
margin: 0;
font-size: 1.1rem;
}
h3 {
margin: 1.25rem 0 0.25rem;
font-size: 1rem;
}
.muted {
color: var(--muted);
}
.small {
font-size: 0.85rem;
}
.pos {
color: var(--pos);
}
.neg {
color: var(--neg);
}
.num {
font-variant-numeric: tabular-nums;
text-align: right;
}
input,
button {
font: inherit;
color: inherit;
background: var(--card);
border: 1px solid var(--border);
border-radius: 6px;
padding: 0.3rem 0.5rem;
}
button {
cursor: pointer;
}
button:hover {
border-color: var(--accent);
}
button.link {
border: none;
background: none;
color: var(--accent);
padding: 0.3rem;
}
button.link.danger {
color: var(--neg);
}
input:focus-visible,
button:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 1px;
}
input.invalid {
border-color: var(--neg);
background: var(--neg-bg);
}
.app-header {
display: flex;
align-items: baseline;
justify-content: space-between;
margin-bottom: 1rem;
}
.save-state {
font-size: 0.85rem;
color: var(--muted);
}
.save-state.saved {
color: var(--pos);
}
.save-state.error {
color: var(--neg);
font-weight: 600;
}
.card {
background: var(--card);
border: 1px solid var(--border);
border-radius: 10px;
padding: 1rem 1.25rem;
margin-bottom: 1rem;
}
.top {
display: grid;
grid-template-columns: minmax(0, 1fr) 260px;
gap: 1rem;
align-items: start;
}
@media (max-width: 820px) {
.top {
grid-template-columns: 1fr;
}
}
/* Week */
.week-nav {
display: flex;
align-items: center;
gap: 0.5rem;
flex-wrap: wrap;
margin-bottom: 0.75rem;
}
.week-nav h2 {
flex: 0 1 auto;
}
.days {
display: flex;
flex-direction: column;
}
.day {
display: grid;
grid-template-columns: 6.5rem auto minmax(0, 1fr) 6.5rem;
align-items: center;
gap: 0.75rem;
padding: 0.4rem 0.5rem;
border-top: 1px solid var(--border);
}
.day.today {
background: var(--today);
border-radius: 6px;
}
.day.weekend .day-name {
color: var(--muted);
}
.day-figures {
text-align: right;
white-space: nowrap;
}
@media (max-width: 640px) {
.day {
grid-template-columns: 1fr auto;
}
.day-input {
grid-column: 1 / -1;
}
}
.mode-toggle {
display: inline-flex;
}
.mode-toggle button {
border-radius: 0;
font-size: 0.8rem;
padding: 0.25rem 0.5rem;
}
.mode-toggle button:first-child {
border-radius: 6px 0 0 6px;
}
.mode-toggle button:last-child {
border-radius: 0 6px 6px 0;
}
.mode-toggle button + button {
border-left: none;
}
.mode-toggle button.active {
background: var(--accent);
color: #fff;
border-color: var(--accent);
}
input.duration {
width: 6.5rem;
}
input.note {
width: 100%;
max-width: 18rem;
}
.range {
display: inline-flex;
align-items: center;
gap: 0.35rem;
flex-wrap: wrap;
}
.range label {
display: inline-flex;
align-items: center;
gap: 0.3rem;
color: var(--muted);
}
input.break {
width: 4.5rem;
}
.week-total {
display: flex;
flex-wrap: wrap;
gap: 1.5rem;
border-top: 2px solid var(--border);
margin-top: 0.25rem;
padding-top: 0.75rem;
}
.week-total div {
display: flex;
flex-direction: column;
}
.week-total strong {
font-size: 1.1rem;
font-variant-numeric: tabular-nums;
}
.week-total .pos strong {
color: var(--pos);
}
.week-total .neg strong {
color: var(--neg);
}
/* Balance */
.balance {
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.balance.pos {
background: var(--pos-bg);
}
.balance.neg {
background: var(--neg-bg);
}
.balance-value {
font-size: 2rem;
font-weight: 700;
font-variant-numeric: tabular-nums;
}
.balance.pos .balance-value {
color: var(--pos);
}
.balance.neg .balance-value {
color: var(--neg);
}
/* History */
.table-wrap {
overflow-x: auto;
margin-top: 0.5rem;
}
table {
width: 100%;
border-collapse: collapse;
}
th,
td {
padding: 0.4rem 0.6rem;
border-bottom: 1px solid var(--border);
text-align: left;
white-space: nowrap;
}
th {
font-weight: 600;
color: var(--muted);
font-size: 0.85rem;
}
th.num,
td.num {
text-align: right;
}
tbody tr {
cursor: pointer;
}
tbody tr:hover {
background: var(--today);
}
tbody tr.selected {
background: var(--today);
font-weight: 600;
}
/* Settings */
.settings summary {
display: flex;
align-items: baseline;
gap: 1rem;
flex-wrap: wrap;
cursor: pointer;
}
.settings-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
gap: 1rem;
margin-top: 1rem;
}
.settings-grid > label {
display: flex;
flex-direction: column;
gap: 0.3rem;
font-size: 0.9rem;
}
fieldset {
border: 1px solid var(--border);
border-radius: 6px;
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
}
label.check {
display: inline-flex;
align-items: center;
gap: 0.2rem;
}
.periods {
list-style: none;
padding: 0;
margin: 0.5rem 0;
display: flex;
flex-direction: column;
gap: 0.4rem;
}
.periods li {
display: flex;
align-items: center;
gap: 0.5rem;
flex-wrap: wrap;
}
.periods input[type='number'] {
width: 4.5rem;
}
/* Header user / login */
.app-header .user {
display: flex;
align-items: baseline;
gap: 0.25rem;
}
.app-header .save-state {
margin-left: auto;
margin-right: 1rem;
}
.login {
display: flex;
justify-content: center;
padding-top: 12vh;
}
.login form {
width: 100%;
max-width: 22rem;
display: flex;
flex-direction: column;
gap: 0.9rem;
}
.login label {
display: flex;
flex-direction: column;
gap: 0.3rem;
font-size: 0.9rem;
}
.login p {
margin: 0;
}
button.primary {
background: var(--accent);
border-color: var(--accent);
color: #fff;
padding: 0.5rem;
}
button:disabled {
opacity: 0.6;
cursor: default;
}
/* Account */
.account summary {
display: flex;
align-items: baseline;
gap: 1rem;
flex-wrap: wrap;
cursor: pointer;
}
.account > button,
.danger-zone {
margin-top: 1rem;
}
.danger-zone {
display: flex;
flex-direction: column;
gap: 0.75rem;
max-width: 28rem;
}
.danger-zone p {
margin: 0;
}
.danger-zone label {
display: flex;
flex-direction: column;
gap: 0.3rem;
font-size: 0.9rem;
}
.danger-zone .actions {
display: flex;
gap: 0.5rem;
}
button.danger {
background: var(--neg);
border-color: var(--neg);
color: #fff;
}
button.danger-outline {
color: var(--neg);
border-color: var(--neg);
}
.login h2 {
font-size: 1rem;
}
.login .hint {
margin-top: -0.6rem;
}
+1
View File
@@ -0,0 +1 @@
/// <reference types="vite/client" />
+12
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.0" />
<title>workh</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/frontend/main.tsx"></script>
</body>
</html>
+4852
View File
File diff suppressed because it is too large Load Diff
+43
View File
@@ -0,0 +1,43 @@
{
"name": "workh",
"version": "0.1.0",
"private": true,
"type": "module",
"scripts": {
"dev": "concurrently -k -n api,web -c blue,green \"tsx watch --env-file-if-exists=.env backend/server.ts\" \"vite\"",
"build": "tsc --noEmit && vite build",
"start": "tsx backend/server.ts",
"typecheck": "tsc --noEmit",
"lint": "oxlint",
"format": "oxfmt",
"format:check": "oxfmt --check",
"test": "vitest run",
"test:watch": "vitest",
"test:e2e": "vite build && playwright test",
"gen-secret": "node -e \"console.log('SESSION_SECRET=' + require('node:crypto').randomBytes(32).toString('base64url'))\""
},
"dependencies": {
"express": "^5.3.0",
"react": "^19.3.0",
"react-dom": "^19.3.0",
"tsx": "^4.23.15"
},
"devDependencies": {
"@playwright/test": "^1.64.0",
"@testing-library/jest-dom": "^7.0.1",
"@testing-library/react": "^16.3.3",
"@testing-library/user-event": "^14.6.7",
"@types/express": "^5.0.6",
"@types/node": "^26.6.5",
"@types/react": "^19.3.0",
"@types/react-dom": "^19.3.0",
"@vitejs/plugin-react": "^6.1.2",
"concurrently": "^10.0.6",
"jsdom": "^30.1.2",
"oxfmt": "^0.72.0",
"oxlint": "^1.87.0",
"typescript": "^7.0.2",
"vite": "^8.3.4",
"vitest": "^5.0.3"
}
}
+26
View File
@@ -0,0 +1,26 @@
import { defineConfig, devices } from '@playwright/test';
// Two servers on throwaway data directories: the normal one, and one that requires an
// invite code. `npm run test:e2e` builds the frontend first, so both serve the same dist/.
const server = (port: number, dir: string, env = '') => ({
command: `rm -rf ${dir} && PORT=${port} DATA_DIR=${dir} SESSION_SECRET=e2e-secret-e2e-secret-e2e-secret-x ${env} node --import tsx backend/server.ts`,
url: `http://localhost:${port}/api/health`,
reuseExistingServer: false,
});
export default defineConfig({
testDir: 'e2e',
fullyParallel: true,
forbidOnly: !!process.env.CI,
retries: process.env.CI ? 1 : 0,
reporter: [['list'], ['html', { open: 'never' }]],
use: {
baseURL: 'http://localhost:3100',
trace: 'retain-on-failure',
},
webServer: [server(3100, 'e2e/.data/main'), server(3101, 'e2e/.data/invite', 'REGISTRATION_CODE=let-me-in-please')],
projects: [
{ name: 'desktop', use: { ...devices['Desktop Chrome'] }, grepInvert: /@mobile/ },
{ name: 'mobile', use: { ...devices['Pixel 7'] }, grep: /@mobile/ },
],
});
+96
View File
@@ -0,0 +1,96 @@
// Shared between the Express server and the React client.
// All durations are whole minutes; all dates are ISO "YYYY-MM-DD" strings.
export interface Settings {
/** Full-time (100%) weekly target in minutes, e.g. 36 h 15 min = 2175. */
fullTimeWeekMinutes: number;
/** ISO weekdays that are working days (1 = Monday ... 7 = Sunday). */
workdays: number[];
/** First day counted towards the running balance. */
trackingStart: string;
/** Balance carried over from before trackingStart (may be negative). */
openingBalanceMinutes: number;
}
/** Workload percentage that applies from `from` until the next period starts. */
export interface WorkloadPeriod {
from: string;
percent: number;
}
export type Entry =
| { mode: 'total'; minutes: number }
| { mode: 'range'; start: string; end: string; breakMin: number }
| { mode: 'off'; note?: string };
export interface Data {
settings: Settings;
workloadPeriods: WorkloadPeriod[];
entries: Record<string, Entry>;
}
export function defaultData(today = new Date()): Data {
const d = new Date(Date.UTC(today.getFullYear(), today.getMonth(), today.getDate()));
const dow = d.getUTCDay() || 7;
d.setUTCDate(d.getUTCDate() - (dow - 1));
const monday = d.toISOString().slice(0, 10);
return {
settings: {
fullTimeWeekMinutes: 36 * 60 + 15,
workdays: [1, 2, 3, 4, 5],
trackingStart: monday,
openingBalanceMinutes: 0,
},
workloadPeriods: [{ from: monday, percent: 100 }],
entries: {},
};
}
const ISO_DATE = /^\d{4}-\d{2}-\d{2}$/;
const HHMM = /^(\d{2}:\d{2})?$/;
const isNum = (v: unknown): v is number => typeof v === 'number' && Number.isFinite(v);
const isObj = (v: unknown): v is Record<string, unknown> => typeof v === 'object' && v !== null && !Array.isArray(v);
function isEntry(v: unknown): v is Entry {
if (!isObj(v)) return false;
switch (v.mode) {
case 'total':
return isNum(v.minutes);
case 'range':
return (
typeof v.start === 'string' &&
HHMM.test(v.start) &&
typeof v.end === 'string' &&
HHMM.test(v.end) &&
isNum(v.breakMin)
);
case 'off':
return v.note === undefined || typeof v.note === 'string';
default:
return false;
}
}
/** Runtime check used by the server before writing data.json. */
export function isData(v: unknown): v is Data {
if (!isObj(v) || !isObj(v.settings) || !Array.isArray(v.workloadPeriods) || !isObj(v.entries)) {
return false;
}
const s = v.settings;
if (
!isNum(s.fullTimeWeekMinutes) ||
!Array.isArray(s.workdays) ||
!s.workdays.every((d) => isNum(d) && d >= 1 && d <= 7) ||
typeof s.trackingStart !== 'string' ||
!ISO_DATE.test(s.trackingStart) ||
!isNum(s.openingBalanceMinutes)
) {
return false;
}
const periodsOk = v.workloadPeriods.every(
(p) => isObj(p) && typeof p.from === 'string' && ISO_DATE.test(p.from) && isNum(p.percent),
);
const entriesOk = Object.entries(v.entries).every(([k, e]) => ISO_DATE.test(k) && isEntry(e));
return periodsOk && entriesOk;
}
+383
View File
@@ -0,0 +1,383 @@
import fs from 'node:fs';
import type { Server } from 'node:http';
import type { AddressInfo } from 'node:net';
import os from 'node:os';
import path from 'node:path';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { defaultData, type Data } from '../../shared/types';
import { createApp } from '../../backend/app';
import type { AppOptions } from '../../backend/app';
const PASSWORD = 'a long test password';
let tmpDir: string;
let server: Server;
let base: string;
async function start(overrides: Partial<AppOptions> = {}) {
server = createApp({
dataDir: path.join(tmpDir, 'data'),
legacyDataFile: path.join(tmpDir, 'data.json'),
distDir: path.join(tmpDir, 'dist'),
sessionSecret: 's'.repeat(32),
loginMaxFailures: 3,
registrationsPerIpPerHour: 100,
...overrides,
}).listen(0, '127.0.0.1');
await new Promise((resolve) => server.once('listening', resolve));
base = `http://127.0.0.1:${(server.address() as AddressInfo).port}`;
}
async function restart(overrides: Partial<AppOptions> = {}) {
await new Promise((resolve) => server.close(resolve));
await start(overrides);
}
beforeEach(async () => {
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'workh-test-'));
fs.mkdirSync(path.join(tmpDir, 'dist'));
fs.writeFileSync(path.join(tmpDir, 'dist', 'index.html'), '<!doctype html><title>workh</title>');
await start();
});
afterEach(async () => {
await new Promise((resolve) => server.close(resolve));
fs.rmSync(tmpDir, { recursive: true, force: true });
});
const json = (method: string, body: unknown, cookie?: string): RequestInit => ({
method,
headers: { 'Content-Type': 'application/json', ...(cookie ? { Cookie: cookie } : {}) },
body: JSON.stringify(body),
});
const cookieOf = (r: Response) => r.headers.get('set-cookie')!.split(';')[0];
const register = (username: string, password = PASSWORD, extra: object = {}) =>
fetch(`${base}/api/register`, json('POST', { username, password, ...extra }));
const login = (username: string, password = PASSWORD) =>
fetch(`${base}/api/login`, json('POST', { username, password }));
/** Registers a user and returns their session cookie ("name=value"). */
async function newUser(username = 'aaro'): Promise<string> {
const r = await register(username);
expect(r.status).toBe(201);
return cookieOf(r);
}
const getData = (cookie: string) => fetch(`${base}/api/data`, { headers: { Cookie: cookie } });
/** Saves on top of the current version, like the app does. */
async function putData(cookie: string, data: unknown) {
const etag = (await getData(cookie)).headers.get('etag')!;
return putAt(cookie, data, etag);
}
const putAt = (cookie: string, data: unknown, etag: string) =>
fetch(`${base}/api/data`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json', Cookie: cookie, 'If-Match': etag },
body: JSON.stringify(data),
});
const deleteAccount = (cookie: string, password = PASSWORD) =>
fetch(`${base}/api/account`, json('DELETE', { password }, cookie));
const withEntry = (minutes: number): Data => ({
...defaultData(),
entries: { '2026-10-06': { mode: 'total', minutes } },
});
describe('public endpoints', () => {
it('serves health and the frontend without login', async () => {
expect((await fetch(`${base}/api/health`)).status).toBe(200);
const page = await fetch(`${base}/`);
expect(page.status).toBe(200);
expect(await page.text()).toContain('<title>workh</title>');
});
it('sets security headers', async () => {
const r = await fetch(`${base}/`);
expect(r.headers.get('content-security-policy')).toContain("default-src 'self'");
expect(r.headers.get('content-security-policy')).toContain("frame-ancestors 'none'");
expect(r.headers.get('x-frame-options')).toBe('DENY');
expect(r.headers.get('x-content-type-options')).toBe('nosniff');
expect(r.headers.get('strict-transport-security')).toMatch(/max-age=/);
expect(r.headers.get('x-powered-by')).toBeNull();
});
it('answers unknown API routes with JSON 404', async () => {
const r = await fetch(`${base}/api/nope`);
expect(r.status).toBe(404);
expect(await r.json()).toEqual({ error: 'Not found' });
});
});
describe('registration', () => {
it('creates an account and logs it in with a hardened cookie', async () => {
const r = await register('Aaro');
expect(r.status).toBe(201);
expect(await r.json()).toEqual({ username: 'aaro' });
const cookie = r.headers.get('set-cookie')!;
expect(cookie).toMatch(/^workh_session=/);
expect(cookie).toMatch(/HttpOnly/i);
expect(cookie).toMatch(/Secure/i);
expect(cookie).toMatch(/SameSite=Strict/i);
const s = await fetch(`${base}/api/session`, { headers: { Cookie: cookieOf(r) } });
expect(await s.json()).toEqual({ username: 'aaro' });
});
it('never stores the plain password', async () => {
await newUser();
const raw = fs.readFileSync(path.join(tmpDir, 'data', 'users.json'), 'utf8');
expect(raw).not.toContain(PASSWORD);
expect(raw).toMatch(/scrypt\$/);
});
it('rejects taken usernames, case-insensitively', async () => {
await newUser('aaro');
const r = await register('AARO');
expect(r.status).toBe(409);
});
it.each([
['ab', 'too short'],
['a'.repeat(33), 'too long'],
['has space', 'space'],
['../etc', 'path characters'],
['-leading', 'leading dash'],
])('rejects the username %j (%s)', async (username) => {
expect((await register(username)).status).toBe(400);
});
it('rejects short passwords', async () => {
const r = await register('aaro', 'short');
expect(r.status).toBe(400);
expect((await r.json()).error).toMatch(/12/);
});
it('requires the invite code when one is configured', async () => {
await restart({ registrationCode: 'secret-invite' });
expect(await (await fetch(`${base}/api/registration`)).json()).toEqual({ open: true, inviteRequired: true });
expect((await register('aaro')).status).toBe(403);
expect((await register('aaro', PASSWORD, { inviteCode: 'wrong' })).status).toBe(403);
expect((await register('aaro', PASSWORD, { inviteCode: 'secret-invite' })).status).toBe(201);
});
it('stops at the user limit', async () => {
await restart({ maxUsers: 2 });
await newUser('one');
await newUser('two');
expect((await register('three')).status).toBe(403);
expect((await (await fetch(`${base}/api/registration`)).json()).open).toBe(false);
});
it('limits sign-ups per IP address', async () => {
await restart({ registrationsPerIpPerHour: 2 });
await newUser('one');
await newUser('two');
const r = await register('three');
expect(r.status).toBe(429);
expect(Number(r.headers.get('retry-after'))).toBeGreaterThan(0);
});
it('gives hours from the old single-user data.json to the first account only', async () => {
fs.writeFileSync(path.join(tmpDir, 'data.json'), JSON.stringify(withEntry(123)));
const first = await newUser('first');
expect((await (await getData(first)).json()).entries['2026-10-06'].minutes).toBe(123);
expect(fs.existsSync(path.join(tmpDir, 'data.json'))).toBe(false);
expect(fs.existsSync(path.join(tmpDir, 'data.json.migrated'))).toBe(true);
const second = await newUser('second');
expect((await (await getData(second)).json()).entries).toEqual({});
});
});
describe('login', () => {
beforeEach(async () => {
await newUser('aaro');
});
it('logs in case-insensitively', async () => {
const r = await login('AARO');
expect(r.status).toBe(200);
expect(await r.json()).toEqual({ username: 'aaro' });
});
it('rejects a wrong password and an unknown user the same way', async () => {
const wrongPw = await login('aaro', 'wrong password!');
const noUser = await login('nobody');
expect(wrongPw.status).toBe(401);
expect(noUser.status).toBe(401);
expect(await wrongPw.json()).toEqual(await noUser.json());
expect(wrongPw.headers.get('set-cookie')).toBeNull();
});
it('rejects malformed login bodies', async () => {
expect((await fetch(`${base}/api/login`, json('POST', { username: 'aaro' }))).status).toBe(400);
});
it('rate-limits repeated failures, even with the right password', async () => {
for (let i = 0; i < 3; i++) expect((await login('aaro', 'wrong password!')).status).toBe(401);
expect((await login('aaro')).status).toBe(429);
});
it('rate-limits per client IP from X-Forwarded-For set by a private-network proxy', async () => {
const asClient = (ip: string, password: string) =>
fetch(`${base}/api/login`, {
...json('POST', { username: 'aaro', password }),
headers: { 'Content-Type': 'application/json', 'X-Forwarded-For': ip },
});
for (let i = 0; i < 3; i++) expect((await asClient('203.0.113.1', 'wrong password!')).status).toBe(401);
expect((await asClient('203.0.113.1', PASSWORD)).status).toBe(429);
// Another client behind the same proxy is not affected.
expect((await asClient('203.0.113.2', PASSWORD)).status).toBe(200);
});
it('logs out by clearing the cookie', async () => {
const cookie = cookieOf(await login('aaro'));
const r = await fetch(`${base}/api/logout`, json('POST', {}, cookie));
expect(r.status).toBe(204);
expect(r.headers.get('set-cookie')).toMatch(/workh_session=;.*Expires=Thu, 01 Jan 1970/i);
});
});
describe('sessions', () => {
it('rejects requests without a session', async () => {
expect((await fetch(`${base}/api/data`)).status).toBe(401);
expect((await fetch(`${base}/api/session`)).status).toBe(401);
expect((await fetch(`${base}/api/data`, json('PUT', defaultData()))).status).toBe(401);
expect((await fetch(`${base}/api/account`, json('DELETE', { password: PASSWORD }))).status).toBe(401);
});
it('rejects forged or tampered cookies', async () => {
const cookie = await newUser();
const tampered = cookie.slice(0, -2) + (cookie.endsWith('A') ? 'BB' : 'AA');
expect((await getData(tampered)).status).toBe(401);
expect((await getData('workh_session=abc.def')).status).toBe(401);
});
it('rejects a cookie signed with another secret', async () => {
const cookie = await newUser();
await restart({ sessionSecret: 'other'.repeat(8) });
expect((await getData(cookie)).status).toBe(401);
});
});
describe('data API', () => {
it('returns defaults, saves, and reads back', async () => {
const cookie = await newUser();
expect((await (await getData(cookie)).json()).settings.fullTimeWeekMinutes).toBe(2175);
expect((await putData(cookie, withEntry(450))).status).toBe(200);
expect((await (await getData(cookie)).json()).entries['2026-10-06']).toEqual({ mode: 'total', minutes: 450 });
});
it('keeps each user’s hours private', async () => {
const alice = await newUser('alice');
const bob = await newUser('bob');
await putData(alice, withEntry(111));
await putData(bob, withEntry(222));
expect((await (await getData(alice)).json()).entries['2026-10-06'].minutes).toBe(111);
expect((await (await getData(bob)).json()).entries['2026-10-06'].minutes).toBe(222);
});
it('rejects invalid shapes and malformed JSON', async () => {
const cookie = await newUser();
expect((await putData(cookie, { x: 1 })).status).toBe(400);
const bad = await fetch(`${base}/api/data`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json', Cookie: cookie, 'If-Match': '"none"' },
body: '{bad',
});
expect(bad.status).toBe(400);
expect((await (await getData(cookie)).json()).entries).toEqual({});
});
it('rejects non-JSON writes (CSRF via HTML forms)', async () => {
const cookie = await newUser();
const form = await fetch(`${base}/api/data`, {
method: 'PUT',
headers: { 'Content-Type': 'application/x-www-form-urlencoded', Cookie: cookie },
body: 'a=1',
});
expect(form.status).toBe(415);
const del = await fetch(`${base}/api/account`, {
method: 'DELETE',
headers: { 'Content-Type': 'text/plain', Cookie: cookie },
body: JSON.stringify({ password: PASSWORD }),
});
expect(del.status).toBe(415);
});
it('rejects oversized bodies', async () => {
const cookie = await newUser();
const huge = { ...defaultData(), padding: 'x'.repeat(2 * 1024 * 1024) };
expect((await putData(cookie, huge)).status).toBe(413);
});
});
describe('data versions (two tabs or devices)', () => {
it('requires the version the client started from', async () => {
const cookie = await newUser();
const r = await fetch(`${base}/api/data`, json('PUT', defaultData(), cookie));
expect(r.status).toBe(428);
});
it('refuses a save based on an old version and returns the current data', async () => {
const cookie = await newUser();
const startRev = (await getData(cookie)).headers.get('etag')!;
const first = await putAt(cookie, withEntry(111), startRev); // tab A
expect(first.status).toBe(200);
const stale = await putAt(cookie, withEntry(222), startRev); // tab B, still on the old version
expect(stale.status).toBe(412);
expect((await stale.json()).entries['2026-10-06'].minutes).toBe(111);
expect(stale.headers.get('etag')).toBe(first.headers.get('etag'));
// Nothing was overwritten.
expect((await (await getData(cookie)).json()).entries['2026-10-06'].minutes).toBe(111);
// Retrying on top of the new version works.
expect((await putAt(cookie, withEntry(222), stale.headers.get('etag')!)).status).toBe(200);
});
it('gives each saved version a new tag', async () => {
const cookie = await newUser();
const a = (await getData(cookie)).headers.get('etag');
const b = (await putData(cookie, withEntry(1))).headers.get('etag');
const c = (await putData(cookie, withEntry(2))).headers.get('etag');
expect(new Set([a, b, c]).size).toBe(3);
expect((await getData(cookie)).headers.get('etag')).toBe(c);
});
});
describe('deleting an account', () => {
it('requires the password', async () => {
const cookie = await newUser();
const r = await deleteAccount(cookie, 'wrong password!');
expect(r.status).toBe(403);
expect((await getData(cookie)).status).toBe(200);
});
it('rate-limits password guesses', async () => {
const cookie = await newUser();
for (let i = 0; i < 3; i++) expect((await deleteAccount(cookie, 'wrong password!')).status).toBe(403);
expect((await deleteAccount(cookie)).status).toBe(429);
});
it('removes the account, its data and its sessions; the name becomes free', async () => {
const cookie = await newUser('aaro');
await putData(cookie, withEntry(450));
const other = await newUser('other');
await putData(other, withEntry(60));
const r = await deleteAccount(cookie);
expect(r.status).toBe(204);
expect(r.headers.get('set-cookie')).toMatch(/workh_session=;/);
expect((await getData(cookie)).status).toBe(401); // old cookie is dead
expect((await login('aaro')).status).toBe(401);
expect(fs.readdirSync(path.join(tmpDir, 'data', 'users'))).toHaveLength(1); // only "other" left
expect((await getData(other)).status).toBe(200);
const again = await newUser('aaro');
expect((await (await getData(again)).json()).entries).toEqual({}); // fresh account, no old hours
});
});
+85
View File
@@ -0,0 +1,85 @@
import { describe, expect, it } from 'vitest';
import {
createSessionToken,
hashPassword,
isPasswordHash,
RateLimiter,
verifyPassword,
verifySessionToken,
} from '../../backend/auth';
const SECRET = 'x'.repeat(32);
describe('password hashing', () => {
it('verifies the right password and rejects others', async () => {
const hash = await hashPassword('correct horse battery');
expect(isPasswordHash(hash)).toBe(true);
expect(hash).not.toContain('correct');
await expect(verifyPassword('correct horse battery', hash)).resolves.toBe(true);
await expect(verifyPassword('wrong', hash)).resolves.toBe(false);
});
it('salts every hash', async () => {
expect(await hashPassword('same')).not.toBe(await hashPassword('same'));
});
it('rejects malformed stored hashes instead of throwing', async () => {
await expect(verifyPassword('pw', 'plaintext')).resolves.toBe(false);
await expect(verifyPassword('pw', '')).resolves.toBe(false);
});
});
describe('session tokens', () => {
const now = 1_700_000_000_000;
it('round-trips a user id', () => {
const token = createSessionToken('aaro', SECRET, 60_000, now);
expect(verifySessionToken(token, SECRET, now + 1000)).toBe('aaro');
});
it('expires', () => {
const token = createSessionToken('aaro', SECRET, 60_000, now);
expect(verifySessionToken(token, SECRET, now + 60_000)).toBeNull();
});
it('rejects a different secret', () => {
const token = createSessionToken('aaro', SECRET, 60_000, now);
expect(verifySessionToken(token, 'y'.repeat(32), now)).toBeNull();
});
it('rejects a tampered payload', () => {
const token = createSessionToken('aaro', SECRET, 60_000, now);
const [, sig] = token.split('.');
const forged = Buffer.from(JSON.stringify({ u: 'aaro', exp: now + 10 * 365 * 86_400_000 })).toString('base64url');
expect(verifySessionToken(`${forged}.${sig}`, SECRET, now)).toBeNull();
});
it('rejects garbage', () => {
for (const t of ['', 'abc', 'a.b.c', '.', 'e30.']) expect(verifySessionToken(t, SECRET, now)).toBeNull();
});
});
describe('RateLimiter', () => {
it('blocks after too many hits and unblocks when the window passes', () => {
let t = 0;
const limiter = new RateLimiter(3, 60_000, () => t);
for (let i = 0; i < 3; i++) {
expect(limiter.retryAfter('ip')).toBe(0);
limiter.hit('ip');
t += 1000;
}
expect(limiter.retryAfter('ip')).toBe(57); // first hit at t=0 leaves the window at 60 s
expect(limiter.retryAfter('other-ip')).toBe(0);
t = 60_001;
expect(limiter.retryAfter('ip')).toBe(0);
});
it('resets', () => {
const limiter = new RateLimiter(2, 60_000, () => 0);
limiter.hit('ip');
limiter.hit('ip');
expect(limiter.retryAfter('ip')).toBeGreaterThan(0);
limiter.reset('ip');
expect(limiter.retryAfter('ip')).toBe(0);
});
});
+72
View File
@@ -0,0 +1,72 @@
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { defaultData } from '../../shared/types';
import { UserStore } from '../../backend/store';
let dir: string;
beforeEach(() => {
dir = fs.mkdtempSync(path.join(os.tmpdir(), 'workh-store-'));
});
afterEach(() => {
fs.rmSync(dir, { recursive: true, force: true });
});
describe('UserStore', () => {
it('creates users with case-insensitive, unique names', () => {
const store = new UserStore(dir);
const u = store.create('Aaro', 'hash')!;
expect(u.username).toBe('aaro');
expect(store.findByName('AARO')?.id).toBe(u.id);
expect(store.create('aaro', 'hash2')).toBeNull();
expect(store.count()).toBe(1);
});
it('persists users across restarts', () => {
const u = new UserStore(dir).create('aaro', 'hash')!;
const reopened = new UserStore(dir);
expect(reopened.findById(u.id)).toEqual(u);
});
it('keeps each user’s hours separate', () => {
const store = new UserStore(dir);
const a = store.create('a-user', 'h')!;
const b = store.create('b-user', 'h')!;
const data = { ...defaultData(), entries: { '2026-10-06': { mode: 'total' as const, minutes: 60 } } };
store.writeData(a.id, data);
expect(store.readData(a.id).data).toEqual(data);
expect(store.readData(b.id).data.entries).toEqual({});
});
it('can seed a new account with existing data', () => {
const store = new UserStore(dir);
const data = { ...defaultData(), entries: { '2026-10-06': { mode: 'total' as const, minutes: 60 } } };
const u = store.create('aaro', 'h', data)!;
expect(store.readData(u.id).data).toEqual(data);
});
it('only writes on top of the expected version', () => {
const store = new UserStore(dir);
const u = store.create('aaro', 'h')!;
const { rev } = store.readData(u.id);
const first = store.writeData(u.id, defaultData(), rev);
expect(first.ok).toBe(true);
const stale = store.writeData(u.id, defaultData(), rev);
expect(stale).toEqual({ ok: false, current: store.readData(u.id) });
});
it('deletes the user and every data file', () => {
const store = new UserStore(dir);
const u = store.create('aaro', 'h')!;
store.writeData(u.id, defaultData());
store.writeData(u.id, defaultData()); // creates .bak
store.delete(u.id);
expect(store.findById(u.id)).toBeUndefined();
expect(new UserStore(dir).count()).toBe(0);
expect(fs.readdirSync(path.join(dir, 'users'))).toEqual([]);
});
});
+304
View File
@@ -0,0 +1,304 @@
// @vitest-environment jsdom
import '@testing-library/jest-dom/vitest';
import { cleanup, render, screen, waitFor, within } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import type { Data } from '../../shared/types';
import { App } from '../../frontend/App';
import { installFakeApi } from './fakeApi';
// Thursday of ISO week 41; the tracked week runs Mon 2026-10-05 – Sun 2026-10-11.
const NOW = new Date('2026-10-08T12:00:00');
const data = (): Data => ({
settings: {
fullTimeWeekMinutes: 2175,
workdays: [1, 2, 3, 4, 5],
trackingStart: '2026-10-05',
openingBalanceMinutes: 0,
},
workloadPeriods: [{ from: '2026-01-01', percent: 100 }],
entries: {
'2026-10-05': { mode: 'total', minutes: 435 },
'2026-10-06': { mode: 'total', minutes: 435 },
},
});
beforeEach(() => {
vi.useFakeTimers({ toFake: ['Date'], now: NOW });
});
afterEach(() => {
cleanup();
vi.useRealTimers();
vi.unstubAllGlobals();
});
async function logIn(user = userEvent.setup(), password = 'secret password', username = 'aaro') {
await user.type(await screen.findByLabelText('Username'), username);
await user.type(screen.getByLabelText('Password'), password);
await user.click(screen.getByRole('button', { name: 'Log in' }));
}
const balanceCard = () => document.querySelector('section.balance') as HTMLElement;
const weekFooter = () => document.querySelector('.week-total') as HTMLElement;
describe('login', () => {
it('shows the login form when there is no session', async () => {
const { fetchMock } = installFakeApi({ data: data() });
render(<App />);
expect(await screen.findByRole('button', { name: 'Log in' })).toBeInTheDocument();
expect(fetchMock).not.toHaveBeenCalledWith('/api/data', expect.anything());
});
it('shows an error for a wrong password and stays on the form', async () => {
installFakeApi({ data: data() });
render(<App />);
await logIn(undefined, 'wrong');
expect(await screen.findByRole('alert')).toHaveTextContent('Wrong username or password');
expect(screen.getByLabelText('Password')).toHaveValue('');
});
it('loads the tracker after logging in', async () => {
installFakeApi({ data: data() });
render(<App />);
await logIn();
expect(await screen.findByRole('heading', { name: /Week 41/ })).toBeInTheDocument();
expect(screen.getByText('aaro')).toBeInTheDocument();
});
it('skips the form with an existing session, and logs out', async () => {
const { state } = installFakeApi({ data: data(), loggedIn: true });
const user = userEvent.setup();
render(<App />);
await screen.findByRole('heading', { name: /Week 41/ });
await user.click(screen.getByRole('button', { name: 'Log out' }));
expect(await screen.findByRole('button', { name: 'Log in' })).toBeInTheDocument();
expect(state.session).toBeNull();
});
});
describe('tracking hours', () => {
it('shows the week totals and the running balance up to today', async () => {
installFakeApi({ data: data(), loggedIn: true });
render(<App />);
await screen.findByRole('heading', { name: /Week 41/ });
// Two 7:15 days logged; full-week target 36:15.
expect(within(weekFooter()).getByText('14 h 30 min')).toBeInTheDocument();
expect(within(weekFooter()).getByText('36 h 15 min')).toBeInTheDocument();
// Balance counts Mon–Thu (today): Wed and Thu missing -> −14 h 30 min.
expect(within(balanceCard()).getByText('−14 h 30 min')).toBeInTheDocument();
expect(within(balanceCard()).getByText('hours short')).toBeInTheDocument();
});
it('updates totals as you type and saves to the server', async () => {
const { state } = installFakeApi({ data: data(), loggedIn: true });
const user = userEvent.setup();
render(<App />);
await screen.findByRole('heading', { name: /Week 41/ });
await user.type(screen.getByLabelText('Hours worked on 2026-10-07'), '8:15');
await user.type(screen.getByLabelText('Hours worked on 2026-10-08'), '7h15');
expect(within(balanceCard()).getByText('+1 h 00 min')).toBeInTheDocument();
expect(within(balanceCard()).getByText('extra hours banked')).toBeInTheDocument();
await waitFor(() => expect(state.data.entries['2026-10-08']).toEqual({ mode: 'total', minutes: 435 }));
expect(state.data.entries['2026-10-07']).toEqual({ mode: 'total', minutes: 495 });
expect(await screen.findByText('Saved ✓')).toBeInTheDocument();
});
it('marks invalid input without saving it', async () => {
const { state } = installFakeApi({ data: data(), loggedIn: true });
const user = userEvent.setup();
render(<App />);
const input = await screen.findByLabelText('Hours worked on 2026-10-07');
await user.type(input, 'abc');
expect(input).toHaveAttribute('aria-invalid', 'true');
await new Promise((r) => setTimeout(r, 700));
expect(state.saves).toHaveLength(0);
});
it('counts start–end ranges minus the break', async () => {
const { state } = installFakeApi({ data: data(), loggedIn: true });
const user = userEvent.setup();
render(<App />);
await screen.findByRole('heading', { name: /Week 41/ });
const wed = screen.getByRole('group', { name: 'Input mode for 2026-10-07' });
await user.click(within(wed).getByRole('button', { name: 'Start–End' }));
const row = wed.closest('.day') as HTMLElement;
await user.type(within(row).getByLabelText('Start'), '08:00');
await user.type(within(row).getByLabelText('End'), '16:00');
expect(within(row).getByText('7:30')).toBeInTheDocument(); // 8 h − 30 min default break
await waitFor(() =>
expect(state.data.entries['2026-10-07']).toEqual({ mode: 'range', start: '08:00', end: '16:00', breakMin: 30 }),
);
});
it('gives an off day no target', async () => {
installFakeApi({ data: data(), loggedIn: true });
const user = userEvent.setup();
render(<App />);
const wed = await screen.findByRole('group', { name: 'Input mode for 2026-10-07' });
await user.click(within(wed).getByRole('button', { name: 'Off' }));
expect(within(weekFooter()).getByText('29 h 00 min')).toBeInTheDocument();
});
});
describe('expired session', () => {
it('asks to log in again and then saves the pending change', async () => {
const { state } = installFakeApi({ data: data(), loggedIn: true });
const user = userEvent.setup();
render(<App />);
await screen.findByRole('heading', { name: /Week 41/ });
state.expireSession = true;
await user.type(screen.getByLabelText('Hours worked on 2026-10-07'), '6');
expect(await screen.findByText(/session expired/i)).toBeInTheDocument();
expect(state.data.entries['2026-10-07']).toBeUndefined();
await logIn(user);
await waitFor(() => expect(state.data.entries['2026-10-07']).toEqual({ mode: 'total', minutes: 360 }));
});
});
describe('creating an account', () => {
async function openSignUp(user: ReturnType<typeof userEvent.setup>) {
await user.click(await screen.findByRole('button', { name: 'Create an account' }));
}
it('signs up and lands in an empty tracker', async () => {
const { state } = installFakeApi({ data: data() });
const user = userEvent.setup();
render(<App />);
await openSignUp(user);
await user.type(screen.getByLabelText('Username'), 'newbie');
await user.type(screen.getByLabelText('Password'), 'long enough password');
await user.type(screen.getByLabelText('Repeat password'), 'long enough password');
await user.click(screen.getByRole('button', { name: 'Create account' }));
expect(await screen.findByRole('heading', { name: /Week 41/ })).toBeInTheDocument();
expect(screen.getByText('newbie')).toBeInTheDocument();
expect(within(weekFooter()).getByText('0 h 00 min')).toBeInTheDocument();
expect(state.users.has('newbie')).toBe(true);
});
it('checks the password length and confirmation before sending', async () => {
const { fetchMock } = installFakeApi({ data: data() });
const user = userEvent.setup();
render(<App />);
await openSignUp(user);
await user.type(screen.getByLabelText('Username'), 'newbie');
await user.type(screen.getByLabelText('Password'), 'short');
await user.type(screen.getByLabelText('Repeat password'), 'short');
await user.click(screen.getByRole('button', { name: 'Create account' }));
expect(screen.getByRole('alert')).toHaveTextContent('at least 12 characters');
await user.type(screen.getByLabelText('Password'), 'long enough password');
await user.type(screen.getByLabelText('Repeat password'), 'a different password');
await user.click(screen.getByRole('button', { name: 'Create account' }));
expect(screen.getByRole('alert')).toHaveTextContent('Passwords do not match');
expect(fetchMock).not.toHaveBeenCalledWith('/api/register', expect.anything());
});
it('shows the server error for a taken username', async () => {
installFakeApi({ data: data() });
const user = userEvent.setup();
render(<App />);
await openSignUp(user);
await user.type(screen.getByLabelText('Username'), 'aaro');
await user.type(screen.getByLabelText('Password'), 'long enough password');
await user.type(screen.getByLabelText('Repeat password'), 'long enough password');
await user.click(screen.getByRole('button', { name: 'Create account' }));
expect(await screen.findByRole('alert')).toHaveTextContent('That username is taken');
});
it('asks for an invite code when the server requires one', async () => {
installFakeApi({ data: data(), registration: { open: true, inviteRequired: true } });
const user = userEvent.setup();
render(<App />);
await openSignUp(user);
expect(screen.getByLabelText('Invite code')).toBeRequired();
});
it('hides sign-up when registration is closed', async () => {
installFakeApi({ data: data(), registration: { open: false, inviteRequired: false } });
render(<App />);
await screen.findByRole('button', { name: 'Log in' });
await waitFor(() => expect(screen.queryByRole('button', { name: 'Create an account' })).toBeNull());
});
});
describe('deleting an account', () => {
async function openDelete(user: ReturnType<typeof userEvent.setup>) {
await screen.findByRole('heading', { name: /Week 41/ });
await user.click(screen.getByText('Account'));
await user.click(screen.getByRole('button', { name: 'Delete account…' }));
}
it('can be cancelled', async () => {
const { state } = installFakeApi({ data: data(), loggedIn: true });
const user = userEvent.setup();
render(<App />);
await openDelete(user);
await user.click(screen.getByRole('button', { name: 'Cancel' }));
expect(screen.getByRole('button', { name: 'Delete account…' })).toBeInTheDocument();
expect(state.users.has('aaro')).toBe(true);
});
it('needs the right password, then deletes and returns to the login screen', async () => {
const { state } = installFakeApi({ data: data(), loggedIn: true });
const user = userEvent.setup();
render(<App />);
await openDelete(user);
const deleteButton = screen.getByRole('button', { name: 'Delete my account' });
expect(deleteButton).toBeDisabled();
await user.type(screen.getByLabelText('Enter your password to confirm'), 'wrong');
await user.click(deleteButton);
expect(await screen.findByRole('alert')).toHaveTextContent('Wrong password');
expect(state.users.has('aaro')).toBe(true);
await user.type(screen.getByLabelText('Enter your password to confirm'), 'secret password');
await user.click(screen.getByRole('button', { name: 'Delete my account' }));
expect(await screen.findByText('Your account and all its data were deleted.')).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Log in' })).toBeInTheDocument();
expect(state.users.has('aaro')).toBe(false);
});
it('drops a not-yet-saved change instead of writing it after deletion', async () => {
const { state } = installFakeApi({ data: data(), loggedIn: true });
const user = userEvent.setup();
render(<App />);
await openDelete(user);
// Type a change, then delete before the 500 ms save debounce fires.
await user.type(screen.getByLabelText('Hours worked on 2026-10-07'), '6');
await user.type(screen.getByLabelText('Enter your password to confirm'), 'secret password');
await user.click(screen.getByRole('button', { name: 'Delete my account' }));
await screen.findByText('Your account and all its data were deleted.');
await new Promise((r) => setTimeout(r, 700));
expect(state.saves).toHaveLength(0);
});
});
describe('expired session with another user', () => {
it('never saves one user’s pending change into another account', async () => {
const { state } = installFakeApi({ data: data(), loggedIn: true });
state.users.set('other', { password: 'other password!', data: data() });
const user = userEvent.setup();
render(<App />);
await screen.findByRole('heading', { name: /Week 41/ });
state.expireSession = true;
await user.type(screen.getByLabelText('Hours worked on 2026-10-07'), '6');
await screen.findByText(/session expired/i);
await logIn(user, 'other password!', 'other');
await screen.findByText('other');
await new Promise((r) => setTimeout(r, 700));
expect(state.saves.filter((s) => s.username === 'other')).toHaveLength(0);
expect(state.users.get('other')!.data.entries['2026-10-07']).toBeUndefined();
});
});
+75
View File
@@ -0,0 +1,75 @@
// @vitest-environment jsdom
import '@testing-library/jest-dom/vitest';
import { cleanup, fireEvent, render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { useState } from 'react';
import { afterEach, describe, expect, it } from 'vitest';
import type { Data } from '../../shared/types';
import { Settings } from '../../frontend/components/Settings';
afterEach(cleanup);
const initial: Data = {
settings: {
fullTimeWeekMinutes: 2175,
workdays: [1, 2, 3, 4, 5],
trackingStart: '2026-10-05',
openingBalanceMinutes: 0,
},
workloadPeriods: [{ from: '2026-01-01', percent: 100 }],
entries: {},
};
function Harness({ onData }: { onData: (d: Data) => void }) {
const [data, setData] = useState(initial);
return (
<Settings
data={data}
today="2026-10-08"
onChange={(d) => {
setData(d);
onData(d);
}}
/>
);
}
describe('Settings', () => {
it('adds a 50% period and shows its weekly hours', async () => {
let latest = initial;
const user = userEvent.setup();
render(<Harness onData={(d) => (latest = d)} />);
await user.click(screen.getByText('Settings'));
await user.click(screen.getByRole('button', { name: '+ Add period' }));
expect(latest.workloadPeriods).toEqual([
{ from: '2026-01-01', percent: 100 },
{ from: '2026-10-08', percent: 50 },
]);
expect(screen.getByText('= 18:08 h/week')).toBeInTheDocument();
});
it('changes a period to 25% and clamps the percentage', async () => {
let latest = initial;
const user = userEvent.setup();
render(<Harness onData={(d) => (latest = d)} />);
await user.click(screen.getByText('Settings'));
const pct = screen.getByDisplayValue('100');
fireEvent.change(pct, { target: { value: '25' } });
expect(latest.workloadPeriods[0].percent).toBe(25);
fireEvent.change(pct, { target: { value: '250' } });
expect(latest.workloadPeriods[0].percent).toBe(100);
});
it('accepts a negative opening balance', async () => {
let latest = initial;
const user = userEvent.setup();
render(<Harness onData={(d) => (latest = d)} />);
await user.click(screen.getByText('Settings'));
const input = screen.getByPlaceholderText('e.g. -2:30');
await user.clear(input);
await user.type(input, '-2:30');
expect(latest.settings.openingBalanceMinutes).toBe(-150);
});
});
+137
View File
@@ -0,0 +1,137 @@
import { describe, expect, it } from 'vitest';
import type { Data } from '../../shared/types';
import {
dayTarget,
formatDuration,
formatHM,
history,
isoWeekNumber,
mondayOf,
parseDuration,
rangeMinutes,
weekSummary,
} from '../../frontend/calc';
// 2026-10-05 is a Monday (ISO week 41).
const MON = '2026-10-05';
function data(overrides: Partial<Data> = {}): Data {
return {
settings: {
fullTimeWeekMinutes: 2175,
workdays: [1, 2, 3, 4, 5],
trackingStart: MON,
openingBalanceMinutes: 0,
},
workloadPeriods: [{ from: '2026-01-01', percent: 100 }],
entries: {},
...overrides,
};
}
const fullWeek = (minutes: number) =>
Object.fromEntries(['05', '06', '07', '08', '09'].map((d) => [`2026-10-${d}`, { mode: 'total' as const, minutes }]));
describe('parseDuration', () => {
it.each([
['7.5', 450],
['7,25', 435],
['7:15', 435],
['7h15', 435],
['7h 15min', 435],
['7h', 420],
['45m', 45],
['-1:30', -90],
['8', 480],
])('%s -> %i', (input, expected) => expect(parseDuration(input)).toBe(expected));
it.each(['', 'abc', '7:75', '7h99'])('rejects %j', (input) => expect(parseDuration(input)).toBeNull());
});
describe('formatting', () => {
it('formats h:mm and long durations', () => {
expect(formatHM(435)).toBe('7:15');
expect(formatHM(-90)).toBe('-1:30');
expect(formatDuration(65, true)).toBe('+1 h 05 min');
expect(formatDuration(-45, true)).toBe('−0 h 45 min');
expect(formatDuration(0, true)).toBe('0 h 00 min');
});
});
describe('dates', () => {
it('finds the Monday and ISO week', () => {
expect(mondayOf('2026-10-11')).toBe(MON);
expect(mondayOf(MON)).toBe(MON);
expect(isoWeekNumber(MON)).toBe(41);
expect(isoWeekNumber('2027-01-01')).toBe(53);
});
});
describe('rangeMinutes', () => {
it('subtracts the break and handles past-midnight shifts', () => {
expect(rangeMinutes('08:00', '16:00', 30)).toBe(450);
expect(rangeMinutes('22:00', '02:00', 0)).toBe(240);
expect(rangeMinutes('', '16:00', 0)).toBe(0);
});
});
describe('targets', () => {
it('is 7:15 per weekday at 100% and 0 at weekends', () => {
const d = data();
expect(dayTarget(MON, d)).toBe(435);
expect(dayTarget('2026-10-10', d)).toBe(0);
expect(weekSummary(MON, d).target).toBe(2175);
});
it('follows 50% and 25% workload periods', () => {
const d = data({
workloadPeriods: [
{ from: '2026-01-01', percent: 100 },
{ from: '2026-10-07', percent: 50 },
{ from: '2026-10-09', percent: 25 },
],
});
expect(dayTarget('2026-10-06', d)).toBe(435);
expect(dayTarget('2026-10-07', d)).toBe(217.5);
expect(dayTarget('2026-10-09', d)).toBe(108.75);
expect(weekSummary(MON, d).target).toBe(435 * 2 + 217.5 * 2 + 108.75);
});
it('gives off days a zero target', () => {
const d = data({ entries: { [MON]: { mode: 'off', note: 'vacation' } } });
expect(dayTarget(MON, d)).toBe(0);
expect(weekSummary(MON, d).target).toBe(2175 - 435);
});
});
describe('weekSummary and history', () => {
it('is even for a full 7:15 week', () => {
const d = data({ entries: fullWeek(435) });
expect(weekSummary(MON, d)).toMatchObject({ worked: 2175, target: 2175, diff: 0 });
});
it('accumulates over/under hours across weeks with an opening balance', () => {
const d = data({
settings: { ...data().settings, openingBalanceMinutes: 30 },
entries: {
...fullWeek(495), // +60 min/day -> +5 h
'2026-10-12': { mode: 'total', minutes: 375 }, // -60 min
},
});
const { rows, balance } = history(d, '2026-10-12');
expect(rows).toHaveLength(2);
expect(rows[0]).toMatchObject({ diff: 300, balance: 330 });
expect(rows[1]).toMatchObject({ diff: -60, balance: 270 });
expect(balance).toBe(270);
});
it('ignores days after today and before the tracking start', () => {
const d = data({ settings: { ...data().settings, trackingStart: '2026-10-07' } });
// Wed + Thu counted, nothing logged -> -2 × 7:15
expect(history(d, '2026-10-08').balance).toBe(-870);
});
it('is empty when tracking starts in the future', () => {
expect(history(data(), '2026-10-01')).toEqual({ rows: [], balance: 0 });
});
});
+96
View File
@@ -0,0 +1,96 @@
import { vi } from 'vitest';
import { defaultData, type Data } from '../../shared/types';
interface FakeUser {
password: string;
data: Data;
/** Data version, bumped on every save (the server's ETag). */
rev?: number;
}
/** In-memory stand-in for the server API, installed as global fetch. */
export function installFakeApi(opts: {
data: Data;
/** Start with this user already logged in (cookie present). */
loggedIn?: boolean;
registration?: { open: boolean; inviteRequired: boolean };
}) {
const state = {
users: new Map<string, FakeUser>([['aaro', { password: 'secret password', data: structuredClone(opts.data) }]]),
session: (opts.loggedIn ? 'aaro' : null) as string | null,
saves: [] as { username: string; data: Data }[],
/** When true, the next request that needs a session gets 401 (simulates expiry). */
expireSession: false,
get data() {
return this.users.get('aaro')?.data as Data;
},
};
const registration = opts.registration ?? { open: true, inviteRequired: false };
const reply = (status: number, body?: unknown, headers: Record<string, string> = {}) =>
Promise.resolve(
new Response(body === undefined ? null : JSON.stringify(body), {
status,
headers: { 'Content-Type': 'application/json', ...headers },
}),
);
const etag = (u: FakeUser) => ({ ETag: `"${u.rev ?? 0}"` });
const notLoggedIn = () => reply(401, { error: 'Not logged in' });
const fetchMock = vi.fn<(url: string, init?: RequestInit) => Promise<Response>>((url, init = {}) => {
const method = init.method ?? 'GET';
const body = init.body ? JSON.parse(init.body as string) : undefined;
if (state.expireSession && url !== '/api/login') {
state.session = null;
state.expireSession = false;
}
const me = state.session ? state.users.get(state.session) : undefined;
switch (`${method} ${url}`) {
case 'GET /api/registration':
return reply(200, registration);
case 'POST /api/register': {
const name = String(body.username).toLowerCase();
if (state.users.has(name)) return reply(409, { error: 'That username is taken' });
state.users.set(name, { password: body.password, data: defaultData() });
state.session = name;
return reply(201, { username: name });
}
case 'POST /api/login': {
const name = String(body.username).toLowerCase();
if (state.users.get(name)?.password !== body.password) {
return reply(401, { error: 'Wrong username or password' });
}
state.session = name;
return reply(200, { username: name });
}
case 'POST /api/logout':
state.session = null;
return reply(204);
case 'GET /api/session':
return me ? reply(200, { username: state.session }) : notLoggedIn();
case 'DELETE /api/account':
if (!me) return notLoggedIn();
if (body.password !== me.password) return reply(403, { error: 'Wrong password' });
state.users.delete(state.session!);
state.session = null;
return reply(204);
case 'GET /api/data':
return me ? reply(200, me.data, etag(me)) : notLoggedIn();
case 'PUT /api/data': {
if (!me) return notLoggedIn();
const ifMatch = (init.headers as Record<string, string> | undefined)?.['If-Match'];
if (ifMatch !== etag(me).ETag) return reply(412, me.data, etag(me));
me.data = body;
me.rev = (me.rev ?? 0) + 1;
state.saves.push({ username: state.session!, data: body });
return reply(200, { ok: true }, etag(me));
}
default:
return reply(404, { error: 'Not found' });
}
});
vi.stubGlobal('fetch', fetchMock);
return { state, fetchMock };
}
+49
View File
@@ -0,0 +1,49 @@
import { describe, expect, it } from 'vitest';
import type { Data } from '../../shared/types';
import { mergeData } from '../../frontend/merge';
const base: Data = {
settings: {
fullTimeWeekMinutes: 2175,
workdays: [1, 2, 3, 4, 5],
trackingStart: '2026-10-05',
openingBalanceMinutes: 0,
},
workloadPeriods: [{ from: '2026-01-01', percent: 100 }],
entries: { '2026-10-05': { mode: 'total', minutes: 435 } },
};
describe('mergeData', () => {
it('keeps changes to different days from both sides', () => {
const mine = { ...base, entries: { ...base.entries, '2026-10-06': { mode: 'total' as const, minutes: 420 } } };
const theirs = { ...base, entries: { ...base.entries, '2026-10-05': { mode: 'total' as const, minutes: 480 } } };
expect(mergeData(base, mine, theirs).entries).toEqual({
'2026-10-05': { mode: 'total', minutes: 480 },
'2026-10-06': { mode: 'total', minutes: 420 },
});
});
it('lets my edit win when both sides changed the same day', () => {
const mine = { ...base, entries: { '2026-10-05': { mode: 'total' as const, minutes: 100 } } };
const theirs = { ...base, entries: { '2026-10-05': { mode: 'total' as const, minutes: 200 } } };
expect(mergeData(base, mine, theirs).entries['2026-10-05']).toEqual({ mode: 'total', minutes: 100 });
});
it('keeps deletions from either side', () => {
const mine = { ...base, entries: {} };
expect(mergeData(base, mine, base).entries).toEqual({});
expect(mergeData(base, base, mine).entries).toEqual({});
});
it('merges settings field by field and periods as a whole', () => {
const mine = { ...base, settings: { ...base.settings, openingBalanceMinutes: -60 } };
const theirs = {
...base,
settings: { ...base.settings, trackingStart: '2026-09-28' },
workloadPeriods: [...base.workloadPeriods, { from: '2026-11-01', percent: 50 }],
};
const merged = mergeData(base, mine, theirs);
expect(merged.settings).toMatchObject({ openingBalanceMinutes: -60, trackingStart: '2026-09-28' });
expect(merged.workloadPeriods).toHaveLength(2);
});
});
+18
View File
@@ -0,0 +1,18 @@
{
"compilerOptions": {
"target": "ES2022",
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"module": "ESNext",
"moduleResolution": "bundler",
"jsx": "react-jsx",
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"skipLibCheck": true,
"isolatedModules": true,
"noEmit": true,
"types": ["node"]
},
"include": ["frontend", "shared", "backend", "tests", "e2e", "vite.config.ts", "playwright.config.ts"]
}
+13
View File
@@ -0,0 +1,13 @@
/// <reference types="vitest/config" />
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
export default defineConfig({
plugins: [react()],
test: {
exclude: ['e2e/**', 'node_modules/**'],
},
server: {
proxy: { '/api': 'http://127.0.0.1:3001' },
},
});