From e68b3b3b55da95f0f7d095a1faac14d40017b26b Mon Sep 17 00:00:00 2001 From: Thomas Joise Date: Fri, 17 Jul 2026 11:00:46 +0530 Subject: [PATCH] =?UTF-8?q?docs(plan):=20ideal-web-app=20pass=20=E2=80=94?= =?UTF-8?q?=2010-task=20implementation=20plan?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 (1M context) --- .../plans/2026-07-17-ideal-web-app.md | 1047 +++++++++++++++++ 1 file changed, 1047 insertions(+) create mode 100644 docs/superpowers/plans/2026-07-17-ideal-web-app.md diff --git a/docs/superpowers/plans/2026-07-17-ideal-web-app.md b/docs/superpowers/plans/2026-07-17-ideal-web-app.md new file mode 100644 index 0000000..fae3f42 --- /dev/null +++ b/docs/superpowers/plans/2026-07-17-ideal-web-app.md @@ -0,0 +1,1047 @@ +# Ideal-Web-App Pass Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Deliver the approved spec `docs/superpowers/specs/2026-07-17-ideal-web-app-design.md` — Pinterest-chrome dialogs, toasts, edit-everywhere, a real send-email modal, a `/settings` hub, favicon + titles. + +**Architecture:** Generic chrome kit (`Dialog`, `ConfirmDialog`, `Toaster`, `FormGrid`) in `packages/ui`; two small additive backend slices (`/settings/reminders|schedules|sharing`); page refits in `apps/hq-web`. No schema changes (settings ride the `setting` table; cadence changes are new dated `reminder_schedule` rows). + +**Tech Stack:** React 19, react-router-dom 7 (HashRouter), Vite 6, vitest 3 (Node env, no DOM — component behavior verified e2e), TypeScript strict + `noUncheckedIndexedAccess`, better-sqlite3 backend. + +## Global Constraints + +- Code style: no semicolons, single quotes, 2-space indent, `x !== undefined` guards. +- `@sims/ui` stays generic: no router, no lucide, no HQ types. +- Money renders via `formatINR` only; never compute money client-side. +- Backend: every mutation audits via `writeAudit` in the same transaction; dated config rows are append-only (a cadence change is a NEW `reminder_schedule` row — never UPDATE/DELETE); portable SQL only. +- Repo gates after EVERY task: `npm run typecheck` exits 0 and `npm test` has zero failures (suite currently 293 green; new tests only add). +- Several page files were touched by two workstreams this week — implementers must READ the current file and anchor edits on code text, not line numbers. +- `git add` only your task's files by explicit path. Commit after every task. All commit messages end with: + `Co-Authored-By: Claude Opus 4.8 (1M context) ` +- z-order contract: drawer 40 < dialog 50 < palette 60 < toasts 70. + +**Verify commands** (repo root `C:\SiMS\hq`): `npm run typecheck` · `npm test` · focused: `npm test -- `. + +--- + +### Task 1: Dialog + ConfirmDialog (+ pill buttons, Modal removed) + +**Files:** +- Create: `packages/ui/src/dialog.tsx` +- Modify: `packages/ui/src/tokens.css` (append CSS + `button.wf.pill` rule) +- Modify: `packages/ui/src/components.tsx` (Button `pill` prop; delete `Modal`) +- Modify: `packages/ui/src/index.ts` + +**Interfaces:** +- Consumes: tokens (`--radius-lg`, `--shadow-lg`, `--bg-raised`, `--border`). +- Produces: `Dialog(props: { open: boolean; onClose: () => void; title: string; size?: 'sm' | 'md' | 'lg'; footer?: ReactNode; children: ReactNode })` and `ConfirmDialog(props: { open: boolean; onClose: () => void; onConfirm: () => void | Promise; title: string; body?: ReactNode; confirmLabel?: string; tone?: 'danger' })`. `Button` gains `pill?: boolean`. + +- [ ] **Step 1: Confirm Modal is dead code** + +Run: `grep -rn "Modal" apps/hq-web/src --include=*.tsx` — expect zero component usages (only the word in comments, if any). If a call site exists, STOP and report BLOCKED. + +- [ ] **Step 2: Create `packages/ui/src/dialog.tsx`** + +```tsx +import { useEffect, useRef, useState, type ReactNode } from 'react' + +const WIDTH: Record<'sm' | 'md' | 'lg', number> = { sm: 420, md: 560, lg: 720 } +const FOCUSABLE = 'input, select, textarea, button, a[href], [tabindex]:not([tabindex="-1"])' + +/** Pinterest-chrome modal: centered rounded card, scrim, focus trap + restore. */ +export function Dialog(props: { + open: boolean + onClose: () => void + title: string + size?: 'sm' | 'md' | 'lg' + footer?: ReactNode + children: ReactNode +}) { + const prevFocus = useRef(null) + const cardRef = useRef(null) + + useEffect(() => { + if (props.open) { + prevFocus.current = document.activeElement as HTMLElement | null + const first = cardRef.current?.querySelector( + 'input, select, textarea, button:not(.wf-dialog-x)', + ) + ;(first ?? cardRef.current ?? undefined)?.focus() + } + }, [props.open]) + useEffect(() => { + if (!props.open) prevFocus.current?.focus() + }, [props.open]) + + if (!props.open) return null + + const onKey = (e: React.KeyboardEvent) => { + if (e.key === 'Escape') { e.preventDefault(); props.onClose(); return } + if (e.key !== 'Tab') return + const nodes = cardRef.current?.querySelectorAll(FOCUSABLE) + const list = nodes === undefined ? [] : [...nodes].filter((n) => !n.hasAttribute('disabled')) + const first = list[0] + const last = list[list.length - 1] + if (first === undefined || last === undefined) { e.preventDefault(); return } + if (e.shiftKey && document.activeElement === first) { e.preventDefault(); last.focus() } + else if (!e.shiftKey && document.activeElement === last) { e.preventDefault(); first.focus() } + } + + return ( +
+
e.stopPropagation()} + onKeyDown={onKey} + > +
+

