commit b6af96cf6dac21435902ec8bec92d0a44556ac07 Author: Aaro Koivukangas Date: Sun Oct 11 03:26:15 2026 +0300 Initial commit diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..89b4af5 --- /dev/null +++ b/.dockerignore @@ -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 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..9ff70bf --- /dev/null +++ b/.gitignore @@ -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 diff --git a/.oxfmtrc.json b/.oxfmtrc.json new file mode 100644 index 0000000..19fc8a2 --- /dev/null +++ b/.oxfmtrc.json @@ -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"] +} diff --git a/.oxlintrc.json b/.oxlintrc.json new file mode 100644 index 0000000..bca5126 --- /dev/null +++ b/.oxlintrc.json @@ -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"] + } + ] + } +} diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..fee4e1d --- /dev/null +++ b/Dockerfile @@ -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"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..c9a88be --- /dev/null +++ b/README.md @@ -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/.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//` (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=" >> .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/` diff --git a/backend/app.ts b/backend/app.ts new file mode 100644 index 0000000..ed27077 --- /dev/null +++ b/backend/app.ts @@ -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 = {}) { + 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; + } +} diff --git a/backend/auth.ts b/backend/auth.ts new file mode 100644 index 0000000..6a837a4 --- /dev/null +++ b/backend/auth.ts @@ -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$$ + +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 { + return new Promise((resolve, reject) => + scryptCb(password, salt, keylen, opts, (err, key) => (err ? reject(err) : resolve(key))), + ); +} + +export async function hashPassword(password: string): Promise { + 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 { + 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; + 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(); + + 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); + } +} diff --git a/backend/config.ts b/backend/config.ts new file mode 100644 index 0000000..1e9b4f7 --- /dev/null +++ b/backend/config.ts @@ -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/.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'; diff --git a/backend/server.ts b/backend/server.ts new file mode 100644 index 0000000..eaf8daf --- /dev/null +++ b/backend/server.ts @@ -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))); diff --git a/backend/store.ts b/backend/store.ts new file mode 100644 index 0000000..e9918e6 --- /dev/null +++ b/backend/store.ts @@ -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 /users.json; each user's hours in /users/.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) }; + } +} diff --git a/e2e/account.spec.ts b/e2e/account.spec.ts new file mode 100644 index 0000000..ac43c90 --- /dev/null +++ b/e2e/account.spec.ts @@ -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; +}); diff --git a/e2e/auth.spec.ts b/e2e/auth.spec.ts new file mode 100644 index 0000000..576ddf5 --- /dev/null +++ b/e2e/auth.spec.ts @@ -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; + }); +}); diff --git a/e2e/fixtures.ts b/e2e/fixtures.ts new file mode 100644 index 0000000..b782564 --- /dev/null +++ b/e2e/fixtures.ts @@ -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 { + 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 { + 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 }; diff --git a/e2e/hours.spec.ts b/e2e/hours.spec.ts new file mode 100644 index 0000000..614a190 --- /dev/null +++ b/e2e/hours.spec.ts @@ -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; + }); +}); diff --git a/e2e/invite.spec.ts b/e2e/invite.spec.ts new file mode 100644 index 0000000..368c7bb --- /dev/null +++ b/e2e/invite.spec.ts @@ -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); + }); +}); diff --git a/e2e/mobile.spec.ts b/e2e/mobile.spec.ts new file mode 100644 index 0000000..26a7403 --- /dev/null +++ b/e2e/mobile.spec.ts @@ -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; + }); +}); diff --git a/e2e/security.spec.ts b/e2e/security.spec.ts new file mode 100644 index 0000000..3369aae --- /dev/null +++ b/e2e/security.spec.ts @@ -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 = ''; + 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([]); + }); +}); diff --git a/e2e/settings.spec.ts b/e2e/settings.spec.ts new file mode 100644 index 0000000..822da97 --- /dev/null +++ b/e2e/settings.spec.ts @@ -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; + }); +}); diff --git a/frontend/App.tsx b/frontend/App.tsx new file mode 100644 index 0000000..0830530 --- /dev/null +++ b/frontend/App.tsx @@ -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({ 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
Loading…
; + 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 ; + } + + return ( + { + 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(null); + const [loadError, setLoadError] = useState(null); + const [saveState, setSaveState] = useState('idle'); + const today = todayISO(); + const [monday, setMonday] = useState(mondayOf(today)); + const timer = useRef | undefined>(undefined); + const pending = useRef(null); + /** The latest version known to be on the server — what our edits are based on. */ + const server = useRef(null); + const saving = useRef(false); + + async function save(next: Data, attempt = 0): Promise { + 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 ( +
+

Could not load data from the server: {loadError}

+
+ ); + } + if (!data) return
Loading…
; + + const { rows, balance } = history(data, today); + + return ( +
+
+

Working hours

+ + {saveState === 'pending' || saveState === 'saving' + ? 'Saving…' + : saveState === 'saved' + ? 'Saved ✓' + : saveState === 'error' + ? 'Save failed!' + : ''} + + + {username} + + +
+ +
+ + +
+ + + + 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(); + }} + /> +
+ ); +} diff --git a/frontend/api.ts b/frontend/api.ts new file mode 100644 index 0000000..6d9cd16 --- /dev/null +++ b/frontend/api.ts @@ -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 = {}) { + 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(method: string, url: string, body?: unknown): Promise { + 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('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('DELETE', '/api/account', { password }), + /** The data plus its version tag, needed to save. */ + load: async (): Promise => { + 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; +} diff --git a/frontend/calc.ts b/frontend/calc.ts new file mode 100644 index 0000000..233952e --- /dev/null +++ b/frontend/calc.ts @@ -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 }; +} diff --git a/frontend/components/Account.tsx b/frontend/components/Account.tsx new file mode 100644 index 0000000..d2a1e4a --- /dev/null +++ b/frontend/components/Account.tsx @@ -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(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 ( +
+ +

Account

+ Logged in as {username} +
+ + {!confirming ? ( + + ) : ( +
+

+ This permanently deletes the account {username} and all logged hours. It cannot be undone. +

+ + {error && ( +

+ {error} +

+ )} +
+ + +
+
+ )} +
+ ); +} diff --git a/frontend/components/AuthScreen.tsx b/frontend/components/AuthScreen.tsx new file mode 100644 index 0000000..2c7e5bf --- /dev/null +++ b/frontend/components/AuthScreen.tsx @@ -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(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 ( +
+
+

Working hours

+ {notice &&

{notice}

} +

{registering ? 'Create account' : 'Log in'}

+ + {registering && ( + + 3–32 letters, numbers, dots, dashes or underscores + + )} + + {registering && ( + + At least {MIN_PASSWORD} characters + + )} + {registering && ( + + )} + {registering && registration?.inviteRequired && ( + + )} + {error && ( +

+ {error} +

+ )} + + {registering ? ( +

+ Already have an account?{' '} + +

+ ) : ( + registration?.open && ( +

+ New here?{' '} + +

+ ) + )} +
+
+ ); +} diff --git a/frontend/components/BalanceCard.tsx b/frontend/components/BalanceCard.tsx new file mode 100644 index 0000000..1150073 --- /dev/null +++ b/frontend/components/BalanceCard.tsx @@ -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 ( +
+ Balance +
{formatDuration(balance, true)}
+
{label}
+
+ {trackingStart} → {today}, including the opening balance +
+
+ ); +} diff --git a/frontend/components/DayRow.tsx b/frontend/components/DayRow.tsx new file mode 100644 index 0000000..1c030fb --- /dev/null +++ b/frontend/components/DayRow.tsx @@ -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 ( +
+
{dayName}
+ +
+ {MODES.map((m) => ( + + ))} +
+ +
+ {mode === 'total' && ( + onChange(minutes === null ? undefined : { mode: 'total', minutes })} + /> + )} + {entry?.mode === 'range' && ( + + onChange({ ...entry, start: e.target.value })} + /> + – + onChange({ ...entry, end: e.target.value })} + /> + + + )} + {entry?.mode === 'off' && ( + onChange({ mode: 'off', note: e.target.value })} + /> + )} +
+ +
+ {formatHM(worked)} + / {formatHM(target)} +
+
+ ); +} diff --git a/frontend/components/DurationInput.tsx b/frontend/components/DurationInput.tsx new file mode 100644 index 0000000..fdaf214 --- /dev/null +++ b/frontend/components/DurationInput.tsx @@ -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 ( + { + 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)); + }} + /> + ); +} diff --git a/frontend/components/HistoryTable.tsx b/frontend/components/HistoryTable.tsx new file mode 100644 index 0000000..2d3a69c --- /dev/null +++ b/frontend/components/HistoryTable.tsx @@ -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 ( +
+

History

+

Nothing tracked yet — the tracking start date is in the future.

+
+ ); + } + + return ( +
+

History

+
+ + + + + + + + + + + + + {[...rows].reverse().map((r) => ( + onSelect(r.monday)} + title="Show this week" + > + + + + + + + + ))} + +
WeekStartsWorkedTargetDifferenceBalance
{isoWeekNumber(r.monday)}{r.monday}{formatDuration(r.worked)}{formatDuration(r.target)}{formatDuration(r.diff, true)}{formatDuration(r.balance, true)}
+
+
+ ); +} diff --git a/frontend/components/Settings.tsx b/frontend/components/Settings.tsx new file mode 100644 index 0000000..b2254cf --- /dev/null +++ b/frontend/components/Settings.tsx @@ -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) => 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 ( +
+ +

Settings

+ + {formatHM(s.fullTimeWeekMinutes)} h/week at 100% · tracking since {s.trackingStart} + +
+ +
+ + + + + + +
+ Working days + {WEEKDAYS.map((name, i) => ( + + ))} +
+
+ +

Workload periods

+

+ Each period applies from its date until the next one starts. Daily target = full-time week ÷ working days × + percentage. +

+
    + {data.workloadPeriods + .map((p, i) => ({ p, i })) + .sort((a, b) => a.p.from.localeCompare(b.p.from)) + .map(({ p, i }) => ( +
  • + from + + e.target.value && + setPeriods(data.workloadPeriods.map((q, j) => (j === i ? { ...q, from: e.target.value } : q))) + } + /> + + setPeriods( + data.workloadPeriods.map((q, j) => + j === i ? { ...q, percent: Math.min(100, Math.max(0, Number(e.target.value) || 0)) } : q, + ), + ) + } + /> + %= {formatHM((s.fullTimeWeekMinutes * p.percent) / 100)} h/week + +
  • + ))} +
+ +
+ ); +} diff --git a/frontend/components/WeekView.tsx b/frontend/components/WeekView.tsx new file mode 100644 index 0000000..3669250 --- /dev/null +++ b/frontend/components/WeekView.tsx @@ -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 ( +
+
+ +

+ Week {isoWeekNumber(monday)} + + {' '} + {fmtDate(monday, { day: 'numeric', month: 'short' })} –{' '} + {fmtDate(sunday, { day: 'numeric', month: 'short', year: 'numeric' })} + +

+ + {!isCurrent && ( + + )} +
+ +
+ {week.days.map((d) => ( + onEntryChange(d.date, entry)} + /> + ))} +
+ +
+
+ Worked + {formatDuration(week.worked)} +
+
+ Target + {formatDuration(week.target)} +
+
0 ? 'pos' : Math.round(week.diff) < 0 ? 'neg' : ''}> + This week + + {formatDuration(week.diff, true)} + {Math.round(week.diff) > 0 ? ' over' : Math.round(week.diff) < 0 ? ' under' : ' — on target'} + +
+
+
+ ); +} diff --git a/frontend/main.tsx b/frontend/main.tsx new file mode 100644 index 0000000..ed3b67f --- /dev/null +++ b/frontend/main.tsx @@ -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( + + + , +); diff --git a/frontend/merge.ts b/frontend/merge.ts new file mode 100644 index 0000000..5457e90 --- /dev/null +++ b/frontend/merge.ts @@ -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 = (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)[key] = pick(base.settings[key], mine.settings[key], theirs.settings[key]); + } + + return { + settings, + workloadPeriods: pick(base.workloadPeriods, mine.workloadPeriods, theirs.workloadPeriods), + entries, + }; +} diff --git a/frontend/styles.css b/frontend/styles.css new file mode 100644 index 0000000..3ac7463 --- /dev/null +++ b/frontend/styles.css @@ -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; +} diff --git a/frontend/vite-env.d.ts b/frontend/vite-env.d.ts new file mode 100644 index 0000000..11f02fe --- /dev/null +++ b/frontend/vite-env.d.ts @@ -0,0 +1 @@ +/// diff --git a/index.html b/index.html new file mode 100644 index 0000000..a6e8a6c --- /dev/null +++ b/index.html @@ -0,0 +1,12 @@ + + + + + + workh + + +
+ + + diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..f775ba2 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,4852 @@ +{ + "name": "workh", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "workh", + "version": "0.1.0", + "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" + } + }, + "node_modules/@adobe/css-tools": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/@adobe/css-tools/-/css-tools-4.5.0.tgz", + "integrity": "sha512-6OzddxPio9UiWTCemp4N8cYLV2ZN1ncRnV1cVGtve7dhPOtRkleRyx32GQCYSwDYgaHU3USMm84tNsvKzRCa1Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/@asamuzakjp/css-color": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-7.1.4.tgz", + "integrity": "sha512-KdNhWlAh2sbYB8Of0Yw84/a+LO/vsBOHJcIg8BpyXo2RGrFWVJvEMRviMON+ysQu0KpV7wxrWrq9jaX0P21GzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@csstools/css-calc": "^3.4.3", + "@csstools/css-color-parser": "^4.2.6", + "@csstools/css-parser-algorithms": "^4.0.2", + "@csstools/css-tokenizer": "^4.0.2", + "lru-cache": "^11.5.3" + }, + "engines": { + "node": "^22.22.2 || ^24.15.0 || >=26.0.0" + } + }, + "node_modules/@asamuzakjp/dom-selector": { + "version": "9.2.4", + "resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-9.2.4.tgz", + "integrity": "sha512-YnVzxDxLaqL0t7Q4wfcgHZjg55Wm6y5EaO7Bhu3q9J7wq/8iw3PhsnWiOQhYekx4VexnbkgWpTT6n+QIV7kczw==", + "dev": true, + "license": "MIT", + "dependencies": { + "bidi-js": "^1.1.0", + "css-tree": "^3.2.1", + "is-potential-custom-element-name": "^1.0.1", + "lru-cache": "^11.5.3" + }, + "engines": { + "node": "^22.22.2 || ^24.15.0 || >=26.0.0" + } + }, + "node_modules/@babel/code-frame": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", + "integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/helper-validator-identifier": "^7.29.7", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/runtime": { + "version": "7.29.10", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.10.tgz", + "integrity": "sha512-NpugTzbKgGrNe45Ui32cN8vCFFMxVtP/9rtfwv0nP5FRDAjvYBxJcLVpYA9mr1jxP6cnIT7x9LUzuDTbWYYNRA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@bramus/specificity": { + "version": "2.4.3", + "resolved": "https://registry.npmjs.org/@bramus/specificity/-/specificity-2.4.3.tgz", + "integrity": "sha512-0GEz8qJWokkUVYQYRukmkSHIFNoBH8QTreeHrAFbvz1XFYc3Y+puhMs8PQ5x8GNugiC2l5jvDBXKLbBIByeSBw==", + "dev": true, + "license": "MIT", + "dependencies": { + "css-tree": "^3.2.1" + }, + "bin": { + "specificity": "bin/cli.js" + } + }, + "node_modules/@csstools/color-helpers": { + "version": "6.1.2", + "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-6.1.2.tgz", + "integrity": "sha512-grhRy3OKmniaAEKXMjua5z/EODX0MSqBGjunw8+j/3HQjOnahs2AGhvEOIYVUWcU6ScApbhLhVrQTX8XqrMrow==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "engines": { + "node": ">=20.19.0" + } + }, + "node_modules/@csstools/css-calc": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-3.4.3.tgz", + "integrity": "sha512-iex20d8CHVkyvg6B7UKV7uHnI2Bqo9g+EFfT9E0y+GvTvhZ/DwONJ+9aKb1dlqm0ZiGsL5RXjp0fCoJYnkeDjA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^4.0.2", + "@csstools/css-tokenizer": "^4.0.2" + } + }, + "node_modules/@csstools/css-color-parser": { + "version": "4.2.6", + "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-4.2.6.tgz", + "integrity": "sha512-iiPQ3iRWwnJkeEn6RIu6SJPr7hYrLz6XZ9s/QZl+2/LI5KQVjpl2fdmDSZKuD4xP6GMmMPgHFFXg6k1Wkz0Trg==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "dependencies": { + "@csstools/color-helpers": "^6.1.2", + "@csstools/css-calc": "^3.4.3" + }, + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^4.0.2", + "@csstools/css-tokenizer": "^4.0.2" + } + }, + "node_modules/@csstools/css-parser-algorithms": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-4.0.2.tgz", + "integrity": "sha512-40cSKyMvK+tq4qz6Awrlye2WGuOKt3FwPgtGg6KTfbHOWNw+Rk1rzbAtZnZ6IBhsY491HLRnDXwoyBAijmmILA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-tokenizer": "^4.0.2" + } + }, + "node_modules/@csstools/css-syntax-patches-for-csstree": { + "version": "1.1.15", + "resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.1.15.tgz", + "integrity": "sha512-J0u7HkVl2nzSlhsiTOp4AmwcUQ3D+mGEEKfBy/7To5/y7F2OHwyLrXfrhR0SMgr4p5Lo+eaMVSeai24zUcBIxA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "peerDependencies": { + "css-tree": "^3.2.1" + }, + "peerDependenciesMeta": { + "css-tree": { + "optional": true + } + } + }, + "node_modules/@csstools/css-tokenizer": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-4.0.2.tgz", + "integrity": "sha512-OoKoR0f76dCY666JlcbhmVTs2drYj1GUXZTYTcbUgJjh9Nv41aFfZ21bPQTERm5+L5cBDo466NltB2lplS5GBw==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.2.tgz", + "integrity": "sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ==", + "cpu": [ + "ppc64" + ], + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.2.tgz", + "integrity": "sha512-kXXoiPVVGQcnIYGOeaovwOURpniDBpSq4A03qkQ+BMQqtGG6HYap3xne9C1O1yo4TR3qxlCX5IqqmX6fFo2Lqg==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.2.tgz", + "integrity": "sha512-5YfKeeI8qWfBZIX+u2xZC3Zlb3Os/gLS2sbEKM+I4ZOcsWmHS2WLysCcQZDAFRslDUU5Oiq44gf6PYN1vGwG5A==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.2.tgz", + "integrity": "sha512-O387ite7SzUyCcy3JQX4P4bLtEA7bLLkx+esve5JHnyYfNTxcVpXZo9jhdB0lTKN44gztELTdU7nS8Nr16Fs1Q==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.2.tgz", + "integrity": "sha512-n4KqkOQrraxHJcgjM1RvwbigfQKIKJVpM7xp+KsxiyUSrRdIXnt73VhrPAx0fV44hgfmIVKjxMN9J1t5jySVkw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.2.tgz", + "integrity": "sha512-uq6suIWYP37qzGddBKPw5QEQPi6HiLGsO7UmkpfyaYNQ3D+rN6w6WfwH+nuqcGXWvawGwxOEroO4YGnFh95azw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.2.tgz", + "integrity": "sha512-n+I0BTSRIoy+d6RPKnEVwql5UwBJolytvY4mAOIEJorKlqgPII8ix6slVVrfZ5Tnj7glIZvloylbB/EJPMWEXw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.2.tgz", + "integrity": "sha512-78XJTJkvPs0kz2w61301PJjXl4g7q3JqiYMZ/M/yVI73EHBrCRTgkhu9oqG7vPqq+a/yadEW8aD+agKlk5xrmg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.2.tgz", + "integrity": "sha512-XlDnu2q5yoqems+xay6wSAcg9DDD7K9RLKZEBOMZm3ckNpJBvOX20tSfby8KfrrhINDyv9V2YVZKY/SpoGJI8w==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.2.tgz", + "integrity": "sha512-pW4AC0P3it8c7do9MVM4p51FzHzdM/TZrerurgRcHJ2WTa1VQ1CIq18xncfpBJw4ojkiZZrKW2yIBWBP92j6Ug==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.2.tgz", + "integrity": "sha512-CYbnj78HsIeA+DhgUKgFCfvNsTHFhMMrinUrMZpDXJXKN8T3XViTZ/+wtHeVxEWY8ewSzTFN+nRmSwO2tZaLUQ==", + "cpu": [ + "ia32" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.2.tgz", + "integrity": "sha512-buwkd8nsph4R+ajRvw0qM5Hja/TXQow3ptzWO2EbG/cqcIkHloRrdlBtQlshyYGTNFvfkfJ5tpPLVkY4DtsPfQ==", + "cpu": [ + "loong64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.2.tgz", + "integrity": "sha512-ZVykbDyk7519VwiNb9Lcj9m8XM6v5V9uKPvrEMkkEedVewf+0itkhahp4HDpgERXhwLRpWFypsGbG/J8s0QjJA==", + "cpu": [ + "mips64el" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.2.tgz", + "integrity": "sha512-CAXl+Dtd9UUuJd8pKKdwh6MLm3MUMiqMPmhZ3tTSXPqfyQ3vDl6R5hZdZ/kYojK4ofXtdfSv1tFq8XzWx3heNQ==", + "cpu": [ + "ppc64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.2.tgz", + "integrity": "sha512-GeXCej4IQtU1B+QlDV8W/RRvbzI3O/Stss+/bCXv4lZls5WGRtu2a+3JkA3i4qIUlMXpcHebWpF8AkJhATowuA==", + "cpu": [ + "riscv64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.2.tgz", + "integrity": "sha512-3H1weTYZPxt/WOhByszQZybS9w5lKzUn1FDMsgEChbHWQwHYQQRfBxgCcZvPhjHfKyJjIievvMmEUawJrdY9Dg==", + "cpu": [ + "s390x" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.2.tgz", + "integrity": "sha512-4xTZr1FUmSoQW4XIWmit3tzQrUTZM+N3P0XV8xROKYF50XfI7xeO90+1bZvNwxIufQ9hDQVRJH5YhgPVF8A/HQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.2.tgz", + "integrity": "sha512-sSATRjPeDBg3pdgHoQfoYBob11Kk1FGa9lui5RIHZCoCkJa9QKlvl3/vKz2usCmYYjs7ymJR/2Nnsqe+Hjt5nw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.2.tgz", + "integrity": "sha512-lqnzCV+mM0gIADaKihiCg6ifgfU2L3h5E33rNQBN1Y4MaVGnzryzmvvf7UHxprpQdE8hpqLolJ9Rl+SkIRDpyw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.2.tgz", + "integrity": "sha512-AL2qJILH7lNjrDmCQDvdxMfAUIv8KMNZOvrwAQ8i8//ntL9FflhOyMJ8OZSMBb8/AWXe3/5v5S20y3zCoZWKoQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.2.tgz", + "integrity": "sha512-QtiuPytchRyC4rwUKhexJdQKvDuZ6hWloi3igqPQNUJCS1/v9EiO3UTOXR6A3FoMo4fnAKbWJdqaIwhOzh8qEw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.2.tgz", + "integrity": "sha512-WkhYDmpTjLvGlScA1rwjRUmhl4k8oXR3cIbtqWmELgU/dFeHHlEllxDvdWcNJV9rbzCexB5vz8gtNewWLgCT7Q==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.2.tgz", + "integrity": "sha512-GPMSkTOtMnv2U2F8gxe4Io6qmVs+YKyp832Etqqxr0hFngmXQ3rzwytelm3GIn7T4VviRUlf3sOgBOiTdvaf7g==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.2.tgz", + "integrity": "sha512-PIhhEkE9uPBleRBrQEJpUn7MBnibZzbGzYWPmY3x+YoVg/95zbjB4CxPPOQ8l5tYYM4mMaCthF8/1DIfBQQyWQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.2.tgz", + "integrity": "sha512-YmJbfTlvU7Sdn9BB+4PRES4oB6pxgS37MAONj+hBr/cpXS1aBPKXxNnDbu+QCWPj0o9dgyxeq79g6c5P8KeuYA==", + "cpu": [ + "ia32" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.2.tgz", + "integrity": "sha512-5ebpxr3nWMzrL/rnUI755Jkuee0bHL/Gq0WTF9lvcpv73wAp5eu8MfBUgWK9bhWvZjj7yX8etf/8tI8Ney695g==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@exodus/bytes": { + "version": "1.16.0", + "resolved": "https://registry.npmjs.org/@exodus/bytes/-/bytes-1.16.0.tgz", + "integrity": "sha512-IcpW84uEn3N7ETtNZMlxKhfl6Pec8rUNGOTBtWbK1FKhJxIFAptZyVrvVRVBimAJxJCgc3PxepxkdWWG4DVzfA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + }, + "peerDependencies": { + "@noble/hashes": "^1.8.0 || ^2.0.0" + }, + "peerDependenciesMeta": { + "@noble/hashes": { + "optional": true + } + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.6.0.tgz", + "integrity": "sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@oxc-project/types": { + "version": "0.153.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.153.0.tgz", + "integrity": "sha512-jSNrRIyvPVLL5E1z/tS5zLAprosGz/FuI1PWwEOz59j3E4TTTzSpqKb/RDGqJhhW0QHYISGXxq582wGx6T11Tw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/oxc-project" + } + }, + "node_modules/@oxfmt/binding-android-arm-eabi": { + "version": "0.72.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-android-arm-eabi/-/binding-android-arm-eabi-0.72.0.tgz", + "integrity": "sha512-u4U1uaDOVBuorGNC/07NLm48nECHOiJJka002YGML16Md2DRvf38RGBt01lH60EOGvNlFVmbLoksQedNBoZHKw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxfmt/binding-android-arm64": { + "version": "0.72.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-android-arm64/-/binding-android-arm64-0.72.0.tgz", + "integrity": "sha512-GCnM+Ae2mWOKC+92y3FOb6eZJeVnBAVz82e3lITUUg+kxukYKzBNAvJ/hhHIfa2A0md55SYYhdWPddSSy3KPVA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxfmt/binding-darwin-arm64": { + "version": "0.72.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-darwin-arm64/-/binding-darwin-arm64-0.72.0.tgz", + "integrity": "sha512-LjQ8tlevdtwraFlWC6AnQ1Forzde0clRmnsP3QmK+ydSElDt/J+5Jc+m+//Ly1G97aP7WCNUw110QMIUw2K7xA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxfmt/binding-darwin-x64": { + "version": "0.72.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-darwin-x64/-/binding-darwin-x64-0.72.0.tgz", + "integrity": "sha512-+ZbcxhB1C34RQR5IldSyY6Z/yalJIxHXhP36PGE4sEq0f/lgL5jCLmOKoDeVm8dqd9gFGDsd1bNWHSGCAwdSkw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxfmt/binding-freebsd-x64": { + "version": "0.72.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-freebsd-x64/-/binding-freebsd-x64-0.72.0.tgz", + "integrity": "sha512-yGPhA8sePEBFV9iGcprhyOk3Bz3fZshswMH3DpYIPZFLT1DG5LjiZ0j6AJQpSueaL6bUK0av1K+5+fe+ldy5EQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxfmt/binding-linux-arm-gnueabihf": { + "version": "0.72.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-0.72.0.tgz", + "integrity": "sha512-/g5axuqFMcAtoQrFLR5EJEMDjR5Ea4GuzpdDmMpPWAgrF1DmL4kD1NPNVlpXEOxpFJnD6lPvLKYj1lzdlrfifA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxfmt/binding-linux-arm-musleabihf": { + "version": "0.72.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-arm-musleabihf/-/binding-linux-arm-musleabihf-0.72.0.tgz", + "integrity": "sha512-InVitdHbS09PM+w+WinIiyX37rC9R3L7lUP9mKy9uKTEDSUFaLjWsLoreRIxKivnv7Ndqc6Trvi+K+b6ZzhiJg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxfmt/binding-linux-arm64-gnu": { + "version": "0.72.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-0.72.0.tgz", + "integrity": "sha512-WKjlyRAxpPjXoqeuwFBim5LscgvY+RNEiZ971C9JlRQZONhJTsXDki/oHFYQE+fzzRpzXh3owZvOdT3kNL2g+g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxfmt/binding-linux-arm64-musl": { + "version": "0.72.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-arm64-musl/-/binding-linux-arm64-musl-0.72.0.tgz", + "integrity": "sha512-poafnbsPpJhR+5Mbwpf8CscugTyiRAzjTOxRVn8ra3X2jnURs2xL5Du2xl5g2iW85lICMglpmLf8jDjZEbNgzQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxfmt/binding-linux-ppc64-gnu": { + "version": "0.72.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-0.72.0.tgz", + "integrity": "sha512-zNUi0gKRsO7S3IfPzsTbnm88OedPuX32hmZFWZRWHvWJlAoZRwVbeO6Nb3hng4vDgL7qUKp4TGqzPz1mhsycYg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxfmt/binding-linux-riscv64-gnu": { + "version": "0.72.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-riscv64-gnu/-/binding-linux-riscv64-gnu-0.72.0.tgz", + "integrity": "sha512-qTY7z/iyF/rWI2HWwWMevXqZW5PZUOofUy2afK7v6rseXnpld3yjgyIk6pCux3DqWQ0lB+f5atvLJNBwzLvFsw==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxfmt/binding-linux-riscv64-musl": { + "version": "0.72.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-riscv64-musl/-/binding-linux-riscv64-musl-0.72.0.tgz", + "integrity": "sha512-w18qfo8vEMD6EKEHtzTvjc6jH8yCyXIT1Bn7PFWJd0PpZyzH/VQDx4xvjCh0LGz6GRzqiezbisTdG9AoXtpkZQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxfmt/binding-linux-s390x-gnu": { + "version": "0.72.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-0.72.0.tgz", + "integrity": "sha512-grW8HwEir+vK7fCcq1ZgL3NHQCfZ1PidiERE9Hbf+24xGys+DoDaoqTht4ccfn0PcBwbViLkwkda/KNpJu2KRQ==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxfmt/binding-linux-x64-gnu": { + "version": "0.72.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-x64-gnu/-/binding-linux-x64-gnu-0.72.0.tgz", + "integrity": "sha512-wQJRQfWBRIn88Pkr449EUViwRayrYUScg7ty+Ihiem21mQ9BUA6S9e/4RAgyNk9lMag0KBouyKskjq61rOC0WQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxfmt/binding-linux-x64-musl": { + "version": "0.72.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-x64-musl/-/binding-linux-x64-musl-0.72.0.tgz", + "integrity": "sha512-T1B0uQzafxuGPjjaPrZJ497nxoi1BiI65JfsYc56nNpmev3msfb/iaTmH2kQuNFqa8PKxpMlf3GhWrWBvBPORQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxfmt/binding-openharmony-arm64": { + "version": "0.72.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-openharmony-arm64/-/binding-openharmony-arm64-0.72.0.tgz", + "integrity": "sha512-OKxxgTurl7+hypVplO8QRAbbQlwEs0j1Y6FZv+ZN2qNbdxQk2muMlzuJNBGTCWwxQ0rkkOD1uK/DW1fvzoUSJA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxfmt/binding-win32-arm64-msvc": { + "version": "0.72.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-0.72.0.tgz", + "integrity": "sha512-L78TKzURqvjuZOCo88ixMbPXf/RP7u31Y0cmjJ/KEmrHmfYhmhEvsDaTYPiyTRne64jUgCbADfY0YeoQk4/Xyw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxfmt/binding-win32-ia32-msvc": { + "version": "0.72.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-win32-ia32-msvc/-/binding-win32-ia32-msvc-0.72.0.tgz", + "integrity": "sha512-gQtFW+Ii8BWgtJTAD17TY/WsdNS5gUfAPov8iI1jeq4r9lRRYImmxPL08UU00/RLaCfBJwh2UgINnDY0iOiZ+A==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxfmt/binding-win32-x64-msvc": { + "version": "0.72.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-win32-x64-msvc/-/binding-win32-x64-msvc-0.72.0.tgz", + "integrity": "sha512-uhooIN+DCzWX5uH7mQkSVCsODqsGluGJ4n3ch4+mITtWKpESMDjUJUSDBAVFuYQDwqY2McPDKdKJbM0oZVW/Qw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-android-arm-eabi": { + "version": "1.87.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-android-arm-eabi/-/binding-android-arm-eabi-1.87.0.tgz", + "integrity": "sha512-22tVIHlB9898OYsBKmYB6He1MCSqdTmK1BVWuSNUTVrpS0KWNege+U9uEYqGJ+KatQag0r/oU44i3OOs46z7HQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-android-arm64": { + "version": "1.87.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-android-arm64/-/binding-android-arm64-1.87.0.tgz", + "integrity": "sha512-JfZQ3MWf74n8ov0DojZpkOduIubYJvZL5tbTMMhDutsal6u1xH1QuNpN+lK+EQwF8AeAfgwTR2fgh5U2br27XA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-darwin-arm64": { + "version": "1.87.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-darwin-arm64/-/binding-darwin-arm64-1.87.0.tgz", + "integrity": "sha512-rkx6Xvvw93z2GImVOyvbZwAZcoY0Z6MJW177XNsSB+i/CBsH7ANyelTp01VZyIkM3iSXtqKVb4+ptBgPHap5pQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-darwin-x64": { + "version": "1.87.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-darwin-x64/-/binding-darwin-x64-1.87.0.tgz", + "integrity": "sha512-cw5nYw7L/pQ2SgGBgOcg/Qo89llQre3vIc/kuBLhSICzu1r5s8L76H3ug6AIFsD98WpROj8/z5JVzkf4daxHaQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-freebsd-x64": { + "version": "1.87.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-freebsd-x64/-/binding-freebsd-x64-1.87.0.tgz", + "integrity": "sha512-x/SuznE6loRZK9gz9AS4ZmzNzSu+5KdeoCS+sVHhnrE8HM6AD8UyTjwldQTTqlXGvkwHASgB0NiTNOc3o3PdAg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-arm-gnueabihf": { + "version": "1.87.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.87.0.tgz", + "integrity": "sha512-+JEDmCAXiiFmKhAjZhgAOwaHWY6WENOQ5QA+lFuY59tgsUCDOTRi46B+lENK6CWvtxsXpwkZXNOgpjAQcpIpnA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-arm-musleabihf": { + "version": "1.87.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm-musleabihf/-/binding-linux-arm-musleabihf-1.87.0.tgz", + "integrity": "sha512-/uC9XqXLAC9AHFLNycwmSQEvKqGrOCgU+uXALI7gNW0DG81TJ/3T2eHnKdRvcb5R1gFEfjxDwI4e4zIa29rICQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-arm64-gnu": { + "version": "1.87.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.87.0.tgz", + "integrity": "sha512-FTRYPsBy/M728vnUv7l+KAwSAa3Qi4BQbhVvy7NYuMTZ0sUa/OWsbMx79HVFw6u/5KWcaYayDsoOiv7xbevzDg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-arm64-musl": { + "version": "1.87.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.87.0.tgz", + "integrity": "sha512-6sC+N7IY0h1egd8SrYUGNN7CUyKJZ4bIZakKa7V7hfinf4jfi8Kq4+klJvFp5DqXcwMn44I5MeBwmJluO1odFg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-ppc64-gnu": { + "version": "1.87.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.87.0.tgz", + "integrity": "sha512-d5Mnn+ICbhoJPi37Ccjy+86T5qfH2+mSTrTMBXNPfB+wqSWOayBBLLIrCXdUy1YssluCVdGkl+DO9w5r5ODmUA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-riscv64-gnu": { + "version": "1.87.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-riscv64-gnu/-/binding-linux-riscv64-gnu-1.87.0.tgz", + "integrity": "sha512-sf5YWuUo1Dx9loqWCgF8P278MycfcD4JHjDC/LV5j11L3Lqa4PFiwCjD6B8kP4AjsgZZkLn3xbpJEtZBaccs4Q==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-riscv64-musl": { + "version": "1.87.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-riscv64-musl/-/binding-linux-riscv64-musl-1.87.0.tgz", + "integrity": "sha512-+aCraYbmK3uU3P0rFojjeaK9pGY6JvBDXh/0CM+gPyWKmNmXmnFe0qj4qj02U4ibFpbd9Pa9CmDvIdjJUd29rA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-s390x-gnu": { + "version": "1.87.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.87.0.tgz", + "integrity": "sha512-7rzbZQIljo6VLWVPqUyLa2P/WV3VFsJB4M52R4NCUJn1A50yjtUsHxiMHFL9opEKGFIiTbZ3VQzmtcJOPUq4NQ==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-x64-gnu": { + "version": "1.87.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.87.0.tgz", + "integrity": "sha512-Dry46Fa+AXE/xt9J/YriujU1pU9mzfNWHGQwtOYibAFH8HLrKWCi93AhIGnuUMiwn2JbuBkRyz51f50Ly4r4ow==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-x64-musl": { + "version": "1.87.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-x64-musl/-/binding-linux-x64-musl-1.87.0.tgz", + "integrity": "sha512-f/HMZez74gJvnRpGz+b5sdGyWrEgRLmFTXg9sNoqjPWogGxYHUhXKTKyMiYsu9rUGR2ciYqL3fk+Ua4JhMJbRA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-openharmony-arm64": { + "version": "1.87.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-openharmony-arm64/-/binding-openharmony-arm64-1.87.0.tgz", + "integrity": "sha512-lEC6ZfPZm/07JlB7FsOWX9o1RiHlXiBW594LSUGuXmekC8LZmvUPP9XzE2WZcNxJi5cHA/Fw56o02c3JxgMA5g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-win32-arm64-msvc": { + "version": "1.87.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.87.0.tgz", + "integrity": "sha512-afBWKOD36dUiA9AenaiWpCg8xfyUVPYznyAAVXu413brK4RA5UI/Xoc4yfeGIT8G1a44MV+JR7hioryerBDzGQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-win32-ia32-msvc": { + "version": "1.87.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-win32-ia32-msvc/-/binding-win32-ia32-msvc-1.87.0.tgz", + "integrity": "sha512-daptNsEOuru4/nJTxV4DBHNJ5GepzmaBVQf+jjgKQGi9NRlZKik+RFq4hUL7ewsF1pwjKM+SkYaMbF82UwFGnw==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-win32-x64-msvc": { + "version": "1.87.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.87.0.tgz", + "integrity": "sha512-k+m/EShyXE29J/r1fjk0HlAzhN5JhIvj044oSJkXTEkCLrWZQgXb/3r4q5LTw/eaKW/4GuMXG2xmiJs3s5rPNw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@playwright/test": { + "version": "1.64.0", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.64.0.tgz", + "integrity": "sha512-oTN9FNYuN6bqQMwiho6jJKLCG2/QOYCixGXuCjRmzOq23eODeukuY2XiAVoVpAECbuezMt1MHQjAiQxK8F+Y+g==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright": "1.64.0" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/@rolldown/binding-android-arm-eabi": { + "version": "1.2.13", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm-eabi/-/binding-android-arm-eabi-1.2.13.tgz", + "integrity": "sha512-Vu/NICrgdRvUOeaJZIyB2FP63s6fO/4ud5l8dXmxDx9yDyn+mN0y+gsvsVviSli+T361Aqo0h46EuMoobrwawA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-android-arm64": { + "version": "1.2.13", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.2.13.tgz", + "integrity": "sha512-6LEoIrGy5vvCd2boNnv2soPSUZj/PRvhp8R/t9KlX+mIZxThIE/ZqxrGXxmuNX0FuvQFvLdZr7S4HrYpYBVhEQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-arm64": { + "version": "1.2.13", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.2.13.tgz", + "integrity": "sha512-CaJSRzlQ6Q+zkDMVA9OkVxBjVoPRCDEE3v0FSJ9kIQBgRjFApA3Si4ijhbNVUji7ZJt+PfO0e46qy4nPInHUPA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-x64": { + "version": "1.2.13", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.2.13.tgz", + "integrity": "sha512-e4r340I5hxSzJkD3YJBxgzq/rJ3wwxwdLLb2FPt1Zpyem20nrQPT11DDEEkazwxaHumH8wpglsp/Ozgj/wT6mw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-freebsd-x64": { + "version": "1.2.13", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.2.13.tgz", + "integrity": "sha512-hoBWDZ4/K/mJKVMbsgUd1jfpA2DiOrJ54mpegObU9TBHBBrtX4FLIXJJQRDPZ/2Dn91GvPob+Z2hwKJzf4E5SQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm-gnueabihf": { + "version": "1.2.13", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.2.13.tgz", + "integrity": "sha512-oF9fZu3vTWnTiVAkDFxU9PJLt1nh20K9RRpuHsR7ZnE2L7XookBbDBVLojCVSLGbvtSlxeUNnjAj6DbNsKyzbg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-gnu": { + "version": "1.2.13", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.2.13.tgz", + "integrity": "sha512-DTpb/+jMiqWMPgphn4ksW1zW+XLJ6dJJ9FMvnIk9I291LhCctMPhMYJrP5t9p6C9qa3KXadDdUEZHBaPh3kNxQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-musl": { + "version": "1.2.13", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.2.13.tgz", + "integrity": "sha512-xU+Kvv9N2A3SY35zFh1FWWNjxbadE/BKeqnkXe7vScU4GZsw757BlVmDLITZEq8DPpk6m7uoDYkK7P+x/uU5Ow==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-ppc64-gnu": { + "version": "1.2.13", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.2.13.tgz", + "integrity": "sha512-XMk/hnhh25oQ756Lclb1Q11h4ll0xC0Esa5w4Vzp+mAE2S3AGJgeM0SonrIgY8fHhjarb6hcoTuwoFG0VVP2ig==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-s390x-gnu": { + "version": "1.2.13", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.2.13.tgz", + "integrity": "sha512-U1BoTHYmlywE2n3HUWjzZ+8EQsKr+OwpxYuDWaubx42tUL8QSL7nOqRz/NVHanibbFP3M1Rxuprz/wNcbMKjqw==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-gnu": { + "version": "1.2.13", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.2.13.tgz", + "integrity": "sha512-J4cm80Pc6wEwV5AZ42o62sIQgDk+Y7R2s646jIwuvO+4V46QB58N4vVuMPWCBaNgursXLpmUWJV//WQAkPvHBQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-musl": { + "version": "1.2.13", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.2.13.tgz", + "integrity": "sha512-W0euZS40U4V6JkmEnVCIX9mcsL6cbXQ4X8c2w+MMOIjFg68TXUW+u3RT+2e1t1pv9mg/EP3pDtK3POXdQ7Rxyw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-openharmony-arm64": { + "version": "1.2.13", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.2.13.tgz", + "integrity": "sha512-do/AF/4n9MRshia3a5aXoy8xM2c6mgfT6D7H/dY6my5z+hfCIR4ab7mk4IRj++nsEQpKy39kd0DQnDq+r692tw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-arm64-msvc": { + "version": "1.2.13", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.2.13.tgz", + "integrity": "sha512-6FtUf2cJZobutJdxnGYX8z0vtbgqU0b2v8vfBhkXb9wp2MppMFtkunE+IaOntyi01rkIQoPW2GHABtfzsC0BRQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-x64-msvc": { + "version": "1.2.13", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.2.13.tgz", + "integrity": "sha512-UKhFaZTeTcbKrb3iU82m17jVcPEAoqDkCN5MBqgPyZSwp/cQmib21crTS0DdH7T1B7wZRWEI89YQAOJMet5yhg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz", + "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@testing-library/dom": { + "version": "10.4.2", + "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.2.tgz", + "integrity": "sha512-yzr2S9HyAIdhz2/6qHgbs665Q7PKVcDF05vsOlHPxG1mo36gKVesdYVeDLnXgfjJ03CrKRk08knc6+E/9m8v2Q==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/code-frame": "^7.10.4", + "@babel/runtime": "^7.12.5", + "@types/aria-query": "^5.0.1", + "aria-query": "5.3.0", + "dom-accessibility-api": "^0.5.9", + "lz-string": "^1.5.0", + "picocolors": "1.1.1", + "pretty-format": "^27.0.2" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@testing-library/jest-dom": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/@testing-library/jest-dom/-/jest-dom-7.0.1.tgz", + "integrity": "sha512-oMDTC3oA+6CXSO2JZnvOI7CA6oVub6kij5ggk9ohwye5slmkwxYDXcPOVxgMw/RQlticjtO0C1RZkR97HgrWMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@adobe/css-tools": "^4.4.0", + "aria-query": "^5.0.0", + "css.escape": "^1.5.1", + "dom-accessibility-api": "^0.6.3", + "picocolors": "^1.1.1", + "redent": "^3.0.0" + }, + "engines": { + "node": ">=22", + "npm": ">=6", + "yarn": ">=1" + }, + "peerDependencies": { + "@testing-library/dom": ">=10 <11", + "vitest": ">= 0.32" + }, + "peerDependenciesMeta": { + "vitest": { + "optional": true + } + } + }, + "node_modules/@testing-library/jest-dom/node_modules/dom-accessibility-api": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.6.3.tgz", + "integrity": "sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@testing-library/react": { + "version": "16.3.3", + "resolved": "https://registry.npmjs.org/@testing-library/react/-/react-16.3.3.tgz", + "integrity": "sha512-Uo193NgQbPMz6lrrhtRQQFcMC6Re/ELLFbbuVL30WDlZxlpZf9/lMHTAVxPRLw1q1iu9OJmR1c2BLiENRstdBg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.12.5" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@testing-library/dom": "^10.0.0", + "@types/react": "^18.0.0 || ^19.0.0", + "@types/react-dom": "^18.0.0 || ^19.0.0", + "react": "^18.0.0 || ^19.0.0", + "react-dom": "^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@testing-library/user-event": { + "version": "14.6.7", + "resolved": "https://registry.npmjs.org/@testing-library/user-event/-/user-event-14.6.7.tgz", + "integrity": "sha512-MPCpX8bxe8zS+JmmTwLp8jd0dy1rAm60Te/SL8JrQM3qvQJcBOs1d7IefJMyZzqM3EWBrDn/LWDt1BCGu4ASfg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12", + "npm": ">=6" + }, + "peerDependencies": { + "@testing-library/dom": ">=7.21.4" + } + }, + "node_modules/@types/aria-query": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", + "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/@types/body-parser": { + "version": "1.19.6", + "resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.6.tgz", + "integrity": "sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/connect": "*", + "@types/node": "*" + } + }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, + "node_modules/@types/connect": { + "version": "3.4.38", + "resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.38.tgz", + "integrity": "sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/express": { + "version": "5.0.6", + "resolved": "https://registry.npmjs.org/@types/express/-/express-5.0.6.tgz", + "integrity": "sha512-sKYVuV7Sv9fbPIt/442koC7+IIwK5olP1KWeD88e/idgoJqDm3JV/YUiPwkoKK92ylff2MGxSz1CSjsXelx0YA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/body-parser": "*", + "@types/express-serve-static-core": "^5.0.0", + "@types/serve-static": "^2" + } + }, + "node_modules/@types/express-serve-static-core": { + "version": "5.1.3", + "resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-5.1.3.tgz", + "integrity": "sha512-dPfW8NFiOF4wOHc7+N/QSxlY9cfSsenewGbAz8C8U/MULPd/YZ27LvJUIlzaXie7e6Ove9YunJGgC9tbHD2cKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*", + "@types/qs": "*", + "@types/range-parser": "*", + "@types/send": "*" + } + }, + "node_modules/@types/http-errors": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@types/http-errors/-/http-errors-2.0.5.tgz", + "integrity": "sha512-r8Tayk8HJnX0FztbZN7oVqGccWgw98T/0neJphO91KkmOzug1KkofZURD4UaD5uH8AqcFLfdPErnBod0u71/qg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "26.6.5", + "resolved": "https://registry.npmjs.org/@types/node/-/node-26.6.5.tgz", + "integrity": "sha512-uMzsv9stDdofIZBx4UGh4Z9ZgXnGC9seRDscilBcTHAeOFk67MZg/k9bWVfO+bk4qZmG+l52hRnLwUiH2LnJTg==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~8.9.0" + } + }, + "node_modules/@types/qs": { + "version": "6.15.1", + "resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.15.1.tgz", + "integrity": "sha512-GZHUBZR9hckSUhrxmp1nG6NwdpM9fCunJwyThLW1X3AyHgd9IlHb6VANpQQqDr2o/qQp6McZ3y/IA2rVzKzSbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/range-parser": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/@types/range-parser/-/range-parser-1.2.7.tgz", + "integrity": "sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/react": { + "version": "19.3.0", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.3.0.tgz", + "integrity": "sha512-N0rFCuH9YoxG9/m61l9MfpJKfmLOVU0em7ipIz6TRgSSkvReLB9vL85GB+yr8Bs5leqpvg96JSwF4ZS1s4viQg==", + "dev": true, + "license": "MIT", + "dependencies": { + "csstype": "^3.2.2" + } + }, + "node_modules/@types/react-dom": { + "version": "19.3.0", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.3.0.tgz", + "integrity": "sha512-ZI7bU42mZXXKHn/qNLEw2IrbiINU7X5+vfgdixBHkCNpYWXjKgfQ/P+uyGb5CjOLB9UcnTeg3rylQtV2hym44Q==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^19.3.0" + } + }, + "node_modules/@types/send": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@types/send/-/send-1.2.1.tgz", + "integrity": "sha512-arsCikDvlU99zl1g69TcAB3mzZPpxgw0UQnaHeC1Nwb015xp8bknZv5rIfri9xTOcMuaVgvabfIRA7PSZVuZIQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/serve-static": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@types/serve-static/-/serve-static-2.2.0.tgz", + "integrity": "sha512-8mam4H1NHLtu7nmtalF7eyBH14QyOASmcxHhSfEoRyr0nP/YdoesEtU+uSRvMe96TW/HPTtkoKqQLl53N7UXMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/http-errors": "*", + "@types/node": "*" + } + }, + "node_modules/@typescript/typescript-aix-ppc64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-aix-ppc64/-/typescript-aix-ppc64-7.0.2.tgz", + "integrity": "sha512-MTKKkWB7p/0E9xi1d1tHtZ5PiLkGEMIq88pK2CubZjOsLtYTLqhgIgi6zepFa+9GHZ6h05NMCkQxGKiPXMxXtQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-darwin-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-darwin-arm64/-/typescript-darwin-arm64-7.0.2.tgz", + "integrity": "sha512-gowzar9MwS/aRWp6f3a4KUqzRjAZjOsmGNCM6LcTgXum+dBfgsBVMN+AgvOCCbguXyick6LJhpBszxMebJ8syA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-darwin-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-darwin-x64/-/typescript-darwin-x64-7.0.2.tgz", + "integrity": "sha512-SZ9xZInqApNlNGc9s0W1VSsktYSOe9cFqNOIqmN1Gs8SmkjKZYFt017G4VwPxASInODuAdbTW7sXiFUf893RgA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-freebsd-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-freebsd-arm64/-/typescript-freebsd-arm64-7.0.2.tgz", + "integrity": "sha512-W5NH4y/J0plIIS5b2xvTEkU7JFxyqdMAOgf+Ilhl0vHQXKO5dZoxd+C/jEtq56c4F3wk71RB4BMRQ2XdI+bwYQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-freebsd-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-freebsd-x64/-/typescript-freebsd-x64-7.0.2.tgz", + "integrity": "sha512-UMGDx5sTpzNw3WiPebH7l90IWfJggEd+egHt/q6p7/Cm3zqoV7VxkGXt+3DxPIw8CcmvAB0j3sVVfbhX+M4Tpw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-arm": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-arm/-/typescript-linux-arm-7.0.2.tgz", + "integrity": "sha512-gffT3xPz9sR7j/YJExkyPntrI0P2EP9XbOyWzth2/Gs0RstK+90RBcO0ncXoXy/beYll1SXw846Nf2zdnEz0QQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-arm64/-/typescript-linux-arm64-7.0.2.tgz", + "integrity": "sha512-Qh4eU4/y3yDjnfjjyPYihMj5/ODIlmt+Bzu17OI+fiSRDW57QmU5SiN63exPRNJPKUzcc1INa1NXdrJ+MqHjUQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-loong64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-loong64/-/typescript-linux-loong64-7.0.2.tgz", + "integrity": "sha512-uEHck9i8hoAzXPiYRib1O7miOnz23SxIeVl6F4LXox+qov1K35jHcEW6VHKvZI+pyvl7fZEP4MCU5LYvIq1GuQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-mips64el": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-mips64el/-/typescript-linux-mips64el-7.0.2.tgz", + "integrity": "sha512-R4KvAMnE43W5Qeqb0Ly56O3mWMWIAgsMyz36DCaycd5nbg/9kzm0liw3JocfRqyJY0KPmzFjbswozXyW0DnIYA==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-ppc64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-ppc64/-/typescript-linux-ppc64-7.0.2.tgz", + "integrity": "sha512-DORx5b3sd/4S7eayxm4FQv+A7CrkUIGRaHiwI8oiHTAI1fAPWhF4J0vAlkC8biAlHSVVwxMQ3tjZ2/DVbnQiiA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-riscv64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-riscv64/-/typescript-linux-riscv64-7.0.2.tgz", + "integrity": "sha512-wf0jqEDOjrPRnKwYRyyJDRo11KMbvMFrU+q4zqKyChODBzvlkbhNQfKvLxQCcwTpdDaXSHZTVuh0JoCrKCUMHQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-s390x": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-s390x/-/typescript-linux-s390x-7.0.2.tgz", + "integrity": "sha512-IkwJc3L7yhytWd/ewjyxNDfOmswCm9GWMJT/ue/dU4aZNbwZeYAetq42VyLmsmSjvoX7z74X6ZaYCtzAr0EuGw==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-x64/-/typescript-linux-x64-7.0.2.tgz", + "integrity": "sha512-EYdf2cNg7rgCWJnxCdJ+F3V39O8ihb37eHAu1LK8oAFizgTQbPOK7zHHXbPt8rX24COqODXeI3sIf0fCXG7H/A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-netbsd-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-netbsd-arm64/-/typescript-netbsd-arm64-7.0.2.tgz", + "integrity": "sha512-+polYF4MF04aPpO5FTkHran9yUQDSXqy5GiSDKpsll5jy3l3+g9QLhpf39T+ePtefhXLOGrLl0QIjkQP6VnelA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-netbsd-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-netbsd-x64/-/typescript-netbsd-x64-7.0.2.tgz", + "integrity": "sha512-8YIT0EHM/3dq10ZOVF/A7pc/YSMtbcecct4rWtexrnSCHOPcpC2KTLXfTCR6vDpnSiY12heNb1GiN/wu+T/FyA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-openbsd-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-openbsd-arm64/-/typescript-openbsd-arm64-7.0.2.tgz", + "integrity": "sha512-APT8+ClYnuYm1u9+kgGXoMj2VzWzcymwh2gNSQVySHfkRDGOTVkoWLjCmOQSaO+PoqQ57B0flRp9SA+7GnnkzQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-openbsd-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-openbsd-x64/-/typescript-openbsd-x64-7.0.2.tgz", + "integrity": "sha512-yX7s+Q0Dln0Dt9tEzZsAjXXR/+ytBM7AlglaqyeMPxQszJ1JhlJdZ6jLA+IzldHtflX81em7lDao1xXu+aRRkg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-sunos-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-sunos-x64/-/typescript-sunos-x64-7.0.2.tgz", + "integrity": "sha512-dLJDGaLZ1D4HPQn62u1n8mBDkJREwMsAkCdkwd4Ieqw+x3TUyTsqY0YiBCtE6H6OzzgGk3iuZ3vFWRS+E8/d1g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-win32-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-win32-arm64/-/typescript-win32-arm64-7.0.2.tgz", + "integrity": "sha512-Gyl1Vy6OsWesLzmq+EP0Fb7b4Nid5232AvcA2SFcdYreldpNtYFFofPjnt62y9hQy7VTaZp65ICJjuAQRaVcIQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-win32-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-win32-x64/-/typescript-win32-x64-7.0.2.tgz", + "integrity": "sha512-0BQ3HkAHHlKLSp1qRvf3SUhGpGsDuhB/jgFw75guyqbxJqEaS0Cw/VFO8i2nHglJUzQCRtMMR/IBAKE3ETMC4g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@vitejs/plugin-react": { + "version": "6.1.2", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-6.1.2.tgz", + "integrity": "sha512-fhJNGUdscQa4HkI0xFxwonWyhnVReCPDbXI7FH7jmzpnVCrFQI7PRrhWecO263610vOrs9tr2Y4xcf8uAdFxbw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@rolldown/pluginutils": "^1.0.1" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "peerDependencies": { + "@rolldown/plugin-babel": "^0.1.7 || ^0.2.0", + "babel-plugin-react-compiler": "^1.0.0", + "oxc-transform-react": "^0.152.0", + "vite": "^8.0.0" + }, + "peerDependenciesMeta": { + "@rolldown/plugin-babel": { + "optional": true + }, + "babel-plugin-react-compiler": { + "optional": true + }, + "oxc-transform-react": { + "optional": true + } + } + }, + "node_modules/@vitest/mocker": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-5.0.3.tgz", + "integrity": "sha512-T8sWAIbkSyAjkwTcaEc3Iu0o9A27X1/kdXrizhZkGuSKScRQtRzclfAMpOTcGdXCsqxeWlpGy3XjqaW8CpLORg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/trace-mapping": "0.3.31", + "@vitest/spy": "5.0.3", + "estree-walker": "^3.0.3", + "magic-string": "^1.2.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/spy": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-5.0.3.tgz", + "integrity": "sha512-XhFysQTB8AZ+P4gMi+Lpo99vg2AZi0qKpaB9yXQl37+CaMEAPO3iH/wGVnSyL5MPERiLezpqTVtrR6UZH5GCXg==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/accepts": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz", + "integrity": "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==", + "license": "MIT", + "dependencies": { + "mime-types": "^3.0.0", + "negotiator": "^1.0.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/ansi-regex": { + "version": "6.4.0", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.4.0.tgz", + "integrity": "sha512-KzTVk2tCWAHtYrvvvaP8bJKJq2pVinhLcGEQdtLIYPbmNGNyYe8QwNaTUYQp2J7/vIsUKt5QCqAfUkYyG9DkOw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/ansi-styles": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", + "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/aria-query": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.0.tgz", + "integrity": "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "dequal": "^2.0.3" + } + }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/bidi-js": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.1.0.tgz", + "integrity": "sha512-fX1Onk0tdVPC7obPWB5EbJ1z7NVhLq4m2xZLq2YXBkxzMXIGRpNMU88n0EPgWseKl12J7zXs7qrDxPK4sRs2fg==", + "dev": true, + "license": "MIT", + "dependencies": { + "require-from-string": "^2.0.2" + } + }, + "node_modules/body-parser": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.3.0.tgz", + "integrity": "sha512-2cGmJupaNgg+QUwVLAucDuWuoMZ6EX9iHDRswZ5lsNYEmwPaRknMPCLZz07yTzVq/83p4o/wzbDZbBrTvGGTIw==", + "license": "MIT", + "dependencies": { + "bytes": "^3.1.2", + "content-type": "^2.0.0", + "debug": "^4.4.3", + "http-errors": "^2.0.1", + "iconv-lite": "^0.7.2", + "on-finished": "^2.4.1", + "qs": "^6.15.2", + "raw-body": "^3.0.2", + "type-is": "^2.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/bytes": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", + "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/chai": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/chai/-/chai-6.3.0.tgz", + "integrity": "sha512-XWAtwJ6OHO+tj0EKCs0Y2UamnyOxseZWltU4x2U2wh8g4AigdjwvtUjvLP2tqkA/avxHEtzxNaqGq/YGNwckKg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/chalk": { + "version": "5.6.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.6.2.tgz", + "integrity": "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.17.0 || ^14.13 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/cliui": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-9.0.1.tgz", + "integrity": "sha512-k7ndgKhwoQveBL+/1tqGJYNz097I7WOvwbmmU2AR5+magtbjPWQTS1C5vzGkBC8Ym8UWRzfKUzUUqFLypY4Q+w==", + "dev": true, + "license": "ISC", + "dependencies": { + "string-width": "^7.2.0", + "strip-ansi": "^7.1.0", + "wrap-ansi": "^9.0.0" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/concurrently": { + "version": "10.0.6", + "resolved": "https://registry.npmjs.org/concurrently/-/concurrently-10.0.6.tgz", + "integrity": "sha512-qgICXXFp2/3nAebRLmxcINKEXM1mLK3ij/y31P02VTs/KIkddIeC05n73DxOXsXMB9LFSqNU1/na3lDlOhcbIw==", + "dev": true, + "license": "MIT", + "dependencies": { + "chalk": "5.6.2", + "rxjs": "7.8.2", + "shell-quote": "1.12.0", + "supports-color": "10.2.2", + "tree-kill": "1.2.2", + "yargs": "18.0.0" + }, + "bin": { + "conc": "dist/bin/index.js", + "concurrently": "dist/bin/index.js" + }, + "engines": { + "node": ">=22" + }, + "funding": { + "url": "https://github.com/open-cli-tools/concurrently?sponsor=1" + } + }, + "node_modules/content-disposition": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-2.0.1.tgz", + "integrity": "sha512-e+H0ZXHSWYrENhQzw1LPuP4oF5MzVKmDU6d3hxlvaPEYLLg62MxtQNPRx4SYSuYJSBUgnQIG4HIN2tEtNv7Dog==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/content-type": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.1.0.tgz", + "integrity": "sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/cookie": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", + "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie-signature": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz", + "integrity": "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==", + "license": "MIT", + "engines": { + "node": ">=6.6.0" + } + }, + "node_modules/css-tree": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.2.1.tgz", + "integrity": "sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==", + "dev": true, + "license": "MIT", + "dependencies": { + "mdn-data": "2.27.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0" + } + }, + "node_modules/css.escape": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/css.escape/-/css.escape-1.5.1.tgz", + "integrity": "sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg==", + "dev": true, + "license": "MIT" + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/data-urls": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-8.0.0.tgz", + "integrity": "sha512-JQUCCK+/CAGbGGx618PlFZMXH81Jtj7t73CHv9dd/zVWvxgGT2zLFRjnKb+Ng46GIwcy+f7f88qd4duqllK+WQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "whatwg-mimetype": "^5.0.0", + "whatwg-url": "^17.1.2" + }, + "engines": { + "node": "^22.14.0 || >=24.0.0" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/decimal.js": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz", + "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==", + "dev": true, + "license": "MIT" + }, + "node_modules/depd": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", + "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/dequal": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", + "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/dom-accessibility-api": { + "version": "0.5.16", + "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", + "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/ee-first": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", + "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", + "license": "MIT" + }, + "node_modules/emoji-regex": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.6.0.tgz", + "integrity": "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==", + "dev": true, + "license": "MIT" + }, + "node_modules/encodeurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", + "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/entities": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-8.1.0.tgz", + "integrity": "sha512-kxL7msIffSuh9aaFAMD7rxAIuTRMAHMeBtgHW2yUdWw732ZNh4MehkF2gdjvtdmikkaIP9bFDDJOPlsvm7avrA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=20.19.0" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-module-lexer": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.3.2.tgz", + "integrity": "sha512-poHGpORABojJJucnV9KbOavETW8lBVnphkW77ER5/BQ5Fz7oXSoCNek7IH3vR5nRjdsEz926ibFYX8KtLQmdyw==", + "dev": true, + "license": "MIT" + }, + "node_modules/es-object-atoms": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz", + "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/esbuild": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.2.tgz", + "integrity": "sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA==", + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.28.2", + "@esbuild/android-arm": "0.28.2", + "@esbuild/android-arm64": "0.28.2", + "@esbuild/android-x64": "0.28.2", + "@esbuild/darwin-arm64": "0.28.2", + "@esbuild/darwin-x64": "0.28.2", + "@esbuild/freebsd-arm64": "0.28.2", + "@esbuild/freebsd-x64": "0.28.2", + "@esbuild/linux-arm": "0.28.2", + "@esbuild/linux-arm64": "0.28.2", + "@esbuild/linux-ia32": "0.28.2", + "@esbuild/linux-loong64": "0.28.2", + "@esbuild/linux-mips64el": "0.28.2", + "@esbuild/linux-ppc64": "0.28.2", + "@esbuild/linux-riscv64": "0.28.2", + "@esbuild/linux-s390x": "0.28.2", + "@esbuild/linux-x64": "0.28.2", + "@esbuild/netbsd-arm64": "0.28.2", + "@esbuild/netbsd-x64": "0.28.2", + "@esbuild/openbsd-arm64": "0.28.2", + "@esbuild/openbsd-x64": "0.28.2", + "@esbuild/openharmony-arm64": "0.28.2", + "@esbuild/sunos-x64": "0.28.2", + "@esbuild/win32-arm64": "0.28.2", + "@esbuild/win32-ia32": "0.28.2", + "@esbuild/win32-x64": "0.28.2" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/escape-html": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", + "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", + "license": "MIT" + }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/etag": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", + "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/expect-type": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz", + "integrity": "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/express": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/express/-/express-5.3.0.tgz", + "integrity": "sha512-qCqy1xSoaugxOD4IVHb1ZWdkvCF2W3aE2okKouWj8VwJ+ztskSiXoBnCSJqfKT+cVPJhuJqe9YLbY425+Zh00g==", + "license": "MIT", + "dependencies": { + "accepts": "^2.0.0", + "body-parser": "^2.3.0", + "content-disposition": "^2.0.1", + "content-type": "^2.0.0", + "cookie": "^0.7.1", + "cookie-signature": "^1.2.1", + "debug": "^4.4.0", + "depd": "^2.0.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "finalhandler": "^2.1.0", + "fresh": "^2.0.0", + "http-errors": "^2.0.0", + "merge-descriptors": "^2.0.0", + "mime-types": "^3.0.0", + "on-finished": "^2.4.1", + "once": "^1.4.0", + "parseurl": "^1.3.3", + "proxy-addr": "^2.0.8", + "qs": "^6.16.0", + "range-parser": "^1.2.1", + "router": "^2.2.0", + "send": "^1.1.0", + "serve-static": "^2.2.0", + "statuses": "^2.0.1", + "type-is": "^2.1.0", + "vary": "^1.1.2" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/finalhandler": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.1.tgz", + "integrity": "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "on-finished": "^2.4.1", + "parseurl": "^1.3.3", + "statuses": "^2.0.1" + }, + "engines": { + "node": ">= 18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/forwarded": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", + "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fresh": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz", + "integrity": "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-caller-file": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", + "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", + "dev": true, + "license": "ISC", + "engines": { + "node": "6.* || 8.* || >= 10.*" + } + }, + "node_modules/get-east-asian-width": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/get-east-asian-width/-/get-east-asian-width-1.7.0.tgz", + "integrity": "sha512-XjH1AECxf0giL2V1aU8vKyRR2ppRUb5c0EvT7zuJTokQ74bNo52zOtghqdWIqrhUD79fo3x0WfKZdOqxF6LG1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", + "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/html-encoding-sniffer": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-7.0.0.tgz", + "integrity": "sha512-UikN5yr7xsCDAq87Or5or0PAlD3HJJOKVzM05az588WnpDJ4Ux7a2A53Qi6gofGg2/EtvF/H4hCi/TXfCW4Y6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@exodus/bytes": "^1.15.1" + }, + "engines": { + "node": "^22.13.0 || >=24.0.0" + } + }, + "node_modules/http-errors": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", + "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", + "license": "MIT", + "dependencies": { + "depd": "~2.0.0", + "inherits": "~2.0.4", + "setprototypeof": "~1.2.0", + "statuses": "~2.0.2", + "toidentifier": "~1.0.1" + }, + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/iconv-lite": { + "version": "0.7.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.3.tgz", + "integrity": "sha512-IKXpvIzjnC9XTAUbVBcMfGS0EPaIXtW6v+zr+RRp+hqULEpo0owZax6wyRwPOJbWbzjYspQwusTsfVr0ifh4uQ==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/indent-string": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-4.0.0.tgz", + "integrity": "sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" + }, + "node_modules/ipaddr.js": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", + "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", + "license": "MIT", + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/is-potential-custom-element-name": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", + "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/is-promise": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz", + "integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==", + "license": "MIT" + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/jsdom": { + "version": "30.1.2", + "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-30.1.2.tgz", + "integrity": "sha512-0FFE/jE1rppmVfUrJUgxqXjcwolZYIGtAgj1pTussMhNZxIxi7W/PvfWaMNroGi2B36X1IKHbexpZ9DhkoOPiQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/css-color": "^7.1.3", + "@asamuzakjp/dom-selector": "^9.2.4", + "@bramus/specificity": "^2.4.2", + "@csstools/css-syntax-patches-for-csstree": "^1.1.15", + "@exodus/bytes": "^1.16.0", + "css-tree": "^3.2.1", + "data-urls": "^8.0.0", + "decimal.js": "^10.6.0", + "html-encoding-sniffer": "^7.0.0", + "is-potential-custom-element-name": "^1.0.1", + "lru-cache": "^11.5.3", + "parse5": "^8.0.1", + "saxes": "^6.0.0", + "tough-cookie": "^6.0.2", + "undici": "^8.11.2", + "w3c-xmlserializer": "^6.0.0", + "webidl-conversions": "^8.0.1", + "whatwg-mimetype": "^5.0.0", + "whatwg-url": "^17.2.0", + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": "^22.22.2 || ^24.15.0 || >=26.0.0" + }, + "peerDependencies": { + "canvas": "^3.2.3" + }, + "peerDependenciesMeta": { + "canvas": { + "optional": true + } + } + }, + "node_modules/lightningcss": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.33.0.tgz", + "integrity": "sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.33.0", + "lightningcss-darwin-arm64": "1.33.0", + "lightningcss-darwin-x64": "1.33.0", + "lightningcss-freebsd-x64": "1.33.0", + "lightningcss-linux-arm-gnueabihf": "1.33.0", + "lightningcss-linux-arm64-gnu": "1.33.0", + "lightningcss-linux-arm64-musl": "1.33.0", + "lightningcss-linux-x64-gnu": "1.33.0", + "lightningcss-linux-x64-musl": "1.33.0", + "lightningcss-win32-arm64-msvc": "1.33.0", + "lightningcss-win32-x64-msvc": "1.33.0" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.33.0.tgz", + "integrity": "sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.33.0.tgz", + "integrity": "sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.33.0.tgz", + "integrity": "sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.33.0.tgz", + "integrity": "sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.33.0.tgz", + "integrity": "sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.33.0.tgz", + "integrity": "sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.33.0.tgz", + "integrity": "sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.33.0.tgz", + "integrity": "sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.33.0.tgz", + "integrity": "sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.33.0.tgz", + "integrity": "sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.33.0.tgz", + "integrity": "sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lru-cache": { + "version": "11.5.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.3.tgz", + "integrity": "sha512-U4N8FgzmWxc8k1VH8Kr6lQg18U7Fjvby6wXHVRX/ZZ7IwWbRMgrRbP0Wrb5q5NVinryp4SQampHKdvtecItxUg==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/lz-string": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz", + "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==", + "dev": true, + "license": "MIT", + "peer": true, + "bin": { + "lz-string": "bin/bin.js" + } + }, + "node_modules/magic-string": { + "version": "1.4.3", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-1.4.3.tgz", + "integrity": "sha512-z12OxmaPGE0F4xlpGdjuAUckipLpQlTAuAmyGhNoD7ODpYUzvDfvtxUbjM/jwznyP178oju/KDdyi220wNJ0RQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.6.0" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/mdn-data": { + "version": "2.27.1", + "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.27.1.tgz", + "integrity": "sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==", + "dev": true, + "license": "CC0-1.0" + }, + "node_modules/media-typer": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.1.tgz", + "integrity": "sha512-yz3xRaG20c6/BOzvYoDaGtPmGscs7YivItZEEqe6GbwNfHuxu9YNmvnEkMzKldAGY4/80pRcQRZSEnhquk9XuQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/merge-descriptors": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-2.0.0.tgz", + "integrity": "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/mime-db": { + "version": "1.54.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", + "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz", + "integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==", + "license": "MIT", + "dependencies": { + "mime-db": "^1.54.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/min-indent": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/min-indent/-/min-indent-1.0.1.tgz", + "integrity": "sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.20", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.20.tgz", + "integrity": "sha512-uKdg2G3GNCKQn9byYOpxbGqrT2fGO5KRt5J/8b3pok8rT6qxGWF6hxMyJiEYtAf+FVyYuD9hRaDqX5uPFYJ4ZQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/negotiator": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.1.0.tgz", + "integrity": "sha512-NMPBRMJgiQHjbd8phG3Vebdx4kZ1H121rbl5IkMqeOsahptB9BKo/d7oJ3zTXqTgagn2bWlNSXkh0QUGM31RYg==", + "license": "MIT", + "dependencies": { + "content-type": "^2.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/object-inspect": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/obug": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/obug/-/obug-2.2.1.tgz", + "integrity": "sha512-XrsrhT5sybtKI6wakr2SPOlGZWWYbUXZ7a0jT8/QOeAPau+1X/bSegNe5YR75oJmEZQbKningirmGOEJCIk61Q==", + "dev": true, + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ], + "license": "MIT", + "engines": { + "node": ">=12.20.0" + } + }, + "node_modules/on-finished": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", + "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", + "license": "MIT", + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/oxfmt": { + "version": "0.72.0", + "resolved": "https://registry.npmjs.org/oxfmt/-/oxfmt-0.72.0.tgz", + "integrity": "sha512-16OQeL0uhPZbfmT9S9WWM0Nv9+BmVi8Ra1uTYaSKj/qSCs6FB30KOhz9JoNYw6thT+K5bVxsk/tSEOu497i34g==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinypool": "2.2.0" + }, + "bin": { + "oxfmt": "bin/oxfmt" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/sponsors/oxc-project" + }, + "optionalDependencies": { + "@oxfmt/binding-android-arm-eabi": "0.72.0", + "@oxfmt/binding-android-arm64": "0.72.0", + "@oxfmt/binding-darwin-arm64": "0.72.0", + "@oxfmt/binding-darwin-x64": "0.72.0", + "@oxfmt/binding-freebsd-x64": "0.72.0", + "@oxfmt/binding-linux-arm-gnueabihf": "0.72.0", + "@oxfmt/binding-linux-arm-musleabihf": "0.72.0", + "@oxfmt/binding-linux-arm64-gnu": "0.72.0", + "@oxfmt/binding-linux-arm64-musl": "0.72.0", + "@oxfmt/binding-linux-ppc64-gnu": "0.72.0", + "@oxfmt/binding-linux-riscv64-gnu": "0.72.0", + "@oxfmt/binding-linux-riscv64-musl": "0.72.0", + "@oxfmt/binding-linux-s390x-gnu": "0.72.0", + "@oxfmt/binding-linux-x64-gnu": "0.72.0", + "@oxfmt/binding-linux-x64-musl": "0.72.0", + "@oxfmt/binding-openharmony-arm64": "0.72.0", + "@oxfmt/binding-win32-arm64-msvc": "0.72.0", + "@oxfmt/binding-win32-ia32-msvc": "0.72.0", + "@oxfmt/binding-win32-x64-msvc": "0.72.0" + }, + "peerDependencies": { + "svelte": "^5.0.0", + "vite-plus": "*" + }, + "peerDependenciesMeta": { + "svelte": { + "optional": true + }, + "vite-plus": { + "optional": true + } + } + }, + "node_modules/oxlint": { + "version": "1.87.0", + "resolved": "https://registry.npmjs.org/oxlint/-/oxlint-1.87.0.tgz", + "integrity": "sha512-drxVlaW7Oqq7wSy1CN25znRu00aRq8n57Q2wniJRxCmtYVz07ufCm7EVDPQFZURrTgQIjzKrVEHAD/HcvJXObg==", + "dev": true, + "license": "MIT", + "bin": { + "oxlint": "bin/oxlint" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/sponsors/oxc-project" + }, + "optionalDependencies": { + "@oxlint/binding-android-arm-eabi": "1.87.0", + "@oxlint/binding-android-arm64": "1.87.0", + "@oxlint/binding-darwin-arm64": "1.87.0", + "@oxlint/binding-darwin-x64": "1.87.0", + "@oxlint/binding-freebsd-x64": "1.87.0", + "@oxlint/binding-linux-arm-gnueabihf": "1.87.0", + "@oxlint/binding-linux-arm-musleabihf": "1.87.0", + "@oxlint/binding-linux-arm64-gnu": "1.87.0", + "@oxlint/binding-linux-arm64-musl": "1.87.0", + "@oxlint/binding-linux-ppc64-gnu": "1.87.0", + "@oxlint/binding-linux-riscv64-gnu": "1.87.0", + "@oxlint/binding-linux-riscv64-musl": "1.87.0", + "@oxlint/binding-linux-s390x-gnu": "1.87.0", + "@oxlint/binding-linux-x64-gnu": "1.87.0", + "@oxlint/binding-linux-x64-musl": "1.87.0", + "@oxlint/binding-openharmony-arm64": "1.87.0", + "@oxlint/binding-win32-arm64-msvc": "1.87.0", + "@oxlint/binding-win32-ia32-msvc": "1.87.0", + "@oxlint/binding-win32-x64-msvc": "1.87.0" + }, + "peerDependencies": { + "oxlint-tsgolint": ">=7.0.2003", + "vite-plus": "*" + }, + "peerDependenciesMeta": { + "oxlint-tsgolint": { + "optional": true + }, + "vite-plus": { + "optional": true + } + } + }, + "node_modules/parse5": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-8.0.1.tgz", + "integrity": "sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw==", + "dev": true, + "license": "MIT", + "dependencies": { + "entities": "^8.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, + "node_modules/parseurl": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", + "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/path-to-regexp": { + "version": "8.4.2", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.4.2.tgz", + "integrity": "sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz", + "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/playwright": { + "version": "1.64.0", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.64.0.tgz", + "integrity": "sha512-kVzTnYFYEyQ9RGzBjNCd2+yEqWmVbxl3AHPynv3qQ6JTjv4a+kwRcnqQwIpuP3NnrnIrE/Q6wWAIAvMfIE2/Yg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.64.0" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/playwright-core": { + "version": "1.64.0", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.64.0.tgz", + "integrity": "sha512-T9r+MZkTECl2+oUcZ26YgZzTrYidTsIhYYkJix+6iDOymOb1FB2RLLSOxRGnZGv5DStt+aduIVbt+E8WygXYpg==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/postcss": { + "version": "8.5.29", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.29.tgz", + "integrity": "sha512-49cGhUbXj8Qenv0iTMxA1cFBzxXoctpC9Ujd77t1WcbJIr6nF/eI7g/8MgxrYldFRuAXvja7xQRwavoW7kgrxQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.19", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.2" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/pretty-format": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz", + "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "ansi-regex": "^5.0.1", + "ansi-styles": "^5.0.0", + "react-is": "^17.0.1" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, + "node_modules/pretty-format/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/pretty-format/node_modules/ansi-styles": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", + "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/proxy-addr": { + "version": "2.0.8", + "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.8.tgz", + "integrity": "sha512-5nnx0yGyVUcY6t9RnWcARWtwT9F1D8O9rt08htPvnd49W1IgZtmLkhu9WfMzQj1cFxjHIO6connUNVW5k7AVyQ==", + "license": "MIT", + "dependencies": { + "forwarded": "0.2.0", + "ipaddr.js": "1.9.1" + }, + "engines": { + "node": ">= 0.10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/qs": { + "version": "6.16.0", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.16.0.tgz", + "integrity": "sha512-h6fhOIaRrID2CbEY2fqs+7t+UXZo+MLAnU5gRIq85uFtdiUPCdsApMlHhXogKVM4HM2DVbIjGNTTYH2OcmP1vA==", + "license": "BSD-3-Clause", + "dependencies": { + "es-define-property": "^1.0.1", + "side-channel": "^1.1.1" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/range-parser": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.3.0.tgz", + "integrity": "sha512-hek2mFQpPuI4E1BBKrSto+BU3e3x4xuarsbiwr3+lf7p44juvFMV0XFWQAP3xUyqXA4RrXLIoaSUGbSt056ZMw==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/raw-body": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-3.0.2.tgz", + "integrity": "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==", + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "http-errors": "~2.0.1", + "iconv-lite": "~0.7.0", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/react": { + "version": "19.3.0", + "resolved": "https://registry.npmjs.org/react/-/react-19.3.0.tgz", + "integrity": "sha512-E8LUcbtBWt20bbl2YoHfx4ZDBdxVTfOKtCZn9cDSJ4l6/nuoApcpIBcj47t2wZoVX8g2ZHuMHbiShgCR1T5Sog==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "19.3.0", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.3.0.tgz", + "integrity": "sha512-JDk8dgif51OjFoDE70+OT9ICyYr+69HlmihNwp1+Nsfbna3t5sIiCa9ZJktDmQ4/1b/rn26hIAR2uYXDMr5r0Q==", + "license": "MIT", + "dependencies": { + "scheduler": "^0.28.0" + }, + "peerDependencies": { + "react": "^19.3.0" + } + }, + "node_modules/react-is": { + "version": "17.0.2", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", + "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/redent": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/redent/-/redent-3.0.0.tgz", + "integrity": "sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==", + "dev": true, + "license": "MIT", + "dependencies": { + "indent-string": "^4.0.0", + "strip-indent": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/rolldown": { + "version": "1.2.13", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.13.tgz", + "integrity": "sha512-/5apOkwWxtACcQtZ5WlrAsKS4JwXVThQPJZ3RqIruRY9UG9M8e0VlXN068xum/JRKA4aIUoJ13ghe/kGCvidiQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@oxc-project/types": "=0.153.0", + "@rolldown/pluginutils": "^1.0.0" + }, + "bin": { + "rolldown": "bin/cli.mjs" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "optionalDependencies": { + "@rolldown/binding-android-arm-eabi": "1.2.13", + "@rolldown/binding-android-arm64": "1.2.13", + "@rolldown/binding-darwin-arm64": "1.2.13", + "@rolldown/binding-darwin-x64": "1.2.13", + "@rolldown/binding-freebsd-x64": "1.2.13", + "@rolldown/binding-linux-arm-gnueabihf": "1.2.13", + "@rolldown/binding-linux-arm64-gnu": "1.2.13", + "@rolldown/binding-linux-arm64-musl": "1.2.13", + "@rolldown/binding-linux-ppc64-gnu": "1.2.13", + "@rolldown/binding-linux-s390x-gnu": "1.2.13", + "@rolldown/binding-linux-x64-gnu": "1.2.13", + "@rolldown/binding-linux-x64-musl": "1.2.13", + "@rolldown/binding-openharmony-arm64": "1.2.13", + "@rolldown/binding-win32-arm64-msvc": "1.2.13", + "@rolldown/binding-win32-x64-msvc": "1.2.13" + } + }, + "node_modules/router": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz", + "integrity": "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.0", + "depd": "^2.0.0", + "is-promise": "^4.0.0", + "parseurl": "^1.3.3", + "path-to-regexp": "^8.0.0" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/rxjs": { + "version": "7.8.2", + "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.2.tgz", + "integrity": "sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.1.0" + } + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "license": "MIT" + }, + "node_modules/saxes": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz", + "integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==", + "dev": true, + "license": "ISC", + "dependencies": { + "xmlchars": "^2.2.0" + }, + "engines": { + "node": ">=v12.22.7" + } + }, + "node_modules/scheduler": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.28.0.tgz", + "integrity": "sha512-juorfCmIkIw8tT+p5BXSm6PJjQF/ycEYmKyzURCIt/RaZIhL+PulbQ9Yu2z1HdOJDdqDTlxA1+xKBmHXJsczAw==", + "license": "MIT" + }, + "node_modules/send": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/send/-/send-1.2.1.tgz", + "integrity": "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.3", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "fresh": "^2.0.0", + "http-errors": "^2.0.1", + "mime-types": "^3.0.2", + "ms": "^2.1.3", + "on-finished": "^2.4.1", + "range-parser": "^1.2.1", + "statuses": "^2.0.2" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/serve-static": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-2.2.1.tgz", + "integrity": "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==", + "license": "MIT", + "dependencies": { + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "parseurl": "^1.3.3", + "send": "^1.2.0" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/setprototypeof": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", + "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", + "license": "ISC" + }, + "node_modules/shell-quote": { + "version": "1.12.0", + "resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.12.0.tgz", + "integrity": "sha512-PcByqNyT/38F2kDNi006HAMRJaULuBzq/FOsw3qdZvX/GA9W/jamDaRskgHjubHiftXK5sIFxLNkvrXUwcof6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.1.tgz", + "integrity": "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4", + "side-channel-list": "^1.0.1", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz", + "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/source-map-js": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.2.tgz", + "integrity": "sha512-KGj/8Y43x35aZVDtt+J4mK1hoLGHULMYfSkODJNQjNDC3oW1PqPoxMwo0pLUsWM/UEGzON/NxeHywEfNXNP3Vw==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/statuses": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", + "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/std-env": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.3.0.tgz", + "integrity": "sha512-OtU/EgQ1kIm5KwqQpBC6ZEMXrZRui11w8zgfTWp8cdO9B8OaPsbA8bTHO2P+HNo1VlUTGMVBwPhydu6poeXiag==", + "dev": true, + "license": "MIT" + }, + "node_modules/string-width": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz", + "integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^10.3.0", + "get-east-asian-width": "^1.0.0", + "strip-ansi": "^7.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/strip-ansi": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz", + "integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^6.2.2" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, + "node_modules/strip-indent": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/strip-indent/-/strip-indent-3.0.0.tgz", + "integrity": "sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "min-indent": "^1.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/supports-color": { + "version": "10.2.2", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-10.2.2.tgz", + "integrity": "sha512-SS+jx45GF1QjgEXQx4NJZV9ImqmO2NPz5FNsIHrsDjh2YsHnawpan7SNQ1o8NuhrbHZy9AZhIoCUiCeaW/C80g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/chalk/supports-color?sponsor=1" + } + }, + "node_modules/tinybench": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-6.2.2.tgz", + "integrity": "sha512-nIZ2N2dFpjBsg2hgitKu7mA+FvsIJuGJOwysRngmVRRmWgR/UyPjwJj8c/fT/dhm8EkHnF5EOogRf+0Z2qrimA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/tinyexec": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.3.1.tgz", + "integrity": "sha512-GCvB3aoys96IuDFBMcTB46JOR6mdMtAToqwiW8JlWhsoh1mhHi/xn9ss/Dg7N555GiJyEt2qzoG/NHCwM6h1EA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tinypool": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/tinypool/-/tinypool-2.2.0.tgz", + "integrity": "sha512-jBrmx4lYmaC9k/mgPbylxs7kBUxHtD8256up+HjLaDFfXScKJQyil+SWXSvhAtT7XHo+yTVlpB81PGmHP8oLSQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^20.0.0 || >=22.0.0" + } + }, + "node_modules/tldts": { + "version": "7.4.18", + "resolved": "https://registry.npmjs.org/tldts/-/tldts-7.4.18.tgz", + "integrity": "sha512-somxA8xKAUzlAxl0TX/jMSYIbRNFIVGnI96pVGJPeMc5BsHmfVXn6mOJT6U++ELnl04eGfzpfcEuZe8WiASkXg==", + "dev": true, + "license": "MIT", + "dependencies": { + "tldts-core": "^7.4.18" + }, + "bin": { + "tldts": "bin/cli.js" + } + }, + "node_modules/tldts-core": { + "version": "7.4.18", + "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.4.18.tgz", + "integrity": "sha512-KI2PKvCmvQI/xD6ypPqkU0i7oxNXR/O58RLRaHR4svsgVUQ2JE/XC8BtMbG6PSehD27I8sZJRcC1ASnCy6lp3g==", + "dev": true, + "license": "MIT" + }, + "node_modules/toidentifier": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", + "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", + "license": "MIT", + "engines": { + "node": ">=0.6" + } + }, + "node_modules/tough-cookie": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-6.0.2.tgz", + "integrity": "sha512-exgYmnmL/sJpR3upZfXG5PoatXQii55xAiXGXzY+sROLZ/Y+SLcp9PgJNI9Vz37HpQ74WvDcLT8eqm+kV3FzrA==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "tldts": "^7.0.5" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/tr46": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-7.0.0.tgz", + "integrity": "sha512-BcDSDW+SIGWVOns1Zm6Yg91q9AEj7eaID0QDY9oIqJD1A8dscNwMcl9Sms2DeuWCNov1X0/oXanHGYQBSLwZsg==", + "dev": true, + "license": "MIT", + "dependencies": { + "punycode": "^2.3.1" + }, + "engines": { + "node": "^22.14.0 || >=24.0.0" + } + }, + "node_modules/tree-kill": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/tree-kill/-/tree-kill-1.2.2.tgz", + "integrity": "sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==", + "dev": true, + "license": "MIT", + "bin": { + "tree-kill": "cli.js" + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "dev": true, + "license": "0BSD" + }, + "node_modules/tsx": { + "version": "4.23.15", + "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.23.15.tgz", + "integrity": "sha512-Yiex1Ovn8z2xPpOWckIiysV1SSyRMY9BkLF++q0yKiDxCqRhosKfMg3janKkiLBwZ5c/YryloKwGZcrEmtwxKw==", + "license": "MIT", + "dependencies": { + "esbuild": "~0.28.0" + }, + "bin": { + "tsx": "dist/cli.mjs" + }, + "engines": { + "node": ">=18.0.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + } + }, + "node_modules/type-is": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.1.0.tgz", + "integrity": "sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA==", + "license": "MIT", + "dependencies": { + "content-type": "^2.0.0", + "media-typer": "^1.1.0", + "mime-types": "^3.0.0" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/typescript": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-7.0.2.tgz", + "integrity": "sha512-8FYau96o3NKOhbjKi/qNvG/W5jhzxkbdm5sj9AbZ/5T5sWqn3hJgLfGx27sRKZWTvyzCP8dLRBTf5tBTSRVUNA==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc" + }, + "engines": { + "node": ">=16.20.0" + }, + "optionalDependencies": { + "@typescript/typescript-aix-ppc64": "7.0.2", + "@typescript/typescript-darwin-arm64": "7.0.2", + "@typescript/typescript-darwin-x64": "7.0.2", + "@typescript/typescript-freebsd-arm64": "7.0.2", + "@typescript/typescript-freebsd-x64": "7.0.2", + "@typescript/typescript-linux-arm": "7.0.2", + "@typescript/typescript-linux-arm64": "7.0.2", + "@typescript/typescript-linux-loong64": "7.0.2", + "@typescript/typescript-linux-mips64el": "7.0.2", + "@typescript/typescript-linux-ppc64": "7.0.2", + "@typescript/typescript-linux-riscv64": "7.0.2", + "@typescript/typescript-linux-s390x": "7.0.2", + "@typescript/typescript-linux-x64": "7.0.2", + "@typescript/typescript-netbsd-arm64": "7.0.2", + "@typescript/typescript-netbsd-x64": "7.0.2", + "@typescript/typescript-openbsd-arm64": "7.0.2", + "@typescript/typescript-openbsd-x64": "7.0.2", + "@typescript/typescript-sunos-x64": "7.0.2", + "@typescript/typescript-win32-arm64": "7.0.2", + "@typescript/typescript-win32-x64": "7.0.2" + } + }, + "node_modules/undici": { + "version": "8.11.2", + "resolved": "https://registry.npmjs.org/undici/-/undici-8.11.2.tgz", + "integrity": "sha512-u4UB2/IrKdU6lFxumHmmo1a3fCQO5tzQllRorfoRS63txhrB7xTpSn1PftwC4qEHkOaqP95fCWW4lJzwErwzhQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=22.19.0" + } + }, + "node_modules/undici-types": { + "version": "8.9.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.9.0.tgz", + "integrity": "sha512-KTDyRTYX8sWmKXAikPHHSyc63CRPETMctyjKFupcC6OBLXT3xsN0e9aF7m+mIXutFWpUXuedtowG7iLOzp0kQg==", + "dev": true, + "license": "MIT" + }, + "node_modules/unpipe": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", + "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/vary": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", + "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/vite": { + "version": "8.3.4", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.3.4.tgz", + "integrity": "sha512-IftLE8phJhdNRIGNEV4JCCvfNScoZQVfcXaN8TQropWGJtOHcu+iAB9pHcuGvmEb/kddw0LBe/jS8ztdC6xdiQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "lightningcss": "^1.33.0", + "picomatch": "^4.0.7", + "postcss": "^8.5.29", + "rolldown": "~1.2.12", + "tinyglobby": "^0.2.17" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "@vitejs/devtools": "^0.7.1", + "esbuild": "^0.27.0 || ^0.28.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "@vitejs/devtools": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vitest": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-5.0.3.tgz", + "integrity": "sha512-xMw97S3rjdtj5dkVat7jCsqWBpvchs3RlpQctUqwJD0KkERk40vz2fJ77lDwW/Vzh/pk18eItYAzkodhSes3jQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/chai": "^5.2.2", + "@vitest/mocker": "5.0.3", + "chai": "^6.2.2", + "es-module-lexer": "^2.3.2", + "expect-type": "^1.4.0", + "magic-string": "^1.2.3", + "obug": "^2.1.4", + "picomatch": "^4.0.7", + "std-env": "^4.2.0", + "tinybench": "^6.1.4", + "tinyexec": "^1.3.0", + "tinyglobby": "^0.2.17", + "why-is-node-running": "3.2.1" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^22.12.0 || ^24.0.0 || >=26.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@opentelemetry/api": "^1.9.0", + "@types/node": "^22.0.0 || >=24.0.0", + "@vitest/browser-playwright": "5.0.3", + "@vitest/browser-preview": "5.0.3", + "@vitest/browser-webdriverio": "^5.0.0-beta.5 || >=5.0.0", + "@vitest/coverage-istanbul": "5.0.3", + "@vitest/coverage-v8": "5.0.3", + "@vitest/ui": "5.0.3", + "happy-dom": "*", + "jsdom": "*", + "vite": "^6.4.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@opentelemetry/api": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser-playwright": { + "optional": true + }, + "@vitest/browser-preview": { + "optional": true + }, + "@vitest/browser-webdriverio": { + "optional": true + }, + "@vitest/coverage-istanbul": { + "optional": true + }, + "@vitest/coverage-v8": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + }, + "vite": { + "optional": false + } + } + }, + "node_modules/w3c-xmlserializer": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-6.0.0.tgz", + "integrity": "sha512-4Nsy8K5Tr6SPDH9jhKJOHf7ChDrc1zufZTVSF7x72hwuEXBqxqk9G6cK+K2NRUtB3iELRJqjXb4JPDMBjMTl2Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": "^22.22.2 || ^24.15.0 || >=26.0.0" + } + }, + "node_modules/webidl-conversions": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-8.0.1.tgz", + "integrity": "sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=20" + } + }, + "node_modules/whatwg-mimetype": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-5.0.0.tgz", + "integrity": "sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20" + } + }, + "node_modules/whatwg-url": { + "version": "17.2.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-17.2.0.tgz", + "integrity": "sha512-IhLIHNcBrzIixC5XXsbr4DxdjajyA5vQ5RwGZMtrUp+GbtE5Nrsge9QKau7jXrC0aTXLYGV0xiW7Tb4YS2aomw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@exodus/bytes": "^1.16.0", + "tr46": "^7.0.0", + "webidl-conversions": "^8.0.1" + }, + "engines": { + "node": "^22.14.0 || >=24.0.0" + } + }, + "node_modules/why-is-node-running": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-3.2.1.tgz", + "integrity": "sha512-Tb2FUhB4vUsGQlfSquQLYkApkuPAFQXGFzxWKHHumVz2dK+X1RUm/HnID4+TfIGYJ1kTcwOaCk/buYCEJr6YjQ==", + "dev": true, + "license": "MIT", + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=20.11" + } + }, + "node_modules/wrap-ansi": { + "version": "9.0.2", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-9.0.2.tgz", + "integrity": "sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.2.1", + "string-width": "^7.0.0", + "strip-ansi": "^7.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "license": "ISC" + }, + "node_modules/xml-name-validator": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz", + "integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/xmlchars": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz", + "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==", + "dev": true, + "license": "MIT" + }, + "node_modules/y18n": { + "version": "5.0.8", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", + "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=10" + } + }, + "node_modules/yargs": { + "version": "18.0.0", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-18.0.0.tgz", + "integrity": "sha512-4UEqdc2RYGHZc7Doyqkrqiln3p9X2DZVxaGbwhn2pi7MrRagKaOcIKe8L3OxYcbhXLgLFUS3zAYuQjKBQgmuNg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cliui": "^9.0.1", + "escalade": "^3.1.1", + "get-caller-file": "^2.0.5", + "string-width": "^7.2.0", + "y18n": "^5.0.5", + "yargs-parser": "^22.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=23" + } + }, + "node_modules/yargs-parser": { + "version": "22.0.0", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-22.0.0.tgz", + "integrity": "sha512-rwu/ClNdSMpkSrUb+d6BRsSkLUq1fmfsY6TOpYzTwvwkg1/NRG85KBy3kq++A8LKQwX6lsu+aWad+2khvuXrqw==", + "dev": true, + "license": "ISC", + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=23" + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..2c2ea18 --- /dev/null +++ b/package.json @@ -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" + } +} diff --git a/playwright.config.ts b/playwright.config.ts new file mode 100644 index 0000000..6f37275 --- /dev/null +++ b/playwright.config.ts @@ -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/ }, + ], +}); diff --git a/shared/types.ts b/shared/types.ts new file mode 100644 index 0000000..9f76971 --- /dev/null +++ b/shared/types.ts @@ -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; +} + +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 => 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; +} diff --git a/tests/backend/app.test.ts b/tests/backend/app.test.ts new file mode 100644 index 0000000..694cdd3 --- /dev/null +++ b/tests/backend/app.test.ts @@ -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 = {}) { + 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 = {}) { + 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'), 'workh'); + 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 { + 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('workh'); + }); + + 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 + }); +}); diff --git a/tests/backend/auth.test.ts b/tests/backend/auth.test.ts new file mode 100644 index 0000000..a2e78be --- /dev/null +++ b/tests/backend/auth.test.ts @@ -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); + }); +}); diff --git a/tests/backend/store.test.ts b/tests/backend/store.test.ts new file mode 100644 index 0000000..d72247a --- /dev/null +++ b/tests/backend/store.test.ts @@ -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([]); + }); +}); diff --git a/tests/frontend/App.test.tsx b/tests/frontend/App.test.tsx new file mode 100644 index 0000000..af38fc6 --- /dev/null +++ b/tests/frontend/App.test.tsx @@ -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(); + 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(); + 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(); + 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(); + 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(); + 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(); + 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(); + 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(); + 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(); + 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(); + 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) { + 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(); + 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(); + 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(); + 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(); + 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(); + 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) { + 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(); + 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(); + 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(); + 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(); + 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(); + }); +}); diff --git a/tests/frontend/Settings.test.tsx b/tests/frontend/Settings.test.tsx new file mode 100644 index 0000000..50660ef --- /dev/null +++ b/tests/frontend/Settings.test.tsx @@ -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 ( + { + 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( (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( (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( (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); + }); +}); diff --git a/tests/frontend/calc.test.ts b/tests/frontend/calc.test.ts new file mode 100644 index 0000000..72c5bc0 --- /dev/null +++ b/tests/frontend/calc.test.ts @@ -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 { + 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 }); + }); +}); diff --git a/tests/frontend/fakeApi.ts b/tests/frontend/fakeApi.ts new file mode 100644 index 0000000..86afc84 --- /dev/null +++ b/tests/frontend/fakeApi.ts @@ -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([['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 = {}) => + 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>((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 | 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 }; +} diff --git a/tests/frontend/merge.test.ts b/tests/frontend/merge.test.ts new file mode 100644 index 0000000..5731396 --- /dev/null +++ b/tests/frontend/merge.test.ts @@ -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); + }); +}); diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..7a32c34 --- /dev/null +++ b/tsconfig.json @@ -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"] +} diff --git a/vite.config.ts b/vite.config.ts new file mode 100644 index 0000000..240fb09 --- /dev/null +++ b/vite.config.ts @@ -0,0 +1,13 @@ +/// +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' }, + }, +});