From faea5f0448bd72d7ccb9917bff59c8a86bca7077 Mon Sep 17 00:00:00 2001 From: Roberto Date: Wed, 13 May 2026 15:59:43 +0200 Subject: [PATCH] docs: add timeline batch-add implementation plan 9 tasks, manual verification per task (no automated test suite). Covers parseDate utility, DateField primitive, EditEventDialog migration, AddEventDialog rewrite with keyboard nav, edit-row mode, batch submit with allSettled error handling. Co-Authored-By: Claude Opus 4.7 --- .../plans/2026-05-13-timeline-batch-add.md | 1579 +++++++++++++++++ 1 file changed, 1579 insertions(+) create mode 100644 docs/superpowers/plans/2026-05-13-timeline-batch-add.md diff --git a/docs/superpowers/plans/2026-05-13-timeline-batch-add.md b/docs/superpowers/plans/2026-05-13-timeline-batch-add.md new file mode 100644 index 0000000..d75bd57 --- /dev/null +++ b/docs/superpowers/plans/2026-05-13-timeline-batch-add.md @@ -0,0 +1,1579 @@ +# Timeline Batch Add 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:** Replace one-event-at-a-time `AddEventDialog` with a fully keyboard-operable, stage-then-commit batch flow. One batch = one project. Extract `DateField` + `parseDate` as shared primitives and migrate `EditEventDialog` to use them. + +**Architecture:** Refactor in place. New shared primitives (`parseDate.ts`, `date-field.tsx`) wrap typed entry over the existing shadcn `Calendar` popover. `AddEventDialog` becomes a project picker + staged list + form. Batch submit reuses the existing `timelineEvents.create` mutation via `Promise.allSettled`. No backend changes. + +**Tech Stack:** +- adiuvAI submodule only — Electron renderer (React 19 + TanStack Router), shadcn/ui new-york, i18next, react-day-picker. +- No automated test suite in this repo (per `adiuvAI/.claude/CLAUDE.md`) → manual verification per task instead of TDD. + +**Reference spec:** `docs/superpowers/specs/2026-05-13-timeline-batch-add-design.md` + +--- + +## File Inventory + +| File | Action | Responsibility | +|---|---|---| +| `adiuvAI/src/renderer/lib/parseDate.ts` | Create | Pure `parseDate` + `parseDateRange` functions, locale-aware | +| `adiuvAI/src/renderer/components/ui/date-field.tsx` | Create | Reusable typed date input + popover calendar | +| `adiuvAI/src/renderer/locales/en/translation.json` | Modify | Add `timeline.*` and `date.keyword.*` keys | +| `adiuvAI/src/renderer/locales/it/translation.json` | Modify | Same keys, IT translations | +| `adiuvAI/src/renderer/locales/es/translation.json` | Modify | Same keys, ES translations | +| `adiuvAI/src/renderer/locales/fr/translation.json` | Modify | Same keys, FR translations | +| `adiuvAI/src/renderer/locales/de/translation.json` | Modify | Same keys, DE translations | +| `adiuvAI/src/renderer/components/timeline/EditEventDialog.tsx` | Modify | Swap popover+Calendar for `` | +| `adiuvAI/src/renderer/components/timeline/AddEventDialog.tsx` | Rewrite | Stage-then-commit batch model | + +Untouched: +- Backend `api/` +- tRPC contracts (`timelineEvents.create` reused) +- DB schema + +--- + +## Task 1: `parseDate` utility + +**Files:** +- Create: `adiuvAI/src/renderer/lib/parseDate.ts` + +- [ ] **Step 1: Locate existing FormatPrefs type** + +Run (PowerShell): +```powershell +Select-String -Path adiuvAI/src/renderer/lib/date.ts -Pattern "FormatPrefs|dateFormat" | Select-Object -First 20 +``` +Expected: prints definition / usage of `FormatPrefs` and its `dateFormat` field (`'DD/MM/YYYY' | 'MM/DD/YYYY' | 'YYYY-MM-DD'` style). + +- [ ] **Step 2: Create `parseDate.ts`** + +Write `adiuvAI/src/renderer/lib/parseDate.ts`: + +```ts +import type { FormatPrefs } from './date'; + +type ParseInput = string | null | undefined; + +const RE_REL = /^\s*([+-])\s*(\d+)\s*([dwm])\s*$/i; +const RE_NUMERIC = /^(\d{1,4})[\/\-.](\d{1,4})(?:[\/\-.](\d{1,4}))?$/; + +function startOfDay(d: Date): Date { + const out = new Date(d.getFullYear(), d.getMonth(), d.getDate()); + return out; +} + +function addDays(d: Date, n: number): Date { + const out = new Date(d); + out.setDate(out.getDate() + n); + return out; +} + +function addMonths(d: Date, n: number): Date { + const out = new Date(d); + out.setMonth(out.getMonth() + n); + return out; +} + +function pivotYear(twoDigit: number): number { + const now = new Date().getFullYear(); + const century = Math.floor(now / 100) * 100; + const offset = now % 100; + // Within +/-50 years of current → current century, else previous/next + return twoDigit > (offset + 50) ? century - 100 + twoDigit : century + twoDigit; +} + +function parseNumeric( + input: string, + prefs: FormatPrefs, + base: Date, +): Date | null { + const m = input.match(RE_NUMERIC); + if (!m) return null; + let day: number, month: number, year: number; + const a = parseInt(m[1], 10); + const b = parseInt(m[2], 10); + const c = m[3] != null ? parseInt(m[3], 10) : NaN; + + if (prefs.dateFormat === 'YYYY-MM-DD') { + if (m[3] == null) return null; + year = a; month = b; day = c; + } else if (prefs.dateFormat === 'MM/DD/YYYY') { + month = a; day = b; + if (m[3] == null) year = base.getFullYear(); + else year = c < 100 ? pivotYear(c) : c; + } else { + // default DD/MM/YYYY + day = a; month = b; + if (m[3] == null) year = base.getFullYear(); + else year = c < 100 ? pivotYear(c) : c; + } + + if (month < 1 || month > 12) return null; + const result = new Date(year, month - 1, day); + if (result.getFullYear() !== year || result.getMonth() !== month - 1 || result.getDate() !== day) { + return null; + } + // Partial date with past day → roll forward to next year + if (m[3] == null && result < startOfDay(base)) { + result.setFullYear(year + 1); + } + return result; +} + +function parseKeyword( + word: string, + keywords: { today: string[]; tomorrow: string[]; yesterday: string[]; weekdays: string[][] }, + base: Date, +): Date | null { + const w = word.trim().toLowerCase(); + if (keywords.today.some((k) => k.toLowerCase() === w)) return startOfDay(base); + if (keywords.tomorrow.some((k) => k.toLowerCase() === w)) return addDays(startOfDay(base), 1); + if (keywords.yesterday.some((k) => k.toLowerCase() === w)) return addDays(startOfDay(base), -1); + for (let i = 0; i < keywords.weekdays.length; i++) { + if (keywords.weekdays[i].some((k) => k.toLowerCase() === w)) { + const today = startOfDay(base); + const diff = (i - today.getDay() + 7) % 7 || 7; + return addDays(today, diff); + } + } + return null; +} + +export type DateKeywords = { + today: string[]; + tomorrow: string[]; + yesterday: string[]; + /** Index 0=Sunday, 6=Saturday. Each entry: aliases (short + long). */ + weekdays: string[][]; +}; + +export function parseDate( + input: ParseInput, + prefs: FormatPrefs, + keywords: DateKeywords, + baseDate: Date = new Date(), +): Date | null { + if (!input) return null; + const trimmed = input.trim(); + if (!trimmed) return null; + + const rel = trimmed.match(RE_REL); + if (rel) { + const sign = rel[1] === '-' ? -1 : 1; + const n = sign * parseInt(rel[2], 10); + const unit = rel[3].toLowerCase(); + const base = startOfDay(baseDate); + if (unit === 'd') return addDays(base, n); + if (unit === 'w') return addDays(base, n * 7); + if (unit === 'm') return addMonths(base, n); + } + + const kw = parseKeyword(trimmed, keywords, baseDate); + if (kw) return kw; + + const num = parseNumeric(trimmed, prefs, baseDate); + if (num) return num; + + return null; +} + +export function parseDateRange( + input: ParseInput, + prefs: FormatPrefs, + keywords: DateKeywords, + baseDate: Date = new Date(), +): { from: Date; to?: Date } | null { + if (!input) return null; + const parts = input.split(/\s*(?:-{1,2}|–|to)\s*/i); + if (parts.length === 1) { + const single = parseDate(parts[0], prefs, keywords, baseDate); + return single ? { from: single } : null; + } + if (parts.length === 2) { + const from = parseDate(parts[0], prefs, keywords, baseDate); + if (!from) return null; + const to = parseDate(parts[1], prefs, keywords, from); + if (!to) return null; + return { from, to }; + } + return null; +} +``` + +- [ ] **Step 3: Verify file compiles** + +Run: +```powershell +cd adiuvAI; npm run lint -- --max-warnings=0 src/renderer/lib/parseDate.ts +``` +Expected: zero errors. + +- [ ] **Step 4: Manual verification via REPL or scratch** + +Open dev tools console after `npm start`, paste: +```js +// In renderer dev console +const { parseDate } = await import('/src/renderer/lib/parseDate.ts'); +const prefs = { dateFormat: 'DD/MM/YYYY' }; +const kw = { + today: ['today'], tomorrow: ['tomorrow'], yesterday: ['yesterday'], + weekdays: [['sun','sunday'],['mon','monday'],['tue','tuesday'],['wed','wednesday'],['thu','thursday'],['fri','friday'],['sat','saturday']], +}; +console.log(parseDate('today', prefs, kw)?.toISOString()); +console.log(parseDate('+3d', prefs, kw)?.toISOString()); +console.log(parseDate('15/03', prefs, kw)?.toISOString()); +console.log(parseDate('15/03/26', prefs, kw)?.toISOString()); +console.log(parseDate('2026-03-15', { dateFormat: 'YYYY-MM-DD' }, kw)?.toISOString()); +console.log(parseDate('mon', prefs, kw)?.toISOString()); +console.log(parseDate('garbage', prefs, kw)); // null +``` +Expected: each prints a valid ISO string except last (null). + +- [ ] **Step 5: Commit** + +```powershell +git -C adiuvAI add src/renderer/lib/parseDate.ts +git -C adiuvAI commit -m "feat(date): add parseDate utility with locale-aware parsing" +``` + +--- + +## Task 2: i18n date keyword keys + +**Files:** +- Modify: `adiuvAI/src/renderer/locales/{en,it,es,fr,de}/translation.json` + +- [ ] **Step 1: Add keys to `en/translation.json`** + +Locate the existing top-level object. Add a new `date` block (or extend if present): + +```json +"date": { + "keyword": { + "today": ["today"], + "tomorrow": ["tomorrow", "tmrw"], + "yesterday": ["yesterday"], + "weekdays": [ + ["sun", "sunday"], + ["mon", "monday"], + ["tue", "tuesday"], + ["wed", "wednesday"], + ["thu", "thursday"], + ["fri", "friday"], + ["sat", "saturday"] + ] + } +} +``` + +- [ ] **Step 2: Add keys to `it/translation.json`** + +```json +"date": { + "keyword": { + "today": ["oggi"], + "tomorrow": ["domani"], + "yesterday": ["ieri"], + "weekdays": [ + ["dom", "domenica"], + ["lun", "lunedì", "lunedi"], + ["mar", "martedì", "martedi"], + ["mer", "mercoledì", "mercoledi"], + ["gio", "giovedì", "giovedi"], + ["ven", "venerdì", "venerdi"], + ["sab", "sabato"] + ] + } +} +``` + +- [ ] **Step 3: Add keys to `es/translation.json`** + +```json +"date": { + "keyword": { + "today": ["hoy"], + "tomorrow": ["mañana", "manana"], + "yesterday": ["ayer"], + "weekdays": [ + ["dom", "domingo"], + ["lun", "lunes"], + ["mar", "martes"], + ["mié", "mie", "miércoles", "miercoles"], + ["jue", "jueves"], + ["vie", "viernes"], + ["sáb", "sab", "sábado", "sabado"] + ] + } +} +``` + +- [ ] **Step 4: Add keys to `fr/translation.json`** + +```json +"date": { + "keyword": { + "today": ["aujourd'hui", "auj"], + "tomorrow": ["demain"], + "yesterday": ["hier"], + "weekdays": [ + ["dim", "dimanche"], + ["lun", "lundi"], + ["mar", "mardi"], + ["mer", "mercredi"], + ["jeu", "jeudi"], + ["ven", "vendredi"], + ["sam", "samedi"] + ] + } +} +``` + +- [ ] **Step 5: Add keys to `de/translation.json`** + +```json +"date": { + "keyword": { + "today": ["heute"], + "tomorrow": ["morgen"], + "yesterday": ["gestern"], + "weekdays": [ + ["so", "sonntag"], + ["mo", "montag"], + ["di", "dienstag"], + ["mi", "mittwoch"], + ["do", "donnerstag"], + ["fr", "freitag"], + ["sa", "samstag"] + ] + } +} +``` + +- [ ] **Step 6: Verify JSON validity** + +Run: +```powershell +foreach ($f in @('en','it','es','fr','de')) { + $p = "adiuvAI/src/renderer/locales/$f/translation.json" + try { Get-Content $p -Raw | ConvertFrom-Json | Out-Null; "$p OK" } catch { "$p FAIL: $_" } +} +``` +Expected: 5 OK lines. + +- [ ] **Step 7: Commit** + +```powershell +git -C adiuvAI add src/renderer/locales/ +git -C adiuvAI commit -m "i18n(date): add date keyword arrays for parseDate" +``` + +--- + +## Task 3: `` primitive + +**Files:** +- Create: `adiuvAI/src/renderer/components/ui/date-field.tsx` + +- [ ] **Step 1: Create `date-field.tsx`** + +Write `adiuvAI/src/renderer/components/ui/date-field.tsx`: + +```tsx +import { useEffect, useRef, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { CalendarIcon } from 'lucide-react'; +import { useFormatPrefs, formatDate } from '@/lib/date'; +import { parseDate, type DateKeywords } from '@/lib/parseDate'; +import { Input } from '@/components/ui/input'; +import { Button } from '@/components/ui/button'; +import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'; +import { Calendar } from '@/components/ui/calendar'; +import { cn } from '@/lib/utils'; + +export type DateFieldProps = { + value: Date | undefined; + onChange: (d: Date | undefined) => void; + onCommit?: (d: Date) => void; + placeholder?: string; + minDate?: Date; + autoFocus?: boolean; + invalidMessage?: string; + className?: string; + 'aria-label'?: string; + id?: string; +}; + +export function DateField({ + value, + onChange, + onCommit, + placeholder, + minDate, + autoFocus, + invalidMessage, + className, + id, + ...rest +}: DateFieldProps) { + const { t, i18n } = useTranslation(); + const prefs = useFormatPrefs(); + const [text, setText] = useState(value ? formatDate(value.getTime(), prefs) : ''); + const [focused, setFocused] = useState(false); + const [invalid, setInvalid] = useState(false); + const [open, setOpen] = useState(false); + const inputRef = useRef(null); + + // External value changes (e.g. popover selection) sync into local text only when not focused. + useEffect(() => { + if (!focused) { + setText(value ? formatDate(value.getTime(), prefs) : ''); + setInvalid(false); + } + }, [value, focused, prefs]); + + function getKeywords(): DateKeywords { + const today = i18n.t('date.keyword.today', { returnObjects: true }) as unknown; + const tomorrow = i18n.t('date.keyword.tomorrow', { returnObjects: true }) as unknown; + const yesterday = i18n.t('date.keyword.yesterday', { returnObjects: true }) as unknown; + const weekdays = i18n.t('date.keyword.weekdays', { returnObjects: true }) as unknown; + return { + today: Array.isArray(today) ? (today as string[]) : ['today'], + tomorrow: Array.isArray(tomorrow) ? (tomorrow as string[]) : ['tomorrow'], + yesterday: Array.isArray(yesterday) ? (yesterday as string[]) : ['yesterday'], + weekdays: Array.isArray(weekdays) + ? (weekdays as string[][]) + : [['sun'],['mon'],['tue'],['wed'],['thu'],['fri'],['sat']], + }; + } + + function tryParse(raw: string): Date | null { + const parsed = parseDate(raw, prefs, getKeywords()); + if (!parsed) return null; + if (minDate && parsed < new Date(minDate.getFullYear(), minDate.getMonth(), minDate.getDate())) { + return null; + } + return parsed; + } + + function commit(raw: string, fireCommit: boolean) { + if (!raw.trim()) { + onChange(undefined); + setInvalid(false); + return; + } + const parsed = tryParse(raw); + if (parsed) { + setInvalid(false); + onChange(parsed); + if (fireCommit) onCommit?.(parsed); + } else { + setInvalid(true); + } + } + + return ( +
+ { + setText(e.target.value); + setInvalid(false); + }} + onFocus={() => setFocused(true)} + onBlur={() => { + setFocused(false); + commit(text, false); + }} + onKeyDown={(e) => { + if (e.key === 'Enter') { + e.preventDefault(); + commit(text, true); + } else if (e.altKey && e.key === 'ArrowDown') { + e.preventDefault(); + setOpen(true); + } + }} + aria-invalid={invalid || !!invalidMessage} + aria-label={rest['aria-label']} + className={cn('pr-8', (invalid || !!invalidMessage) && 'ring-1 ring-destructive')} + /> + + + + + + { + if (d) { + onChange(d); + setText(formatDate(d.getTime(), prefs)); + setInvalid(false); + onCommit?.(d); + } + setOpen(false); + inputRef.current?.focus(); + }} + disabled={minDate ? { before: minDate } : undefined} + /> + + + {invalidMessage && ( +

{invalidMessage}

+ )} +
+ ); +} +``` + +- [ ] **Step 2: Lint** + +```powershell +cd adiuvAI; npm run lint -- --max-warnings=0 src/renderer/components/ui/date-field.tsx +``` +Expected: zero errors. + +- [ ] **Step 3: Commit** + +```powershell +git -C adiuvAI add src/renderer/components/ui/date-field.tsx +git -C adiuvAI commit -m "feat(ui): add DateField with typed entry + calendar popover" +``` + +Manual smoke test of DateField happens during Task 4 migration (real consumer). + +--- + +## Task 4: Migrate `EditEventDialog` to `` + +**Files:** +- Modify: `adiuvAI/src/renderer/components/timeline/EditEventDialog.tsx` + +- [ ] **Step 1: Replace state model** + +Remove `dateRange` and `singleDate` state; replace with `date` + `endDate`. Update the `useEffect` that hydrates from `event` accordingly. Final state declarations: + +```ts +const [title, setTitle] = useState(''); +const [type, setType] = useState('milestone'); +const [date, setDate] = useState(); +const [endDate, setEndDate] = useState(); +``` + +Replace the `useEffect` body: + +```ts +useEffect(() => { + if (event) { + setTitle(event.title); + setType(event.type ?? 'milestone'); + setDate(new Date(event.date)); + if (event.type === 'activity' && event.endDate) { + setEndDate(new Date(event.endDate)); + } else { + setEndDate(undefined); + } + } +}, [event]); +``` + +- [ ] **Step 2: Replace `handleSubmit` to use new state** + +```ts +function handleSubmit(e: React.FormEvent) { + e.preventDefault(); + if (!event || !title.trim() || !date) return; + + if (isActivity) { + const hasEnd = endDate && endDate.getTime() !== date.getTime(); + const nextDate = date.getTime(); + const nextEndDate = hasEnd ? endDate!.getTime() : null; + pendingPrevRef.current = { + kind: 'update', + id: event.id, + prev: { + title: event.title, + type: (event.type ?? 'milestone') as 'milestone' | 'checkpoint' | 'activity', + date: event.date, + endDate: event.endDate ?? null, + }, + next: { title: title.trim(), type: 'activity', date: nextDate, endDate: nextEndDate }, + }; + updateEvent.mutate({ id: event.id, title: title.trim(), type: 'activity', date: nextDate, endDate: nextEndDate }); + } else { + const nextDate = date.getTime(); + pendingPrevRef.current = { + kind: 'update', + id: event.id, + prev: { + title: event.title, + type: (event.type ?? 'milestone') as 'milestone' | 'checkpoint' | 'activity', + date: event.date, + endDate: event.endDate ?? null, + }, + next: { title: title.trim(), type, date: nextDate, endDate: null }, + }; + updateEvent.mutate({ id: event.id, title: title.trim(), type, date: nextDate, endDate: null }); + } +} + +const canSubmit = isActivity ? (title.trim() && date) : (title.trim() && date); +``` + +- [ ] **Step 3: Replace JSX date pickers with ``** + +Remove `Popover`/`Calendar` imports and the two `Popover` blocks inside the form. Add at top of file: + +```ts +import { DateField } from '@/components/ui/date-field'; +``` + +Replace the `{isActivity ? (…) : (…)}` block with: + +```tsx +{isActivity ? ( +
+ + +
+) : ( + +)} +``` + +Delete unused imports (`Popover`, `PopoverContent`, `PopoverTrigger`, `Calendar`, `CalendarIcon`, `formatDate`, `useFormatPrefs`, `type DateRange`). + +- [ ] **Step 4: Lint** + +```powershell +cd adiuvAI; npm run lint -- --max-warnings=0 src/renderer/components/timeline/EditEventDialog.tsx +``` +Expected: zero errors. + +- [ ] **Step 5: Manual smoke test** + +```powershell +cd adiuvAI; npm start +``` +1. Open the app, navigate to `/timeline`, click an existing event (it triggers `EditEventDialog`). +2. Verify date input shows formatted date. +3. Tab into date input, type `+3d`, press Enter — formatted value appears, save button enabled. +4. Type `garbage`, press Enter — red ring, save disabled. +5. Click calendar icon, pick a date — input updates. +6. For an activity, verify end-date field appears, type `+1w` from start. +7. Save → toast appears, event updated on timeline. + +- [ ] **Step 6: Add new i18n keys used in EditEventDialog** + +If `timeline.pickStart` / `timeline.pickEnd` don't exist yet in any locale file, add them. Check first: +```powershell +Select-String -Path adiuvAI/src/renderer/locales/*/translation.json -Pattern "pickStart|pickEnd" +``` +If absent, add these to all 5 locales: +``` +"pickStart": "Pick start date" (EN) +"pickEnd": "Pick end date" (EN) +"pickStart": "Data inizio" (IT) +"pickEnd": "Data fine" (IT) +"pickStart": "Fecha de inicio" (ES) +"pickEnd": "Fecha de fin" (ES) +"pickStart": "Date de début" (FR) +"pickEnd": "Date de fin" (FR) +"pickStart": "Startdatum" (DE) +"pickEnd": "Enddatum" (DE) +``` +Insert under the existing `timeline.*` block in each file. + +- [ ] **Step 7: Commit** + +```powershell +git -C adiuvAI add src/renderer/components/timeline/EditEventDialog.tsx src/renderer/locales/ +git -C adiuvAI commit -m "refactor(timeline): migrate EditEventDialog to DateField" +``` + +--- + +## Task 5: i18n keys for batch flow + +**Files:** +- Modify: `adiuvAI/src/renderer/locales/{en,it,es,fr,de}/translation.json` + +- [ ] **Step 1: Add `timeline.*` keys to EN** + +Inside the existing `"timeline": { … }` block in `en/translation.json`, add: + +```json +"endBeforeStart": "End must be after start", +"dateInvalid": "Unrecognized date", +"batchCreated_one": "1 event created", +"batchCreated_other": "{{count}} events created", +"batchPartial": "{{ok}} created, {{failed}} failed", +"batchFailed": "Could not create events", +"staged_one": "1 event staged", +"staged_other": "{{count}} events staged", +"emptyStagedHint": "Type a title, set a date, press Enter", +"editRow": "Edit", +"removeRow": "Remove", +"projectLocked": "Project locked after first event", +"confirmCloseStaged": "Discard {{count}} staged events?", +"saveAll": "Save {{count}}", +"update": "Update" +``` + +- [ ] **Step 2: Add same keys to IT (Italian)** + +```json +"endBeforeStart": "La fine deve essere dopo l'inizio", +"dateInvalid": "Data non riconosciuta", +"batchCreated_one": "1 evento creato", +"batchCreated_other": "{{count}} eventi creati", +"batchPartial": "{{ok}} creati, {{failed}} falliti", +"batchFailed": "Impossibile creare gli eventi", +"staged_one": "1 evento in coda", +"staged_other": "{{count}} eventi in coda", +"emptyStagedHint": "Inserisci un titolo, imposta una data, premi Invio", +"editRow": "Modifica", +"removeRow": "Rimuovi", +"projectLocked": "Progetto bloccato dopo il primo evento", +"confirmCloseStaged": "Eliminare {{count}} eventi in coda?", +"saveAll": "Salva {{count}}", +"update": "Aggiorna" +``` + +- [ ] **Step 3: Add same keys to ES (Spanish)** + +```json +"endBeforeStart": "El fin debe ser posterior al inicio", +"dateInvalid": "Fecha no reconocida", +"batchCreated_one": "1 evento creado", +"batchCreated_other": "{{count}} eventos creados", +"batchPartial": "{{ok}} creados, {{failed}} fallidos", +"batchFailed": "No se pudieron crear los eventos", +"staged_one": "1 evento en cola", +"staged_other": "{{count}} eventos en cola", +"emptyStagedHint": "Escribe un título, elige una fecha, pulsa Intro", +"editRow": "Editar", +"removeRow": "Quitar", +"projectLocked": "Proyecto bloqueado tras el primer evento", +"confirmCloseStaged": "¿Descartar {{count}} eventos en cola?", +"saveAll": "Guardar {{count}}", +"update": "Actualizar" +``` + +- [ ] **Step 4: Add same keys to FR (French)** + +```json +"endBeforeStart": "La fin doit être après le début", +"dateInvalid": "Date non reconnue", +"batchCreated_one": "1 événement créé", +"batchCreated_other": "{{count}} événements créés", +"batchPartial": "{{ok}} créés, {{failed}} échoués", +"batchFailed": "Impossible de créer les événements", +"staged_one": "1 événement en attente", +"staged_other": "{{count}} événements en attente", +"emptyStagedHint": "Saisissez un titre, choisissez une date, appuyez sur Entrée", +"editRow": "Modifier", +"removeRow": "Retirer", +"projectLocked": "Projet verrouillé après le premier événement", +"confirmCloseStaged": "Abandonner {{count}} événements en attente ?", +"saveAll": "Enregistrer {{count}}", +"update": "Mettre à jour" +``` + +- [ ] **Step 5: Add same keys to DE (German)** + +```json +"endBeforeStart": "Ende muss nach dem Start liegen", +"dateInvalid": "Datum nicht erkannt", +"batchCreated_one": "1 Ereignis erstellt", +"batchCreated_other": "{{count}} Ereignisse erstellt", +"batchPartial": "{{ok}} erstellt, {{failed}} fehlgeschlagen", +"batchFailed": "Ereignisse konnten nicht erstellt werden", +"staged_one": "1 Ereignis vorbereitet", +"staged_other": "{{count}} Ereignisse vorbereitet", +"emptyStagedHint": "Titel eingeben, Datum festlegen, Enter drücken", +"editRow": "Bearbeiten", +"removeRow": "Entfernen", +"projectLocked": "Projekt nach dem ersten Ereignis gesperrt", +"confirmCloseStaged": "{{count}} vorbereitete Ereignisse verwerfen?", +"saveAll": "{{count}} speichern", +"update": "Aktualisieren" +``` + +- [ ] **Step 6: Verify JSON validity** + +```powershell +foreach ($f in @('en','it','es','fr','de')) { + $p = "adiuvAI/src/renderer/locales/$f/translation.json" + try { Get-Content $p -Raw | ConvertFrom-Json | Out-Null; "$p OK" } catch { "$p FAIL: $_" } +} +``` +Expected: 5 OK. + +- [ ] **Step 7: Add `toast.timeline.batchCreated` key** + +`useNotify` uses keys under `toast.*`. Check current usage: +```powershell +Select-String -Path adiuvAI/src/renderer/locales/en/translation.json -Pattern "timeline" -Context 0,2 +``` +Add these to the `toast.timeline.*` block in **all 5 locales**, mirroring the strings from steps 1–5: + +``` +toast.timeline.batchCreated_one "1 event created" (translate per locale) +toast.timeline.batchCreated_other "{{count}} events created" +toast.timeline.batchPartial "{{ok}} created, {{failed}} failed" +toast.timeline.batchFailed "Could not create events" +``` + +- [ ] **Step 8: Commit** + +```powershell +git -C adiuvAI add src/renderer/locales/ +git -C adiuvAI commit -m "i18n(timeline): add keys for batch-add dialog" +``` + +--- + +## Task 6: `AddEventDialog` rewrite — Part A: skeleton + project picker + form + basic staging + +**Files:** +- Rewrite: `adiuvAI/src/renderer/components/timeline/AddEventDialog.tsx` + +- [ ] **Step 1: Replace file with new structure (project picker + form + staged list + basic add)** + +Overwrite `AddEventDialog.tsx`: + +```tsx +import { useRef, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { useFormatPrefs, formatDate } from '@/lib/date'; +import { Check, X } from 'lucide-react'; +import { trpc } from '@/lib/trpc'; +import { useNotify } from '@/hooks/useNotify'; +import { Button } from '@/components/ui/button'; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogFooter, +} from '@/components/ui/dialog'; +import { Input } from '@/components/ui/input'; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select'; +import { ScrollArea } from '@/components/ui/scroll-area'; +import { ToggleGroup, ToggleGroupItem } from '@/components/ui/toggle-group'; +import { DateField } from '@/components/ui/date-field'; +import { cn } from '@/lib/utils'; +import type { TimelineEventType } from './ProjectTimeline'; +import type { HistoryEntry } from './history-types'; + +interface AddEventDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; + defaultProjectId?: string; + onRecordHistory?: (entry: HistoryEntry) => void; +} + +type StagedEvent = { + id: string; + title: string; + type: TimelineEventType; + date: Date; + endDate?: Date; +}; + +type Mode = { kind: 'add' } | { kind: 'edit'; id: string }; + +function newLocalId(): string { + return 'staged_' + Math.random().toString(36).slice(2, 10); +} + +export function AddEventDialog({ open, onOpenChange, defaultProjectId, onRecordHistory }: AddEventDialogProps) { + const { t } = useTranslation(); + const prefs = useFormatPrefs(); + const { notify, notifyError } = useNotify(); + + const [projectId, setProjectId] = useState(defaultProjectId ?? ''); + const [staged, setStaged] = useState([]); + const [mode, setMode] = useState({ kind: 'add' }); + + const [title, setTitle] = useState(''); + const [type, setType] = useState('milestone'); + const [date, setDate] = useState(); + const [endDate, setEndDate] = useState(); + + const titleRef = useRef(null); + const closedRef = useRef(false); + + const showProjectSelect = !defaultProjectId; + const projectLocked = staged.length > 0; + const isActivity = type === 'activity'; + + const { data: projectsList } = trpc.projects.listAll.useQuery(undefined, { + enabled: showProjectSelect, + }); + const utils = trpc.useUtils(); + const createEvent = trpc.timelineEvents.create.useMutation(); + + function resetForm() { + setTitle(''); + setDate(undefined); + setEndDate(undefined); + setMode({ kind: 'add' }); + setTimeout(() => titleRef.current?.focus(), 0); + } + + function handleClose() { + closedRef.current = true; + setTitle(''); + setType('milestone'); + setDate(undefined); + setEndDate(undefined); + setProjectId(defaultProjectId ?? ''); + setStaged([]); + setMode({ kind: 'add' }); + onOpenChange(false); + } + + function attemptClose() { + if (staged.length === 0) { + handleClose(); + return; + } + const ok = window.confirm(t('timeline.confirmCloseStaged', { count: staged.length })); + if (ok) handleClose(); + } + + function formValid(): boolean { + if (!title.trim()) return false; + if (!date) return false; + if (isActivity && endDate && endDate < date) return false; + if (showProjectSelect && !projectId) return false; + return true; + } + + function stageOrUpdate() { + if (!formValid() || !date) return; + const entry: StagedEvent = { + id: mode.kind === 'edit' ? mode.id : newLocalId(), + title: title.trim(), + type, + date, + endDate: isActivity ? endDate : undefined, + }; + if (mode.kind === 'edit') { + setStaged((prev) => prev.map((e) => (e.id === entry.id ? entry : e))); + } else { + setStaged((prev) => [...prev, entry]); + } + resetForm(); + } + + async function saveBatch() { + if (staged.length === 0) return; + const pid = defaultProjectId || projectId || undefined; + closedRef.current = false; + + const results = await Promise.allSettled( + staged.map((e) => + createEvent.mutateAsync({ + title: e.title, + date: e.date.getTime(), + endDate: e.endDate ? e.endDate.getTime() : undefined, + type: e.type, + projectId: pid, + }), + ), + ); + + let okCount = 0; + const failedIds = new Set(); + results.forEach((r, i) => { + const s = staged[i]; + if (r.status === 'fulfilled') { + okCount += 1; + onRecordHistory?.({ + kind: 'create', + id: r.value.id, + payload: { + id: r.value.id, + projectId: pid ?? null, + title: s.title, + date: s.date.getTime(), + endDate: s.endDate ? s.endDate.getTime() : null, + type: s.type, + isCompleted: 0, + isAiSuggested: 0, + }, + }); + } else { + failedIds.add(s.id); + } + }); + + if (closedRef.current) return; // dialog already closed, skip toasts + void utils.timelineEvents.list.invalidate(); + + if (failedIds.size === 0) { + notify('success', 'toast.timeline.batchCreated', { count: okCount }); + handleClose(); + return; + } + if (okCount === 0) { + const firstError = results.find((r) => r.status === 'rejected') as PromiseRejectedResult | undefined; + notifyError('toast.timeline.batchFailed', firstError?.reason); + } else { + notify('warning', 'toast.timeline.batchPartial', { ok: okCount, failed: failedIds.size }); + } + setStaged((prev) => prev.filter((e) => failedIds.has(e.id))); + } + + function onFormKeyDown(e: React.KeyboardEvent) { + if ((e.ctrlKey || e.metaKey) && e.key === 'Enter') { + e.preventDefault(); + void saveBatch(); + } else if (e.key === 'Enter') { + e.preventDefault(); + stageOrUpdate(); + } else if (e.key === 'Escape') { + e.preventDefault(); + attemptClose(); + } + } + + return ( + { if (!v) attemptClose(); else onOpenChange(v); }}> + + + {t('timeline.addEventTitle')} + + + {showProjectSelect && ( + + )} + + {staged.length === 0 ? ( +

{t('timeline.emptyStagedHint')}

+ ) : ( + +
    + {staged.map((e) => ( +
  • + + {e.title} + + {e.type === 'milestone' + ? t('timeline.typeMilestone') + : e.type === 'checkpoint' + ? t('timeline.typeCheckpoint') + : t('timeline.typeActivity')} + + + {e.endDate + ? `${formatDate(e.date.getTime(), prefs)} – ${formatDate(e.endDate.getTime(), prefs)}` + : formatDate(e.date.getTime(), prefs)} + + +
  • + ))} +
+
+ )} + +
+ { if (v) setType(v as TimelineEventType); }} + className="justify-start" + > + {t('timeline.typeMilestone')} + {t('timeline.typeCheckpoint')} + {t('timeline.typeActivity')} + + + setTitle(e.target.value)} + autoFocus + /> + + {isActivity ? ( +
+ + +
+ ) : ( + + )} +
+ + + + + + +
+
+ ); +} +``` + +- [ ] **Step 2: Lint** + +```powershell +cd adiuvAI; npm run lint -- --max-warnings=0 src/renderer/components/timeline/AddEventDialog.tsx +``` +Expected: zero errors. + +- [ ] **Step 3: Manual smoke test** + +```powershell +cd adiuvAI; npm start +``` +1. From `/timeline`, click "Add event". Project picker shows; pick one. +2. Type a title, type `today` in date, press Enter — row appears in staged list, form resets. +3. Repeat with `+3d`, switch type to checkpoint. +4. Switch type to activity; two date fields appear; type `today` then Tab, `+5d`, Enter. +5. Click "Save 3" → all 3 events appear on timeline, dialog closes, toast shows count. +6. Open dialog from `ProjectDetail` (project preset) — project picker hidden, same flow works. +7. Stage 2 events, click ✕ on first row — row disappears, count drops to 1. +8. Stage event, click Cancel → confirm prompt, OK → dialog closes. + +- [ ] **Step 4: Commit** + +```powershell +git -C adiuvAI add src/renderer/components/timeline/AddEventDialog.tsx +git -C adiuvAI commit -m "feat(timeline): batch-stage flow in AddEventDialog" +``` + +--- + +## Task 7: `AddEventDialog` — Part B: keyboard nav for staged list + edit-row mode + +**Files:** +- Modify: `adiuvAI/src/renderer/components/timeline/AddEventDialog.tsx` + +- [ ] **Step 1: Add focus state for staged rows** + +Add near the other state hooks in `AddEventDialog`: + +```ts +const [focusedRowId, setFocusedRowId] = useState(null); +const rowRefs = useRef>(new Map()); +``` + +- [ ] **Step 2: Add ArrowUp handler to title input** + +Replace the `` block with: + +```tsx + setTitle(e.target.value)} + onKeyDown={(e) => { + if ( + e.key === 'ArrowUp' && + staged.length > 0 && + (e.currentTarget.selectionStart ?? 0) === 0 + ) { + e.preventDefault(); + const last = staged[staged.length - 1]; + setFocusedRowId(last.id); + rowRefs.current.get(last.id)?.focus(); + } + }} + autoFocus +/> +``` + +- [ ] **Step 3: Add helpers for row actions** + +Above `return (` in the component, add: + +```ts +function loadRowIntoForm(row: StagedEvent) { + setTitle(row.title); + setType(row.type); + setDate(row.date); + setEndDate(row.endDate); + setMode({ kind: 'edit', id: row.id }); + setFocusedRowId(null); + setTimeout(() => titleRef.current?.focus(), 0); +} + +function removeRow(id: string) { + const idx = staged.findIndex((s) => s.id === id); + setStaged((prev) => prev.filter((s) => s.id !== id)); + setFocusedRowId(null); + setTimeout(() => { + const next = staged[idx + 1] ?? staged[idx - 1]; + if (next) { + const el = rowRefs.current.get(next.id); + if (el) { + setFocusedRowId(next.id); + el.focus(); + return; + } + } + titleRef.current?.focus(); + }, 0); +} + +function onRowKeyDown(e: React.KeyboardEvent, row: StagedEvent) { + const idx = staged.findIndex((s) => s.id === row.id); + if (e.key === 'ArrowDown') { + e.preventDefault(); + const next = staged[idx + 1]; + if (next) { + setFocusedRowId(next.id); + rowRefs.current.get(next.id)?.focus(); + } else { + setFocusedRowId(null); + titleRef.current?.focus(); + } + } else if (e.key === 'ArrowUp') { + e.preventDefault(); + const prev = staged[idx - 1]; + if (prev) { + setFocusedRowId(prev.id); + rowRefs.current.get(prev.id)?.focus(); + } + } else if (e.key === 'Enter') { + e.preventDefault(); + loadRowIntoForm(row); + } else if (e.key === 'Delete' || e.key === 'Backspace') { + e.preventDefault(); + removeRow(row.id); + } else if (e.key === 'Escape') { + e.preventDefault(); + setFocusedRowId(null); + titleRef.current?.focus(); + } +} +``` + +- [ ] **Step 4: Make rows focusable, wire ref + onKeyDown** + +Replace the `
  • ` inside the staged list map with: + +```tsx +
  • { + if (el) rowRefs.current.set(e.id, el); + else rowRefs.current.delete(e.id); + }} + tabIndex={focusedRowId === e.id ? 0 : -1} + role="option" + aria-selected={focusedRowId === e.id} + onKeyDown={(ev) => onRowKeyDown(ev, e)} + onFocus={() => setFocusedRowId(e.id)} + className={cn( + 'flex items-center gap-2 px-2 py-1.5 text-sm outline-none', + focusedRowId === e.id && 'bg-accent/40', + mode.kind === 'edit' && mode.id === e.id && 'ring-1 ring-primary/40', + )} +> +``` + +Change the `
      ` opening tag to: +```tsx +
        +``` + +- [ ] **Step 5: Dim form while a row is focused** + +Wrap the form `
        ` with conditional class: + +```tsx +
        +``` + +- [ ] **Step 6: Update Add button label when editing** + +Already shows `t('timeline.update')` for `mode.kind === 'edit'` (set in Task 6). Verify it still does. + +- [ ] **Step 7: Lint** + +```powershell +cd adiuvAI; npm run lint -- --max-warnings=0 src/renderer/components/timeline/AddEventDialog.tsx +``` +Expected: zero errors. + +- [ ] **Step 8: Manual keyboard verification** + +```powershell +cd adiuvAI; npm start +``` +1. Open dialog. Stage 3 events with keyboard only (Tab + types). +2. With focus in title input (empty), press ↑ — last row gets focus ring. +3. ↑/↓ navigate rows. +4. Press Enter on middle row — fields populate, "Add" button now reads "Update", row highlighted with ring. +5. Change title, Enter — row updated in place, count unchanged, mode resets to add. +6. ↑ to a row, Del — row removed, focus moves to next or back to title. +7. Esc on a row → focus returns to title. + +- [ ] **Step 9: Commit** + +```powershell +git -C adiuvAI add src/renderer/components/timeline/AddEventDialog.tsx +git -C adiuvAI commit -m "feat(timeline): keyboard nav + edit mode for staged rows" +``` + +--- + +## Task 8: `AddEventDialog` — Part C: polish (end-before-start, locked-project hint, end-date sync) + +**Files:** +- Modify: `adiuvAI/src/renderer/components/timeline/AddEventDialog.tsx` + +- [ ] **Step 1: Surface end-before-start error** + +In the `isActivity` branch of the form JSX, replace the end `` with: + +```tsx + +``` + +- [ ] **Step 2: Auto-clear end date when start changes past it** + +Replace the start `` in the `isActivity` branch with: + +```tsx + { + setDate(d); + if (d && endDate && endDate < d) setEndDate(undefined); + }} + placeholder={t('timeline.pickStart')} + aria-label={t('timeline.pickStart')} + className="flex-1" +/> +``` + +- [ ] **Step 3: Tooltip on disabled project picker** + +Wrap the `` — spec says shadcn `Command` but `