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

49 KiB
Raw Blame History

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):

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 handleSubmit to 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
  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:

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.batchCreated key

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 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
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
  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
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
  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
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
  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
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:

  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.