# Timeline Batch Add — Design **Date:** 2026-05-13 **Status:** Draft, awaiting user review **Scope:** `adiuvAI/` submodule only ## Problem Adding timeline events today goes through `AddEventDialog.tsx` one event at a time. The dialog already supports a sequential "add then add another" loop, but: - Each event commits immediately on Enter (no client-side staging). - The project picker appears at the bottom, after type/title/date. - Date entry requires opening a calendar popover — keyboard-hostile. - A user planning a project (kickoff + milestones + activities) clicks through the dialog 5–10 times to seed a project's timeline. ## Goal Single dialog session lets the user pick a project, stage multiple timeline events of mixed types, review, and commit the batch. Fully operable without a mouse. ## Non-goals - New backend endpoint. Re-use `trpc.timelineEvents.create` per event. - Reordering staged events. Order is "as added". - Bulk import (CSV/paste). Out of scope. - Cross-project batch. One batch = one project. ## Architecture Refactor `adiuvAI/src/renderer/components/timeline/AddEventDialog.tsx` in place. Same callsites (`routes/timeline.tsx`, `components/projects/ProjectDetail.tsx`), same props (`open`, `onOpenChange`, `defaultProjectId?`, `onRecordHistory?`). Two new shared primitives extracted from this work: - `adiuvAI/src/renderer/lib/parseDate.ts` — pure date parser, locale-aware. - `adiuvAI/src/renderer/components/ui/date-field.tsx` — controlled date input with typed entry + popover fallback. Existing `EditEventDialog.tsx` migrates to `` as part of this work. `TaskFormDialog.tsx` is **out of scope** — it uses `TZDate` plus time-of-day (H/M) selectors, which DateField does not cover. A follow-up pass should add `timezone` + `showTime` props to DateField, then migrate TaskFormDialog. ## State model ```ts type StagedEvent = { id: string; // nanoid, local-only key title: string; type: 'milestone' | 'checkpoint' | 'activity'; date: Date; endDate?: Date; // activity only }; type Mode = { kind: 'add' } | { kind: 'edit'; id: string }; // In AddEventDialog const [projectId, setProjectId] = useState(defaultProjectId ?? ''); const [staged, setStaged] = useState([]); const [mode, setMode] = useState({ kind: 'add' }); // Form fields: const [title, setTitle] = useState(''); const [type, setType] = useState('milestone'); const [date, setDate] = useState(); const [endDate, setEndDate] = useState(); const [focusedRowId, setFocusedRowId] = useState(null); ``` ## Layout ``` ┌─── Add timeline events ─────────────────┐ │ │ │ Project [ Search project… ▾ ] │ ← hidden when defaultProjectId set │ │ locked when staged.length > 0 │ ┌─ Staged list (scrollable, max ~6) ─┐ │ │ │ ✓ Kickoff milestone 15/03 ✕│ │ │ │ ✓ Phase 1 checkpoint 22/03 ✕│ │ │ └───────────────────────────────────┘ │ │ ───────────────────────────────────── │ │ ( Milestone | Checkpoint | Activity ) │ │ [ Event title… ] │ │ [ Date ] [End date ] │ ← end shown only for activity │ │ │ [Cancel] [Add ↵] [Save N] │ └─────────────────────────────────────────┘ ``` States: 1. **Fresh open** — empty staged list with hint text, form ready, focus on project picker (or title if `defaultProjectId`). 2. **N staged, form ready** — staged list visible, form empty, focus on title. 3. **Row focused** — form dimmed (`opacity-50 pointer-events-none`), staged row has focus ring. 4. **Editing row** — form populated from row, "Add ↵" button reads "Update ↵", row in list highlighted. ## Components ### Shared primitives (new) **`lib/parseDate.ts`** — pure functions, no React. ```ts export function parseDate( input: string, prefs: FormatPrefs, baseDate?: Date, ): Date | null; export function parseDateRange( input: string, prefs: FormatPrefs, baseDate?: Date, ): { from: Date; to?: Date } | null; ``` Accepts: - Keywords: `today`, `tomorrow`, `yesterday` (i18n-aware via current `i18n.language`) - Relative: `+Nd`, `+Nw`, `+Nm`, `-Nd` - Weekday names in current UI language (next occurrence): `mon`/`monday`, `lun`/`lunedì`, etc. - Partial date: `DD/MM` or `MM/DD` (per `prefs.dateFormat`) → current year, year-rollover if past - Full date: `DD/MM/YYYY`, `MM/DD/YYYY`, `YYYY-MM-DD` (per prefs) Returns `null` on unparseable. No date library — small regex + native `Date`. **`components/ui/date-field.tsx`** — controlled input. ```ts type DateFieldProps = { value: Date | undefined; onChange: (d: Date | undefined) => void; placeholder?: string; minDate?: Date; autoFocus?: boolean; invalidMessage?: string; className?: string; 'aria-label'?: string; id?: string; onCommit?: (d: Date) => void; // fired on Enter after valid parse }; ``` Internal: text input + calendar icon button → Popover wrapping shadcn `Calendar`. Reads `useFormatPrefs()` internally. Behavior: - Display formatted value (via `formatDate(prefs)`) when input not focused and value valid. - Show raw user text while focused. - Parse on blur and on Enter — if valid, call `onChange(date)`; if invalid, set `aria-invalid="true"` and red ring. - Alt+↓ opens popover. Calendar selection commits via `onChange` and closes popover. - `Enter` inside input: `e.preventDefault()`, parse, call `onChange(parsed)`, then call optional `onCommit?: (d: Date) => void` prop synchronously with the parsed value. Parent uses `onCommit` to stage without relying on `useState` flush. If invalid, no `onCommit` call, no propagation. ### Internal to AddEventDialog (not exported) **``** — shadcn `Command` inside `Popover`. Typeable filter. Disabled when `staged.length > 0` (visual: muted, tooltip "Project locked after first event"). Hidden when `defaultProjectId` set. **``** — `
    `. Empty state: muted hint `t('timeline.emptyStagedHint')`. Each row `
  • ` with: - Type badge (color from existing palette — chart-1/2/3 mapping by type) - Title (truncate) - Date(s), formatted per prefs - ✕ icon button (hover-visible only) for mouse users; aria-label `t('timeline.removeRow')` Roving tabindex managed by `focusedRowId`. List itself has `tabIndex={0}` when no row focused, so Tab reaches it. **``** — wraps: - `ToggleGroup` for type (existing pattern) - `Input` for title (autoFocus when `mode.kind === 'add'`) - `` for `date` - `` for `endDate`, mounted only when `type === 'activity'`, `minDate` = `date` ## Keyboard map | Context | Key | Action | |----------------------|----------------|--------| | Project picker open | type | filter list | | | ↑/↓ | nav results | | | Enter | select, focus title | | | Esc | close picker | | Form, any field | Tab/Shift+Tab | cycle: project → type → title → date → endDate → footer buttons | | | Enter (valid) | stage event (add) or update row (edit), focus title | | | Ctrl+Enter | save batch (if N ≥ 1) | | | Esc | close dialog (confirm if staged > 0) | | Title field | ↑ (caret at 0) | focus last staged row | | Type toggle | ←/→ | cycle types | | Date field | Alt+↓ | open calendar popover | | | Enter | parse + commit + advance focus | | Staged row | ↑/↓ | move focus | | | Enter | load row → form, mode=edit | | | Del/Backspace | remove row, focus next or form | | | Esc | focus form title | | Footer Save button | Enter/Space | save batch | ## Data flow ``` type+title+date entered, Enter pressed → validateForm() → if mode.add: setStaged([...staged, newEvent]); resetForm(); focusTitle() → if mode.edit: setStaged(staged.map(e => e.id===mode.id ? newEvent : e)); setMode({kind:'add'}); resetForm(); focusTitle() Save N pressed (or Ctrl+Enter) → for each staged event: results = await Promise.allSettled( staged.map(e => createEvent.mutateAsync({ title: e.title, date: e.date.getTime(), endDate: e.endDate?.getTime(), type: e.type, projectId: defaultProjectId || projectId || undefined, })) ) → for each fulfilled: onRecordHistory?.({kind:'create', id, payload:...}) → utils.timelineEvents.list.invalidate() // once, not per event → if all fulfilled: notify success, close → if partial: keep rejected rows in staged, mark with error tooltip, notify warning → if all rejected: notify error, no rows removed ``` ## Error handling Per-field (inline, no toast): - Empty title → submit disabled, Enter no-op. - Unparseable date → red ring on `DateField`, `aria-invalid="true"`, submit disabled. - Activity `endDate < date` → red ring on end field, message `timeline.endBeforeStart`, submit disabled. - No project selected (picker shown) → submit disabled, picker gets focus ring. Batch failure (per data flow above): - All success → toast + close. - Partial → keep failed rows with error tooltip, toast warns count. - All fail → toast error, dialog stays open. Edge cases: - Dialog closed mid-batch: fire-and-forget mutations continue server-side; UI suppresses their toasts after close (track via local `closedRef`). - Project deleted between selection and submit → falls into partial-fail path. Acceptable. - `defaultProjectId` for deleted project → already handled by existing callsite contracts. ## i18n keys (added to all 5 locales) ``` timeline.endBeforeStart "End must be after start" timeline.dateInvalid "Unrecognized date" timeline.batchCreated_one "1 event created" timeline.batchCreated_other "{{count}} events created" timeline.batchPartial "{{ok}} created, {{failed}} failed" timeline.batchFailed "Could not create events" timeline.staged_one "1 event staged" timeline.staged_other "{{count}} events staged" timeline.emptyStagedHint "Type a title, set a date, press Enter" timeline.editRow "Edit" timeline.removeRow "Remove" timeline.projectLocked "Project locked after first event" timeline.confirmCloseStaged "Discard {{count}} staged events?" timeline.saveAll "Save {{count}}" timeline.update "Update" common.add existing — re-use common.cancel existing — re-use ``` Date parser keywords (per locale): ``` date.keyword.today date.keyword.tomorrow date.keyword.yesterday date.keyword.weekdays array, mon..sun in locale (short + long) ``` ## File touch list New: - `adiuvAI/src/renderer/lib/parseDate.ts` - `adiuvAI/src/renderer/components/ui/date-field.tsx` Modified: - `adiuvAI/src/renderer/components/timeline/AddEventDialog.tsx` — full rewrite to staged-batch model. - `adiuvAI/src/renderer/components/timeline/EditEventDialog.tsx` — swap popover+Calendar for ``. - `adiuvAI/src/renderer/locales/{en,it,es,fr,de}/translation.json` — add i18n keys above. Untouched: - Backend (`api/`): no schema, no router changes. - tRPC contracts: re-use `timelineEvents.create`. - DB schema: no migration. ## Testing Repo has no automated test suite (per `adiuvAI/.claude/CLAUDE.md`). Manual verification before merge: - [ ] Open from `/timeline`: project picker visible, locks after first staged. - [ ] Open from `ProjectDetail`: project picker hidden, preset used. - [ ] Parse — type and verify: `today`, `tomorrow`, `+3d`, `+1w`, `mon`, `15/03`, `15/03/26`, `2026-03-15`. Repeat with `dateFormat` switched in Settings. - [ ] Switch UI language to IT, type `oggi`, `domani`, `lun` — parse works. - [ ] Stage 3 mixed-type events, Save → all created, history records 3 entries, toast plural correct. - [ ] Stage 2, kill network mid-save → failed row stays with error tooltip, toast warns count. - [ ] Pure keyboard run: open dialog, Tab to project, type+Enter, type title, Tab, type date, Enter (stage), repeat ×3, Ctrl+Enter (save). Mouse never touched. - [ ] ↑ from title moves to last row, Enter loads to form for edit, Esc returns to form. - [ ] Del on focused row removes it, focus advances. - [ ] Esc with staged > 0 shows confirm; cancel keeps dialog, OK closes. - [ ] `EditEventDialog` opens, `DateField` shows existing date formatted, edit and save works. - [ ] Reduced-motion preference respected (no popover spring if user has it). ## Open questions None known at design time. Resolved during brainstorming: - Batch model: stage then commit all. - Project scope: one project per batch, locked after first event. - Date entry: typed input with smart parse, calendar popover as fallback. - Range entry: two fields (start → Tab → end). - Row edit: arrow nav, Enter edit, Del remove. - Components: `DateField` + `parseDate` extracted as shared primitives, migrate `EditEventDialog` in this work. `TaskFormDialog` deferred (needs timezone + time-of-day support on DateField).