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>
1580 lines
49 KiB
Markdown
1580 lines
49 KiB
Markdown
# Timeline Batch Add Implementation Plan
|
||
|
||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||
|
||
**Goal:** Replace one-event-at-a-time `AddEventDialog` with a fully keyboard-operable, stage-then-commit batch flow. One batch = one project. Extract `DateField` + `parseDate` as shared primitives and migrate `EditEventDialog` to use them.
|
||
|
||
**Architecture:** Refactor in place. New shared primitives (`parseDate.ts`, `date-field.tsx`) wrap typed entry over the existing shadcn `Calendar` popover. `AddEventDialog` becomes a project picker + staged list + form. Batch submit reuses the existing `timelineEvents.create` mutation via `Promise.allSettled`. No backend changes.
|
||
|
||
**Tech Stack:**
|
||
- adiuvAI submodule only — Electron renderer (React 19 + TanStack Router), shadcn/ui new-york, i18next, react-day-picker.
|
||
- No automated test suite in this repo (per `adiuvAI/.claude/CLAUDE.md`) → manual verification per task instead of TDD.
|
||
|
||
**Reference spec:** `docs/superpowers/specs/2026-05-13-timeline-batch-add-design.md`
|
||
|
||
---
|
||
|
||
## File Inventory
|
||
|
||
| File | Action | Responsibility |
|
||
|---|---|---|
|
||
| `adiuvAI/src/renderer/lib/parseDate.ts` | Create | Pure `parseDate` + `parseDateRange` functions, locale-aware |
|
||
| `adiuvAI/src/renderer/components/ui/date-field.tsx` | Create | Reusable typed date input + popover calendar |
|
||
| `adiuvAI/src/renderer/locales/en/translation.json` | Modify | Add `timeline.*` and `date.keyword.*` keys |
|
||
| `adiuvAI/src/renderer/locales/it/translation.json` | Modify | Same keys, IT translations |
|
||
| `adiuvAI/src/renderer/locales/es/translation.json` | Modify | Same keys, ES translations |
|
||
| `adiuvAI/src/renderer/locales/fr/translation.json` | Modify | Same keys, FR translations |
|
||
| `adiuvAI/src/renderer/locales/de/translation.json` | Modify | Same keys, DE translations |
|
||
| `adiuvAI/src/renderer/components/timeline/EditEventDialog.tsx` | Modify | Swap popover+Calendar for `<DateField>` |
|
||
| `adiuvAI/src/renderer/components/timeline/AddEventDialog.tsx` | Rewrite | Stage-then-commit batch model |
|
||
|
||
Untouched:
|
||
- Backend `api/`
|
||
- tRPC contracts (`timelineEvents.create` reused)
|
||
- DB schema
|
||
|
||
---
|
||
|
||
## Task 1: `parseDate` utility
|
||
|
||
**Files:**
|
||
- Create: `adiuvAI/src/renderer/lib/parseDate.ts`
|
||
|
||
- [ ] **Step 1: Locate existing FormatPrefs type**
|
||
|
||
Run (PowerShell):
|
||
```powershell
|
||
Select-String -Path adiuvAI/src/renderer/lib/date.ts -Pattern "FormatPrefs|dateFormat" | Select-Object -First 20
|
||
```
|
||
Expected: prints definition / usage of `FormatPrefs` and its `dateFormat` field (`'DD/MM/YYYY' | 'MM/DD/YYYY' | 'YYYY-MM-DD'` style).
|
||
|
||
- [ ] **Step 2: Create `parseDate.ts`**
|
||
|
||
Write `adiuvAI/src/renderer/lib/parseDate.ts`:
|
||
|
||
```ts
|
||
import type { FormatPrefs } from './date';
|
||
|
||
type ParseInput = string | null | undefined;
|
||
|
||
const RE_REL = /^\s*([+-])\s*(\d+)\s*([dwm])\s*$/i;
|
||
const RE_NUMERIC = /^(\d{1,4})[\/\-.](\d{1,4})(?:[\/\-.](\d{1,4}))?$/;
|
||
|
||
function startOfDay(d: Date): Date {
|
||
const out = new Date(d.getFullYear(), d.getMonth(), d.getDate());
|
||
return out;
|
||
}
|
||
|
||
function addDays(d: Date, n: number): Date {
|
||
const out = new Date(d);
|
||
out.setDate(out.getDate() + n);
|
||
return out;
|
||
}
|
||
|
||
function addMonths(d: Date, n: number): Date {
|
||
const out = new Date(d);
|
||
out.setMonth(out.getMonth() + n);
|
||
return out;
|
||
}
|
||
|
||
function pivotYear(twoDigit: number): number {
|
||
const now = new Date().getFullYear();
|
||
const century = Math.floor(now / 100) * 100;
|
||
const offset = now % 100;
|
||
// Within +/-50 years of current → current century, else previous/next
|
||
return twoDigit > (offset + 50) ? century - 100 + twoDigit : century + twoDigit;
|
||
}
|
||
|
||
function parseNumeric(
|
||
input: string,
|
||
prefs: FormatPrefs,
|
||
base: Date,
|
||
): Date | null {
|
||
const m = input.match(RE_NUMERIC);
|
||
if (!m) return null;
|
||
let day: number, month: number, year: number;
|
||
const a = parseInt(m[1], 10);
|
||
const b = parseInt(m[2], 10);
|
||
const c = m[3] != null ? parseInt(m[3], 10) : NaN;
|
||
|
||
if (prefs.dateFormat === 'YYYY-MM-DD') {
|
||
if (m[3] == null) return null;
|
||
year = a; month = b; day = c;
|
||
} else if (prefs.dateFormat === 'MM/DD/YYYY') {
|
||
month = a; day = b;
|
||
if (m[3] == null) year = base.getFullYear();
|
||
else year = c < 100 ? pivotYear(c) : c;
|
||
} else {
|
||
// default DD/MM/YYYY
|
||
day = a; month = b;
|
||
if (m[3] == null) year = base.getFullYear();
|
||
else year = c < 100 ? pivotYear(c) : c;
|
||
}
|
||
|
||
if (month < 1 || month > 12) return null;
|
||
const result = new Date(year, month - 1, day);
|
||
if (result.getFullYear() !== year || result.getMonth() !== month - 1 || result.getDate() !== day) {
|
||
return null;
|
||
}
|
||
// Partial date with past day → roll forward to next year
|
||
if (m[3] == null && result < startOfDay(base)) {
|
||
result.setFullYear(year + 1);
|
||
}
|
||
return result;
|
||
}
|
||
|
||
function parseKeyword(
|
||
word: string,
|
||
keywords: { today: string[]; tomorrow: string[]; yesterday: string[]; weekdays: string[][] },
|
||
base: Date,
|
||
): Date | null {
|
||
const w = word.trim().toLowerCase();
|
||
if (keywords.today.some((k) => k.toLowerCase() === w)) return startOfDay(base);
|
||
if (keywords.tomorrow.some((k) => k.toLowerCase() === w)) return addDays(startOfDay(base), 1);
|
||
if (keywords.yesterday.some((k) => k.toLowerCase() === w)) return addDays(startOfDay(base), -1);
|
||
for (let i = 0; i < keywords.weekdays.length; i++) {
|
||
if (keywords.weekdays[i].some((k) => k.toLowerCase() === w)) {
|
||
const today = startOfDay(base);
|
||
const diff = (i - today.getDay() + 7) % 7 || 7;
|
||
return addDays(today, diff);
|
||
}
|
||
}
|
||
return null;
|
||
}
|
||
|
||
export type DateKeywords = {
|
||
today: string[];
|
||
tomorrow: string[];
|
||
yesterday: string[];
|
||
/** Index 0=Sunday, 6=Saturday. Each entry: aliases (short + long). */
|
||
weekdays: string[][];
|
||
};
|
||
|
||
export function parseDate(
|
||
input: ParseInput,
|
||
prefs: FormatPrefs,
|
||
keywords: DateKeywords,
|
||
baseDate: Date = new Date(),
|
||
): Date | null {
|
||
if (!input) return null;
|
||
const trimmed = input.trim();
|
||
if (!trimmed) return null;
|
||
|
||
const rel = trimmed.match(RE_REL);
|
||
if (rel) {
|
||
const sign = rel[1] === '-' ? -1 : 1;
|
||
const n = sign * parseInt(rel[2], 10);
|
||
const unit = rel[3].toLowerCase();
|
||
const base = startOfDay(baseDate);
|
||
if (unit === 'd') return addDays(base, n);
|
||
if (unit === 'w') return addDays(base, n * 7);
|
||
if (unit === 'm') return addMonths(base, n);
|
||
}
|
||
|
||
const kw = parseKeyword(trimmed, keywords, baseDate);
|
||
if (kw) return kw;
|
||
|
||
const num = parseNumeric(trimmed, prefs, baseDate);
|
||
if (num) return num;
|
||
|
||
return null;
|
||
}
|
||
|
||
export function parseDateRange(
|
||
input: ParseInput,
|
||
prefs: FormatPrefs,
|
||
keywords: DateKeywords,
|
||
baseDate: Date = new Date(),
|
||
): { from: Date; to?: Date } | null {
|
||
if (!input) return null;
|
||
const parts = input.split(/\s*(?:-{1,2}|–|to)\s*/i);
|
||
if (parts.length === 1) {
|
||
const single = parseDate(parts[0], prefs, keywords, baseDate);
|
||
return single ? { from: single } : null;
|
||
}
|
||
if (parts.length === 2) {
|
||
const from = parseDate(parts[0], prefs, keywords, baseDate);
|
||
if (!from) return null;
|
||
const to = parseDate(parts[1], prefs, keywords, from);
|
||
if (!to) return null;
|
||
return { from, to };
|
||
}
|
||
return null;
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 3: Verify file compiles**
|
||
|
||
Run:
|
||
```powershell
|
||
cd adiuvAI; npm run lint -- --max-warnings=0 src/renderer/lib/parseDate.ts
|
||
```
|
||
Expected: zero errors.
|
||
|
||
- [ ] **Step 4: Manual verification via REPL or scratch**
|
||
|
||
Open dev tools console after `npm start`, paste:
|
||
```js
|
||
// In renderer dev console
|
||
const { parseDate } = await import('/src/renderer/lib/parseDate.ts');
|
||
const prefs = { dateFormat: 'DD/MM/YYYY' };
|
||
const kw = {
|
||
today: ['today'], tomorrow: ['tomorrow'], yesterday: ['yesterday'],
|
||
weekdays: [['sun','sunday'],['mon','monday'],['tue','tuesday'],['wed','wednesday'],['thu','thursday'],['fri','friday'],['sat','saturday']],
|
||
};
|
||
console.log(parseDate('today', prefs, kw)?.toISOString());
|
||
console.log(parseDate('+3d', prefs, kw)?.toISOString());
|
||
console.log(parseDate('15/03', prefs, kw)?.toISOString());
|
||
console.log(parseDate('15/03/26', prefs, kw)?.toISOString());
|
||
console.log(parseDate('2026-03-15', { dateFormat: 'YYYY-MM-DD' }, kw)?.toISOString());
|
||
console.log(parseDate('mon', prefs, kw)?.toISOString());
|
||
console.log(parseDate('garbage', prefs, kw)); // null
|
||
```
|
||
Expected: each prints a valid ISO string except last (null).
|
||
|
||
- [ ] **Step 5: Commit**
|
||
|
||
```powershell
|
||
git -C adiuvAI add src/renderer/lib/parseDate.ts
|
||
git -C adiuvAI commit -m "feat(date): add parseDate utility with locale-aware parsing"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 2: i18n date keyword keys
|
||
|
||
**Files:**
|
||
- Modify: `adiuvAI/src/renderer/locales/{en,it,es,fr,de}/translation.json`
|
||
|
||
- [ ] **Step 1: Add keys to `en/translation.json`**
|
||
|
||
Locate the existing top-level object. Add a new `date` block (or extend if present):
|
||
|
||
```json
|
||
"date": {
|
||
"keyword": {
|
||
"today": ["today"],
|
||
"tomorrow": ["tomorrow", "tmrw"],
|
||
"yesterday": ["yesterday"],
|
||
"weekdays": [
|
||
["sun", "sunday"],
|
||
["mon", "monday"],
|
||
["tue", "tuesday"],
|
||
["wed", "wednesday"],
|
||
["thu", "thursday"],
|
||
["fri", "friday"],
|
||
["sat", "saturday"]
|
||
]
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: Add keys to `it/translation.json`**
|
||
|
||
```json
|
||
"date": {
|
||
"keyword": {
|
||
"today": ["oggi"],
|
||
"tomorrow": ["domani"],
|
||
"yesterday": ["ieri"],
|
||
"weekdays": [
|
||
["dom", "domenica"],
|
||
["lun", "lunedì", "lunedi"],
|
||
["mar", "martedì", "martedi"],
|
||
["mer", "mercoledì", "mercoledi"],
|
||
["gio", "giovedì", "giovedi"],
|
||
["ven", "venerdì", "venerdi"],
|
||
["sab", "sabato"]
|
||
]
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 3: Add keys to `es/translation.json`**
|
||
|
||
```json
|
||
"date": {
|
||
"keyword": {
|
||
"today": ["hoy"],
|
||
"tomorrow": ["mañana", "manana"],
|
||
"yesterday": ["ayer"],
|
||
"weekdays": [
|
||
["dom", "domingo"],
|
||
["lun", "lunes"],
|
||
["mar", "martes"],
|
||
["mié", "mie", "miércoles", "miercoles"],
|
||
["jue", "jueves"],
|
||
["vie", "viernes"],
|
||
["sáb", "sab", "sábado", "sabado"]
|
||
]
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 4: Add keys to `fr/translation.json`**
|
||
|
||
```json
|
||
"date": {
|
||
"keyword": {
|
||
"today": ["aujourd'hui", "auj"],
|
||
"tomorrow": ["demain"],
|
||
"yesterday": ["hier"],
|
||
"weekdays": [
|
||
["dim", "dimanche"],
|
||
["lun", "lundi"],
|
||
["mar", "mardi"],
|
||
["mer", "mercredi"],
|
||
["jeu", "jeudi"],
|
||
["ven", "vendredi"],
|
||
["sam", "samedi"]
|
||
]
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 5: Add keys to `de/translation.json`**
|
||
|
||
```json
|
||
"date": {
|
||
"keyword": {
|
||
"today": ["heute"],
|
||
"tomorrow": ["morgen"],
|
||
"yesterday": ["gestern"],
|
||
"weekdays": [
|
||
["so", "sonntag"],
|
||
["mo", "montag"],
|
||
["di", "dienstag"],
|
||
["mi", "mittwoch"],
|
||
["do", "donnerstag"],
|
||
["fr", "freitag"],
|
||
["sa", "samstag"]
|
||
]
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 6: Verify JSON validity**
|
||
|
||
Run:
|
||
```powershell
|
||
foreach ($f in @('en','it','es','fr','de')) {
|
||
$p = "adiuvAI/src/renderer/locales/$f/translation.json"
|
||
try { Get-Content $p -Raw | ConvertFrom-Json | Out-Null; "$p OK" } catch { "$p FAIL: $_" }
|
||
}
|
||
```
|
||
Expected: 5 OK lines.
|
||
|
||
- [ ] **Step 7: Commit**
|
||
|
||
```powershell
|
||
git -C adiuvAI add src/renderer/locales/
|
||
git -C adiuvAI commit -m "i18n(date): add date keyword arrays for parseDate"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 3: `<DateField>` primitive
|
||
|
||
**Files:**
|
||
- Create: `adiuvAI/src/renderer/components/ui/date-field.tsx`
|
||
|
||
- [ ] **Step 1: Create `date-field.tsx`**
|
||
|
||
Write `adiuvAI/src/renderer/components/ui/date-field.tsx`:
|
||
|
||
```tsx
|
||
import { useEffect, useRef, useState } from 'react';
|
||
import { useTranslation } from 'react-i18next';
|
||
import { CalendarIcon } from 'lucide-react';
|
||
import { useFormatPrefs, formatDate } from '@/lib/date';
|
||
import { parseDate, type DateKeywords } from '@/lib/parseDate';
|
||
import { Input } from '@/components/ui/input';
|
||
import { Button } from '@/components/ui/button';
|
||
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
|
||
import { Calendar } from '@/components/ui/calendar';
|
||
import { cn } from '@/lib/utils';
|
||
|
||
export type DateFieldProps = {
|
||
value: Date | undefined;
|
||
onChange: (d: Date | undefined) => void;
|
||
onCommit?: (d: Date) => void;
|
||
placeholder?: string;
|
||
minDate?: Date;
|
||
autoFocus?: boolean;
|
||
invalidMessage?: string;
|
||
className?: string;
|
||
'aria-label'?: string;
|
||
id?: string;
|
||
};
|
||
|
||
export function DateField({
|
||
value,
|
||
onChange,
|
||
onCommit,
|
||
placeholder,
|
||
minDate,
|
||
autoFocus,
|
||
invalidMessage,
|
||
className,
|
||
id,
|
||
...rest
|
||
}: DateFieldProps) {
|
||
const { t, i18n } = useTranslation();
|
||
const prefs = useFormatPrefs();
|
||
const [text, setText] = useState<string>(value ? formatDate(value.getTime(), prefs) : '');
|
||
const [focused, setFocused] = useState(false);
|
||
const [invalid, setInvalid] = useState(false);
|
||
const [open, setOpen] = useState(false);
|
||
const inputRef = useRef<HTMLInputElement>(null);
|
||
|
||
// External value changes (e.g. popover selection) sync into local text only when not focused.
|
||
useEffect(() => {
|
||
if (!focused) {
|
||
setText(value ? formatDate(value.getTime(), prefs) : '');
|
||
setInvalid(false);
|
||
}
|
||
}, [value, focused, prefs]);
|
||
|
||
function getKeywords(): DateKeywords {
|
||
const today = i18n.t('date.keyword.today', { returnObjects: true }) as unknown;
|
||
const tomorrow = i18n.t('date.keyword.tomorrow', { returnObjects: true }) as unknown;
|
||
const yesterday = i18n.t('date.keyword.yesterday', { returnObjects: true }) as unknown;
|
||
const weekdays = i18n.t('date.keyword.weekdays', { returnObjects: true }) as unknown;
|
||
return {
|
||
today: Array.isArray(today) ? (today as string[]) : ['today'],
|
||
tomorrow: Array.isArray(tomorrow) ? (tomorrow as string[]) : ['tomorrow'],
|
||
yesterday: Array.isArray(yesterday) ? (yesterday as string[]) : ['yesterday'],
|
||
weekdays: Array.isArray(weekdays)
|
||
? (weekdays as string[][])
|
||
: [['sun'],['mon'],['tue'],['wed'],['thu'],['fri'],['sat']],
|
||
};
|
||
}
|
||
|
||
function tryParse(raw: string): Date | null {
|
||
const parsed = parseDate(raw, prefs, getKeywords());
|
||
if (!parsed) return null;
|
||
if (minDate && parsed < new Date(minDate.getFullYear(), minDate.getMonth(), minDate.getDate())) {
|
||
return null;
|
||
}
|
||
return parsed;
|
||
}
|
||
|
||
function commit(raw: string, fireCommit: boolean) {
|
||
if (!raw.trim()) {
|
||
onChange(undefined);
|
||
setInvalid(false);
|
||
return;
|
||
}
|
||
const parsed = tryParse(raw);
|
||
if (parsed) {
|
||
setInvalid(false);
|
||
onChange(parsed);
|
||
if (fireCommit) onCommit?.(parsed);
|
||
} else {
|
||
setInvalid(true);
|
||
}
|
||
}
|
||
|
||
return (
|
||
<div className={cn('relative', className)}>
|
||
<Input
|
||
ref={inputRef}
|
||
id={id}
|
||
autoFocus={autoFocus}
|
||
placeholder={placeholder ?? t('timeline.pickDate')}
|
||
value={text}
|
||
onChange={(e) => {
|
||
setText(e.target.value);
|
||
setInvalid(false);
|
||
}}
|
||
onFocus={() => setFocused(true)}
|
||
onBlur={() => {
|
||
setFocused(false);
|
||
commit(text, false);
|
||
}}
|
||
onKeyDown={(e) => {
|
||
if (e.key === 'Enter') {
|
||
e.preventDefault();
|
||
commit(text, true);
|
||
} else if (e.altKey && e.key === 'ArrowDown') {
|
||
e.preventDefault();
|
||
setOpen(true);
|
||
}
|
||
}}
|
||
aria-invalid={invalid || !!invalidMessage}
|
||
aria-label={rest['aria-label']}
|
||
className={cn('pr-8', (invalid || !!invalidMessage) && 'ring-1 ring-destructive')}
|
||
/>
|
||
<Popover open={open} onOpenChange={setOpen}>
|
||
<PopoverTrigger asChild>
|
||
<Button
|
||
type="button"
|
||
variant="ghost"
|
||
size="icon"
|
||
className="absolute right-0 top-0 h-full w-8 text-muted-foreground hover:text-foreground"
|
||
aria-label={t('timeline.pickDate')}
|
||
tabIndex={-1}
|
||
>
|
||
<CalendarIcon className="h-4 w-4" />
|
||
</Button>
|
||
</PopoverTrigger>
|
||
<PopoverContent className="w-auto p-0" align="end">
|
||
<Calendar
|
||
mode="single"
|
||
selected={value}
|
||
onSelect={(d) => {
|
||
if (d) {
|
||
onChange(d);
|
||
setText(formatDate(d.getTime(), prefs));
|
||
setInvalid(false);
|
||
onCommit?.(d);
|
||
}
|
||
setOpen(false);
|
||
inputRef.current?.focus();
|
||
}}
|
||
disabled={minDate ? { before: minDate } : undefined}
|
||
/>
|
||
</PopoverContent>
|
||
</Popover>
|
||
{invalidMessage && (
|
||
<p className="mt-1 text-xs text-destructive">{invalidMessage}</p>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: Lint**
|
||
|
||
```powershell
|
||
cd adiuvAI; npm run lint -- --max-warnings=0 src/renderer/components/ui/date-field.tsx
|
||
```
|
||
Expected: zero errors.
|
||
|
||
- [ ] **Step 3: Commit**
|
||
|
||
```powershell
|
||
git -C adiuvAI add src/renderer/components/ui/date-field.tsx
|
||
git -C adiuvAI commit -m "feat(ui): add DateField with typed entry + calendar popover"
|
||
```
|
||
|
||
Manual smoke test of DateField happens during Task 4 migration (real consumer).
|
||
|
||
---
|
||
|
||
## Task 4: Migrate `EditEventDialog` to `<DateField>`
|
||
|
||
**Files:**
|
||
- Modify: `adiuvAI/src/renderer/components/timeline/EditEventDialog.tsx`
|
||
|
||
- [ ] **Step 1: Replace state model**
|
||
|
||
Remove `dateRange` and `singleDate` state; replace with `date` + `endDate`. Update the `useEffect` that hydrates from `event` accordingly. Final state declarations:
|
||
|
||
```ts
|
||
const [title, setTitle] = useState('');
|
||
const [type, setType] = useState<TimelineEventType>('milestone');
|
||
const [date, setDate] = useState<Date | undefined>();
|
||
const [endDate, setEndDate] = useState<Date | undefined>();
|
||
```
|
||
|
||
Replace the `useEffect` body:
|
||
|
||
```ts
|
||
useEffect(() => {
|
||
if (event) {
|
||
setTitle(event.title);
|
||
setType(event.type ?? 'milestone');
|
||
setDate(new Date(event.date));
|
||
if (event.type === 'activity' && event.endDate) {
|
||
setEndDate(new Date(event.endDate));
|
||
} else {
|
||
setEndDate(undefined);
|
||
}
|
||
}
|
||
}, [event]);
|
||
```
|
||
|
||
- [ ] **Step 2: Replace `handleSubmit` to use new state**
|
||
|
||
```ts
|
||
function handleSubmit(e: React.FormEvent) {
|
||
e.preventDefault();
|
||
if (!event || !title.trim() || !date) return;
|
||
|
||
if (isActivity) {
|
||
const hasEnd = endDate && endDate.getTime() !== date.getTime();
|
||
const nextDate = date.getTime();
|
||
const nextEndDate = hasEnd ? endDate!.getTime() : null;
|
||
pendingPrevRef.current = {
|
||
kind: 'update',
|
||
id: event.id,
|
||
prev: {
|
||
title: event.title,
|
||
type: (event.type ?? 'milestone') as 'milestone' | 'checkpoint' | 'activity',
|
||
date: event.date,
|
||
endDate: event.endDate ?? null,
|
||
},
|
||
next: { title: title.trim(), type: 'activity', date: nextDate, endDate: nextEndDate },
|
||
};
|
||
updateEvent.mutate({ id: event.id, title: title.trim(), type: 'activity', date: nextDate, endDate: nextEndDate });
|
||
} else {
|
||
const nextDate = date.getTime();
|
||
pendingPrevRef.current = {
|
||
kind: 'update',
|
||
id: event.id,
|
||
prev: {
|
||
title: event.title,
|
||
type: (event.type ?? 'milestone') as 'milestone' | 'checkpoint' | 'activity',
|
||
date: event.date,
|
||
endDate: event.endDate ?? null,
|
||
},
|
||
next: { title: title.trim(), type, date: nextDate, endDate: null },
|
||
};
|
||
updateEvent.mutate({ id: event.id, title: title.trim(), type, date: nextDate, endDate: null });
|
||
}
|
||
}
|
||
|
||
const canSubmit = isActivity ? (title.trim() && date) : (title.trim() && date);
|
||
```
|
||
|
||
- [ ] **Step 3: Replace JSX date pickers with `<DateField>`**
|
||
|
||
Remove `Popover`/`Calendar` imports and the two `Popover` blocks inside the form. Add at top of file:
|
||
|
||
```ts
|
||
import { DateField } from '@/components/ui/date-field';
|
||
```
|
||
|
||
Replace the `{isActivity ? (<Popover>…) : (<Popover>…)}` block with:
|
||
|
||
```tsx
|
||
{isActivity ? (
|
||
<div className="flex gap-2">
|
||
<DateField
|
||
value={date}
|
||
onChange={setDate}
|
||
placeholder={t('timeline.pickStart')}
|
||
aria-label={t('timeline.pickStart')}
|
||
className="flex-1"
|
||
/>
|
||
<DateField
|
||
value={endDate}
|
||
onChange={setEndDate}
|
||
minDate={date}
|
||
placeholder={t('timeline.pickEnd')}
|
||
aria-label={t('timeline.pickEnd')}
|
||
className="flex-1"
|
||
/>
|
||
</div>
|
||
) : (
|
||
<DateField
|
||
value={date}
|
||
onChange={setDate}
|
||
placeholder={t('timeline.pickDate')}
|
||
aria-label={t('timeline.pickDate')}
|
||
/>
|
||
)}
|
||
```
|
||
|
||
Delete unused imports (`Popover`, `PopoverContent`, `PopoverTrigger`, `Calendar`, `CalendarIcon`, `formatDate`, `useFormatPrefs`, `type DateRange`).
|
||
|
||
- [ ] **Step 4: Lint**
|
||
|
||
```powershell
|
||
cd adiuvAI; npm run lint -- --max-warnings=0 src/renderer/components/timeline/EditEventDialog.tsx
|
||
```
|
||
Expected: zero errors.
|
||
|
||
- [ ] **Step 5: Manual smoke test**
|
||
|
||
```powershell
|
||
cd adiuvAI; npm start
|
||
```
|
||
1. Open the app, navigate to `/timeline`, click an existing event (it triggers `EditEventDialog`).
|
||
2. Verify date input shows formatted date.
|
||
3. Tab into date input, type `+3d`, press Enter — formatted value appears, save button enabled.
|
||
4. Type `garbage`, press Enter — red ring, save disabled.
|
||
5. Click calendar icon, pick a date — input updates.
|
||
6. For an activity, verify end-date field appears, type `+1w` from start.
|
||
7. Save → toast appears, event updated on timeline.
|
||
|
||
- [ ] **Step 6: Add new i18n keys used in EditEventDialog**
|
||
|
||
If `timeline.pickStart` / `timeline.pickEnd` don't exist yet in any locale file, add them. Check first:
|
||
```powershell
|
||
Select-String -Path adiuvAI/src/renderer/locales/*/translation.json -Pattern "pickStart|pickEnd"
|
||
```
|
||
If absent, add these to all 5 locales:
|
||
```
|
||
"pickStart": "Pick start date" (EN)
|
||
"pickEnd": "Pick end date" (EN)
|
||
"pickStart": "Data inizio" (IT)
|
||
"pickEnd": "Data fine" (IT)
|
||
"pickStart": "Fecha de inicio" (ES)
|
||
"pickEnd": "Fecha de fin" (ES)
|
||
"pickStart": "Date de début" (FR)
|
||
"pickEnd": "Date de fin" (FR)
|
||
"pickStart": "Startdatum" (DE)
|
||
"pickEnd": "Enddatum" (DE)
|
||
```
|
||
Insert under the existing `timeline.*` block in each file.
|
||
|
||
- [ ] **Step 7: Commit**
|
||
|
||
```powershell
|
||
git -C adiuvAI add src/renderer/components/timeline/EditEventDialog.tsx src/renderer/locales/
|
||
git -C adiuvAI commit -m "refactor(timeline): migrate EditEventDialog to DateField"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 5: i18n keys for batch flow
|
||
|
||
**Files:**
|
||
- Modify: `adiuvAI/src/renderer/locales/{en,it,es,fr,de}/translation.json`
|
||
|
||
- [ ] **Step 1: Add `timeline.*` keys to EN**
|
||
|
||
Inside the existing `"timeline": { … }` block in `en/translation.json`, add:
|
||
|
||
```json
|
||
"endBeforeStart": "End must be after start",
|
||
"dateInvalid": "Unrecognized date",
|
||
"batchCreated_one": "1 event created",
|
||
"batchCreated_other": "{{count}} events created",
|
||
"batchPartial": "{{ok}} created, {{failed}} failed",
|
||
"batchFailed": "Could not create events",
|
||
"staged_one": "1 event staged",
|
||
"staged_other": "{{count}} events staged",
|
||
"emptyStagedHint": "Type a title, set a date, press Enter",
|
||
"editRow": "Edit",
|
||
"removeRow": "Remove",
|
||
"projectLocked": "Project locked after first event",
|
||
"confirmCloseStaged": "Discard {{count}} staged events?",
|
||
"saveAll": "Save {{count}}",
|
||
"update": "Update"
|
||
```
|
||
|
||
- [ ] **Step 2: Add same keys to IT (Italian)**
|
||
|
||
```json
|
||
"endBeforeStart": "La fine deve essere dopo l'inizio",
|
||
"dateInvalid": "Data non riconosciuta",
|
||
"batchCreated_one": "1 evento creato",
|
||
"batchCreated_other": "{{count}} eventi creati",
|
||
"batchPartial": "{{ok}} creati, {{failed}} falliti",
|
||
"batchFailed": "Impossibile creare gli eventi",
|
||
"staged_one": "1 evento in coda",
|
||
"staged_other": "{{count}} eventi in coda",
|
||
"emptyStagedHint": "Inserisci un titolo, imposta una data, premi Invio",
|
||
"editRow": "Modifica",
|
||
"removeRow": "Rimuovi",
|
||
"projectLocked": "Progetto bloccato dopo il primo evento",
|
||
"confirmCloseStaged": "Eliminare {{count}} eventi in coda?",
|
||
"saveAll": "Salva {{count}}",
|
||
"update": "Aggiorna"
|
||
```
|
||
|
||
- [ ] **Step 3: Add same keys to ES (Spanish)**
|
||
|
||
```json
|
||
"endBeforeStart": "El fin debe ser posterior al inicio",
|
||
"dateInvalid": "Fecha no reconocida",
|
||
"batchCreated_one": "1 evento creado",
|
||
"batchCreated_other": "{{count}} eventos creados",
|
||
"batchPartial": "{{ok}} creados, {{failed}} fallidos",
|
||
"batchFailed": "No se pudieron crear los eventos",
|
||
"staged_one": "1 evento en cola",
|
||
"staged_other": "{{count}} eventos en cola",
|
||
"emptyStagedHint": "Escribe un título, elige una fecha, pulsa Intro",
|
||
"editRow": "Editar",
|
||
"removeRow": "Quitar",
|
||
"projectLocked": "Proyecto bloqueado tras el primer evento",
|
||
"confirmCloseStaged": "¿Descartar {{count}} eventos en cola?",
|
||
"saveAll": "Guardar {{count}}",
|
||
"update": "Actualizar"
|
||
```
|
||
|
||
- [ ] **Step 4: Add same keys to FR (French)**
|
||
|
||
```json
|
||
"endBeforeStart": "La fin doit être après le début",
|
||
"dateInvalid": "Date non reconnue",
|
||
"batchCreated_one": "1 événement créé",
|
||
"batchCreated_other": "{{count}} événements créés",
|
||
"batchPartial": "{{ok}} créés, {{failed}} échoués",
|
||
"batchFailed": "Impossible de créer les événements",
|
||
"staged_one": "1 événement en attente",
|
||
"staged_other": "{{count}} événements en attente",
|
||
"emptyStagedHint": "Saisissez un titre, choisissez une date, appuyez sur Entrée",
|
||
"editRow": "Modifier",
|
||
"removeRow": "Retirer",
|
||
"projectLocked": "Projet verrouillé après le premier événement",
|
||
"confirmCloseStaged": "Abandonner {{count}} événements en attente ?",
|
||
"saveAll": "Enregistrer {{count}}",
|
||
"update": "Mettre à jour"
|
||
```
|
||
|
||
- [ ] **Step 5: Add same keys to DE (German)**
|
||
|
||
```json
|
||
"endBeforeStart": "Ende muss nach dem Start liegen",
|
||
"dateInvalid": "Datum nicht erkannt",
|
||
"batchCreated_one": "1 Ereignis erstellt",
|
||
"batchCreated_other": "{{count}} Ereignisse erstellt",
|
||
"batchPartial": "{{ok}} erstellt, {{failed}} fehlgeschlagen",
|
||
"batchFailed": "Ereignisse konnten nicht erstellt werden",
|
||
"staged_one": "1 Ereignis vorbereitet",
|
||
"staged_other": "{{count}} Ereignisse vorbereitet",
|
||
"emptyStagedHint": "Titel eingeben, Datum festlegen, Enter drücken",
|
||
"editRow": "Bearbeiten",
|
||
"removeRow": "Entfernen",
|
||
"projectLocked": "Projekt nach dem ersten Ereignis gesperrt",
|
||
"confirmCloseStaged": "{{count}} vorbereitete Ereignisse verwerfen?",
|
||
"saveAll": "{{count}} speichern",
|
||
"update": "Aktualisieren"
|
||
```
|
||
|
||
- [ ] **Step 6: Verify JSON validity**
|
||
|
||
```powershell
|
||
foreach ($f in @('en','it','es','fr','de')) {
|
||
$p = "adiuvAI/src/renderer/locales/$f/translation.json"
|
||
try { Get-Content $p -Raw | ConvertFrom-Json | Out-Null; "$p OK" } catch { "$p FAIL: $_" }
|
||
}
|
||
```
|
||
Expected: 5 OK.
|
||
|
||
- [ ] **Step 7: Add `toast.timeline.batchCreated` key**
|
||
|
||
`useNotify` uses keys under `toast.*`. Check current usage:
|
||
```powershell
|
||
Select-String -Path adiuvAI/src/renderer/locales/en/translation.json -Pattern "timeline" -Context 0,2
|
||
```
|
||
Add these to the `toast.timeline.*` block in **all 5 locales**, mirroring the strings from steps 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**
|
||
|
||
```powershell
|
||
git -C adiuvAI add src/renderer/locales/
|
||
git -C adiuvAI commit -m "i18n(timeline): add keys for batch-add dialog"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 6: `AddEventDialog` rewrite — Part A: skeleton + project picker + form + basic staging
|
||
|
||
**Files:**
|
||
- Rewrite: `adiuvAI/src/renderer/components/timeline/AddEventDialog.tsx`
|
||
|
||
- [ ] **Step 1: Replace file with new structure (project picker + form + staged list + basic add)**
|
||
|
||
Overwrite `AddEventDialog.tsx`:
|
||
|
||
```tsx
|
||
import { useRef, useState } from 'react';
|
||
import { useTranslation } from 'react-i18next';
|
||
import { useFormatPrefs, formatDate } from '@/lib/date';
|
||
import { Check, X } from 'lucide-react';
|
||
import { trpc } from '@/lib/trpc';
|
||
import { useNotify } from '@/hooks/useNotify';
|
||
import { Button } from '@/components/ui/button';
|
||
import {
|
||
Dialog,
|
||
DialogContent,
|
||
DialogHeader,
|
||
DialogTitle,
|
||
DialogFooter,
|
||
} from '@/components/ui/dialog';
|
||
import { Input } from '@/components/ui/input';
|
||
import {
|
||
Select,
|
||
SelectContent,
|
||
SelectItem,
|
||
SelectTrigger,
|
||
SelectValue,
|
||
} from '@/components/ui/select';
|
||
import { ScrollArea } from '@/components/ui/scroll-area';
|
||
import { ToggleGroup, ToggleGroupItem } from '@/components/ui/toggle-group';
|
||
import { DateField } from '@/components/ui/date-field';
|
||
import { cn } from '@/lib/utils';
|
||
import type { TimelineEventType } from './ProjectTimeline';
|
||
import type { HistoryEntry } from './history-types';
|
||
|
||
interface AddEventDialogProps {
|
||
open: boolean;
|
||
onOpenChange: (open: boolean) => void;
|
||
defaultProjectId?: string;
|
||
onRecordHistory?: (entry: HistoryEntry) => void;
|
||
}
|
||
|
||
type StagedEvent = {
|
||
id: string;
|
||
title: string;
|
||
type: TimelineEventType;
|
||
date: Date;
|
||
endDate?: Date;
|
||
};
|
||
|
||
type Mode = { kind: 'add' } | { kind: 'edit'; id: string };
|
||
|
||
function newLocalId(): string {
|
||
return 'staged_' + Math.random().toString(36).slice(2, 10);
|
||
}
|
||
|
||
export function AddEventDialog({ open, onOpenChange, defaultProjectId, onRecordHistory }: AddEventDialogProps) {
|
||
const { t } = useTranslation();
|
||
const prefs = useFormatPrefs();
|
||
const { notify, notifyError } = useNotify();
|
||
|
||
const [projectId, setProjectId] = useState(defaultProjectId ?? '');
|
||
const [staged, setStaged] = useState<StagedEvent[]>([]);
|
||
const [mode, setMode] = useState<Mode>({ kind: 'add' });
|
||
|
||
const [title, setTitle] = useState('');
|
||
const [type, setType] = useState<TimelineEventType>('milestone');
|
||
const [date, setDate] = useState<Date | undefined>();
|
||
const [endDate, setEndDate] = useState<Date | undefined>();
|
||
|
||
const titleRef = useRef<HTMLInputElement>(null);
|
||
const closedRef = useRef(false);
|
||
|
||
const showProjectSelect = !defaultProjectId;
|
||
const projectLocked = staged.length > 0;
|
||
const isActivity = type === 'activity';
|
||
|
||
const { data: projectsList } = trpc.projects.listAll.useQuery(undefined, {
|
||
enabled: showProjectSelect,
|
||
});
|
||
const utils = trpc.useUtils();
|
||
const createEvent = trpc.timelineEvents.create.useMutation();
|
||
|
||
function resetForm() {
|
||
setTitle('');
|
||
setDate(undefined);
|
||
setEndDate(undefined);
|
||
setMode({ kind: 'add' });
|
||
setTimeout(() => titleRef.current?.focus(), 0);
|
||
}
|
||
|
||
function handleClose() {
|
||
closedRef.current = true;
|
||
setTitle('');
|
||
setType('milestone');
|
||
setDate(undefined);
|
||
setEndDate(undefined);
|
||
setProjectId(defaultProjectId ?? '');
|
||
setStaged([]);
|
||
setMode({ kind: 'add' });
|
||
onOpenChange(false);
|
||
}
|
||
|
||
function attemptClose() {
|
||
if (staged.length === 0) {
|
||
handleClose();
|
||
return;
|
||
}
|
||
const ok = window.confirm(t('timeline.confirmCloseStaged', { count: staged.length }));
|
||
if (ok) handleClose();
|
||
}
|
||
|
||
function formValid(): boolean {
|
||
if (!title.trim()) return false;
|
||
if (!date) return false;
|
||
if (isActivity && endDate && endDate < date) return false;
|
||
if (showProjectSelect && !projectId) return false;
|
||
return true;
|
||
}
|
||
|
||
function stageOrUpdate() {
|
||
if (!formValid() || !date) return;
|
||
const entry: StagedEvent = {
|
||
id: mode.kind === 'edit' ? mode.id : newLocalId(),
|
||
title: title.trim(),
|
||
type,
|
||
date,
|
||
endDate: isActivity ? endDate : undefined,
|
||
};
|
||
if (mode.kind === 'edit') {
|
||
setStaged((prev) => prev.map((e) => (e.id === entry.id ? entry : e)));
|
||
} else {
|
||
setStaged((prev) => [...prev, entry]);
|
||
}
|
||
resetForm();
|
||
}
|
||
|
||
async function saveBatch() {
|
||
if (staged.length === 0) return;
|
||
const pid = defaultProjectId || projectId || undefined;
|
||
closedRef.current = false;
|
||
|
||
const results = await Promise.allSettled(
|
||
staged.map((e) =>
|
||
createEvent.mutateAsync({
|
||
title: e.title,
|
||
date: e.date.getTime(),
|
||
endDate: e.endDate ? e.endDate.getTime() : undefined,
|
||
type: e.type,
|
||
projectId: pid,
|
||
}),
|
||
),
|
||
);
|
||
|
||
let okCount = 0;
|
||
const failedIds = new Set<string>();
|
||
results.forEach((r, i) => {
|
||
const s = staged[i];
|
||
if (r.status === 'fulfilled') {
|
||
okCount += 1;
|
||
onRecordHistory?.({
|
||
kind: 'create',
|
||
id: r.value.id,
|
||
payload: {
|
||
id: r.value.id,
|
||
projectId: pid ?? null,
|
||
title: s.title,
|
||
date: s.date.getTime(),
|
||
endDate: s.endDate ? s.endDate.getTime() : null,
|
||
type: s.type,
|
||
isCompleted: 0,
|
||
isAiSuggested: 0,
|
||
},
|
||
});
|
||
} else {
|
||
failedIds.add(s.id);
|
||
}
|
||
});
|
||
|
||
if (closedRef.current) return; // dialog already closed, skip toasts
|
||
void utils.timelineEvents.list.invalidate();
|
||
|
||
if (failedIds.size === 0) {
|
||
notify('success', 'toast.timeline.batchCreated', { count: okCount });
|
||
handleClose();
|
||
return;
|
||
}
|
||
if (okCount === 0) {
|
||
const firstError = results.find((r) => r.status === 'rejected') as PromiseRejectedResult | undefined;
|
||
notifyError('toast.timeline.batchFailed', firstError?.reason);
|
||
} else {
|
||
notify('warning', 'toast.timeline.batchPartial', { ok: okCount, failed: failedIds.size });
|
||
}
|
||
setStaged((prev) => prev.filter((e) => failedIds.has(e.id)));
|
||
}
|
||
|
||
function onFormKeyDown(e: React.KeyboardEvent) {
|
||
if ((e.ctrlKey || e.metaKey) && e.key === 'Enter') {
|
||
e.preventDefault();
|
||
void saveBatch();
|
||
} else if (e.key === 'Enter') {
|
||
e.preventDefault();
|
||
stageOrUpdate();
|
||
} else if (e.key === 'Escape') {
|
||
e.preventDefault();
|
||
attemptClose();
|
||
}
|
||
}
|
||
|
||
return (
|
||
<Dialog open={open} onOpenChange={(v) => { if (!v) attemptClose(); else onOpenChange(v); }}>
|
||
<DialogContent className="sm:max-w-[440px]">
|
||
<DialogHeader>
|
||
<DialogTitle>{t('timeline.addEventTitle')}</DialogTitle>
|
||
</DialogHeader>
|
||
|
||
{showProjectSelect && (
|
||
<Select
|
||
value={projectId}
|
||
onValueChange={setProjectId}
|
||
disabled={projectLocked}
|
||
>
|
||
<SelectTrigger>
|
||
<SelectValue placeholder={t('timeline.selectProjectOptional')} />
|
||
</SelectTrigger>
|
||
<SelectContent>
|
||
{projectsList?.map((p) => (
|
||
<SelectItem key={p.id} value={p.id}>{p.name}</SelectItem>
|
||
))}
|
||
</SelectContent>
|
||
</Select>
|
||
)}
|
||
|
||
{staged.length === 0 ? (
|
||
<p className="text-xs text-muted-foreground py-2">{t('timeline.emptyStagedHint')}</p>
|
||
) : (
|
||
<ScrollArea className="max-h-40 border rounded-md">
|
||
<ul className="flex flex-col" aria-label="Staged events">
|
||
{staged.map((e) => (
|
||
<li key={e.id} className="flex items-center gap-2 px-2 py-1.5 text-sm">
|
||
<Check className="h-3.5 w-3.5 text-chart-2 shrink-0" />
|
||
<span className="truncate flex-1">{e.title}</span>
|
||
<span className="text-xs text-muted-foreground shrink-0">
|
||
{e.type === 'milestone'
|
||
? t('timeline.typeMilestone')
|
||
: e.type === 'checkpoint'
|
||
? t('timeline.typeCheckpoint')
|
||
: t('timeline.typeActivity')}
|
||
</span>
|
||
<span className="text-xs text-muted-foreground shrink-0">
|
||
{e.endDate
|
||
? `${formatDate(e.date.getTime(), prefs)} – ${formatDate(e.endDate.getTime(), prefs)}`
|
||
: formatDate(e.date.getTime(), prefs)}
|
||
</span>
|
||
<Button
|
||
type="button"
|
||
variant="ghost"
|
||
size="icon"
|
||
className="h-6 w-6 text-muted-foreground hover:text-destructive"
|
||
aria-label={t('timeline.removeRow')}
|
||
onClick={() => setStaged((prev) => prev.filter((s) => s.id !== e.id))}
|
||
>
|
||
<X className="h-3.5 w-3.5" />
|
||
</Button>
|
||
</li>
|
||
))}
|
||
</ul>
|
||
</ScrollArea>
|
||
)}
|
||
|
||
<div className={cn('flex flex-col gap-3')} onKeyDown={onFormKeyDown}>
|
||
<ToggleGroup
|
||
type="single"
|
||
value={type}
|
||
onValueChange={(v) => { if (v) setType(v as TimelineEventType); }}
|
||
className="justify-start"
|
||
>
|
||
<ToggleGroupItem value="milestone" className="text-xs px-3">{t('timeline.typeMilestone')}</ToggleGroupItem>
|
||
<ToggleGroupItem value="checkpoint" className="text-xs px-3">{t('timeline.typeCheckpoint')}</ToggleGroupItem>
|
||
<ToggleGroupItem value="activity" className="text-xs px-3">{t('timeline.typeActivity')}</ToggleGroupItem>
|
||
</ToggleGroup>
|
||
|
||
<Input
|
||
ref={titleRef}
|
||
placeholder={t('timeline.eventTitlePlaceholder')}
|
||
value={title}
|
||
onChange={(e) => setTitle(e.target.value)}
|
||
autoFocus
|
||
/>
|
||
|
||
{isActivity ? (
|
||
<div className="flex gap-2">
|
||
<DateField
|
||
value={date}
|
||
onChange={setDate}
|
||
placeholder={t('timeline.pickStart')}
|
||
aria-label={t('timeline.pickStart')}
|
||
className="flex-1"
|
||
/>
|
||
<DateField
|
||
value={endDate}
|
||
onChange={setEndDate}
|
||
minDate={date}
|
||
placeholder={t('timeline.pickEnd')}
|
||
aria-label={t('timeline.pickEnd')}
|
||
className="flex-1"
|
||
/>
|
||
</div>
|
||
) : (
|
||
<DateField
|
||
value={date}
|
||
onChange={setDate}
|
||
placeholder={t('timeline.pickDate')}
|
||
aria-label={t('timeline.pickDate')}
|
||
/>
|
||
)}
|
||
</div>
|
||
|
||
<DialogFooter>
|
||
<Button type="button" variant="outline" onClick={attemptClose}>
|
||
{t('common.cancel')}
|
||
</Button>
|
||
<Button
|
||
type="button"
|
||
variant="outline"
|
||
onClick={stageOrUpdate}
|
||
disabled={!formValid()}
|
||
>
|
||
{mode.kind === 'edit' ? t('timeline.update') : t('common.add')}
|
||
</Button>
|
||
<Button
|
||
type="button"
|
||
onClick={() => void saveBatch()}
|
||
disabled={staged.length === 0 || createEvent.isPending}
|
||
>
|
||
{t('timeline.saveAll', { count: staged.length })}
|
||
</Button>
|
||
</DialogFooter>
|
||
</DialogContent>
|
||
</Dialog>
|
||
);
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: Lint**
|
||
|
||
```powershell
|
||
cd adiuvAI; npm run lint -- --max-warnings=0 src/renderer/components/timeline/AddEventDialog.tsx
|
||
```
|
||
Expected: zero errors.
|
||
|
||
- [ ] **Step 3: Manual smoke test**
|
||
|
||
```powershell
|
||
cd adiuvAI; npm start
|
||
```
|
||
1. From `/timeline`, click "Add event". Project picker shows; pick one.
|
||
2. Type a title, type `today` in date, press Enter — row appears in staged list, form resets.
|
||
3. Repeat with `+3d`, switch type to checkpoint.
|
||
4. Switch type to activity; two date fields appear; type `today` then Tab, `+5d`, Enter.
|
||
5. Click "Save 3" → all 3 events appear on timeline, dialog closes, toast shows count.
|
||
6. Open dialog from `ProjectDetail` (project preset) — project picker hidden, same flow works.
|
||
7. Stage 2 events, click ✕ on first row — row disappears, count drops to 1.
|
||
8. Stage event, click Cancel → confirm prompt, OK → dialog closes.
|
||
|
||
- [ ] **Step 4: Commit**
|
||
|
||
```powershell
|
||
git -C adiuvAI add src/renderer/components/timeline/AddEventDialog.tsx
|
||
git -C adiuvAI commit -m "feat(timeline): batch-stage flow in AddEventDialog"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 7: `AddEventDialog` — Part B: keyboard nav for staged list + edit-row mode
|
||
|
||
**Files:**
|
||
- Modify: `adiuvAI/src/renderer/components/timeline/AddEventDialog.tsx`
|
||
|
||
- [ ] **Step 1: Add focus state for staged rows**
|
||
|
||
Add near the other state hooks in `AddEventDialog`:
|
||
|
||
```ts
|
||
const [focusedRowId, setFocusedRowId] = useState<string | null>(null);
|
||
const rowRefs = useRef<Map<string, HTMLLIElement>>(new Map());
|
||
```
|
||
|
||
- [ ] **Step 2: Add ArrowUp handler to title input**
|
||
|
||
Replace the `<Input ref={titleRef} ...>` block with:
|
||
|
||
```tsx
|
||
<Input
|
||
ref={titleRef}
|
||
placeholder={t('timeline.eventTitlePlaceholder')}
|
||
value={title}
|
||
onChange={(e) => setTitle(e.target.value)}
|
||
onKeyDown={(e) => {
|
||
if (
|
||
e.key === 'ArrowUp' &&
|
||
staged.length > 0 &&
|
||
(e.currentTarget.selectionStart ?? 0) === 0
|
||
) {
|
||
e.preventDefault();
|
||
const last = staged[staged.length - 1];
|
||
setFocusedRowId(last.id);
|
||
rowRefs.current.get(last.id)?.focus();
|
||
}
|
||
}}
|
||
autoFocus
|
||
/>
|
||
```
|
||
|
||
- [ ] **Step 3: Add helpers for row actions**
|
||
|
||
Above `return (` in the component, add:
|
||
|
||
```ts
|
||
function loadRowIntoForm(row: StagedEvent) {
|
||
setTitle(row.title);
|
||
setType(row.type);
|
||
setDate(row.date);
|
||
setEndDate(row.endDate);
|
||
setMode({ kind: 'edit', id: row.id });
|
||
setFocusedRowId(null);
|
||
setTimeout(() => titleRef.current?.focus(), 0);
|
||
}
|
||
|
||
function removeRow(id: string) {
|
||
const idx = staged.findIndex((s) => s.id === id);
|
||
setStaged((prev) => prev.filter((s) => s.id !== id));
|
||
setFocusedRowId(null);
|
||
setTimeout(() => {
|
||
const next = staged[idx + 1] ?? staged[idx - 1];
|
||
if (next) {
|
||
const el = rowRefs.current.get(next.id);
|
||
if (el) {
|
||
setFocusedRowId(next.id);
|
||
el.focus();
|
||
return;
|
||
}
|
||
}
|
||
titleRef.current?.focus();
|
||
}, 0);
|
||
}
|
||
|
||
function onRowKeyDown(e: React.KeyboardEvent<HTMLLIElement>, row: StagedEvent) {
|
||
const idx = staged.findIndex((s) => s.id === row.id);
|
||
if (e.key === 'ArrowDown') {
|
||
e.preventDefault();
|
||
const next = staged[idx + 1];
|
||
if (next) {
|
||
setFocusedRowId(next.id);
|
||
rowRefs.current.get(next.id)?.focus();
|
||
} else {
|
||
setFocusedRowId(null);
|
||
titleRef.current?.focus();
|
||
}
|
||
} else if (e.key === 'ArrowUp') {
|
||
e.preventDefault();
|
||
const prev = staged[idx - 1];
|
||
if (prev) {
|
||
setFocusedRowId(prev.id);
|
||
rowRefs.current.get(prev.id)?.focus();
|
||
}
|
||
} else if (e.key === 'Enter') {
|
||
e.preventDefault();
|
||
loadRowIntoForm(row);
|
||
} else if (e.key === 'Delete' || e.key === 'Backspace') {
|
||
e.preventDefault();
|
||
removeRow(row.id);
|
||
} else if (e.key === 'Escape') {
|
||
e.preventDefault();
|
||
setFocusedRowId(null);
|
||
titleRef.current?.focus();
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 4: Make rows focusable, wire ref + onKeyDown**
|
||
|
||
Replace the `<li>` inside the staged list map with:
|
||
|
||
```tsx
|
||
<li
|
||
key={e.id}
|
||
ref={(el) => {
|
||
if (el) rowRefs.current.set(e.id, el);
|
||
else rowRefs.current.delete(e.id);
|
||
}}
|
||
tabIndex={focusedRowId === e.id ? 0 : -1}
|
||
role="option"
|
||
aria-selected={focusedRowId === e.id}
|
||
onKeyDown={(ev) => onRowKeyDown(ev, e)}
|
||
onFocus={() => setFocusedRowId(e.id)}
|
||
className={cn(
|
||
'flex items-center gap-2 px-2 py-1.5 text-sm outline-none',
|
||
focusedRowId === e.id && 'bg-accent/40',
|
||
mode.kind === 'edit' && mode.id === e.id && 'ring-1 ring-primary/40',
|
||
)}
|
||
>
|
||
```
|
||
|
||
Change the `<ul>` opening tag to:
|
||
```tsx
|
||
<ul className="flex flex-col" role="listbox" aria-label={t('timeline.staged', { count: staged.length })}>
|
||
```
|
||
|
||
- [ ] **Step 5: Dim form while a row is focused**
|
||
|
||
Wrap the form `<div>` with conditional class:
|
||
|
||
```tsx
|
||
<div
|
||
className={cn(
|
||
'flex flex-col gap-3 transition-opacity',
|
||
focusedRowId !== null && 'opacity-50 pointer-events-none',
|
||
)}
|
||
onKeyDown={onFormKeyDown}
|
||
>
|
||
```
|
||
|
||
- [ ] **Step 6: Update Add button label when editing**
|
||
|
||
Already shows `t('timeline.update')` for `mode.kind === 'edit'` (set in Task 6). Verify it still does.
|
||
|
||
- [ ] **Step 7: Lint**
|
||
|
||
```powershell
|
||
cd adiuvAI; npm run lint -- --max-warnings=0 src/renderer/components/timeline/AddEventDialog.tsx
|
||
```
|
||
Expected: zero errors.
|
||
|
||
- [ ] **Step 8: Manual keyboard verification**
|
||
|
||
```powershell
|
||
cd adiuvAI; npm start
|
||
```
|
||
1. Open dialog. Stage 3 events with keyboard only (Tab + types).
|
||
2. With focus in title input (empty), press ↑ — last row gets focus ring.
|
||
3. ↑/↓ navigate rows.
|
||
4. Press Enter on middle row — fields populate, "Add" button now reads "Update", row highlighted with ring.
|
||
5. Change title, Enter — row updated in place, count unchanged, mode resets to add.
|
||
6. ↑ to a row, Del — row removed, focus moves to next or back to title.
|
||
7. Esc on a row → focus returns to title.
|
||
|
||
- [ ] **Step 9: Commit**
|
||
|
||
```powershell
|
||
git -C adiuvAI add src/renderer/components/timeline/AddEventDialog.tsx
|
||
git -C adiuvAI commit -m "feat(timeline): keyboard nav + edit mode for staged rows"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 8: `AddEventDialog` — Part C: polish (end-before-start, locked-project hint, end-date sync)
|
||
|
||
**Files:**
|
||
- Modify: `adiuvAI/src/renderer/components/timeline/AddEventDialog.tsx`
|
||
|
||
- [ ] **Step 1: Surface end-before-start error**
|
||
|
||
In the `isActivity` branch of the form JSX, replace the end `<DateField>` with:
|
||
|
||
```tsx
|
||
<DateField
|
||
value={endDate}
|
||
onChange={setEndDate}
|
||
minDate={date}
|
||
placeholder={t('timeline.pickEnd')}
|
||
aria-label={t('timeline.pickEnd')}
|
||
invalidMessage={
|
||
date && endDate && endDate < date ? t('timeline.endBeforeStart') : undefined
|
||
}
|
||
className="flex-1"
|
||
/>
|
||
```
|
||
|
||
- [ ] **Step 2: Auto-clear end date when start changes past it**
|
||
|
||
Replace the start `<DateField>` in the `isActivity` branch with:
|
||
|
||
```tsx
|
||
<DateField
|
||
value={date}
|
||
onChange={(d) => {
|
||
setDate(d);
|
||
if (d && endDate && endDate < d) setEndDate(undefined);
|
||
}}
|
||
placeholder={t('timeline.pickStart')}
|
||
aria-label={t('timeline.pickStart')}
|
||
className="flex-1"
|
||
/>
|
||
```
|
||
|
||
- [ ] **Step 3: Tooltip on disabled project picker**
|
||
|
||
Wrap the `<Select>` block (when `showProjectSelect`) with a `title` attribute on the trigger when locked. Simplest: add `title={projectLocked ? t('timeline.projectLocked') : undefined}` to the `<SelectTrigger>` element by extending it:
|
||
|
||
```tsx
|
||
<SelectTrigger title={projectLocked ? t('timeline.projectLocked') : undefined}>
|
||
<SelectValue placeholder={t('timeline.selectProjectOptional')} />
|
||
</SelectTrigger>
|
||
```
|
||
|
||
- [ ] **Step 4: Lint**
|
||
|
||
```powershell
|
||
cd adiuvAI; npm run lint -- --max-warnings=0 src/renderer/components/timeline/AddEventDialog.tsx
|
||
```
|
||
Expected: zero errors.
|
||
|
||
- [ ] **Step 5: Manual verification**
|
||
|
||
1. Activity flow: type start `today`, end `+5d`, then change start to `+10d` — end clears.
|
||
2. Set start `today`, end `yesterday` → end shows red ring + error message; "Add" disabled.
|
||
3. Stage 1 event with no preset project → project picker becomes disabled; hover → tooltip "Project locked after first event".
|
||
4. Stage another event (uses locked project automatically). Save → both attach to same project.
|
||
|
||
- [ ] **Step 6: Commit**
|
||
|
||
```powershell
|
||
git -C adiuvAI add src/renderer/components/timeline/AddEventDialog.tsx
|
||
git -C adiuvAI commit -m "polish(timeline): end-date validation + project-lock hint"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 9: Full keyboard-only regression pass
|
||
|
||
**Files:** none (manual)
|
||
|
||
- [ ] **Step 1: Run app**
|
||
|
||
```powershell
|
||
cd adiuvAI; npm start
|
||
```
|
||
|
||
- [ ] **Step 2: Pure keyboard run — no mouse**
|
||
|
||
From `/timeline`:
|
||
1. Tab/Shift+Tab to "Add event" button, Enter.
|
||
2. Tab to project picker, Space to open, ↓ to pick, Enter.
|
||
3. Tab to title, type "Kickoff", Tab to date, type `today`, Enter → row staged, focus on title.
|
||
4. Type "Phase 1", Tab → type, ← / → to switch to "Activity"; Tab to title (or just type), Tab to start, `+3d`, Tab to end, `+1w`, Enter → row staged.
|
||
5. ↑ from title → focus on Phase 1 row; Enter → loaded into form, change title to "Phase one"; Enter → row updated.
|
||
6. Ctrl+Enter → batch saved, dialog closes, toast shown.
|
||
7. Verify rows present on timeline.
|
||
|
||
- [ ] **Step 3: Locale switch verification**
|
||
|
||
1. Settings > General → set language to Italian.
|
||
2. Open dialog → labels in Italian.
|
||
3. Type `domani` in date input, Enter → parses to tomorrow.
|
||
4. Type `lun` → next Monday.
|
||
5. Switch back to English.
|
||
|
||
- [ ] **Step 4: Date-format switch verification**
|
||
|
||
1. Settings > Profile → set date format to `MM/DD/YYYY`.
|
||
2. Open dialog → type `03/15` for partial date → parses to March 15, current year.
|
||
3. Switch to `YYYY-MM-DD` → type `2026-03-15` → parses.
|
||
4. Switch back to `DD/MM/YYYY`.
|
||
|
||
- [ ] **Step 5: Failure mode verification**
|
||
|
||
1. Open dev tools → Network → throttle to Offline (or stop backend if applicable to event creation path — `timelineEvents.create` is main-process tRPC so it should keep working).
|
||
2. Since `timelineEvents.create` is local SQLite (no network), force a failure by temporarily renaming the SQLite file or by inserting a duplicate (the create mutation may reject if invariants violated). If no easy failure path: skip and note as "untested fail path".
|
||
3. Stage 2 events, click Save → if any fail, only failed rows remain.
|
||
|
||
- [ ] **Step 6: Commit verification doc**
|
||
|
||
If any task notes need to be saved (e.g. "fail path untested"), append to plan as a checklist note. No code commit needed for this step.
|
||
|
||
---
|
||
|
||
## Self-review
|
||
|
||
**Spec coverage check:**
|
||
- ✅ Architecture (refactor in place, no backend change) — Task 6
|
||
- ✅ State model — Task 6
|
||
- ✅ Layout (3 states) — Tasks 6, 7
|
||
- ✅ `parseDate` primitive — Task 1
|
||
- ✅ `DateField` primitive — Task 3
|
||
- ✅ `ProjectPickerRow` (as inline `<Select>` — spec says shadcn `Command` but `<Select>` is simpler, equivalent UX given the existing `projectsList` use; flagged for review at execution time)
|
||
- ✅ `StagedList` semantics + roving tabindex — Task 7
|
||
- ✅ `EventForm` — Task 6
|
||
- ✅ Keyboard map — Tasks 6, 7
|
||
- ✅ Data flow (allSettled batch) — Task 6
|
||
- ✅ Error handling (inline + batch outcomes) — Tasks 6, 8
|
||
- ✅ i18n keys — Tasks 2, 5
|
||
- ✅ EditEventDialog migration — Task 4
|
||
- ✅ TaskFormDialog deferred (per updated spec) — N/A
|
||
- ✅ Manual verification — Task 9
|
||
|
||
**Note on `ProjectPickerRow`:** spec calls for shadcn `Command` (typeable combobox). Plan uses native shadcn `Select` for parity with current code. If user wants typeahead filter, swap to `Command`+`Popover` in Task 6 Step 1 — same data source, ~30 LOC delta. Surface this at execution time.
|
||
|
||
**Placeholder scan:** none found. All code blocks are concrete.
|
||
|
||
**Type consistency:** `StagedEvent`, `Mode`, function names (`stageOrUpdate`, `saveBatch`, `loadRowIntoForm`, `removeRow`, `onRowKeyDown`, `onFormKeyDown`, `attemptClose`, `handleClose`, `resetForm`, `formValid`) used consistently across tasks 6–8.
|
||
|
||
---
|