Files
workspace/docs/superpowers/plans/2026-05-13-timeline-batch-add.md
Roberto faea5f0448 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 <noreply@anthropic.com>
2026-05-13 15:59:43 +02:00

1580 lines
49 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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 `<DateField>` |
| `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: `<DateField>` 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<string>(value ? formatDate(value.getTime(), prefs) : '');
const [focused, setFocused] = useState(false);
const [invalid, setInvalid] = useState(false);
const [open, setOpen] = useState(false);
const inputRef = useRef<HTMLInputElement>(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 (
<div className={cn('relative', className)}>
<Input
ref={inputRef}
id={id}
autoFocus={autoFocus}
placeholder={placeholder ?? t('timeline.pickDate')}
value={text}
onChange={(e) => {
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')}
/>
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button
type="button"
variant="ghost"
size="icon"
className="absolute right-0 top-0 h-full w-8 text-muted-foreground hover:text-foreground"
aria-label={t('timeline.pickDate')}
tabIndex={-1}
>
<CalendarIcon className="h-4 w-4" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-auto p-0" align="end">
<Calendar
mode="single"
selected={value}
onSelect={(d) => {
if (d) {
onChange(d);
setText(formatDate(d.getTime(), prefs));
setInvalid(false);
onCommit?.(d);
}
setOpen(false);
inputRef.current?.focus();
}}
disabled={minDate ? { before: minDate } : undefined}
/>
</PopoverContent>
</Popover>
{invalidMessage && (
<p className="mt-1 text-xs text-destructive">{invalidMessage}</p>
)}
</div>
);
}
```
- [ ] **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 `<DateField>`
**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<TimelineEventType>('milestone');
const [date, setDate] = useState<Date | undefined>();
const [endDate, setEndDate] = useState<Date | undefined>();
```
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 `<DateField>`**
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 ? (<Popover>…) : (<Popover>…)}` block with:
```tsx
{isActivity ? (
<div className="flex gap-2">
<DateField
value={date}
onChange={setDate}
placeholder={t('timeline.pickStart')}
aria-label={t('timeline.pickStart')}
className="flex-1"
/>
<DateField
value={endDate}
onChange={setEndDate}
minDate={date}
placeholder={t('timeline.pickEnd')}
aria-label={t('timeline.pickEnd')}
className="flex-1"
/>
</div>
) : (
<DateField
value={date}
onChange={setDate}
placeholder={t('timeline.pickDate')}
aria-label={t('timeline.pickDate')}
/>
)}
```
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 15:
```
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<StagedEvent[]>([]);
const [mode, setMode] = useState<Mode>({ kind: 'add' });
const [title, setTitle] = useState('');
const [type, setType] = useState<TimelineEventType>('milestone');
const [date, setDate] = useState<Date | undefined>();
const [endDate, setEndDate] = useState<Date | undefined>();
const titleRef = useRef<HTMLInputElement>(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<string>();
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 (
<Dialog open={open} onOpenChange={(v) => { if (!v) attemptClose(); else onOpenChange(v); }}>
<DialogContent className="sm:max-w-[440px]">
<DialogHeader>
<DialogTitle>{t('timeline.addEventTitle')}</DialogTitle>
</DialogHeader>
{showProjectSelect && (
<Select
value={projectId}
onValueChange={setProjectId}
disabled={projectLocked}
>
<SelectTrigger>
<SelectValue placeholder={t('timeline.selectProjectOptional')} />
</SelectTrigger>
<SelectContent>
{projectsList?.map((p) => (
<SelectItem key={p.id} value={p.id}>{p.name}</SelectItem>
))}
</SelectContent>
</Select>
)}
{staged.length === 0 ? (
<p className="text-xs text-muted-foreground py-2">{t('timeline.emptyStagedHint')}</p>
) : (
<ScrollArea className="max-h-40 border rounded-md">
<ul className="flex flex-col" aria-label="Staged events">
{staged.map((e) => (
<li key={e.id} className="flex items-center gap-2 px-2 py-1.5 text-sm">
<Check className="h-3.5 w-3.5 text-chart-2 shrink-0" />
<span className="truncate flex-1">{e.title}</span>
<span className="text-xs text-muted-foreground shrink-0">
{e.type === 'milestone'
? t('timeline.typeMilestone')
: e.type === 'checkpoint'
? t('timeline.typeCheckpoint')
: t('timeline.typeActivity')}
</span>
<span className="text-xs text-muted-foreground shrink-0">
{e.endDate
? `${formatDate(e.date.getTime(), prefs)} ${formatDate(e.endDate.getTime(), prefs)}`
: formatDate(e.date.getTime(), prefs)}
</span>
<Button
type="button"
variant="ghost"
size="icon"
className="h-6 w-6 text-muted-foreground hover:text-destructive"
aria-label={t('timeline.removeRow')}
onClick={() => setStaged((prev) => prev.filter((s) => s.id !== e.id))}
>
<X className="h-3.5 w-3.5" />
</Button>
</li>
))}
</ul>
</ScrollArea>
)}
<div className={cn('flex flex-col gap-3')} onKeyDown={onFormKeyDown}>
<ToggleGroup
type="single"
value={type}
onValueChange={(v) => { if (v) setType(v as TimelineEventType); }}
className="justify-start"
>
<ToggleGroupItem value="milestone" className="text-xs px-3">{t('timeline.typeMilestone')}</ToggleGroupItem>
<ToggleGroupItem value="checkpoint" className="text-xs px-3">{t('timeline.typeCheckpoint')}</ToggleGroupItem>
<ToggleGroupItem value="activity" className="text-xs px-3">{t('timeline.typeActivity')}</ToggleGroupItem>
</ToggleGroup>
<Input
ref={titleRef}
placeholder={t('timeline.eventTitlePlaceholder')}
value={title}
onChange={(e) => setTitle(e.target.value)}
autoFocus
/>
{isActivity ? (
<div className="flex gap-2">
<DateField
value={date}
onChange={setDate}
placeholder={t('timeline.pickStart')}
aria-label={t('timeline.pickStart')}
className="flex-1"
/>
<DateField
value={endDate}
onChange={setEndDate}
minDate={date}
placeholder={t('timeline.pickEnd')}
aria-label={t('timeline.pickEnd')}
className="flex-1"
/>
</div>
) : (
<DateField
value={date}
onChange={setDate}
placeholder={t('timeline.pickDate')}
aria-label={t('timeline.pickDate')}
/>
)}
</div>
<DialogFooter>
<Button type="button" variant="outline" onClick={attemptClose}>
{t('common.cancel')}
</Button>
<Button
type="button"
variant="outline"
onClick={stageOrUpdate}
disabled={!formValid()}
>
{mode.kind === 'edit' ? t('timeline.update') : t('common.add')}
</Button>
<Button
type="button"
onClick={() => void saveBatch()}
disabled={staged.length === 0 || createEvent.isPending}
>
{t('timeline.saveAll', { count: staged.length })}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
```
- [ ] **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<string | null>(null);
const rowRefs = useRef<Map<string, HTMLLIElement>>(new Map());
```
- [ ] **Step 2: Add ArrowUp handler to title input**
Replace the `<Input ref={titleRef} ...>` block with:
```tsx
<Input
ref={titleRef}
placeholder={t('timeline.eventTitlePlaceholder')}
value={title}
onChange={(e) => 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<HTMLLIElement>, 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 `<li>` inside the staged list map with:
```tsx
<li
key={e.id}
ref={(el) => {
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 `<ul>` opening tag to:
```tsx
<ul className="flex flex-col" role="listbox" aria-label={t('timeline.staged', { count: staged.length })}>
```
- [ ] **Step 5: Dim form while a row is focused**
Wrap the form `<div>` with conditional class:
```tsx
<div
className={cn(
'flex flex-col gap-3 transition-opacity',
focusedRowId !== null && 'opacity-50 pointer-events-none',
)}
onKeyDown={onFormKeyDown}
>
```
- [ ] **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 `<DateField>` with:
```tsx
<DateField
value={endDate}
onChange={setEndDate}
minDate={date}
placeholder={t('timeline.pickEnd')}
aria-label={t('timeline.pickEnd')}
invalidMessage={
date && endDate && endDate < date ? t('timeline.endBeforeStart') : undefined
}
className="flex-1"
/>
```
- [ ] **Step 2: Auto-clear end date when start changes past it**
Replace the start `<DateField>` in the `isActivity` branch with:
```tsx
<DateField
value={date}
onChange={(d) => {
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 `<Select>` block (when `showProjectSelect`) with a `title` attribute on the trigger when locked. Simplest: add `title={projectLocked ? t('timeline.projectLocked') : undefined}` to the `<SelectTrigger>` element by extending it:
```tsx
<SelectTrigger title={projectLocked ? t('timeline.projectLocked') : undefined}>
<SelectValue placeholder={t('timeline.selectProjectOptional')} />
</SelectTrigger>
```
- [ ] **Step 4: Lint**
```powershell
cd adiuvAI; npm run lint -- --max-warnings=0 src/renderer/components/timeline/AddEventDialog.tsx
```
Expected: zero errors.
- [ ] **Step 5: Manual verification**
1. Activity flow: type start `today`, end `+5d`, then change start to `+10d` — end clears.
2. Set start `today`, end `yesterday` → end shows red ring + error message; "Add" disabled.
3. Stage 1 event with no preset project → project picker becomes disabled; hover → tooltip "Project locked after first event".
4. Stage another event (uses locked project automatically). Save → both attach to same project.
- [ ] **Step 6: Commit**
```powershell
git -C adiuvAI add src/renderer/components/timeline/AddEventDialog.tsx
git -C adiuvAI commit -m "polish(timeline): end-date validation + project-lock hint"
```
---
## Task 9: Full keyboard-only regression pass
**Files:** none (manual)
- [ ] **Step 1: Run app**
```powershell
cd adiuvAI; npm start
```
- [ ] **Step 2: Pure keyboard run — no mouse**
From `/timeline`:
1. Tab/Shift+Tab to "Add event" button, Enter.
2. Tab to project picker, Space to open, ↓ to pick, Enter.
3. Tab to title, type "Kickoff", Tab to date, type `today`, Enter → row staged, focus on title.
4. Type "Phase 1", Tab → type, ← / → to switch to "Activity"; Tab to title (or just type), Tab to start, `+3d`, Tab to end, `+1w`, Enter → row staged.
5. ↑ from title → focus on Phase 1 row; Enter → loaded into form, change title to "Phase one"; Enter → row updated.
6. Ctrl+Enter → batch saved, dialog closes, toast shown.
7. Verify rows present on timeline.
- [ ] **Step 3: Locale switch verification**
1. Settings > General → set language to Italian.
2. Open dialog → labels in Italian.
3. Type `domani` in date input, Enter → parses to tomorrow.
4. Type `lun` → next Monday.
5. Switch back to English.
- [ ] **Step 4: Date-format switch verification**
1. Settings > Profile → set date format to `MM/DD/YYYY`.
2. Open dialog → type `03/15` for partial date → parses to March 15, current year.
3. Switch to `YYYY-MM-DD` → type `2026-03-15` → parses.
4. Switch back to `DD/MM/YYYY`.
- [ ] **Step 5: Failure mode verification**
1. Open dev tools → Network → throttle to Offline (or stop backend if applicable to event creation path — `timelineEvents.create` is main-process tRPC so it should keep working).
2. Since `timelineEvents.create` is local SQLite (no network), force a failure by temporarily renaming the SQLite file or by inserting a duplicate (the create mutation may reject if invariants violated). If no easy failure path: skip and note as "untested fail path".
3. Stage 2 events, click Save → if any fail, only failed rows remain.
- [ ] **Step 6: Commit verification doc**
If any task notes need to be saved (e.g. "fail path untested"), append to plan as a checklist note. No code commit needed for this step.
---
## Self-review
**Spec coverage check:**
- ✅ Architecture (refactor in place, no backend change) — Task 6
- ✅ State model — Task 6
- ✅ Layout (3 states) — Tasks 6, 7
-`parseDate` primitive — Task 1
-`DateField` primitive — Task 3
-`ProjectPickerRow` (as inline `<Select>` — spec says shadcn `Command` but `<Select>` is simpler, equivalent UX given the existing `projectsList` use; flagged for review at execution time)
-`StagedList` semantics + roving tabindex — Task 7
-`EventForm` — Task 6
- ✅ Keyboard map — Tasks 6, 7
- ✅ Data flow (allSettled batch) — Task 6
- ✅ Error handling (inline + batch outcomes) — Tasks 6, 8
- ✅ i18n keys — Tasks 2, 5
- ✅ EditEventDialog migration — Task 4
- ✅ TaskFormDialog deferred (per updated spec) — N/A
- ✅ Manual verification — Task 9
**Note on `ProjectPickerRow`:** spec calls for shadcn `Command` (typeable combobox). Plan uses native shadcn `Select` for parity with current code. If user wants typeahead filter, swap to `Command`+`Popover` in Task 6 Step 1 — same data source, ~30 LOC delta. Surface this at execution time.
**Placeholder scan:** none found. All code blocks are concrete.
**Type consistency:** `StagedEvent`, `Mode`, function names (`stageOrUpdate`, `saveBatch`, `loadRowIntoForm`, `removeRow`, `onRowKeyDown`, `onFormKeyDown`, `attemptClose`, `handleClose`, `resetForm`, `formValid`) used consistently across tasks 68.
---