mirror of
https://github.com/iritmaximus/workh.git
synced 2026-10-12 19:13:33 +03:00
Initial commit
This commit is contained in:
@@ -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
@@ -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
|
||||||
@@ -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"]
|
||||||
|
}
|
||||||
@@ -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
@@ -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"]
|
||||||
@@ -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
@@ -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
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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';
|
||||||
@@ -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)));
|
||||||
@@ -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) };
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
});
|
||||||
@@ -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
@@ -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 };
|
||||||
@@ -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;
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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;
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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;
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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 };
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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));
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>,
|
||||||
|
);
|
||||||
@@ -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,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
Vendored
+1
@@ -0,0 +1 @@
|
|||||||
|
/// <reference types="vite/client" />
|
||||||
+12
@@ -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>
|
||||||
Generated
+4852
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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/ },
|
||||||
|
],
|
||||||
|
});
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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 });
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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 };
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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"]
|
||||||
|
}
|
||||||
@@ -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' },
|
||||||
|
},
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user