# 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 `