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>
49 KiB
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.createreused) - DB schema
Task 1: parseDate utility
Files:
-
Create:
adiuvAI/src/renderer/lib/parseDate.ts -
Step 1: Locate existing FormatPrefs type
Run (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:
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:
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:
// 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
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):
"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
"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
"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
"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
"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:
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
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:
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
cd adiuvAI; npm run lint -- --max-warnings=0 src/renderer/components/ui/date-field.tsx
Expected: zero errors.
- Step 3: Commit
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:
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:
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
handleSubmitto use new state
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:
import { DateField } from '@/components/ui/date-field';
Replace the {isActivity ? (<Popover>…) : (<Popover>…)} block with:
{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
cd adiuvAI; npm run lint -- --max-warnings=0 src/renderer/components/timeline/EditEventDialog.tsx
Expected: zero errors.
- Step 5: Manual smoke test
cd adiuvAI; npm start
- Open the app, navigate to
/timeline, click an existing event (it triggersEditEventDialog). - Verify date input shows formatted date.
- Tab into date input, type
+3d, press Enter — formatted value appears, save button enabled. - Type
garbage, press Enter — red ring, save disabled. - Click calendar icon, pick a date — input updates.
- For an activity, verify end-date field appears, type
+1wfrom start. - 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:
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
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:
"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)
"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)
"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)
"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)
"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
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.batchCreatedkey
useNotify uses keys under toast.*. Check current usage:
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
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:
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
cd adiuvAI; npm run lint -- --max-warnings=0 src/renderer/components/timeline/AddEventDialog.tsx
Expected: zero errors.
- Step 3: Manual smoke test
cd adiuvAI; npm start
- From
/timeline, click "Add event". Project picker shows; pick one. - Type a title, type
todayin date, press Enter — row appears in staged list, form resets. - Repeat with
+3d, switch type to checkpoint. - Switch type to activity; two date fields appear; type
todaythen Tab,+5d, Enter. - Click "Save 3" → all 3 events appear on timeline, dialog closes, toast shows count.
- Open dialog from
ProjectDetail(project preset) — project picker hidden, same flow works. - Stage 2 events, click ✕ on first row — row disappears, count drops to 1.
- Stage event, click Cancel → confirm prompt, OK → dialog closes.
- Step 4: Commit
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:
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:
<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:
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:
<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:
<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:
<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
cd adiuvAI; npm run lint -- --max-warnings=0 src/renderer/components/timeline/AddEventDialog.tsx
Expected: zero errors.
- Step 8: Manual keyboard verification
cd adiuvAI; npm start
- Open dialog. Stage 3 events with keyboard only (Tab + types).
- With focus in title input (empty), press ↑ — last row gets focus ring.
- ↑/↓ navigate rows.
- Press Enter on middle row — fields populate, "Add" button now reads "Update", row highlighted with ring.
- Change title, Enter — row updated in place, count unchanged, mode resets to add.
- ↑ to a row, Del — row removed, focus moves to next or back to title.
- Esc on a row → focus returns to title.
- Step 9: Commit
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:
<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:
<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:
<SelectTrigger title={projectLocked ? t('timeline.projectLocked') : undefined}>
<SelectValue placeholder={t('timeline.selectProjectOptional')} />
</SelectTrigger>
- Step 4: Lint
cd adiuvAI; npm run lint -- --max-warnings=0 src/renderer/components/timeline/AddEventDialog.tsx
Expected: zero errors.
- Step 5: Manual verification
- Activity flow: type start
today, end+5d, then change start to+10d— end clears. - Set start
today, endyesterday→ end shows red ring + error message; "Add" disabled. - Stage 1 event with no preset project → project picker becomes disabled; hover → tooltip "Project locked after first event".
- Stage another event (uses locked project automatically). Save → both attach to same project.
- Step 6: Commit
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
cd adiuvAI; npm start
- Step 2: Pure keyboard run — no mouse
From /timeline:
- Tab/Shift+Tab to "Add event" button, Enter.
- Tab to project picker, Space to open, ↓ to pick, Enter.
- Tab to title, type "Kickoff", Tab to date, type
today, Enter → row staged, focus on title. - 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. - ↑ from title → focus on Phase 1 row; Enter → loaded into form, change title to "Phase one"; Enter → row updated.
- Ctrl+Enter → batch saved, dialog closes, toast shown.
- Verify rows present on timeline.
- Step 3: Locale switch verification
- Settings > General → set language to Italian.
- Open dialog → labels in Italian.
- Type
domaniin date input, Enter → parses to tomorrow. - Type
lun→ next Monday. - Switch back to English.
- Step 4: Date-format switch verification
- Settings > Profile → set date format to
MM/DD/YYYY. - Open dialog → type
03/15for partial date → parses to March 15, current year. - Switch to
YYYY-MM-DD→ type2026-03-15→ parses. - Switch back to
DD/MM/YYYY.
- Step 5: Failure mode verification
- Open dev tools → Network → throttle to Offline (or stop backend if applicable to event creation path —
timelineEvents.createis main-process tRPC so it should keep working). - Since
timelineEvents.createis 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". - 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
- ✅
parseDateprimitive — Task 1 - ✅
DateFieldprimitive — Task 3 - ✅
ProjectPickerRow(as inline<Select>— spec says shadcnCommandbut<Select>is simpler, equivalent UX given the existingprojectsListuse; flagged for review at execution time) - ✅
StagedListsemantics + 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 6–8.