TaskFormDialog uses TZDate + H/M selectors; DateField primitive does not cover timezone or time-of-day. Track as follow-up after DateField gains timezone/time support. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
14 KiB
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.createper 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 <DateField> 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
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<StagedEvent[]>([]);
const [mode, setMode] = useState<Mode>({ kind: 'add' });
// Form fields:
const [title, setTitle] = useState('');
const [type, setType] = useState<TimelineEventType>('milestone');
const [date, setDate] = useState<Date | undefined>();
const [endDate, setEndDate] = useState<Date | undefined>();
const [focusedRowId, setFocusedRowId] = useState<string | null>(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:
- Fresh open — empty staged list with hint text, form ready, focus on project picker (or title if
defaultProjectId). - N staged, form ready — staged list visible, form empty, focus on title.
- Row focused — form dimmed (
opacity-50 pointer-events-none), staged row has focus ring. - 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.
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 currenti18n.language) - Relative:
+Nd,+Nw,+Nm,-Nd - Weekday names in current UI language (next occurrence):
mon/monday,lun/lunedì, etc. - Partial date:
DD/MMorMM/DD(perprefs.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.
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, setaria-invalid="true"and red ring. - Alt+↓ opens popover. Calendar selection commits via
onChangeand closes popover. Enterinside input:e.preventDefault(), parse, callonChange(parsed), then call optionalonCommit?: (d: Date) => voidprop synchronously with the parsed value. Parent usesonCommitto stage without relying onuseStateflush. If invalid, noonCommitcall, no propagation.
Internal to AddEventDialog (not exported)
<ProjectPickerRow> — shadcn Command inside Popover. Typeable filter. Disabled when staged.length > 0 (visual: muted, tooltip "Project locked after first event"). Hidden when defaultProjectId set.
<StagedList> — <ul role="listbox" aria-label="Staged events">. Empty state: muted hint t('timeline.emptyStagedHint'). Each row <li role="option" tabIndex={-1}> 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.
<EventForm> — wraps:
ToggleGroupfor type (existing pattern)Inputfor title (autoFocus whenmode.kind === 'add')<DateField>fordate<DateField>forendDate, mounted only whentype === '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, messagetimeline.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.
defaultProjectIdfor 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.tsadiuvAI/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<DateField>.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 withdateFormatswitched 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.
EditEventDialogopens,DateFieldshows 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+parseDateextracted as shared primitives, migrateEditEventDialogin this work.TaskFormDialogdeferred (needs timezone + time-of-day support on DateField).