{props.title}

+ +
+
{props.children}
+ {props.footer !== undefined &&
{props.footer}
} +
+
+ ) +} + +/** Small confirm variant — kills window.confirm. Busy state while onConfirm resolves. */ +export function ConfirmDialog(props: { + open: boolean + onClose: () => void + onConfirm: () => void | Promise + title: string + body?: ReactNode + confirmLabel?: string + tone?: 'danger' +}) { + const [busy, setBusy] = useState(false) + const confirm = () => { + const out = props.onConfirm() + if (out instanceof Promise) { + setBusy(true) + out.finally(() => { setBusy(false); props.onClose() }) + } else { + props.onClose() + } + } + return ( + + + + + } + > + {props.body !== undefined &&
{props.body}
} +
+ ) +} +``` + +- [ ] **Step 3: CSS — append to `packages/ui/src/tokens.css`** + +```css +/* ================= dialogs (Pinterest chrome) ================= */ +.wf-dialog-back { + position: fixed; inset: 0; background: rgba(15, 14, 11, 0.45); + backdrop-filter: blur(4px); z-index: 50; + display: flex; align-items: center; justify-content: center; padding: 20px; +} +.wf-dialog { + background: var(--bg-raised); border: 1px solid var(--border); + border-radius: 16px; box-shadow: var(--shadow-lg); + width: 100%; max-height: 86vh; display: flex; flex-direction: column; +} +.wf-dialog-head { display: flex; align-items: center; gap: 10px; padding: 18px 20px 0; } +.wf-dialog-head h2 { margin: 0; font-size: 17px; flex: 1; } +.wf-dialog-x { + border: none; background: var(--bg-inset); color: var(--text-dim); + width: 30px; height: 30px; border-radius: 50%; cursor: pointer; font-size: 13px; +} +.wf-dialog-x:hover { background: var(--bg-hover); color: var(--text); } +.wf-dialog-body { padding: 14px 20px; overflow-y: auto; } +.wf-dialog-foot { + display: flex; justify-content: flex-end; gap: 8px; padding: 12px 20px 18px; +} +button.wf.pill { border-radius: 999px; padding: 8px 18px; } +``` + +Also delete the now-unused `.wf-modal-back` / `.wf-modal` rules. + +- [ ] **Step 4: components.tsx — pill prop, Modal deleted** + +In `Button`, extend props with `pill?: boolean` and the class computation to append `' pill'` when set: + +```tsx + const cls = `wf${props.tone === 'primary' ? ' primary' : props.tone === 'danger' ? ' danger' : ''}${props.pill === true ? ' pill' : ''}` +``` + +Delete the entire `Modal` function. Export `Dialog`/`ConfirmDialog` via `packages/ui/src/index.ts`: + +```ts +export * from './dialog' +``` + +- [ ] **Step 5: Verify** — `npm run typecheck` exits 0 (proves Modal truly had no call sites); `npm test` zero failures. + +- [ ] **Step 6: Commit** + +```bash +git add packages/ui/src/dialog.tsx packages/ui/src/tokens.css packages/ui/src/components.tsx packages/ui/src/index.ts +git commit -m "feat(ui): Dialog + ConfirmDialog (Pinterest chrome), pill buttons; Modal removed" +``` + +--- + +### Task 2: Toaster (TDD) + FormGrid + +**Files:** +- Create: `packages/ui/src/toast-store.ts`, `packages/ui/src/toast.tsx`, `packages/ui/src/formgrid.tsx` +- Test: `packages/ui/test/toast-store.test.ts` +- Modify: `packages/ui/src/tokens.css` (toast + formgrid CSS), `packages/ui/src/index.ts`, `apps/hq-web/src/main.tsx` (wrap `ToastProvider`) + +**Interfaces:** +- Produces: `ToastProvider({ children })`, `useToast(): { ok: (msg: string) => void; err: (msg: string) => void }`; pure store: `Toast { id: number; tone: 'ok' | 'err'; message: string }`, `ToastState { nextId: number; toasts: Toast[] }`, `initialToasts`, `TOAST_LIMIT = 3`, `pushToast(s, tone, message): ToastState`, `dismissToast(s, id): ToastState`. `FormGrid({ children })`, `FormField({ label, wide?, children })`. + +- [ ] **Step 1: Write the failing test** — `packages/ui/test/toast-store.test.ts`: + +```ts +import { describe, expect, it } from 'vitest' +import { dismissToast, initialToasts, pushToast, TOAST_LIMIT } from '../src/toast-store' + +describe('toast store', () => { + it('pushes with increasing ids', () => { + let s = pushToast(initialToasts, 'ok', 'saved') + s = pushToast(s, 'err', 'failed') + expect(s.toasts.map((t) => t.id)).toEqual([1, 2]) + expect(s.toasts[1]).toMatchObject({ tone: 'err', message: 'failed' }) + }) + it('keeps only the newest TOAST_LIMIT toasts', () => { + let s = initialToasts + for (let i = 0; i < TOAST_LIMIT + 2; i++) s = pushToast(s, 'ok', `m${i}`) + expect(s.toasts).toHaveLength(TOAST_LIMIT) + expect(s.toasts[0]!.message).toBe('m2') + }) + it('dismisses by id and ignores unknown ids', () => { + let s = pushToast(initialToasts, 'ok', 'a') + s = pushToast(s, 'ok', 'b') + s = dismissToast(s, 1) + expect(s.toasts.map((t) => t.message)).toEqual(['b']) + expect(dismissToast(s, 99).toasts).toHaveLength(1) + }) +}) +``` + +- [ ] **Step 2: Run — expect FAIL** — `npm test -- toast-store` → cannot find module. + +- [ ] **Step 3: Implement `packages/ui/src/toast-store.ts`** + +```ts +/** Pure toast state — testable without a DOM. */ + +export interface Toast { id: number; tone: 'ok' | 'err'; message: string } +export interface ToastState { nextId: number; toasts: Toast[] } + +export const TOAST_LIMIT = 3 +export const initialToasts: ToastState = { nextId: 1, toasts: [] } + +export function pushToast(s: ToastState, tone: Toast['tone'], message: string): ToastState { + const toast: Toast = { id: s.nextId, tone, message } + const toasts = [...s.toasts, toast].slice(-TOAST_LIMIT) + return { nextId: s.nextId + 1, toasts } +} + +export function dismissToast(s: ToastState, id: number): ToastState { + return { ...s, toasts: s.toasts.filter((t) => t.id !== id) } +} +``` + +- [ ] **Step 4: Run — expect PASS** — `npm test -- toast-store` → 3 pass. + +- [ ] **Step 5: Create `packages/ui/src/toast.tsx`** + +```tsx +import { createContext, useCallback, useContext, useMemo, useRef, useState, type ReactNode } from 'react' +import { dismissToast, initialToasts, pushToast, type Toast, type ToastState } from './toast-store' + +const AUTO_DISMISS_MS = 4000 + +interface ToastApi { ok: (msg: string) => void; err: (msg: string) => void } +const Ctx = createContext(null) + +/** Mount once at app root; renders the bottom-right stack. */ +export function ToastProvider(props: { children: ReactNode }) { + const [state, setState] = useState(initialToasts) + const stateRef = useRef(state) + stateRef.current = state + + const push = useCallback((tone: Toast['tone'], message: string) => { + const id = stateRef.current.nextId + setState((s) => pushToast(s, tone, message)) + window.setTimeout(() => setState((s) => dismissToast(s, id)), AUTO_DISMISS_MS) + }, []) + + const api = useMemo(() => ({ + ok: (msg) => push('ok', msg), + err: (msg) => push('err', msg), + }), [push]) + + return ( + + {props.children} +
+ {state.toasts.map((t) => ( +
+ {t.message} + +
+ ))} +
+
+ ) +} + +export function useToast(): ToastApi { + const api = useContext(Ctx) + if (api === null) throw new Error('useToast requires at the app root') + return api +} +``` + +- [ ] **Step 6: Create `packages/ui/src/formgrid.tsx`** + +```tsx +import type { ReactNode } from 'react' +import { Field } from './components' + +/** Two-column field grid for dialog bodies; single column under 560px. */ +export function FormGrid(props: { children: ReactNode }) { + return
{props.children}
+} + +export function FormField(props: { label: string; wide?: boolean; children: ReactNode }) { + return ( +
+ {props.children} +
+ ) +} +``` + +- [ ] **Step 7: CSS — append to tokens.css** + +```css +/* ================= toasts ================= */ +.wf-toasts { + position: fixed; right: 16px; bottom: 16px; z-index: 70; + display: flex; flex-direction: column; gap: 8px; max-width: 360px; +} +.wf-toast { + display: flex; align-items: center; gap: 10px; + background: var(--bg-raised); border: 1px solid var(--border); + border-radius: var(--radius); box-shadow: var(--shadow-lg); + padding: 10px 14px; font-size: 13px; +} +.wf-toast .msg { flex: 1; } +.wf-toast.ok { border-color: color-mix(in srgb, var(--ok) 40%, transparent); } +.wf-toast.err { border-color: color-mix(in srgb, var(--err) 45%, transparent); background: var(--err-soft); } +.wf-toast button { + border: none; background: none; color: var(--text-dim); cursor: pointer; font-size: 11px; padding: 2px; +} + +/* ================= form grid (dialog bodies) ================= */ +.wf-formgrid { display: grid; grid-template-columns: 1fr 1fr; gap: 2px 12px; } +.wf-formgrid .wide { grid-column: 1 / -1; } +@media (max-width: 560px) { .wf-formgrid { grid-template-columns: 1fr; } } +``` + +- [ ] **Step 8: Export + mount** — `index.ts` gains `export * from './toast'` and `export * from './formgrid'` (NOT toast-store — internal). In `apps/hq-web/src/main.tsx` import `ToastProvider` from `@sims/ui` and wrap: `` inside `App`. + +- [ ] **Step 9: Verify** — typecheck 0; `npm test` zero failures (3 new). + +- [ ] **Step 10: Commit** + +```bash +git add packages/ui/src/toast-store.ts packages/ui/src/toast.tsx packages/ui/src/formgrid.tsx packages/ui/test/toast-store.test.ts packages/ui/src/tokens.css packages/ui/src/index.ts apps/hq-web/src/main.tsx +git commit -m "feat(ui): Toaster with pure store (TDD) + FormGrid; provider mounted at root" +``` + +--- + +### Task 3: Backend — reminder settings + schedule endpoints (TDD) + +**Files:** +- Modify: `apps/hq/src/repos-reminders.ts` (export `parseDayOffsets`; add `listSchedules`, `insertSchedule`) +- Modify: `apps/hq/src/api.ts` (three routes beside the existing `/settings/company` block) +- Test: `apps/hq/test/settings-reminders.test.ts` + +**Interfaces:** +- Consumes: existing `SCHEDULE_DEFAULTS`, `resolveSchedule`, `setSetting`, `writeAudit`, `requireOwner`; seeded setting keys `reminders.overdue_days` (default '7') / `reminders.renewal_days` (default '15'). +- Produces: `ScheduleRow { id: string; ruleKind: string; effectiveFrom: string; effectiveTo: string | null; dayOffsets: string; subject: string | null; body: string | null }`; `listSchedules(db): ScheduleRow[]`; `insertSchedule(db, userId, input: { ruleKind: string; effectiveFrom: string; dayOffsets: string; subject?: string; body?: string }): ScheduleRow`. Routes: `GET /settings/reminders` → `{ ok, overdueDays: number, renewalDays: number, schedules: ScheduleRow[], total: number }` (owner); `PUT /settings/reminders` body `{ overdueDays?, renewalDays? }` (ints ≥ 1, owner, audited via `setSetting`); `POST /settings/schedules` → `{ ok, schedule }` (owner, audited, append-only). + +- [ ] **Step 1: Write the failing test** — `apps/hq/test/settings-reminders.test.ts`. Mirror the setup style of `apps/hq/test/reminder-schedule.test.ts` (in-memory `openDb(':memory:')` + `seedIfEmpty`). Test cases: + +```ts +import { describe, expect, it } from 'vitest' +import { openDb } from '../src/db' +import { seedIfEmpty } from '../src/seed' +import { insertSchedule, listSchedules, resolveSchedule } from '../src/repos-reminders' + +const fresh = () => { const db = openDb(':memory:'); seedIfEmpty(db); return db } + +describe('schedule settings', () => { + it('lists the two seeded open-ended rows', () => { + const rows = listSchedules(fresh()) + expect(rows.map((r) => r.ruleKind).sort()).toEqual(['invoice_overdue', 'quote_followup']) + }) + it('insertSchedule appends a dated row that resolveSchedule picks from its date', () => { + const db = fresh() + const row = insertSchedule(db, 'u1', { + ruleKind: 'quote_followup', effectiveFrom: '2026-08-01', dayOffsets: ' 2, 5,5, 9 ', + }) + expect(row.dayOffsets).toBe('2,5,9') // normalized: trimmed, deduped, sorted + expect(resolveSchedule(db, 'quote_followup', '2026-08-02').dayOffsets).toEqual([2, 5, 9]) + expect(resolveSchedule(db, 'quote_followup', '2026-07-20').dayOffsets).toEqual([3, 7, 14]) + expect(listSchedules(db)).toHaveLength(3) // append-only: old row still there + }) + it('rejects bad input', () => { + const db = fresh() + expect(() => insertSchedule(db, 'u1', { ruleKind: 'nope', effectiveFrom: '2026-08-01', dayOffsets: '3' })).toThrow() + expect(() => insertSchedule(db, 'u1', { ruleKind: 'quote_followup', effectiveFrom: '01-08-2026', dayOffsets: '3' })).toThrow() + expect(() => insertSchedule(db, 'u1', { ruleKind: 'quote_followup', effectiveFrom: '2026-08-01', dayOffsets: 'x,-2' })).toThrow() + }) + it('audits the insert', () => { + const db = fresh() + const row = insertSchedule(db, 'u1', { ruleKind: 'invoice_overdue', effectiveFrom: '2026-09-01', dayOffsets: '10,20' }) + const audit = db.prepare(`SELECT * FROM audit_log WHERE entity='reminder_schedule' AND entity_id=?`).get(row.id) + expect(audit).toBeDefined() + }) +}) +``` + +Adjust the audit-table column names to whatever `apps/hq/test/` siblings assert against (read one first — e.g. `clients.test.ts`); `resolveSchedule`'s exact signature likewise (read `repos-reminders.ts:246+`). Keep assertions semantically identical. + +- [ ] **Step 2: Run — expect FAIL** — `npm test -- settings-reminders` → `insertSchedule`/`listSchedules` not exported. + +- [ ] **Step 3: Implement in `repos-reminders.ts`** + +Export the existing private `parseDayOffsets`. Then append: + +```ts +export interface ScheduleRow { + id: string; ruleKind: string; effectiveFrom: string; effectiveTo: string | null + dayOffsets: string; subject: string | null; body: string | null +} + +const SCHEDULE_KINDS = Object.keys(SCHEDULE_DEFAULTS) + +/** All dated schedule rows, newest first within each rule kind (bounded set). */ +export function listSchedules(db: DB): ScheduleRow[] { + const rows = db.prepare( + `SELECT id, rule_kind, effective_from, effective_to, day_offsets, subject, body + FROM reminder_schedule ORDER BY rule_kind, effective_from DESC`, + ).all() as { id: string; rule_kind: string; effective_from: string; effective_to: string | null; day_offsets: string; subject: string | null; body: string | null }[] + return rows.map((r) => ({ + id: r.id, ruleKind: r.rule_kind, effectiveFrom: r.effective_from, effectiveTo: r.effective_to, + dayOffsets: r.day_offsets, subject: r.subject, body: r.body, + })) +} + +/** Append a NEW dated cadence row (rule 3: config change = new row, never an edit). */ +export function insertSchedule(db: DB, userId: string, input: { + ruleKind: string; effectiveFrom: string; dayOffsets: string; subject?: string; body?: string +}): ScheduleRow { + if (!SCHEDULE_KINDS.includes(input.ruleKind)) throw new Error(`Unknown rule kind: ${input.ruleKind}`) + if (!/^\d{4}-\d{2}-\d{2}$/.test(input.effectiveFrom)) throw new Error('effectiveFrom must be YYYY-MM-DD') + const offsets = parseDayOffsets(input.dayOffsets) + if (offsets.length === 0) throw new Error('dayOffsets must contain at least one positive integer') + const id = uuidv7() + const csv = offsets.join(',') + db.transaction(() => { + db.prepare( + `INSERT INTO reminder_schedule (id, rule_kind, effective_from, effective_to, day_offsets, subject, body) + VALUES (?, ?, ?, NULL, ?, ?, ?)`, + ).run(id, input.ruleKind, input.effectiveFrom, csv, input.subject ?? null, input.body ?? null) + writeAudit(db, userId, 'create', 'reminder_schedule', id, undefined, { + ruleKind: input.ruleKind, effectiveFrom: input.effectiveFrom, dayOffsets: csv, + }) + })() + return listSchedules(db).find((r) => r.id === id)! +} +``` + +(Match the file's existing imports — `uuidv7`, `writeAudit`, `DB` are already imported there.) + +- [ ] **Step 4: Run — expect PASS** — `npm test -- settings-reminders`. + +- [ ] **Step 5: Routes in `apps/hq/src/api.ts`** — add beside the `/settings/company` block. Read how the scan reads `reminders.overdue_days` (grep it) and reuse that helper for GET: + +```ts + // ---------- reminder cadences (owner; dated rows are append-only — rule 3) ---------- + r.get('/settings/reminders', requireAuth, requireOwner, (_req, res) => { + const schedules = listSchedules(db) + res.json({ + ok: true, + overdueDays: readIntSetting('reminders.overdue_days', 7), + renewalDays: readIntSetting('reminders.renewal_days', 15), + schedules, total: schedules.length, + }) + }) + r.put('/settings/reminders', requireAuth, requireOwner, (req, res) => { + const body = req.body as { overdueDays?: unknown; renewalDays?: unknown } + try { + for (const [field, key] of [['overdueDays', 'reminders.overdue_days'], ['renewalDays', 'reminders.renewal_days']] as const) { + const v = body[field] + if (v === undefined) continue + if (typeof v !== 'number' || !Number.isInteger(v) || v < 1) throw new Error(`${field} must be a positive integer`) + setSetting(db, staffId(res), key, String(v)) + } + res.json({ ok: true }) + } catch (err) { + res.status(400).json({ ok: false, error: err instanceof Error ? err.message : String(err) }) + } + }) + r.post('/settings/schedules', requireAuth, requireOwner, (req, res) => { + const body = req.body as { ruleKind?: string; effectiveFrom?: string; dayOffsets?: string; subject?: string; body?: string } + try { + const schedule = insertSchedule(db, staffId(res), { + ruleKind: body.ruleKind ?? '', effectiveFrom: body.effectiveFrom ?? '', dayOffsets: body.dayOffsets ?? '', + ...(body.subject !== undefined && body.subject !== '' ? { subject: body.subject } : {}), + ...(body.body !== undefined && body.body !== '' ? { body: body.body } : {}), + }) + res.json({ ok: true, schedule }) + } catch (err) { + res.status(400).json({ ok: false, error: err instanceof Error ? err.message : String(err) }) + } + }) +``` + +`readIntSetting` — write it local to api.ts if no equivalent exists (grep first): reads `setting` value, `Number()` it, fallback on NaN. `staffId(res)` — use whatever the neighboring routes use to get the acting user id. + +- [ ] **Step 6: Verify** — typecheck 0; full `npm test` zero failures. + +- [ ] **Step 7: Commit** + +```bash +git add apps/hq/src/repos-reminders.ts apps/hq/src/api.ts apps/hq/test/settings-reminders.test.ts +git commit -m "feat(settings): reminder day-settings + append-only dated schedule endpoints (owner, audited)" +``` + +--- + +### Task 4: Backend — sharing default (TDD) + +**Files:** +- Modify: `apps/hq/src/repos-shares.ts` (`mintShare` default from setting) +- Modify: `apps/hq/src/api.ts` (GET/PUT `/settings/sharing`) +- Test: `apps/hq/test/settings-sharing.test.ts` + +**Interfaces:** +- Produces: setting row `share.default_expiry_days` (`'never'` or a positive-int string; absent → 30). `GET /settings/sharing` → `{ ok, defaultExpiryDays: number | null }` (null = never; owner). `PUT /settings/sharing` body `{ defaultExpiryDays: number | null }` (owner, audited via `setSetting`). `mintShare` unchanged signature; explicit `opts.expiresDays` always wins. + +- [ ] **Step 1: Failing test** — `apps/hq/test/settings-sharing.test.ts` (mirror `shares.test.ts` setup — it creates a client + document to mint against; copy that arrange block): + +```ts +// cases (exact arrange code copied from shares.test.ts): +// 1. no setting row → mintShare default expiry ≈ now+30d (assert expiresAt date prefix) +// 2. setting '7' → expiry ≈ now+7d +// 3. setting 'never' → expiresAt null +// 4. explicit opts.expiresDays: 3 wins over setting '7'; explicit null wins too +// 5. setting garbage 'abc' → falls back to 30 +``` + +- [ ] **Step 2: FAIL** — `npm test -- settings-sharing`. + +- [ ] **Step 3: Implement** — in `repos-shares.ts`, above `mintShare`: + +```ts +/** Owner-set default link lifetime; 'never' → null, absent/garbage → 30. */ +function defaultExpiryDays(db: DB): number | null { + const row = db.prepare(`SELECT value FROM setting WHERE key='share.default_expiry_days'`) + .get() as { value: string } | undefined + if (row === undefined) return 30 + if (row.value === 'never') return null + const n = Number(row.value) + return Number.isInteger(n) && n > 0 ? n : 30 +} +``` + +and in `mintShare` change the default line to: + +```ts + const expiresDays = opts.expiresDays === undefined ? defaultExpiryDays(db) : opts.expiresDays +``` + +Routes in api.ts (beside the other settings routes): + +```ts + // ---------- sharing defaults (owner) ---------- + r.get('/settings/sharing', requireAuth, requireOwner, (_req, res) => { + const row = db.prepare(`SELECT value FROM setting WHERE key='share.default_expiry_days'`) + .get() as { value: string } | undefined + const days = row === undefined ? 30 : row.value === 'never' ? null : Number(row.value) + res.json({ ok: true, defaultExpiryDays: days !== null && (!Number.isInteger(days) || days < 1) ? 30 : days }) + }) + r.put('/settings/sharing', requireAuth, requireOwner, (req, res) => { + const v = (req.body as { defaultExpiryDays?: unknown }).defaultExpiryDays + if (v !== null && (typeof v !== 'number' || !Number.isInteger(v) || v < 1)) { + res.status(400).json({ ok: false, error: 'defaultExpiryDays must be a positive integer or null (never)' }) + return + } + setSetting(db, staffId(res), 'share.default_expiry_days', v === null ? 'never' : String(v)) + res.json({ ok: true, defaultExpiryDays: v }) + }) +``` + +- [ ] **Step 4: PASS** — focused, then full suite + typecheck. + +- [ ] **Step 5: Commit** + +```bash +git add apps/hq/src/repos-shares.ts apps/hq/src/api.ts apps/hq/test/settings-sharing.test.ts +git commit -m "feat(settings): configurable default share-link expiry (owner, audited)" +``` + +--- + +### Task 5: Send-email modal + document ConfirmDialogs + +**Files:** +- Create: `apps/hq-web/src/components/SendDialog.tsx` +- Modify: `apps/hq-web/src/pages/DocumentView.tsx` + +**Interfaces:** +- Consumes: `Dialog`, `ConfirmDialog`, `useToast`, `Field` from `@sims/ui`; `getEmailStatus`, `documentAction`, types from `../api`. +- Produces: `SendDialog(props: { open: boolean; onClose: () => void; doc: Doc; client: Client | undefined; onSend: (to: string, subject: string) => void })`. + +**READ THE CURRENT FILE FIRST** — DocumentView was modified by two workstreams (it now also has convert-and-send + supersede confirms). Enumerate every popup with `grep -n "window.prompt\|window.confirm" apps/hq-web/src/pages/DocumentView.tsx` and convert ALL of them. + +- [ ] **Step 1: Create `SendDialog.tsx`** + +```tsx +import { useEffect, useMemo, useState } from 'react' +import { Button, Dialog, Field, Notice } from '@sims/ui' +import { getEmailStatus, type Client, type Doc, type EmailStatus } from '../api' + +const OTHER = '__other__' + +/** Real send composer — To from the client's contacts, Subject prefilled, Gmail state inline. */ +export function SendDialog(props: { + open: boolean + onClose: () => void + doc: Doc + client: Client | undefined + onSend: (to: string, subject: string) => void +}) { + const emails = useMemo( + () => (props.client?.contacts ?? []).map((c) => c.email).filter((e): e is string => e !== undefined && e !== ''), + [props.client], + ) + const [pick, setPick] = useState('') + const [other, setOther] = useState('') + const [subject, setSubject] = useState('') + const [gmail, setGmail] = useState() + + useEffect(() => { + if (!props.open) return + setPick(emails[0] ?? OTHER) + setOther('') + setSubject(`${props.doc.docType} ${props.doc.docNo ?? ''}`.trim()) + getEmailStatus().then(setGmail).catch(() => setGmail(undefined)) + }, [props.open, emails, props.doc]) + + const to = pick === OTHER ? other.trim() : pick + const down = gmail !== undefined && (!gmail.connected || gmail.dead) + const valid = /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(to) && subject.trim() !== '' && !down + + return ( + + + + + } + > + {down && ( + Gmail is disconnected — run gmail-connect on the server, then retry. + )} + + + + {pick === OTHER && ( + + setOther(e.target.value)} /> + + )} + + setSubject(e.target.value)} /> + + {gmail?.address !== undefined && ( +

Sends from {gmail.address}.

+ )} +
+ ) +} +``` + +- [ ] **Step 2: Refit DocumentView** + +1. Imports: add `ConfirmDialog, useToast` to the `@sims/ui` import; `import { SendDialog } from '../components/SendDialog'`. +2. State: `const toast = useToast()`, `const [sending, setSending] = useState(false)`, and one confirm slot: `const [confirm, setConfirm] = useState<{ title: string; body: string; label: string; tone?: 'danger'; run: () => void } | undefined>()`. +3. Replace the whole `send()` prompt function: the Send button does `setSending(true)`; render ` setSending(false)} doc={doc} client={client.data} onSend={(to, subject) => act('send', { to, subject })} />`. +4. Replace EVERY `window.confirm(...)` site with `setConfirm({ title, body, label, tone, run: () => act(...) })` using each site's existing copy (cancel + supersede get `tone: 'danger'`); render once near the end: + +```tsx + {confirm !== undefined && ( + setConfirm(undefined)} + onConfirm={confirm.run} + title={confirm.title} + body={confirm.body} + confirmLabel={confirm.label} + {...(confirm.tone !== undefined ? { tone: confirm.tone } : {})} + /> + )} +``` + +5. Toasts in `act()`: on success `toast.ok(...)` with a friendly label per action (`issue` → 'Number issued', `send` → \`Sent to ${to}\` — pass a message argument through act or toast at call sites); on failure replace `setActionErr(friendly(...))` with `toast.err(friendly(...))` and delete the `actionErr` state + Notice. +6. Keep ShareActions untouched except: its revoke button gets a ConfirmDialog too (same slot pattern, inside ShareActions with its own local state) — optional if time-boxed; if skipped, note it in the report. + +- [ ] **Step 3: Verify** — typecheck; full suite; `grep -c "window\." apps/hq-web/src/pages/DocumentView.tsx` → 0. + +- [ ] **Step 4: Commit** + +```bash +git add apps/hq-web/src/components/SendDialog.tsx apps/hq-web/src/pages/DocumentView.tsx +git commit -m "feat(hq-web): send-email composer dialog + styled confirms — window.prompt/confirm eliminated" +``` + +--- + +### Task 6: Client edit + New-client dialog + +**Files:** +- Create: `apps/hq-web/src/components/ClientFormDialog.tsx` +- Modify: `apps/hq-web/src/pages/ClientDetail.tsx` (Edit button), `apps/hq-web/src/pages/Clients.tsx` (replace inline NewClientForm) + +**Interfaces:** +- Consumes: `Dialog`, `FormGrid`, `FormField`, `Button`, `Notice`, `useToast`; `createClient`, `patchClient`, `type Client`, `type ClientContact` from `../api`. +- Produces: `ClientFormDialog(props: { open: boolean; onClose: () => void; initial?: Client; onSaved: (c: Client) => void })` — create mode when `initial` is undefined. + +- [ ] **Step 1: Create `ClientFormDialog.tsx`** + +```tsx +import { useEffect, useState } from 'react' +import { Button, Dialog, FormField, FormGrid, Notice, useToast } from '@sims/ui' +import { createClient, patchClient, type Client, type ClientContact } from '../api' + +interface ContactDraft { name: string; email: string; phone: string; role: string } +const EMPTY_CONTACT: ContactDraft = { name: '', email: '', phone: '', role: '' } + +/** Create/edit a client — identity, address, notes, and the contacts repeater. */ +export function ClientFormDialog(props: { + open: boolean + onClose: () => void + initial?: Client + onSaved: (c: Client) => void +}) { + const toast = useToast() + const editing = props.initial !== undefined + const [f, setF] = useState({ name: '', gstin: '', stateCode: '32', address: '', notes: '' }) + const [contacts, setContacts] = useState([]) + const [error, setError] = useState() + const [saving, setSaving] = useState(false) + + useEffect(() => { + if (!props.open) return + const c = props.initial + setError(undefined) + setF({ + name: c?.name ?? '', gstin: c?.gstin ?? '', stateCode: c?.stateCode ?? '32', + address: c?.address ?? '', notes: c?.notes ?? '', + }) + setContacts((c?.contacts ?? []).map((ct) => ({ + name: ct.name, email: ct.email ?? '', phone: ct.phone ?? '', role: ct.role ?? '', + }))) + }, [props.open, props.initial]) + + const set = (k: keyof typeof f) => (e: { target: { value: string } }) => + setF((p) => ({ ...p, [k]: e.target.value })) + const setContact = (i: number, k: keyof ContactDraft) => (e: { target: { value: string } }) => + setContacts((prev) => prev.map((c, j) => (j === i ? { ...c, [k]: e.target.value } : c))) + + const save = () => { + if (f.name.trim() === '') { setError('Name is required'); return } + if (!/^\d{2}$/.test(f.stateCode.trim())) { setError('State code must be two digits'); return } + const cleaned: ClientContact[] = contacts + .filter((c) => c.name.trim() !== '' || c.email.trim() !== '' || c.phone.trim() !== '') + .map((c) => ({ + name: c.name.trim() !== '' ? c.name.trim() : (c.email.trim() !== '' ? c.email.trim() : c.phone.trim()), + ...(c.email.trim() !== '' ? { email: c.email.trim() } : {}), + ...(c.phone.trim() !== '' ? { phone: c.phone.trim() } : {}), + ...(c.role.trim() !== '' ? { role: c.role.trim() } : {}), + })) + const body = { + name: f.name.trim(), stateCode: f.stateCode.trim(), + gstin: f.gstin.trim().toUpperCase(), address: f.address, notes: f.notes, contacts: cleaned, + } + setError(undefined) + setSaving(true) + const call = editing ? patchClient(props.initial!.id, body) : createClient(body) + call + .then((c) => { toast.ok(editing ? 'Client saved' : `Client ${c.code} created`); props.onSaved(c); props.onClose() }) + .catch((e: Error) => setError(e.message)) + .finally(() => setSaving(false)) + } + + return ( + + + + + } + > + + + + +