Compare commits
24 Commits
5274f014b9
...
feat/proje
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1a8acd08c0 | ||
|
|
82a7a8dc27 | ||
|
|
5a90dbc832 | ||
|
|
f72aaa8424 | ||
|
|
fa09ed2156 | ||
|
|
1341fb3144 | ||
|
|
3705316a25 | ||
|
|
72d7cc2f6e | ||
|
|
e1d15b3edd | ||
|
|
faea5f0448 | ||
|
|
c68e23b713 | ||
|
|
aba0f38816 | ||
|
|
8dffbc714c | ||
|
|
0abed9563b | ||
|
|
361f89a29d | ||
|
|
74e2152596 | ||
|
|
649e4f00a5 | ||
|
|
1c8c7e2ddc | ||
|
|
b111c76661 | ||
|
|
804a0a5af3 | ||
|
|
314d5656ae | ||
|
|
e073f4f774 | ||
|
|
20240c5fea | ||
|
|
310410350f |
@@ -1,15 +1,6 @@
|
|||||||
{
|
{
|
||||||
"permissions": {
|
"permissions": {
|
||||||
"allow": [
|
"allow": []
|
||||||
"mcp__langfuse__createTextPrompt",
|
|
||||||
"mcp__langfuse-docs__searchLangfuseDocs",
|
|
||||||
"Bash(python -m ruff check . --fix)",
|
|
||||||
"Bash(ruff check *)",
|
|
||||||
"Bash(powershell -Command \"cd 'c:\\\\\\\\_temp\\\\\\\\_adiuvai_workspace\\\\\\\\api'; .venv\\\\\\\\Scripts\\\\\\\\pytest.exe tests/test_memory_relations.py -v 2>&1 | Out-File -FilePath 'C:\\\\\\\\Users\\\\\\\\musso\\\\\\\\AppData\\\\\\\\Local\\\\\\\\Temp\\\\\\\\pytest_phase3.txt' -Encoding UTF8; Get-Content 'C:\\\\\\\\Users\\\\\\\\musso\\\\\\\\AppData\\\\\\\\Local\\\\\\\\Temp\\\\\\\\pytest_phase3.txt'\")",
|
|
||||||
"mcp__postgres__execute_sql",
|
|
||||||
"mcp__langfuse__listPrompts",
|
|
||||||
"mcp__langfuse__getPrompt"
|
|
||||||
]
|
|
||||||
},
|
},
|
||||||
"enabledPlugins": {
|
"enabledPlugins": {
|
||||||
"caveman@caveman": true
|
"caveman@caveman": true
|
||||||
|
|||||||
@@ -1,21 +0,0 @@
|
|||||||
{
|
|
||||||
"permissions": {
|
|
||||||
"allow": [
|
|
||||||
"Skill(shadcn)",
|
|
||||||
"Bash(npm run *)",
|
|
||||||
"Bash(node -e \"const d = require\\('date-fns'\\); console.log\\(typeof d.eachDayOfInterval, typeof d.eachWeekOfInterval, typeof d.eachMonthOfInterval, typeof d.startOfMonth\\)\")",
|
|
||||||
"Bash(export LANGFUSE_PUBLIC_KEY=pk-lf-0e62a9eb-0978-4e2e-b3ad-bb36194701b8)",
|
|
||||||
"Bash(export LANGFUSE_SECRET_KEY=sk-lf-286c165f-1c84-4a36-b0b0-cfe5b680897d)",
|
|
||||||
"Bash(export LANGFUSE_HOST=https://langfuse.muticolturano.com)",
|
|
||||||
"Bash(npx langfuse-cli *)",
|
|
||||||
"mcp__langfuse-docs__getLangfuseDocsPage",
|
|
||||||
"Bash(python -c ' *)",
|
|
||||||
"WebFetch(domain:ui.shadcn.com)"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"enabledMcpjsonServers": [
|
|
||||||
"langfuse-docs",
|
|
||||||
"langfuse",
|
|
||||||
"postgres"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
2
.gitignore
vendored
2
.gitignore
vendored
@@ -9,6 +9,8 @@ docs/node_modules
|
|||||||
docs/package.json
|
docs/package.json
|
||||||
docs/package-lock.json
|
docs/package-lock.json
|
||||||
tmp/
|
tmp/
|
||||||
|
.superpowers/
|
||||||
graphify-out/cache/
|
graphify-out/cache/
|
||||||
graphify-out/manifest.json
|
graphify-out/manifest.json
|
||||||
graphify-out/cost.json
|
graphify-out/cost.json
|
||||||
|
.claude/settings.local.json
|
||||||
|
|||||||
2
adiuvAI
2
adiuvAI
Submodule adiuvAI updated: dd3f1442b0...81fe6d29e2
2
api
2
api
Submodule api updated: 67562b8092...cc0e258e8c
309
docs/2026-05-08-task-ux-evolution-design.md
Normal file
309
docs/2026-05-08-task-ux-evolution-design.md
Normal file
@@ -0,0 +1,309 @@
|
|||||||
|
# Task UX Evolution — Design
|
||||||
|
|
||||||
|
**Date:** 2026-05-08
|
||||||
|
**Scope:** adiuvAI desktop app (renderer + main process)
|
||||||
|
**Status:** Approved by user, ready for implementation plan
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
|
||||||
|
Evolve the task management UX:
|
||||||
|
|
||||||
|
1. Replace the list-of-cards view with a paginated **shadcn Table**, keep the card grid as an alternative view, share pagination across both.
|
||||||
|
2. Replace `TaskDetailDialog` with a **right-side `Sheet`** (sticky header + scrolling body + sticky composer), add **attachments** support.
|
||||||
|
3. Redesign the create dialog as a **quick-capture form** with pill-style property controls. Edit dialog reuses the same shell.
|
||||||
|
4. Apply the same task list view inside the **project detail page**, scoped to that project.
|
||||||
|
|
||||||
|
Single spec, single implementation plan. All four subsystems ship together.
|
||||||
|
|
||||||
|
## Non-goals
|
||||||
|
|
||||||
|
- AI-driven estimate generation (column added now, populated by a future agent).
|
||||||
|
- Comment attachments (composer has no attach icon).
|
||||||
|
- Per-column header sorting in the table (existing `Order by` Select stays).
|
||||||
|
- Reporter / Tags fields (image reference includes them but spec excludes).
|
||||||
|
|
||||||
|
## 1. Architecture & shared state
|
||||||
|
|
||||||
|
A new `TaskListView` component owns the task list rendering for both the Tasks page and the Project detail page. It encapsulates the toolbar, the table or grid body, and the pager. The page consuming it passes a task array plus an optional `hideProjectColumn` flag.
|
||||||
|
|
||||||
|
**Persisted state (`localStorage`):**
|
||||||
|
- `tasksViewMode`: `'list' | 'grid'` (already exists, kept).
|
||||||
|
- `tasksPageSize`: `10 | 25 | 50 | 100` — default `25`.
|
||||||
|
|
||||||
|
Page index is component-local and resets per route entry. It also resets when the search, status filter, or `Order by` changes.
|
||||||
|
|
||||||
|
**Pagination scope:** Tasks page and Project page each maintain their own page state. Toggling list ↔ grid within a page preserves the current page.
|
||||||
|
|
||||||
|
**Why client-side slicing:** task list is already fully loaded via `trpc.tasks.list`. No backend pagination required at this scale.
|
||||||
|
|
||||||
|
## 2. Database schema
|
||||||
|
|
||||||
|
Two changes to `src/main/db/schema.ts`:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
// tasks: add column
|
||||||
|
estimate: integer('estimate'), // minutes, nullable
|
||||||
|
|
||||||
|
// new table
|
||||||
|
export const taskAttachments = sqliteTable('task_attachments', {
|
||||||
|
id: text('id').primaryKey().$defaultFn(() => randomUUID()),
|
||||||
|
taskId: text('task_id').notNull(),
|
||||||
|
filename: text('filename').notNull(),
|
||||||
|
mimeType: text('mime_type'),
|
||||||
|
sizeBytes: integer('size_bytes').notNull(),
|
||||||
|
storedPath: text('stored_path').notNull(), // relative to userData/attachments
|
||||||
|
createdAt: integer('created_at').notNull(),
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
Migration generated with `drizzle-kit generate`. Per project convention, no foreign key constraint — cascade is handled in the `tasks.delete` tRPC procedure (delete attachment files + rows before deleting the task).
|
||||||
|
|
||||||
|
## 3. Attachments — file storage and IPC
|
||||||
|
|
||||||
|
**Storage path:** `app.getPath('userData') / attachments / <taskId> / <uuid>-<sanitizedFilename>`.
|
||||||
|
|
||||||
|
**Sanitization:** strip path separators, control characters, leading dots; cap filename at 200 chars.
|
||||||
|
|
||||||
|
**Limits:**
|
||||||
|
- Soft cap 50 MB per file. Larger files trigger a warning toast and are not uploaded.
|
||||||
|
- No per-task total cap.
|
||||||
|
|
||||||
|
**New tRPC sub-router `taskAttachments`** (in `src/main/router/index.ts`):
|
||||||
|
|
||||||
|
| Procedure | Input | Behavior |
|
||||||
|
|---|---|---|
|
||||||
|
| `list` | `{ taskId }` | Returns attachment rows for the task. |
|
||||||
|
| `pick` | `{}` | Main: `dialog.showOpenDialog({ properties: ['openFile', 'multiSelections'] })`. Returns `Array<{ path, name, size }>`. |
|
||||||
|
| `create` | `{ taskId, sourcePath, filename, sizeBytes, mimeType? }` | Main: `fs.mkdir(userData/attachments/<taskId>, recursive)`, copy file with new uuid name, insert row. |
|
||||||
|
| `delete` | `{ id }` | Look up row, `fs.unlink(storedPath)`, delete row. |
|
||||||
|
| `open` | `{ id }` | `shell.openPath(absoluteStoredPath)`. |
|
||||||
|
|
||||||
|
**Helper module `src/main/attachments/storage.ts`:** path resolution, sanitize, copy, delete. Keeps tRPC procedures thin.
|
||||||
|
|
||||||
|
**Tasks router updates:**
|
||||||
|
- `tasks.update` accepts `estimate?: number | null`.
|
||||||
|
- `tasks.delete` enumerates `taskAttachments` for the task and deletes files + rows before deleting the task row.
|
||||||
|
|
||||||
|
## 4. Table view
|
||||||
|
|
||||||
|
**Component:** `TaskTable` using shadcn `Table` / `TableHeader` / `TableBody` / `TableRow` / `TableCell`.
|
||||||
|
|
||||||
|
**Container styling (translucent card over gradient bg):**
|
||||||
|
```
|
||||||
|
bg-card/65 backdrop-blur-xl border border-border/50 rounded-lg shadow
|
||||||
|
```
|
||||||
|
The `--card` token is used (not a hard-coded color), so dark mode works.
|
||||||
|
|
||||||
|
**Columns:**
|
||||||
|
1. **Task** — title, single line, truncate with tooltip.
|
||||||
|
2. **Project** — `Client › Project` breadcrumb. Client text muted, project text foreground. Hidden when `hideProjectColumn` is set. Click navigates to the project page.
|
||||||
|
3. **Priority** — existing `<PriorityBadge>` component (arrow icon + colored text, no pill).
|
||||||
|
4. **Due** — `formatDueDate(t.dueDate, prefs)`. Overdue: red text. None: muted `—`.
|
||||||
|
5. **Assignee** — `<AssigneeStack>`: overlapping avatars (max 2 visible), `+N` chip if more, tooltip listing all. None: muted `—`.
|
||||||
|
|
||||||
|
**Row interaction:**
|
||||||
|
- Click row → opens `TaskDetailSheet`.
|
||||||
|
- Right-click / context menu (kept from current `TaskRow` behavior): **Edit**, **Delete**, **Change status →** submenu (To Do / In Progress / Done with checkmark on current).
|
||||||
|
|
||||||
|
**Sorting:** existing `Order by` Select in the toolbar remains the only sort control. No per-column header sort.
|
||||||
|
|
||||||
|
**Empty state:** existing `<Empty>` component spans all columns when the filtered list is empty.
|
||||||
|
|
||||||
|
## 5. Pagination
|
||||||
|
|
||||||
|
**Component:** `TaskPager` rendered in its own translucent card box below the list/grid (same style tokens as the table card, separate box).
|
||||||
|
|
||||||
|
**Layout:**
|
||||||
|
|
||||||
|
```
|
||||||
|
┌──────────────────────────────────────────────────────────────────┐
|
||||||
|
│ Showing 1–25 of 312 tasks Rows per page: [25 ▾] │
|
||||||
|
│ ‹ 1 2 3 4 5 … 13 › │
|
||||||
|
└──────────────────────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
**Behavior:**
|
||||||
|
- Page-number window: always include first, last, current. Up to 7 buttons total. Ellipsis when the gap is greater than 1.
|
||||||
|
- `ResizeObserver` on the pager → reduce visible buttons on narrow widths (7 → 5 → 3 → just prev/next).
|
||||||
|
- Page-size change resets `pageIndex` to 0.
|
||||||
|
- If filters trim the total below `pageIndex * pageSize`, snap `pageIndex` to the last valid page.
|
||||||
|
- Pager renders **for both list and grid views**, identically.
|
||||||
|
|
||||||
|
## 6. Detail sheet
|
||||||
|
|
||||||
|
**Replaces:** `TaskDetailDialog.tsx` → new `TaskDetailSheet.tsx` using shadcn `Sheet`, right side, width ~480 px.
|
||||||
|
|
||||||
|
**Three fixed regions:**
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────┐ ← STICKY HEADER
|
||||||
|
│ Acme › Communications │ breadcrumb (small, muted)
|
||||||
|
│ Draft Q2 investor update email │ title (18 px, semibold)
|
||||||
|
│ [↑ High priority] [● In progress] chip row
|
||||||
|
│ [⋯] │ overflow menu (Edit, Delete)
|
||||||
|
├─────────────────────────────────┤
|
||||||
|
│ │ ← SCROLLING BODY
|
||||||
|
│ ┌─ Properties card ──────────┐ │
|
||||||
|
│ │ Assignee | Due │ │
|
||||||
|
│ │ Estimate | Created │ │
|
||||||
|
│ │ Files: [chip] [chip] [+Add]│ │
|
||||||
|
│ └────────────────────────────┘ │
|
||||||
|
│ │
|
||||||
|
│ Description │
|
||||||
|
│ <body text> │
|
||||||
|
│ │
|
||||||
|
│ ── separator ── │
|
||||||
|
│ │
|
||||||
|
│ Comments · 4 │
|
||||||
|
│ <comment list, no inner scroll>│
|
||||||
|
│ │
|
||||||
|
├─────────────────────────────────┤ ← STICKY COMPOSER
|
||||||
|
│ [👤] ┌──────────────┐ [↑] │
|
||||||
|
│ │ Write comment│ │
|
||||||
|
│ └──────────────┘ │
|
||||||
|
└─────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
**Header chips:**
|
||||||
|
- Priority: existing `<PriorityBadge>` (arrow + colored text).
|
||||||
|
- Status: pill using existing `STATUS_CONFIG` colors.
|
||||||
|
- Both are clickable → popover to change.
|
||||||
|
|
||||||
|
**Properties card** (translucent inner box, 2-column grid):
|
||||||
|
- Assignee, Due, Estimate, Created — each row is small uppercase label + value.
|
||||||
|
- Estimate shows muted `—` until the AI agent ships.
|
||||||
|
- Files row spans both columns: horizontal chip strip. Each chip: `📎 filename · sizeKB ×`. `+ Add` is a dashed pill that triggers `taskAttachments.pick`.
|
||||||
|
- Click chip filename → `taskAttachments.open`.
|
||||||
|
- Click × → confirm + `taskAttachments.delete`.
|
||||||
|
|
||||||
|
**Comments:** no inner ScrollArea — they scroll with the body.
|
||||||
|
|
||||||
|
**Composer (sticky bottom):** reuses the home / AI input wrapper styling:
|
||||||
|
|
||||||
|
```
|
||||||
|
rounded-2xl bg-background/70 backdrop-blur-xl
|
||||||
|
border border-border/50 shadow-lg ring-1 ring-border/20
|
||||||
|
focus-within:shadow-xl focus-within:border-ring/50
|
||||||
|
```
|
||||||
|
|
||||||
|
Internally reuses the existing `ChatInputBox` component via a new `'comment'` variant (auto-grow textarea + `ArrowUp` send button, draft persistence, `⌘ + Enter` submit). **No attach icon in the composer.**
|
||||||
|
|
||||||
|
**Edit / Delete:** moved into the header overflow menu. The previous footer action bar is removed.
|
||||||
|
|
||||||
|
## 7. Create / Edit dialog
|
||||||
|
|
||||||
|
**Components:**
|
||||||
|
- `TaskFormDialog` — shared shell, props: `mode: 'create' | 'edit'`, initial values, `onSubmit`.
|
||||||
|
- `NewTaskDialog` and `EditTaskDialog` become thin wrappers (different default values + mutation).
|
||||||
|
|
||||||
|
**Layout:**
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─ New task ─────────────────── ⌘+Enter to create ─┐
|
||||||
|
│ │
|
||||||
|
│ What needs to be done? ← 22 px input │
|
||||||
|
│ Add a description… ← textarea │
|
||||||
|
│ │
|
||||||
|
│ PROPERTIES │
|
||||||
|
│ [📁 Project: Acme › Communications] │
|
||||||
|
│ [↑ Priority: High] [● Status: To Do] │
|
||||||
|
│ [📅 Due: Apr 30, 2026] │
|
||||||
|
│ [+ Add assignees] ← dashed (empty) │
|
||||||
|
│ │
|
||||||
|
│ ────────────────────────────────────────────────│
|
||||||
|
│ [📎] * [Cancel] [Create task] │
|
||||||
|
└──────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
`*` 📎 icon-pill is **only visible in Edit mode**. Attachments are blocked in Create mode (need a `taskId`); the user saves first, then attaches via the detail sheet or via Edit.
|
||||||
|
|
||||||
|
**Pill states:**
|
||||||
|
- Set: `bg-card/70`, solid border, label + value visible.
|
||||||
|
- Empty: dashed border, muted text.
|
||||||
|
|
||||||
|
**Pill click → field-specific shadcn `Popover`:**
|
||||||
|
- Project — Select w/ inline create flow (existing logic preserved: project + client + sub-client).
|
||||||
|
- Priority — three-option select (high / medium / low).
|
||||||
|
- Status — three-option select.
|
||||||
|
- Due — Calendar + optional hour/minute selectors.
|
||||||
|
- Assignees — existing Popover (known-assignees list + add-new).
|
||||||
|
|
||||||
|
**Keyboard:** `⌘ / Ctrl + Enter` submits. Title `autoFocus`.
|
||||||
|
|
||||||
|
## 8. Project page integration
|
||||||
|
|
||||||
|
**File:** `src/renderer/routes/projects.$projectId.tsx`.
|
||||||
|
|
||||||
|
The existing tasks tab content is replaced by `<TaskListView projectId={...} hideProjectColumn />`. The toolbar (status tabs, search, `Order by`, view toggle, **New task** button) is identical to the Tasks page. Pagination state is local to this page (separate from the Tasks page state). Clicking a row opens the same `TaskDetailSheet`.
|
||||||
|
|
||||||
|
No changes to other project tabs (overview, notes, timeline).
|
||||||
|
|
||||||
|
## 9. Files
|
||||||
|
|
||||||
|
**New files:**
|
||||||
|
|
||||||
|
```
|
||||||
|
src/renderer/components/tasks/
|
||||||
|
TaskListView.tsx — shared toolbar + table/grid + pager
|
||||||
|
TaskTable.tsx — shadcn Table renderer
|
||||||
|
TaskTableRow.tsx — single row + context menu
|
||||||
|
TaskPager.tsx — pagination card box
|
||||||
|
TaskDetailSheet.tsx — right-side Sheet replacing TaskDetailDialog
|
||||||
|
TaskFormDialog.tsx — shared shell for create/edit
|
||||||
|
TaskAttachmentChip.tsx — file chip
|
||||||
|
AssigneeStack.tsx — overlapping avatars + overflow chip
|
||||||
|
StatusBadge.tsx — status pill (existing STATUS_CONFIG colors)
|
||||||
|
|
||||||
|
src/main/
|
||||||
|
attachments/storage.ts — path resolution, sanitize, copy, delete helpers
|
||||||
|
```
|
||||||
|
|
||||||
|
**Modified files:**
|
||||||
|
|
||||||
|
```
|
||||||
|
src/renderer/routes/tasks.tsx — render <TaskListView>
|
||||||
|
src/renderer/routes/projects.$projectId.tsx — tasks tab uses <TaskListView hideProjectColumn>
|
||||||
|
src/renderer/components/tasks/NewTaskDialog.tsx — wrapper around TaskFormDialog (mode='create')
|
||||||
|
src/renderer/components/tasks/EditTaskDialog.tsx — wrapper around TaskFormDialog (mode='edit')
|
||||||
|
src/main/db/schema.ts — tasks.estimate, taskAttachments
|
||||||
|
src/main/db/migrations/ — new generated migration
|
||||||
|
src/main/router/index.ts — taskAttachments router; tasks.update accepts estimate; tasks.delete cascades attachments
|
||||||
|
```
|
||||||
|
|
||||||
|
**Deleted files:**
|
||||||
|
|
||||||
|
```
|
||||||
|
src/renderer/components/tasks/TaskDetailDialog.tsx — replaced by TaskDetailSheet
|
||||||
|
src/renderer/components/tasks/TaskRow.tsx — replaced by TaskTableRow (TaskCard kept for grid)
|
||||||
|
```
|
||||||
|
|
||||||
|
## 10. i18n keys
|
||||||
|
|
||||||
|
New keys in the `tasks.*` namespace, added to all five language files (en, it, es, fr, de):
|
||||||
|
|
||||||
|
```
|
||||||
|
tasks.colTask, tasks.colProject, tasks.colPriority, tasks.colDue, tasks.colAssignee
|
||||||
|
tasks.rowsPerPage, tasks.showingNofM (plural-aware), tasks.noAssignees
|
||||||
|
tasks.estimate, tasks.attachments, tasks.addFile, tasks.removeFile, tasks.fileTooLarge
|
||||||
|
tasks.changeStatus, tasks.properties
|
||||||
|
tasks.confirmDeleteAttachment
|
||||||
|
```
|
||||||
|
|
||||||
|
## 11. Out-of-scope follow-ups
|
||||||
|
|
||||||
|
- AI agent that generates `estimate` for a task.
|
||||||
|
- Comment attachments.
|
||||||
|
- Per-column sort in the table.
|
||||||
|
- Backend pagination (only needed if task counts grow much larger).
|
||||||
|
|
||||||
|
## 12. Implementation order (suggested)
|
||||||
|
|
||||||
|
A natural shipping order; the implementation plan can refine:
|
||||||
|
|
||||||
|
1. DB migration (estimate column + taskAttachments table).
|
||||||
|
2. Main-process attachments storage module + tRPC sub-router.
|
||||||
|
3. `TaskDetailSheet` with attachment UI (deletes the old dialog).
|
||||||
|
4. `TaskFormDialog` shared shell; rewire `NewTaskDialog` / `EditTaskDialog`.
|
||||||
|
5. `TaskListView`, `TaskTable`, `TaskPager`, `AssigneeStack`, `StatusBadge`. Wire into Tasks page.
|
||||||
|
6. Wire `TaskListView` into project detail page with `hideProjectColumn`.
|
||||||
|
7. i18n keys for all five languages.
|
||||||
BIN
docs/2026-05-08-task-ux-evolution-plan.md
Normal file
BIN
docs/2026-05-08-task-ux-evolution-plan.md
Normal file
Binary file not shown.
201
docs/2026-05-14-task-form-dialog-kbd-design.md
Normal file
201
docs/2026-05-14-task-form-dialog-kbd-design.md
Normal file
@@ -0,0 +1,201 @@
|
|||||||
|
# Task Form Dialog — keyboard + header polish — Design
|
||||||
|
|
||||||
|
**Date:** 2026-05-14
|
||||||
|
**Scope:** adiuvAI renderer (`src/renderer/components/tasks/TaskFormDialog.tsx`) + supporting libs
|
||||||
|
**Status:** Approved by user (mockup at `docs/mockups/2026-05-14-task-form-dialog-mockup.html`), ready for implementation plan
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
|
||||||
|
Port three UX features shipped in the timeline batch-add `AddEventDialog` (`docs/2026-05-08-task-ux-evolution-design.md` § timeline batch) into `TaskFormDialog`:
|
||||||
|
|
||||||
|
1. **Header style** — `DialogTitle` + `DialogDescription` (no separator border), matching `AddEventDialog`.
|
||||||
|
2. **Full keyboard navigation** — Tab/Shift-Tab between fields & pills, arrow keys within pills row, Enter to open focused pill, arrow keys inside list popovers + calendar, Esc to close popover.
|
||||||
|
3. **Date + time via keyboard** — replace the Calendar + 2× hour/minute `Select` triplet with a typeable `DateField` that supports an optional `HH:MM` suffix and respects `FormatPrefs.dateFormat`.
|
||||||
|
|
||||||
|
## Non-goals
|
||||||
|
|
||||||
|
- Migrating `TaskFormDialog` to a Sheet (deferred — see `docs/2026-05-08-task-ux-evolution-plan.md`).
|
||||||
|
- Touching `NewTaskDialog` / `EditTaskDialog` wrappers (no behavior change).
|
||||||
|
- Changes to other property popovers' rendering beyond keyboard handling.
|
||||||
|
- Inline project creation flow (`InlineProjectForm`) — unchanged.
|
||||||
|
|
||||||
|
## 1. Header
|
||||||
|
|
||||||
|
Replace the current minimal header:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<DialogHeader className="px-5 py-3 border-b border-border/40">
|
||||||
|
<DialogTitle className="text-sm font-medium">{...}</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
```
|
||||||
|
|
||||||
|
with the `AddEventDialog` style:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>{mode === 'create' ? t('tasks.newTask') : t('tasks.editTask')}</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
{mode === 'create' ? t('tasks.newTaskDescription') : t('tasks.editTaskDescription')}
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
```
|
||||||
|
|
||||||
|
**No border-bottom** under the header — body flows directly under it. Keep the existing `bg-card/92 backdrop-blur-xl` overlay on `DialogContent`.
|
||||||
|
|
||||||
|
New i18n keys (all 5 languages): `tasks.newTaskDescription`, `tasks.editTaskDescription`.
|
||||||
|
|
||||||
|
## 2. Keyboard navigation
|
||||||
|
|
||||||
|
### Pills row — roving focus + arrow movement
|
||||||
|
|
||||||
|
The property pills (`Project · Priority · Status · Due · Assignees`) become a roving-tabindex group:
|
||||||
|
|
||||||
|
- Only one pill at a time has `tabindex={0}`; the rest have `tabindex={-1}`. Default focused pill = first (Project).
|
||||||
|
- `Tab` / `Shift+Tab` enters/exits the group as a single stop. Inside the group, `Tab` exits forward to the footer; on entry from the footer-side, focus restores to the last-focused pill.
|
||||||
|
- `ArrowRight` / `ArrowDown` → next pill (clamped at end).
|
||||||
|
- `ArrowLeft` / `ArrowUp` → previous pill (clamped at start).
|
||||||
|
- `Home` / `End` → first / last pill.
|
||||||
|
- `Enter` or `Space` on focused pill → open its popover.
|
||||||
|
|
||||||
|
Implementation: a small hook `useRovingFocus(ref, count)` returning `(index) => { tabIndex, onKeyDown, onFocus }`. Pills consume it inside `PropertyPill` (kept presentational) via a wrapping `<button>`.
|
||||||
|
|
||||||
|
`PropertyPill` is already a `<button>`-ish trigger via `<span>`. To support real focus rings + key events, change the trigger element rendered by `PopoverTrigger asChild` from `<span>` to a `<button type="button">`. Visible focus ring matches `--ring` via `focus-visible:ring-2 ring-ring/30`.
|
||||||
|
|
||||||
|
### List popovers — Project, Priority, Status, Assignees
|
||||||
|
|
||||||
|
shadcn `Popover` does not provide list semantics. Inside each popover content:
|
||||||
|
|
||||||
|
- Items render with `role="option"` (or `menuitem`) and roving `tabIndex` (active item = `0`, rest `-1`).
|
||||||
|
- When popover opens, focus moves to the currently-selected item (or first item).
|
||||||
|
- `ArrowDown` / `ArrowUp` move the active item; `Home`/`End` jump to ends.
|
||||||
|
- `Enter` / `Space` selects the active item.
|
||||||
|
- For single-select popovers (Project, Priority, Status) selection closes the popover and returns focus to the originating pill.
|
||||||
|
- For multi-select (Assignees) selection toggles; popover stays open. `Esc` closes and returns focus to the pill.
|
||||||
|
- `Tab` inside a popover closes the popover (focus returns to pill, then the next Tab advances normally).
|
||||||
|
|
||||||
|
Implementation: a single shared hook `useListboxKeys(items, opts)` consumed by each popover content. Items are sourced from existing data (`projectsList`, `knownAssignees`, hard-coded priority/status arrays).
|
||||||
|
|
||||||
|
### Calendar — keyboard
|
||||||
|
|
||||||
|
The shadcn `Calendar` already supports arrow-key day navigation and `Enter` to select (via react-day-picker). We need only to confirm that focus lands on the calendar grid when the Due popover opens. The new `DateField` (§3) replaces the current Popover+Calendar+Selects assembly and embeds the calendar.
|
||||||
|
|
||||||
|
### Description — Enter
|
||||||
|
|
||||||
|
Keep existing behavior: `Enter` inserts a newline in the description textarea. The form-level `⌘/Ctrl+Enter` submit handler already lives on the `<form>` element and continues to work; the footer's "⌘+Enter to create" hint is removed from the UI (the shortcut still works).
|
||||||
|
|
||||||
|
## 3. Date + time via keyboard
|
||||||
|
|
||||||
|
### Strategy — extend existing `DateField`
|
||||||
|
|
||||||
|
Reuse `src/renderer/components/ui/date-field.tsx` (already typeable, format-aware via `useFormatPrefs`, with embedded Calendar). Add **optional time** support behind a new prop `withTime?: boolean`.
|
||||||
|
|
||||||
|
When `withTime` is on:
|
||||||
|
- The text input accepts either a bare date (`30/04/2026`, `Apr 30`, `+3d`, `tomorrow`, …) or date-with-time suffix (`30/04/2026 14:30`).
|
||||||
|
- The Popover content gains a small `Time` row under the Calendar — two `Select`s (hour 00–23, minute in 5-min steps) identical to the current TaskFormDialog implementation. They edit the time portion of the committed `Date`.
|
||||||
|
- Display value after commit: `<date in FormatPrefs.dateFormat> HH:MM` when time component is non-midnight, otherwise just the date.
|
||||||
|
|
||||||
|
### Parser extension (`lib/parseDate.ts`)
|
||||||
|
|
||||||
|
`parseDate(input, prefs, keywords)` adopts optional trailing time:
|
||||||
|
|
||||||
|
- Regex split: `RE_TIME = /\s+(\d{1,2}):(\d{2})\s*$/`.
|
||||||
|
- If matched, parse `HH`/`MM` (`0–23` / `0–59`), strip the suffix, parse remaining string with the existing logic, then set `hours` and `minutes` on the result.
|
||||||
|
- If time match is invalid (e.g. `25:99`), whole input is invalid.
|
||||||
|
|
||||||
|
Unit-test cases (existing tests if any get extended; otherwise small new file):
|
||||||
|
|
||||||
|
| Input | Format pref | Expected |
|
||||||
|
|---|---|---|
|
||||||
|
| `30/04/2026 14:30` | `dd/MM/yyyy` | 2026-04-30 14:30 local |
|
||||||
|
| `04/30/2026 09:00` | `MM/dd/yyyy` | 2026-04-30 09:00 |
|
||||||
|
| `2026-04-30 23:59` | `yyyy-MM-dd` | 2026-04-30 23:59 |
|
||||||
|
| `tomorrow 08:15` | any | next-day 08:15 |
|
||||||
|
| `30/04/2026 25:00` | any | invalid |
|
||||||
|
| `30/04/2026` | dd/MM | 2026-04-30 00:00 (date only, time unchanged) |
|
||||||
|
|
||||||
|
### Caller change in `TaskFormDialog`
|
||||||
|
|
||||||
|
The whole Due Popover block (Calendar + hour/minute Selects + clear button) is replaced by:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<DateField
|
||||||
|
withTime
|
||||||
|
value={values.dueDate ? new Date(values.dueDate) : undefined}
|
||||||
|
onChange={(d) => setValues((v) => ({ ...v, dueDate: d ? d.getTime() : null }))}
|
||||||
|
placeholder={t('tasks.colDue')}
|
||||||
|
aria-label={t('tasks.colDue')}
|
||||||
|
/>
|
||||||
|
```
|
||||||
|
|
||||||
|
The pill itself remains for display when the field is collapsed. Two arrangements considered:
|
||||||
|
|
||||||
|
- **(A) Pill opens a popover containing the `DateField`** — keeps visual parity with the other pills. The `DateField` *inside* the popover is just an `Input` + Calendar, no nested Popover. Recommended.
|
||||||
|
- **(B) `DateField` replaces the pill inline in the row** — visually breaks the pill row.
|
||||||
|
|
||||||
|
Going with **(A)**. To avoid a nested-popover (`Popover` inside `PopoverContent`), `DateField` gains a `flat?: boolean` prop. When `flat` is set, it renders:
|
||||||
|
|
||||||
|
- the typeable `Input`,
|
||||||
|
- the `Calendar` inline (no internal `Popover` wrapper),
|
||||||
|
- the Time row (when `withTime`).
|
||||||
|
|
||||||
|
The Due pill's `PopoverContent` renders `<DateField withTime flat />`. Outside the task dialog, existing callers (e.g. `AddEventDialog`) keep using the default (non-flat) DateField with its own popover trigger.
|
||||||
|
|
||||||
|
The Due popover content:
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─ Due popover ───────────────────┐
|
||||||
|
│ [📅 30/04/2026 14:30 ] │ ← typeable Input (parses date + time)
|
||||||
|
│ Calendar grid (kbd nav) │
|
||||||
|
│ ── ── ── ── ── ── ── ── ── ── │
|
||||||
|
│ Time: [HH ⌄] : [MM ⌄] [Clear] │ ← shown only when withTime
|
||||||
|
└─────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
(The mockup illustrated standalone segments; that was a sketch — the real impl reuses `DateField`'s single-input typeable parser, which is already keyboard-driven via `parseDate`.)
|
||||||
|
|
||||||
|
## 4. Files
|
||||||
|
|
||||||
|
**Modified:**
|
||||||
|
|
||||||
|
```
|
||||||
|
src/renderer/components/tasks/TaskFormDialog.tsx — new header; roving focus on pills row; replace Due popover with <DateField withTime />; drop the "⌘+Enter" hint
|
||||||
|
src/renderer/components/ui/date-field.tsx — new props withTime + flat; Time Selects; expanded onCommit/text-display logic
|
||||||
|
src/renderer/lib/parseDate.ts — accept optional trailing " HH:MM"
|
||||||
|
src/renderer/locales/{en,it,es,fr,de}/translation.json
|
||||||
|
— add tasks.newTaskDescription, tasks.editTaskDescription
|
||||||
|
```
|
||||||
|
|
||||||
|
**New (small, kept local to features):**
|
||||||
|
|
||||||
|
```
|
||||||
|
src/renderer/hooks/useRovingFocus.ts — generic roving-tabindex hook
|
||||||
|
src/renderer/hooks/useListboxKeys.ts — popover-list arrow/enter/esc handler
|
||||||
|
```
|
||||||
|
|
||||||
|
If a unit-test setup is later introduced for `parseDate`, add cases there. Not blocking.
|
||||||
|
|
||||||
|
## 5. Accessibility
|
||||||
|
|
||||||
|
- Pills row: `role="toolbar"` with `aria-label={t('tasks.properties')}`; pills are `<button>` with descriptive `aria-label` (e.g. `Project: Acme · Communications`).
|
||||||
|
- Listbox popovers: container `role="listbox"`, items `role="option"`, `aria-selected` on the chosen one. Single-select popovers also set `aria-activedescendant` on the listbox when convenient; otherwise rely on `.focus()`.
|
||||||
|
- Multi-select Assignees uses `aria-multiselectable="true"`.
|
||||||
|
- `DateField` keeps existing `aria-invalid` + `aria-describedby` semantics.
|
||||||
|
|
||||||
|
## 6. Out-of-scope follow-ups
|
||||||
|
|
||||||
|
- Project popover inline-create flow keyboard polish (currently a sub-form inside the popover — separate effort).
|
||||||
|
- `DateField` natural-language time keywords (e.g. `tomorrow 9am`) — only `HH:MM` accepted.
|
||||||
|
- Migrating `TaskFormDialog` shell to a Sheet — already deferred.
|
||||||
|
|
||||||
|
## 7. Implementation order (suggested)
|
||||||
|
|
||||||
|
1. `useRovingFocus` + `useListboxKeys` hooks (no UI changes).
|
||||||
|
2. `parseDate` time-suffix support; refresh existing parseDate tests.
|
||||||
|
3. `DateField` `withTime` prop + time Selects in Popover.
|
||||||
|
4. `TaskFormDialog`:
|
||||||
|
- Header swap (Title + Description, no border).
|
||||||
|
- Pills row wired to `useRovingFocus`; pill trigger element switched to `<button>`.
|
||||||
|
- Each list popover wired to `useListboxKeys`.
|
||||||
|
- Due popover content replaced by `<DateField withTime />`.
|
||||||
|
- Remove footer `⌘+Enter` hint.
|
||||||
|
5. i18n strings in all five languages.
|
||||||
1172
docs/2026-05-14-task-form-dialog-kbd-plan.md
Normal file
1172
docs/2026-05-14-task-form-dialog-kbd-plan.md
Normal file
File diff suppressed because it is too large
Load Diff
852
docs/mockups/2026-05-14-task-form-dialog-mockup.html
Normal file
852
docs/mockups/2026-05-14-task-form-dialog-mockup.html
Normal file
@@ -0,0 +1,852 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<title>Task Form Dialog — keyboard-driven mockup</title>
|
||||||
|
<style>
|
||||||
|
:root {
|
||||||
|
--bg: #f4edf3;
|
||||||
|
--canvas: #ebe4ea;
|
||||||
|
--card: #ffffff;
|
||||||
|
--card-soft: #fbf7fa;
|
||||||
|
--border: #c8c3cd;
|
||||||
|
--border-soft: #d8d4dc;
|
||||||
|
--text: #1a1a1a;
|
||||||
|
--muted: #6e6a73;
|
||||||
|
--primary: #fbc881;
|
||||||
|
--primary-fg: #4a3210;
|
||||||
|
--accent: #e9e5ee;
|
||||||
|
--ring: #8a8ea9;
|
||||||
|
--danger: #c4423a;
|
||||||
|
--green: #5a8a55;
|
||||||
|
}
|
||||||
|
@media (prefers-color-scheme: dark) {
|
||||||
|
:root {
|
||||||
|
--bg: #0c0c0c;
|
||||||
|
--canvas: #161616;
|
||||||
|
--card: #1a1a1a;
|
||||||
|
--card-soft: #202020;
|
||||||
|
--border: #323232;
|
||||||
|
--border-soft: #2a2a2a;
|
||||||
|
--text: #f5f5f5;
|
||||||
|
--muted: #9a9a9a;
|
||||||
|
--primary: #fbc881;
|
||||||
|
--primary-fg: #4a3210;
|
||||||
|
--accent: #2a2a2a;
|
||||||
|
--ring: #8a8ea9;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
* { box-sizing: border-box; }
|
||||||
|
html, body {
|
||||||
|
margin: 0; padding: 0;
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, "Inter", "Geist", system-ui, sans-serif;
|
||||||
|
background: var(--bg);
|
||||||
|
color: var(--text);
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
letter-spacing: -0.01em;
|
||||||
|
}
|
||||||
|
body {
|
||||||
|
min-height: 100vh;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
padding: 40px 20px;
|
||||||
|
}
|
||||||
|
.page-hint {
|
||||||
|
position: fixed; left: 16px; top: 16px;
|
||||||
|
background: var(--card); border: 1px solid var(--border);
|
||||||
|
border-radius: 8px; padding: 10px 12px;
|
||||||
|
font-size: 12px; max-width: 280px; line-height: 1.5;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
.page-hint strong { color: var(--text); }
|
||||||
|
.page-hint kbd {
|
||||||
|
font-family: ui-monospace, SFMono-Regular, monospace;
|
||||||
|
font-size: 11px;
|
||||||
|
background: var(--accent); border: 1px solid var(--border-soft);
|
||||||
|
border-radius: 4px; padding: 1px 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Dialog */
|
||||||
|
.overlay {
|
||||||
|
width: 580px; max-width: 100%;
|
||||||
|
background: rgba(255,255,255,0.92);
|
||||||
|
backdrop-filter: blur(20px);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 12px;
|
||||||
|
box-shadow: 0 20px 50px -10px rgba(0,0,0,0.18), 0 0 0 1px rgba(255,255,255,0.4) inset;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
@media (prefers-color-scheme: dark) {
|
||||||
|
.overlay { background: rgba(26,26,26,0.92); }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Header — AddEventDialog style: title + description, no separator */
|
||||||
|
.dlg-header {
|
||||||
|
padding: 18px 22px 8px;
|
||||||
|
}
|
||||||
|
.dlg-title {
|
||||||
|
font-size: 16px; font-weight: 600; margin: 0;
|
||||||
|
letter-spacing: -0.02em;
|
||||||
|
}
|
||||||
|
.dlg-desc {
|
||||||
|
margin: 4px 0 0; font-size: 13px; color: var(--muted);
|
||||||
|
line-height: 1.45;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Body */
|
||||||
|
.dlg-body { padding: 18px 22px 12px; }
|
||||||
|
.title-input {
|
||||||
|
width: 100%;
|
||||||
|
border: none; outline: none; background: transparent;
|
||||||
|
font: inherit; color: inherit;
|
||||||
|
font-size: 22px; font-weight: 500;
|
||||||
|
letter-spacing: -0.02em;
|
||||||
|
line-height: 1.2;
|
||||||
|
}
|
||||||
|
.title-input::placeholder { color: var(--muted); opacity: 0.7; }
|
||||||
|
.desc-input {
|
||||||
|
margin-top: 8px;
|
||||||
|
width: 100%;
|
||||||
|
border: none; outline: none; background: transparent;
|
||||||
|
font: inherit; color: inherit;
|
||||||
|
font-size: 13px;
|
||||||
|
resize: none;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
.desc-input::placeholder { color: var(--muted); opacity: 0.7; }
|
||||||
|
|
||||||
|
/* Properties section */
|
||||||
|
.props-label {
|
||||||
|
font-size: 10px; text-transform: uppercase; letter-spacing: 0.08em;
|
||||||
|
color: var(--muted); margin: 14px 0 8px;
|
||||||
|
}
|
||||||
|
.pills { display: flex; flex-wrap: wrap; gap: 6px; }
|
||||||
|
|
||||||
|
/* Pill */
|
||||||
|
.pill {
|
||||||
|
display: inline-flex; align-items: center; gap: 6px;
|
||||||
|
height: 28px; padding: 0 10px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 14px;
|
||||||
|
background: var(--card-soft);
|
||||||
|
font-size: 12px; color: var(--text);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 120ms, border-color 120ms, box-shadow 120ms;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
.pill[data-empty="true"] {
|
||||||
|
border-style: dashed;
|
||||||
|
color: var(--muted);
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
.pill:focus-visible,
|
||||||
|
.pill[data-focused="true"] {
|
||||||
|
outline: none;
|
||||||
|
border-color: var(--ring);
|
||||||
|
box-shadow: 0 0 0 3px rgba(138,142,169,0.25);
|
||||||
|
}
|
||||||
|
.pill .pill-label { color: var(--muted); }
|
||||||
|
.pill .pill-value { font-weight: 500; }
|
||||||
|
.pill .pill-sep { color: var(--muted); opacity: 0.5; }
|
||||||
|
.pill-icon { font-size: 11px; line-height: 1; }
|
||||||
|
.pill .pi-up { color: #c4423a; }
|
||||||
|
.pill .pi-mid { color: #b97a14; }
|
||||||
|
.pill .pi-down { color: var(--muted); }
|
||||||
|
|
||||||
|
/* Footer */
|
||||||
|
.dlg-footer {
|
||||||
|
display: flex; align-items: center; justify-content: space-between;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 12px 22px;
|
||||||
|
border-top: 1px solid var(--border-soft);
|
||||||
|
background: rgba(0,0,0,0.015);
|
||||||
|
}
|
||||||
|
@media (prefers-color-scheme: dark) {
|
||||||
|
.dlg-footer { background: rgba(255,255,255,0.02); }
|
||||||
|
}
|
||||||
|
.kbd-hint { font-size: 11px; color: var(--muted); }
|
||||||
|
.kbd-hint kbd {
|
||||||
|
font-family: ui-monospace, SFMono-Regular, monospace;
|
||||||
|
font-size: 10px;
|
||||||
|
background: var(--accent); border: 1px solid var(--border-soft);
|
||||||
|
border-radius: 4px; padding: 1px 5px;
|
||||||
|
}
|
||||||
|
.btn {
|
||||||
|
height: 30px; padding: 0 14px; border-radius: 8px;
|
||||||
|
font: inherit; font-size: 13px; font-weight: 500;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
background: transparent; color: var(--text);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.btn:focus-visible {
|
||||||
|
outline: none;
|
||||||
|
box-shadow: 0 0 0 3px rgba(138,142,169,0.25);
|
||||||
|
border-color: var(--ring);
|
||||||
|
}
|
||||||
|
.btn-primary {
|
||||||
|
background: var(--primary);
|
||||||
|
color: var(--primary-fg);
|
||||||
|
border-color: transparent;
|
||||||
|
}
|
||||||
|
.btn-primary:disabled { opacity: 0.5; cursor: not-allowed; }
|
||||||
|
.footer-actions { display: flex; gap: 6px; }
|
||||||
|
|
||||||
|
/* Popover */
|
||||||
|
.popover {
|
||||||
|
position: absolute;
|
||||||
|
z-index: 100;
|
||||||
|
min-width: 220px;
|
||||||
|
background: var(--card);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 10px;
|
||||||
|
box-shadow: 0 12px 32px -8px rgba(0,0,0,0.2);
|
||||||
|
padding: 4px;
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
.popover[data-open="true"] { display: block; }
|
||||||
|
.pop-item {
|
||||||
|
display: flex; align-items: center; gap: 8px;
|
||||||
|
padding: 6px 10px;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 13px;
|
||||||
|
cursor: pointer;
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
.pop-item:hover,
|
||||||
|
.pop-item:focus,
|
||||||
|
.pop-item[data-active="true"] { background: var(--accent); }
|
||||||
|
.pop-item:focus { box-shadow: inset 0 0 0 1px var(--ring); }
|
||||||
|
.pop-item .check { width: 14px; color: var(--muted); }
|
||||||
|
.pop-item[data-selected="true"] .check::before { content: "✓"; color: var(--text); }
|
||||||
|
|
||||||
|
/* DateField segments */
|
||||||
|
.datefield {
|
||||||
|
display: inline-flex; align-items: center;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 4px 8px;
|
||||||
|
background: var(--card-soft);
|
||||||
|
font-family: ui-monospace, SFMono-Regular, monospace;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
.datefield:focus-within {
|
||||||
|
border-color: var(--ring);
|
||||||
|
box-shadow: 0 0 0 3px rgba(138,142,169,0.25);
|
||||||
|
}
|
||||||
|
.segment {
|
||||||
|
min-width: 1.8ch; text-align: center; padding: 2px 1px;
|
||||||
|
border-radius: 3px; outline: none; cursor: text;
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
.segment[data-placeholder="true"] { color: var(--muted); opacity: 0.6; }
|
||||||
|
.segment:focus { background: var(--accent); }
|
||||||
|
.seg-sep { color: var(--muted); padding: 0 1px; user-select: none; }
|
||||||
|
|
||||||
|
.date-pop {
|
||||||
|
padding: 12px;
|
||||||
|
min-width: 280px;
|
||||||
|
}
|
||||||
|
.date-pop .field-label {
|
||||||
|
font-size: 11px; color: var(--muted);
|
||||||
|
text-transform: uppercase; letter-spacing: 0.06em;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
}
|
||||||
|
.date-pop .cal {
|
||||||
|
margin-top: 12px;
|
||||||
|
border-top: 1px solid var(--border-soft);
|
||||||
|
padding-top: 10px;
|
||||||
|
}
|
||||||
|
.cal-head {
|
||||||
|
display: flex; align-items: center; justify-content: space-between;
|
||||||
|
font-size: 12px; margin-bottom: 6px;
|
||||||
|
}
|
||||||
|
.cal-head .month { font-weight: 600; }
|
||||||
|
.cal-head button {
|
||||||
|
border: 1px solid var(--border); background: transparent;
|
||||||
|
border-radius: 6px; width: 22px; height: 22px;
|
||||||
|
color: var(--text); cursor: pointer;
|
||||||
|
}
|
||||||
|
.cal-grid {
|
||||||
|
display: grid; grid-template-columns: repeat(7, 1fr);
|
||||||
|
gap: 2px; font-size: 11px;
|
||||||
|
}
|
||||||
|
.cal-dow {
|
||||||
|
color: var(--muted); text-align: center;
|
||||||
|
padding: 4px 0; font-weight: 500;
|
||||||
|
}
|
||||||
|
.cal-day {
|
||||||
|
text-align: center; padding: 5px 0;
|
||||||
|
border-radius: 5px; cursor: pointer; outline: none;
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
.cal-day:focus,
|
||||||
|
.cal-day[data-active="true"] { background: var(--accent); }
|
||||||
|
.cal-day[data-selected="true"] {
|
||||||
|
background: var(--primary); color: var(--primary-fg);
|
||||||
|
}
|
||||||
|
.cal-day[data-other-month="true"] { color: var(--muted); opacity: 0.4; }
|
||||||
|
|
||||||
|
.pop-anchor { position: relative; display: inline-flex; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="page-hint">
|
||||||
|
<strong>Keyboard demo</strong><br>
|
||||||
|
<kbd>Tab</kbd>/<kbd>Shift+Tab</kbd> cycles fields + pills.<br>
|
||||||
|
<kbd>Enter</kbd> opens focused pill.<br>
|
||||||
|
<kbd>↑</kbd>/<kbd>↓</kbd> inside popovers and calendar.<br>
|
||||||
|
<kbd>Esc</kbd> closes popover.<br>
|
||||||
|
Due pill: type date directly (segment edit).
|
||||||
|
<hr style="border:none; border-top:1px solid var(--border-soft); margin:8px 0;">
|
||||||
|
<label style="font-size:11px;">FormatPrefs.dateFormat:
|
||||||
|
<select id="fmt-pref" style="margin-top:4px; width:100%; padding:4px; font: inherit; font-size:11px;">
|
||||||
|
<option value="dd/MM/yyyy">dd/MM/yyyy</option>
|
||||||
|
<option value="MM/dd/yyyy">MM/dd/yyyy</option>
|
||||||
|
<option value="yyyy-MM-dd">yyyy-MM-dd</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="overlay" role="dialog" aria-modal="true" aria-labelledby="dlg-title">
|
||||||
|
<header class="dlg-header">
|
||||||
|
<h2 id="dlg-title" class="dlg-title">New task</h2>
|
||||||
|
<p class="dlg-desc">Capture what needs doing. Set properties below or skip and refine later.</p>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div class="dlg-body">
|
||||||
|
<input class="title-input" id="f-title" placeholder="What needs to be done?" autofocus />
|
||||||
|
<textarea class="desc-input" id="f-desc" rows="3" placeholder="Add a description…"></textarea>
|
||||||
|
|
||||||
|
<div class="props-label">Properties</div>
|
||||||
|
<div class="pills" id="pills">
|
||||||
|
|
||||||
|
<!-- Project pill -->
|
||||||
|
<span class="pop-anchor">
|
||||||
|
<button type="button" class="pill" data-pill="project" data-empty="true" tabindex="0">
|
||||||
|
<span class="pill-icon">📁</span>
|
||||||
|
<span class="pill-label">Project</span>
|
||||||
|
</button>
|
||||||
|
<div class="popover" data-popover="project" role="listbox">
|
||||||
|
<div class="pop-item" data-active="true" data-value="">
|
||||||
|
<span class="check"></span>No project
|
||||||
|
</div>
|
||||||
|
<div class="pop-item" data-value="acme-comm">
|
||||||
|
<span class="check"></span>Acme · Communications
|
||||||
|
</div>
|
||||||
|
<div class="pop-item" data-value="testing-bot">
|
||||||
|
<span class="check"></span>Testing · AI ChatBot
|
||||||
|
</div>
|
||||||
|
<div class="pop-item" data-value="adiuvai-app">
|
||||||
|
<span class="check"></span>AdiuvAI · App
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<!-- Priority pill -->
|
||||||
|
<span class="pop-anchor">
|
||||||
|
<button type="button" class="pill" data-pill="priority" tabindex="0">
|
||||||
|
<span class="pill-icon pi-mid">→</span>
|
||||||
|
<span class="pill-label">Priority</span>
|
||||||
|
<span class="pill-sep">·</span>
|
||||||
|
<span class="pill-value">Medium</span>
|
||||||
|
</button>
|
||||||
|
<div class="popover" data-popover="priority" role="listbox" style="min-width:160px;">
|
||||||
|
<div class="pop-item" data-value="high"><span class="check"></span>High</div>
|
||||||
|
<div class="pop-item" data-active="true" data-selected="true" data-value="medium"><span class="check"></span>Medium</div>
|
||||||
|
<div class="pop-item" data-value="low"><span class="check"></span>Low</div>
|
||||||
|
</div>
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<!-- Status pill -->
|
||||||
|
<span class="pop-anchor">
|
||||||
|
<button type="button" class="pill" data-pill="status" tabindex="0">
|
||||||
|
<span class="pill-icon">○</span>
|
||||||
|
<span class="pill-label">Status</span>
|
||||||
|
<span class="pill-sep">·</span>
|
||||||
|
<span class="pill-value">To do</span>
|
||||||
|
</button>
|
||||||
|
<div class="popover" data-popover="status" role="listbox" style="min-width:170px;">
|
||||||
|
<div class="pop-item" data-active="true" data-selected="true" data-value="todo"><span class="check"></span>To do</div>
|
||||||
|
<div class="pop-item" data-value="in_progress"><span class="check"></span>In progress</div>
|
||||||
|
<div class="pop-item" data-value="done"><span class="check"></span>Done</div>
|
||||||
|
</div>
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<!-- Due pill -->
|
||||||
|
<span class="pop-anchor">
|
||||||
|
<button type="button" class="pill" data-pill="due" data-empty="true" tabindex="0">
|
||||||
|
<span class="pill-icon">📅</span>
|
||||||
|
<span class="pill-label">Due</span>
|
||||||
|
</button>
|
||||||
|
<div class="popover date-pop" data-popover="due" role="dialog" style="min-width:300px;">
|
||||||
|
<div class="field-label">Date</div>
|
||||||
|
<div class="datefield" id="datefield" tabindex="-1"><!-- segments injected by JS --></div>
|
||||||
|
<div class="cal" id="calendar">
|
||||||
|
<div class="cal-head">
|
||||||
|
<button type="button" data-nav="-1">‹</button>
|
||||||
|
<span class="month" id="cal-month">May 2026</span>
|
||||||
|
<button type="button" data-nav="1">›</button>
|
||||||
|
</div>
|
||||||
|
<div class="cal-grid" id="cal-grid"><!-- filled by JS --></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<!-- Assignees pill -->
|
||||||
|
<span class="pop-anchor">
|
||||||
|
<button type="button" class="pill" data-pill="assignees" data-empty="true" tabindex="0">
|
||||||
|
<span class="pill-icon">+</span>
|
||||||
|
<span class="pill-label">Add assignees</span>
|
||||||
|
</button>
|
||||||
|
<div class="popover" data-popover="assignees" role="listbox">
|
||||||
|
<div class="pop-item" data-active="true" data-value="alex"><span class="check"></span>Alex Morgan</div>
|
||||||
|
<div class="pop-item" data-value="priya"><span class="check"></span>Priya Shah</div>
|
||||||
|
<div class="pop-item" data-value="yo"><span class="check"></span>You</div>
|
||||||
|
</div>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<footer class="dlg-footer">
|
||||||
|
<div></div>
|
||||||
|
<div class="footer-actions">
|
||||||
|
<button type="button" class="btn">Cancel</button>
|
||||||
|
<button type="button" class="btn btn-primary">Create task</button>
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
/* ---------- popover open/close + arrow nav ---------- */
|
||||||
|
const pills = document.querySelectorAll('.pill');
|
||||||
|
const popovers = document.querySelectorAll('.popover');
|
||||||
|
|
||||||
|
function closeAllPopovers() {
|
||||||
|
popovers.forEach((p) => p.setAttribute('data-open', 'false'));
|
||||||
|
}
|
||||||
|
|
||||||
|
const pillArr = Array.from(pills);
|
||||||
|
pills.forEach((pill) => {
|
||||||
|
pill.addEventListener('click', (e) => openPopoverFor(pill));
|
||||||
|
pill.addEventListener('keydown', (e) => {
|
||||||
|
if (e.key === 'Enter' || e.key === ' ') {
|
||||||
|
e.preventDefault();
|
||||||
|
openPopoverFor(pill);
|
||||||
|
} else if (e.key === 'ArrowRight' || e.key === 'ArrowDown') {
|
||||||
|
e.preventDefault();
|
||||||
|
const idx = pillArr.indexOf(pill);
|
||||||
|
const next = pillArr[Math.min(idx + 1, pillArr.length - 1)];
|
||||||
|
next && next.focus();
|
||||||
|
} else if (e.key === 'ArrowLeft' || e.key === 'ArrowUp') {
|
||||||
|
e.preventDefault();
|
||||||
|
const idx = pillArr.indexOf(pill);
|
||||||
|
const prev = pillArr[Math.max(idx - 1, 0)];
|
||||||
|
prev && prev.focus();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
function openPopoverFor(pill) {
|
||||||
|
const which = pill.dataset.pill;
|
||||||
|
const pop = document.querySelector(`.popover[data-popover="${which}"]`);
|
||||||
|
if (!pop) return;
|
||||||
|
closeAllPopovers();
|
||||||
|
pop.setAttribute('data-open', 'true');
|
||||||
|
if (which === 'due') {
|
||||||
|
// focus first date segment
|
||||||
|
const firstSeg = pop.querySelector('.segment');
|
||||||
|
if (firstSeg) firstSeg.focus();
|
||||||
|
} else {
|
||||||
|
const items = pop.querySelectorAll('.pop-item');
|
||||||
|
items.forEach((i) => i.setAttribute('tabindex', '-1'));
|
||||||
|
const active = pop.querySelector('.pop-item[data-active="true"]') || items[0];
|
||||||
|
if (active) {
|
||||||
|
active.setAttribute('tabindex', '0');
|
||||||
|
active.focus();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener('keydown', (e) => {
|
||||||
|
if (e.key === 'Escape') {
|
||||||
|
const open = document.querySelector('.popover[data-open="true"]');
|
||||||
|
if (open) {
|
||||||
|
e.preventDefault();
|
||||||
|
closePopover(open);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/* ---------- list popover keyboard ---------- */
|
||||||
|
popovers.forEach((pop) => {
|
||||||
|
if (pop.dataset.popover === 'due') return;
|
||||||
|
const items = Array.from(pop.querySelectorAll('.pop-item'));
|
||||||
|
items.forEach((it) => {
|
||||||
|
it.setAttribute('tabindex', '-1');
|
||||||
|
it.addEventListener('click', () => selectPopItem(pop, it));
|
||||||
|
it.addEventListener('keydown', (e) => onPopItemKey(e, pop, items, it));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
function onPopItemKey(e, pop, items, item) {
|
||||||
|
const idx = items.indexOf(item);
|
||||||
|
if (e.key === 'ArrowDown') {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
moveFocus(items, Math.min(idx + 1, items.length - 1));
|
||||||
|
} else if (e.key === 'ArrowUp') {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
moveFocus(items, Math.max(idx - 1, 0));
|
||||||
|
} else if (e.key === 'Home') {
|
||||||
|
e.preventDefault(); moveFocus(items, 0);
|
||||||
|
} else if (e.key === 'End') {
|
||||||
|
e.preventDefault(); moveFocus(items, items.length - 1);
|
||||||
|
} else if (e.key === 'Enter' || e.key === ' ') {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
selectPopItem(pop, item);
|
||||||
|
} else if (e.key === 'Escape') {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
closePopover(pop);
|
||||||
|
} else if (e.key === 'Tab') {
|
||||||
|
closePopover(pop);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function moveFocus(items, target) {
|
||||||
|
items.forEach((i) => i.setAttribute('tabindex', '-1'));
|
||||||
|
const el = items[target];
|
||||||
|
el.setAttribute('tabindex', '0');
|
||||||
|
el.focus();
|
||||||
|
}
|
||||||
|
function closePopover(pop) {
|
||||||
|
pop.setAttribute('data-open', 'false');
|
||||||
|
const pill = document.querySelector(`.pill[data-pill="${pop.dataset.popover}"]`);
|
||||||
|
pill && pill.focus();
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectPopItem(pop, item) {
|
||||||
|
const which = pop.dataset.popover;
|
||||||
|
if (which === 'assignees') {
|
||||||
|
item.toggleAttribute('data-selected');
|
||||||
|
} else {
|
||||||
|
pop.querySelectorAll('.pop-item').forEach((i) => i.removeAttribute('data-selected'));
|
||||||
|
item.setAttribute('data-selected', 'true');
|
||||||
|
}
|
||||||
|
updatePillFrom(pop);
|
||||||
|
if (which !== 'assignees') closePopover(pop);
|
||||||
|
}
|
||||||
|
|
||||||
|
function updatePillFrom(pop) {
|
||||||
|
const which = pop.dataset.popover;
|
||||||
|
const pill = document.querySelector(`.pill[data-pill="${which}"]`);
|
||||||
|
if (!pill) return;
|
||||||
|
if (which === 'assignees') {
|
||||||
|
const sel = Array.from(pop.querySelectorAll('.pop-item[data-selected="true"]'));
|
||||||
|
if (sel.length === 0) {
|
||||||
|
pill.setAttribute('data-empty', 'true');
|
||||||
|
pill.innerHTML = '<span class="pill-icon">+</span><span class="pill-label">Add assignees</span>';
|
||||||
|
} else {
|
||||||
|
pill.removeAttribute('data-empty');
|
||||||
|
const names = sel.map((s) => s.textContent.trim());
|
||||||
|
pill.innerHTML = `<span class="pill-icon">👤</span><span class="pill-label">Assignees</span><span class="pill-sep">·</span><span class="pill-value">${names.join(', ')}</span>`;
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const cur = pop.querySelector('.pop-item[data-selected="true"]');
|
||||||
|
if (which === 'project') {
|
||||||
|
if (!cur || cur.dataset.value === '') {
|
||||||
|
pill.setAttribute('data-empty', 'true');
|
||||||
|
pill.innerHTML = '<span class="pill-icon">📁</span><span class="pill-label">Project</span>';
|
||||||
|
} else {
|
||||||
|
pill.removeAttribute('data-empty');
|
||||||
|
pill.innerHTML = `<span class="pill-icon">📁</span><span class="pill-label">Project</span><span class="pill-sep">·</span><span class="pill-value">${cur.textContent.trim()}</span>`;
|
||||||
|
}
|
||||||
|
} else if (which === 'priority') {
|
||||||
|
const v = cur.dataset.value;
|
||||||
|
const icon = v === 'high' ? '<span class="pill-icon pi-up">↑</span>'
|
||||||
|
: v === 'low' ? '<span class="pill-icon pi-down">↓</span>'
|
||||||
|
: '<span class="pill-icon pi-mid">→</span>';
|
||||||
|
const label = v[0].toUpperCase() + v.slice(1);
|
||||||
|
pill.innerHTML = `${icon}<span class="pill-label">Priority</span><span class="pill-sep">·</span><span class="pill-value">${label}</span>`;
|
||||||
|
} else if (which === 'status') {
|
||||||
|
const v = cur.dataset.value;
|
||||||
|
const icon = v === 'done' ? '✓' : v === 'in_progress' ? '◐' : '○';
|
||||||
|
const label = v === 'in_progress' ? 'In progress' : v === 'todo' ? 'To do' : 'Done';
|
||||||
|
pill.innerHTML = `<span class="pill-icon">${icon}</span><span class="pill-label">Status</span><span class="pill-sep">·</span><span class="pill-value">${label}</span>`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- DateField — format-aware segments ---------- */
|
||||||
|
const SEG_DEFS = {
|
||||||
|
day: { len: 2, min: 1, max: 31, ph: 'DD' },
|
||||||
|
month: { len: 2, min: 1, max: 12, ph: 'MM' },
|
||||||
|
year: { len: 4, min: 1900, max: 2100, ph: 'YYYY' },
|
||||||
|
hour: { len: 2, min: 0, max: 23, ph: 'HH' },
|
||||||
|
minute: { len: 2, min: 0, max: 59, ph: 'MM' },
|
||||||
|
};
|
||||||
|
const FMT_LAYOUT = {
|
||||||
|
'dd/MM/yyyy': [['day','/'],['month','/'],['year',null]],
|
||||||
|
'MM/dd/yyyy': [['month','/'],['day','/'],['year',null]],
|
||||||
|
'yyyy-MM-dd': [['year','-'],['month','-'],['day',null]],
|
||||||
|
};
|
||||||
|
let currentFmt = 'dd/MM/yyyy';
|
||||||
|
|
||||||
|
function renderDateField() {
|
||||||
|
const df = document.getElementById('datefield');
|
||||||
|
const cur = readDateField();
|
||||||
|
df.innerHTML = '';
|
||||||
|
const layout = FMT_LAYOUT[currentFmt].concat([null, ['hour',':'], ['minute', null]]);
|
||||||
|
layout.forEach((entry) => {
|
||||||
|
if (entry === null) {
|
||||||
|
const sp = document.createElement('span');
|
||||||
|
sp.className = 'seg-sep'; sp.innerHTML = ' ';
|
||||||
|
df.appendChild(sp); return;
|
||||||
|
}
|
||||||
|
const [key, sep] = entry;
|
||||||
|
const def = SEG_DEFS[key];
|
||||||
|
const seg = document.createElement('span');
|
||||||
|
seg.className = 'segment';
|
||||||
|
seg.contentEditable = 'true';
|
||||||
|
seg.dataset.seg = key;
|
||||||
|
seg.dataset.len = def.len; seg.dataset.min = def.min; seg.dataset.max = def.max;
|
||||||
|
const v = cur[key];
|
||||||
|
if (v == null) {
|
||||||
|
seg.dataset.placeholder = 'true';
|
||||||
|
seg.textContent = def.ph;
|
||||||
|
} else {
|
||||||
|
seg.dataset.placeholder = 'false';
|
||||||
|
seg.textContent = String(v).padStart(def.len, '0');
|
||||||
|
}
|
||||||
|
df.appendChild(seg);
|
||||||
|
if (sep) {
|
||||||
|
const s = document.createElement('span');
|
||||||
|
s.className = 'seg-sep'; s.textContent = sep;
|
||||||
|
df.appendChild(s);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
bindDateSegments();
|
||||||
|
}
|
||||||
|
|
||||||
|
function bindDateSegments() {
|
||||||
|
const dfSegments = Array.from(document.querySelectorAll('.segment'));
|
||||||
|
dfSegments.forEach((seg, idx) => {
|
||||||
|
seg.addEventListener('focus', () => {
|
||||||
|
if (seg.dataset.placeholder === 'true') {
|
||||||
|
seg.textContent = '';
|
||||||
|
}
|
||||||
|
// select all
|
||||||
|
const range = document.createRange();
|
||||||
|
range.selectNodeContents(seg);
|
||||||
|
const sel = window.getSelection();
|
||||||
|
sel.removeAllRanges();
|
||||||
|
sel.addRange(range);
|
||||||
|
});
|
||||||
|
seg.addEventListener('blur', () => {
|
||||||
|
const len = parseInt(seg.dataset.len, 10);
|
||||||
|
const min = parseInt(seg.dataset.min, 10);
|
||||||
|
const max = parseInt(seg.dataset.max, 10);
|
||||||
|
let v = seg.textContent.replace(/\D/g, '');
|
||||||
|
if (!v) {
|
||||||
|
seg.dataset.placeholder = 'true';
|
||||||
|
seg.textContent = seg.dataset.seg.toUpperCase().slice(0,len).padEnd(len, seg.dataset.seg[0].toUpperCase());
|
||||||
|
// reset to nice placeholder
|
||||||
|
const ph = { day:'DD', month:'MM', year:'YYYY', hour:'HH', minute:'MM' }[seg.dataset.seg];
|
||||||
|
seg.textContent = ph;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let n = parseInt(v, 10);
|
||||||
|
if (n < min) n = min;
|
||||||
|
if (n > max) n = max;
|
||||||
|
seg.dataset.placeholder = 'false';
|
||||||
|
seg.textContent = String(n).padStart(len, '0');
|
||||||
|
refreshSelectedDay();
|
||||||
|
});
|
||||||
|
seg.addEventListener('keydown', (e) => {
|
||||||
|
const len = parseInt(seg.dataset.len, 10);
|
||||||
|
if (e.key === 'ArrowRight' || (e.key === '/' || e.key === ':') ) {
|
||||||
|
e.preventDefault();
|
||||||
|
const next = dfSegments[idx + 1];
|
||||||
|
if (next) next.focus();
|
||||||
|
} else if (e.key === 'ArrowLeft') {
|
||||||
|
e.preventDefault();
|
||||||
|
const prev = dfSegments[idx - 1];
|
||||||
|
if (prev) prev.focus();
|
||||||
|
} else if (e.key === 'ArrowUp' || e.key === 'ArrowDown') {
|
||||||
|
e.preventDefault();
|
||||||
|
const min = parseInt(seg.dataset.min, 10);
|
||||||
|
const max = parseInt(seg.dataset.max, 10);
|
||||||
|
const cur = parseInt(seg.textContent.replace(/\D/g,''), 10);
|
||||||
|
const base = isNaN(cur) ? min : cur;
|
||||||
|
let n = base + (e.key === 'ArrowUp' ? 1 : -1);
|
||||||
|
if (n < min) n = max;
|
||||||
|
if (n > max) n = min;
|
||||||
|
seg.dataset.placeholder = 'false';
|
||||||
|
seg.textContent = String(n).padStart(len, '0');
|
||||||
|
refreshSelectedDay();
|
||||||
|
} else if (/^\d$/.test(e.key)) {
|
||||||
|
const cur = seg.textContent.replace(/\D/g,'');
|
||||||
|
if (cur.length >= len) {
|
||||||
|
e.preventDefault();
|
||||||
|
seg.textContent = e.key;
|
||||||
|
// place caret at end
|
||||||
|
}
|
||||||
|
// when reaching len, advance to next segment after this char
|
||||||
|
setTimeout(() => {
|
||||||
|
if ((seg.textContent || '').replace(/\D/g,'').length >= len) {
|
||||||
|
const next = dfSegments[idx + 1];
|
||||||
|
if (next) next.focus();
|
||||||
|
}
|
||||||
|
}, 0);
|
||||||
|
} else if (e.key === 'Backspace' && seg.textContent === '') {
|
||||||
|
const prev = dfSegments[idx - 1];
|
||||||
|
if (prev) { e.preventDefault(); prev.focus(); }
|
||||||
|
} else if (e.key === 'Enter') {
|
||||||
|
e.preventDefault();
|
||||||
|
seg.blur();
|
||||||
|
const pop = document.querySelector('.popover[data-popover="due"]');
|
||||||
|
closePopover(pop);
|
||||||
|
updateDuePill();
|
||||||
|
} else if (e.key === 'Escape') {
|
||||||
|
e.preventDefault();
|
||||||
|
const pop = document.querySelector('.popover[data-popover="due"]');
|
||||||
|
closePopover(pop);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
function readDateFieldFromDOM() {
|
||||||
|
return readDateField();
|
||||||
|
}
|
||||||
|
renderDateField();
|
||||||
|
document.getElementById('fmt-pref').addEventListener('change', (e) => {
|
||||||
|
currentFmt = e.target.value;
|
||||||
|
renderDateField();
|
||||||
|
updateDuePill();
|
||||||
|
});
|
||||||
|
|
||||||
|
function readDateField() {
|
||||||
|
const get = (k) => {
|
||||||
|
const s = document.querySelector(`.segment[data-seg="${k}"]`);
|
||||||
|
if (!s || s.dataset.placeholder === 'true') return null;
|
||||||
|
const v = s.textContent.replace(/\D/g,'');
|
||||||
|
return v ? parseInt(v, 10) : null;
|
||||||
|
};
|
||||||
|
return {
|
||||||
|
day: get('day'), month: get('month'), year: get('year'),
|
||||||
|
hour: get('hour'), minute: get('minute'),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDateValue(d) {
|
||||||
|
const day = String(d.day).padStart(2,'0');
|
||||||
|
const month = String(d.month).padStart(2,'0');
|
||||||
|
const year = String(d.year);
|
||||||
|
switch (currentFmt) {
|
||||||
|
case 'MM/dd/yyyy': return `${month}/${day}/${year}`;
|
||||||
|
case 'yyyy-MM-dd': return `${year}-${month}-${day}`;
|
||||||
|
default: return `${day}/${month}/${year}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function updateDuePill() {
|
||||||
|
const d = readDateField();
|
||||||
|
const pill = document.querySelector('.pill[data-pill="due"]');
|
||||||
|
if (d.day && d.month && d.year) {
|
||||||
|
pill.removeAttribute('data-empty');
|
||||||
|
const time = d.hour != null && d.minute != null
|
||||||
|
? ` ${String(d.hour).padStart(2,'0')}:${String(d.minute).padStart(2,'0')}` : '';
|
||||||
|
pill.innerHTML = `<span class="pill-icon">📅</span><span class="pill-label">Due</span><span class="pill-sep">·</span><span class="pill-value">${formatDateValue(d)}${time}</span>`;
|
||||||
|
} else {
|
||||||
|
pill.setAttribute('data-empty', 'true');
|
||||||
|
pill.innerHTML = '<span class="pill-icon">📅</span><span class="pill-label">Due</span>';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- Mini calendar ---------- */
|
||||||
|
let calYear = 2026, calMonth = 5; // May 2026
|
||||||
|
function renderCalendar() {
|
||||||
|
const grid = document.getElementById('cal-grid');
|
||||||
|
document.getElementById('cal-month').textContent =
|
||||||
|
new Date(calYear, calMonth - 1, 1).toLocaleString(undefined, { month: 'long', year: 'numeric' });
|
||||||
|
grid.innerHTML = '';
|
||||||
|
const dows = ['Mo','Tu','We','Th','Fr','Sa','Su'];
|
||||||
|
dows.forEach((d) => {
|
||||||
|
const el = document.createElement('div');
|
||||||
|
el.className = 'cal-dow'; el.textContent = d;
|
||||||
|
grid.appendChild(el);
|
||||||
|
});
|
||||||
|
const first = new Date(calYear, calMonth - 1, 1);
|
||||||
|
const offset = (first.getDay() + 6) % 7; // Mon-first
|
||||||
|
const daysInMonth = new Date(calYear, calMonth, 0).getDate();
|
||||||
|
const daysPrev = new Date(calYear, calMonth - 1, 0).getDate();
|
||||||
|
for (let i = offset - 1; i >= 0; i--) {
|
||||||
|
const el = document.createElement('div');
|
||||||
|
el.className = 'cal-day';
|
||||||
|
el.dataset.otherMonth = 'true';
|
||||||
|
el.textContent = daysPrev - i;
|
||||||
|
grid.appendChild(el);
|
||||||
|
}
|
||||||
|
for (let d = 1; d <= daysInMonth; d++) {
|
||||||
|
const el = document.createElement('div');
|
||||||
|
el.className = 'cal-day';
|
||||||
|
el.tabIndex = 0;
|
||||||
|
el.textContent = d;
|
||||||
|
el.dataset.day = d;
|
||||||
|
el.addEventListener('click', () => pickCalDay(d));
|
||||||
|
el.addEventListener('keydown', (e) => onCalKey(e, d));
|
||||||
|
grid.appendChild(el);
|
||||||
|
}
|
||||||
|
refreshSelectedDay();
|
||||||
|
}
|
||||||
|
function refreshSelectedDay() {
|
||||||
|
const d = readDateField();
|
||||||
|
const days = document.querySelectorAll('.cal-day[data-day]');
|
||||||
|
days.forEach((el) => el.removeAttribute('data-selected'));
|
||||||
|
if (d.day && d.month === calMonth && d.year === calYear) {
|
||||||
|
const tgt = document.querySelector(`.cal-day[data-day="${d.day}"]`);
|
||||||
|
if (tgt) tgt.setAttribute('data-selected', 'true');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function pickCalDay(d) {
|
||||||
|
const segDay = document.querySelector('.segment[data-seg="day"]');
|
||||||
|
const segMonth = document.querySelector('.segment[data-seg="month"]');
|
||||||
|
const segYear = document.querySelector('.segment[data-seg="year"]');
|
||||||
|
segDay.dataset.placeholder = 'false'; segDay.textContent = String(d).padStart(2,'0');
|
||||||
|
segMonth.dataset.placeholder = 'false'; segMonth.textContent = String(calMonth).padStart(2,'0');
|
||||||
|
segYear.dataset.placeholder = 'false'; segYear.textContent = String(calYear);
|
||||||
|
refreshSelectedDay();
|
||||||
|
updateDuePill();
|
||||||
|
}
|
||||||
|
function onCalKey(e, d) {
|
||||||
|
const grid = document.getElementById('cal-grid');
|
||||||
|
const days = Array.from(grid.querySelectorAll('.cal-day[data-day]'));
|
||||||
|
const idx = days.findIndex((el) => parseInt(el.dataset.day,10) === d);
|
||||||
|
let target = null;
|
||||||
|
if (e.key === 'ArrowRight') target = days[idx + 1];
|
||||||
|
else if (e.key === 'ArrowLeft') target = days[idx - 1];
|
||||||
|
else if (e.key === 'ArrowDown') target = days[idx + 7];
|
||||||
|
else if (e.key === 'ArrowUp') target = days[idx - 7];
|
||||||
|
else if (e.key === 'Enter') { e.preventDefault(); pickCalDay(d); return; }
|
||||||
|
if (target) { e.preventDefault(); target.focus(); }
|
||||||
|
}
|
||||||
|
document.querySelectorAll('[data-nav]').forEach((b) => {
|
||||||
|
b.addEventListener('click', () => {
|
||||||
|
const dir = parseInt(b.dataset.nav, 10);
|
||||||
|
calMonth += dir;
|
||||||
|
if (calMonth < 1) { calMonth = 12; calYear--; }
|
||||||
|
if (calMonth > 12) { calMonth = 1; calYear++; }
|
||||||
|
renderCalendar();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
renderCalendar();
|
||||||
|
|
||||||
|
/* ---------- click outside closes popovers ---------- */
|
||||||
|
document.addEventListener('mousedown', (e) => {
|
||||||
|
const inPop = e.target.closest('.popover');
|
||||||
|
const inPill = e.target.closest('.pill');
|
||||||
|
if (!inPop && !inPill) closeAllPopovers();
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
3035
docs/superpowers/plans/2026-05-11-project-folder-integration.md
Normal file
3035
docs/superpowers/plans/2026-05-11-project-folder-integration.md
Normal file
File diff suppressed because it is too large
Load Diff
1579
docs/superpowers/plans/2026-05-13-timeline-batch-add.md
Normal file
1579
docs/superpowers/plans/2026-05-13-timeline-batch-add.md
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,386 @@
|
|||||||
|
# Project Folder Integration — Design
|
||||||
|
|
||||||
|
**Date:** 2026-05-11
|
||||||
|
**Status:** Approved (brainstorming complete)
|
||||||
|
**Author:** Roberto + Claude
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
|
||||||
|
Let users link a local (or shared-PC) folder to an adiuvAI project. Adiuvai scans the folder, generates per-file summaries via LLM, and exposes the resulting manifest to the Home, Brief, and Task-Brief agents so they can answer project questions with awareness of the user's local files.
|
||||||
|
|
||||||
|
## Non-goals
|
||||||
|
|
||||||
|
- Multi-folder linking per project (deferred — 1 folder per project for now).
|
||||||
|
- Full-text RAG over file contents (we use lightweight per-file summaries instead).
|
||||||
|
- File editing from inside adiuvAI (read-only).
|
||||||
|
- Token-usage display in the project UI (recorded backend-side; dedicated Settings page comes later).
|
||||||
|
- Web SPA support (Electron-only — web SPA has no filesystem access).
|
||||||
|
|
||||||
|
## Strategy
|
||||||
|
|
||||||
|
**Hybrid AI File System (manifest first, optional wiki tier later).**
|
||||||
|
|
||||||
|
Phase 1 (this spec): build a lightweight manifest — for each indexable file record `(relativePath, kind, size, mtime, 1-line LLM summary)`. The agent receives the manifest pre-injected into its system prompt and reads full file contents lazily via a scoped tool.
|
||||||
|
|
||||||
|
Phase 2 (future, out of scope): for folders above N files or on user opt-in, generate per-folder + per-file wiki summaries written to a structured index. Not built now.
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────── adiuvAI (Electron) ───────────────────────┐
|
||||||
|
│ Renderer (React) │
|
||||||
|
│ • Project hero: <FolderChip> (status glance) │
|
||||||
|
│ • <FilesTab>: link/unlink, browse, rescan, progress, browser │
|
||||||
|
│ • <FolderBrowser>: tree view of manifest │
|
||||||
|
│ │
|
||||||
|
│ Main (Node) │
|
||||||
|
│ • db/schema.ts: +projectFolderFiles, +projects.folderPath │
|
||||||
|
│ • files/scanner.ts: walk + filter + mtime delta │
|
||||||
|
│ • files/indexer.ts: orchestrates WS index session │
|
||||||
|
│ • files/daily-rescan.ts: 24h-stale check on app start │
|
||||||
|
│ • router/projectFolders.ts: tRPC procedures │
|
||||||
|
│ • api/backend-client.ts: +sendIndexBatch frame │
|
||||||
|
│ • api/drizzle-executor.ts: +read_project_folder_manifest, │
|
||||||
|
│ +read_project_folder_file actions │
|
||||||
|
└───────────────────────────────────────────────────────────────────┘
|
||||||
|
│ /api/v1/device WS
|
||||||
|
▼
|
||||||
|
┌────────────────────────── api (FastAPI) ─────────────────────────┐
|
||||||
|
│ device_ws.py: +index_file_batch / +index_file_result frames │
|
||||||
|
│ core/folder_indexer.py: summarize text / vision per file │
|
||||||
|
│ core/deep_agent.py: pre-inject manifest when project context set │
|
||||||
|
│ agents/folder_agent.py: scoped read_project_folder_file tool │
|
||||||
|
│ billing/tier_manager.py: +folder_max_files, +folder_monthly_tokens│
|
||||||
|
│ models.py: +AgentRunLog.tokens_used, +MonthlyTokenUsage table │
|
||||||
|
└───────────────────────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
**Privacy invariant:** file content travels to the backend only transiently — for summarization — and is never persisted there. Summaries and manifest entries live in the local SQLite database. Token usage is recorded backend-side because it gates the user's tier quota.
|
||||||
|
|
||||||
|
## Decisions
|
||||||
|
|
||||||
|
| Topic | Decision |
|
||||||
|
|-------|----------|
|
||||||
|
| Retrieval strategy | Hybrid: manifest first, optional wiki tier later (phase 2, out of scope) |
|
||||||
|
| File scope | Text whitelist (.md, .txt, .pdf, .docx, .csv, code) + images (.png/.jpg) summarized via gpt-4o-mini vision |
|
||||||
|
| Cardinality | One folder per project |
|
||||||
|
| Rescan triggers | Manual button + daily auto (24h staleness check on app start) + on-demand mtime delta when manifest is read |
|
||||||
|
| Rate-limit metric | Tokens-per-month per user **and** total file-count cap per folder, both tier-gated |
|
||||||
|
| Indexing pipeline | WS streaming over existing `/api/v1/device` with new frame types |
|
||||||
|
| Agent access | Pre-inject manifest into system prompt; lazy reads via scoped `read_project_folder_file` tool |
|
||||||
|
| UI placement | Hero chip + dedicated "Files" tab in `ProjectTabBar` |
|
||||||
|
| Platform | Electron-only (web SPA: tab disabled) |
|
||||||
|
| Token-usage display | Out of scope (record backend-side, surface in Settings later) |
|
||||||
|
|
||||||
|
## Schema Changes
|
||||||
|
|
||||||
|
### adiuvAI local SQLite (`src/main/db/schema.ts`)
|
||||||
|
|
||||||
|
Extend `projects`:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
projects: {
|
||||||
|
// existing columns...
|
||||||
|
folderPath: text('folder_path'), // nullable absolute path or UNC
|
||||||
|
folderLastScannedAt: integer('folder_last_scanned_at'),// ms, nullable
|
||||||
|
folderLastScanStatus: text('folder_last_scan_status'), // 'idle' | 'scanning' | 'error'
|
||||||
|
folderTotalFiles: integer('folder_total_files').default(0),
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
New table `projectFolderFiles`:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
projectFolderFiles: {
|
||||||
|
id: text('id').primaryKey(),
|
||||||
|
projectId: text('project_id').notNull(), // FK projects.id (no DB constraint per convention)
|
||||||
|
relativePath: text('relative_path').notNull(), // path relative to folderPath
|
||||||
|
ext: text('ext').notNull(), // '.md', '.png', ...
|
||||||
|
kind: text('kind').notNull(), // 'text' | 'image' | 'pdf' | 'docx' | 'skipped' | 'error'
|
||||||
|
sizeBytes: integer('size_bytes').notNull(),
|
||||||
|
mtimeMs: integer('mtime_ms').notNull(),
|
||||||
|
summary: text('summary'), // nullable, ≤500 chars
|
||||||
|
summaryUpdatedAt: integer('summary_updated_at'),
|
||||||
|
// Unique index: (projectId, relativePath)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### api Postgres (alembic migration)
|
||||||
|
|
||||||
|
```python
|
||||||
|
op.add_column('agent_run_logs',
|
||||||
|
sa.Column('tokens_used', sa.Integer(), nullable=False, server_default='0'))
|
||||||
|
|
||||||
|
op.create_table('monthly_token_usage',
|
||||||
|
sa.Column('user_id', UUID(as_uuid=False), ForeignKey('users.id', ondelete='CASCADE'), nullable=False),
|
||||||
|
sa.Column('year_month', sa.String(7), nullable=False), # 'YYYY-MM'
|
||||||
|
sa.Column('feature', sa.String(64), nullable=False), # 'folder_index'
|
||||||
|
sa.Column('tokens_used', sa.Integer, nullable=False, server_default='0'),
|
||||||
|
sa.PrimaryKeyConstraint('user_id', 'year_month', 'feature'),
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Tier matrix (`app/billing/tier_manager.py`)
|
||||||
|
|
||||||
|
| Feature | Free | Pro | Power | Team |
|
||||||
|
|-------------------------|------|-----|-------|------|
|
||||||
|
| `folder_max_files` | 200 | 5000| -1 | -1 |
|
||||||
|
| `folder_monthly_tokens` | 100k | 2M | -1 | -1 |
|
||||||
|
|
||||||
|
## Indexing Pipeline
|
||||||
|
|
||||||
|
### New WS frame types on `/api/v1/device`
|
||||||
|
|
||||||
|
| Direction | Frame | Payload |
|
||||||
|
|-----------|---------------------------|---------|
|
||||||
|
| C → S | `index_session_start` | `{ sessionId, projectId, totalFiles }` |
|
||||||
|
| C → S | `index_file_batch` | `{ sessionId, files: [{relPath, kind, content/imageB64, sizeBytes, mtimeMs}] }` (batches of 5) |
|
||||||
|
| S → C | `index_file_result` | `{ sessionId, relPath, summary, tokensUsed, error? }` |
|
||||||
|
| S → C | `index_session_progress` | `{ sessionId, processed, total }` |
|
||||||
|
| C → S | `index_session_cancel` | `{ sessionId }` |
|
||||||
|
| S → C | `index_session_done` | `{ sessionId, status: 'completed' \| 'cancelled' \| 'quota_exceeded' \| 'error' }` |
|
||||||
|
|
||||||
|
### Flow (Electron `files/indexer.ts`)
|
||||||
|
|
||||||
|
1. tRPC `projectFolders.startScan({ projectId })`.
|
||||||
|
2. `scanner.ts` walks `folderPath`:
|
||||||
|
- Filter by whitelist (text exts + .png/.jpg).
|
||||||
|
- Apply size cap (1 MB / file).
|
||||||
|
- Compute mtime delta vs `projectFolderFiles`.
|
||||||
|
- Returns `{ newFiles[], changedFiles[], deletedFiles[] }`.
|
||||||
|
3. Backend pre-flight: `POST /api/v1/billing/quota/check { feature: 'folder_index', estimated_files: N }`:
|
||||||
|
- Rejects 402 if `folder_max_files` exceeded for the user's tier.
|
||||||
|
- Rejects 402 if `folder_monthly_tokens` already exhausted.
|
||||||
|
4. Open `index_session_start` over WS.
|
||||||
|
5. For each batch of 5 files:
|
||||||
|
- Read content (text) or base64-encode (image).
|
||||||
|
- Send `index_file_batch`.
|
||||||
|
- Await `index_file_result × 5`.
|
||||||
|
- Upsert `projectFolderFiles` row with the returned summary.
|
||||||
|
- Backend atomically increments `MonthlyTokenUsage` and writes a row in `AgentRunLog` with `tokens_used`.
|
||||||
|
6. Send `index_session_done`. Update `projects.folderLastScannedAt`, `.folderTotalFiles`, `.folderLastScanStatus = 'idle'`.
|
||||||
|
7. Delete `projectFolderFiles` rows for `deletedFiles`.
|
||||||
|
|
||||||
|
### Backend (`core/folder_indexer.py`)
|
||||||
|
|
||||||
|
- `summarize_text(content, ext) → (summary, tokens)` via `gpt-4o-mini`, Langfuse prompt `folder_file_summary_text`.
|
||||||
|
- `summarize_image(b64) → (summary, tokens)` via `gpt-4o-mini` vision, Langfuse prompt `folder_file_summary_image`.
|
||||||
|
- After each summarization, atomically increment `MonthlyTokenUsage(user_id, year_month, 'folder_index', +tokens)`. If the increment would exceed cap, the call returns a `quota_exceeded` error in `index_file_result`, and the session sends `index_session_done(status='quota_exceeded')`.
|
||||||
|
|
||||||
|
### Rescan triggers
|
||||||
|
|
||||||
|
- **Manual button** → tRPC `projectFolders.startScan` mutation.
|
||||||
|
- **On-demand mtime check** → inside `read_project_folder_manifest` drizzle-executor action: if any tracked mtime is stale, fire-and-forget `startScan` before returning the current manifest.
|
||||||
|
- **Daily auto** → `app.on('ready')` iterates user projects; if `folderLastScannedAt < now − 24h` and `folderPath != null`, queue `startScan`.
|
||||||
|
|
||||||
|
`projects.folderLastScanStatus === 'scanning'` blocks new scan triggers (manual button disabled, daily auto + mtime on-demand both skip).
|
||||||
|
|
||||||
|
## Agent Integration
|
||||||
|
|
||||||
|
### Manifest pre-injection
|
||||||
|
|
||||||
|
In `core/deep_agent.py`, every agent run that has a resolved `projectId` builds a compact manifest block and prepends it to the system prompt:
|
||||||
|
|
||||||
|
```
|
||||||
|
<linked_folder>
|
||||||
|
path: D:\Clients\Acme\Brand (214 files, scanned 2h ago)
|
||||||
|
files:
|
||||||
|
- /briefs/kickoff.md [text] Project kickoff notes; scope, stakeholders, deadlines
|
||||||
|
- /logos/logo-v3.png [image] Final logo, golden-yellow palette on white
|
||||||
|
- /research/competitor.pdf [pdf] Competitor brand audit, 12 entries
|
||||||
|
...
|
||||||
|
</linked_folder>
|
||||||
|
```
|
||||||
|
|
||||||
|
Format: `relativePath [kind] summary`. If the rendered block exceeds ~3000 tokens, truncate to the top N files by `mtimeMs DESC` and append:
|
||||||
|
|
||||||
|
```
|
||||||
|
… {M} more files omitted, use read_project_folder_file to access by path
|
||||||
|
```
|
||||||
|
|
||||||
|
The backend pulls the manifest via the new drizzle-executor action:
|
||||||
|
|
||||||
|
```
|
||||||
|
action: read_project_folder_manifest
|
||||||
|
data: { projectId }
|
||||||
|
returns: { folderPath, lastScannedAt, files: [{relPath, kind, summary}] }
|
||||||
|
```
|
||||||
|
|
||||||
|
### projectId resolution per agent
|
||||||
|
|
||||||
|
- `run_task_brief_research_stream` — `task.projectId`.
|
||||||
|
- `run_home` — null unless the user message is project-scoped (via `@project` mention or active project context passed from renderer).
|
||||||
|
- `run_brief` — backend cannot enumerate projects directly because projects live in the local SQLite. It calls a new `execute_on_client` action `list_projects_with_folder_manifests` that returns `[{ projectId, projectName, folderPath, lastScannedAt, files: [{relPath, kind, summary}] }]` for every project that has a linked folder. The backend then builds a **multi-project compact manifest** (top 5 most-recently-modified files per project).
|
||||||
|
|
||||||
|
### New scoped tool (`agents/folder_agent.py`)
|
||||||
|
|
||||||
|
```python
|
||||||
|
@tool
|
||||||
|
async def read_project_folder_file(project_id: str, relative_path: str) -> str:
|
||||||
|
"""Read full content of a file inside the project's linked folder."""
|
||||||
|
result = await execute_on_client(
|
||||||
|
action="read_project_folder_file",
|
||||||
|
data={"projectId": project_id, "relativePath": relative_path},
|
||||||
|
)
|
||||||
|
return result.get("content", "") or f"File not found: {relative_path}"
|
||||||
|
```
|
||||||
|
|
||||||
|
Backed by a new `drizzle-executor` action that:
|
||||||
|
1. Looks up `projects.folderPath` for the projectId.
|
||||||
|
2. Resolves `path.join(folderPath, relativePath)` with traversal guard (`..` and absolute paths rejected).
|
||||||
|
3. Reads the file via the existing fs helpers. Image → returns base64. Text → returns content (size-capped).
|
||||||
|
|
||||||
|
The existing journey-only `FILESYSTEM_TOOLS` are not added to home/brief/task-brief; only the new scoped tool is bound.
|
||||||
|
|
||||||
|
## UI
|
||||||
|
|
||||||
|
### Hero chip (`ProjectDetail.tsx`)
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<FolderChip
|
||||||
|
projectId={project.id}
|
||||||
|
folderPath={project.folderPath}
|
||||||
|
totalFiles={project.folderTotalFiles}
|
||||||
|
lastScannedAt={project.folderLastScannedAt}
|
||||||
|
scanStatus={project.folderLastScanStatus}
|
||||||
|
onClick={() => scrollToTab('files')}
|
||||||
|
/>
|
||||||
|
```
|
||||||
|
|
||||||
|
States:
|
||||||
|
- **Unlinked:** dashed pill "📁 Link folder" + Sparkles icon.
|
||||||
|
- **Linked idle:** "📁 214 files · 2h ago" with soft golden-yellow background.
|
||||||
|
- **Scanning:** "📁 indexing 47/214" + spinner.
|
||||||
|
- **Error:** "📁 Scan failed" red-tinted; click → Files tab.
|
||||||
|
|
||||||
|
### Files tab
|
||||||
|
|
||||||
|
Add `'files'` to `SECTIONS` in `ProjectTabBar.tsx`. The tab body:
|
||||||
|
|
||||||
|
```
|
||||||
|
┌──────────────────────────────────────────────────┐
|
||||||
|
│ Linked folder │
|
||||||
|
│ ┌────────────────────────────────────────────┐ │
|
||||||
|
│ │ 📁 D:\Clients\Acme\Brand [⋯ menu] │ │
|
||||||
|
│ │ 214 files · last scanned 2h ago │ │
|
||||||
|
│ │ [Rescan] [Unlink] │ │
|
||||||
|
│ └────────────────────────────────────────────┘ │
|
||||||
|
│ │
|
||||||
|
│ Files (filter: [All] [Text] [Images] [PDF]) │
|
||||||
|
│ ┌────────────────────────────────────────────┐ │
|
||||||
|
│ │ briefs/kickoff.md │ │
|
||||||
|
│ │ Project kickoff notes; scope, deadlines │ │
|
||||||
|
│ │ logos/logo-v3.png │ │
|
||||||
|
│ │ Final logo, golden-yellow on white │ │
|
||||||
|
│ │ ... │ │
|
||||||
|
│ └────────────────────────────────────────────┘ │
|
||||||
|
└──────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
### Empty state (no folder linked)
|
||||||
|
|
||||||
|
```
|
||||||
|
<Empty>
|
||||||
|
Sparkles
|
||||||
|
Link a project folder
|
||||||
|
Connect a local folder so AI agents can read its files
|
||||||
|
when answering questions about this project.
|
||||||
|
[Choose folder...] ← opens Electron dialog.showOpenDialog
|
||||||
|
</Empty>
|
||||||
|
```
|
||||||
|
|
||||||
|
### New components (`src/renderer/components/projects/folder/`)
|
||||||
|
|
||||||
|
- `FolderChip.tsx`
|
||||||
|
- `FilesSection.tsx` (mounts inside `ProjectDetail`)
|
||||||
|
- `FolderLinkCard.tsx` (path + actions)
|
||||||
|
- `FolderFileList.tsx` (virtualized list of manifest entries)
|
||||||
|
- `FolderUnlinkDialog.tsx`
|
||||||
|
|
||||||
|
### Platform gating
|
||||||
|
|
||||||
|
Feature is **Electron-only**. Wrap entry points in `platform.isElectron`. On the web SPA, the Files tab renders disabled with a tooltip "Folder linking available in desktop app".
|
||||||
|
|
||||||
|
### Folder dialog
|
||||||
|
|
||||||
|
New tRPC `projectFolders.chooseFolder` mutation invokes `dialog.showOpenDialog({ properties: ['openDirectory'] })` in the main process and returns the selected path.
|
||||||
|
|
||||||
|
### i18n
|
||||||
|
|
||||||
|
Add `projects.folder.*` keys (title, link CTA, browse, rescan, unlink, status strings, empty state copy, error toasts) to all 5 locale JSON files: en, it, es, fr, de.
|
||||||
|
|
||||||
|
## Error Handling
|
||||||
|
|
||||||
|
### Quota exhaustion
|
||||||
|
|
||||||
|
- Pre-flight 402 → toast `"Folder too big for {tier} plan — max {N} files"` or `"Monthly token budget exhausted (resets {date})"`. Folder not linked.
|
||||||
|
- Mid-scan `quota_exceeded` frame → partial manifest kept, scan marked `error`, toast as above, banner in Files tab `"Indexing paused — quota exhausted"`.
|
||||||
|
|
||||||
|
### Path errors
|
||||||
|
|
||||||
|
- Folder no longer exists at scan start → tRPC throws → toast `"Folder not found: {path}"`. `folderLastScanStatus = 'error'`. User offered Unlink or Re-link.
|
||||||
|
- Permission denied on a file during scan → file skipped, logged in `projectFolderFiles` with `kind='skipped'`, no summary. Skipped files appear greyed in the Files tab.
|
||||||
|
- Path traversal attempt in `read_project_folder_file` (relativePath contains `..` or is absolute) → tool returns `"Access denied"`; backend logs a warning. Hard fail, no fallback.
|
||||||
|
|
||||||
|
### Network / WS failures
|
||||||
|
|
||||||
|
- WS drop mid-scan: the in-flight session is abandoned server-side and the local `folderLastScanStatus` is flipped from `'scanning'` to `'error'`. The next trigger (manual rescan, daily auto, or the next on-demand mtime check) starts a **new** session; because the scanner's mtime delta only re-summarizes files whose `mtimeMs` changed (or that have no row yet), already-indexed files are skipped naturally — there is no explicit session-resume protocol.
|
||||||
|
- Backend 5xx on summarize → file marked `kind='error'`, retried in the next rescan, not auto-retried inline.
|
||||||
|
|
||||||
|
### File-type fallbacks
|
||||||
|
|
||||||
|
- PDF parse fails (corrupt) → skipped, `kind='skipped'` with `summary='Could not extract text'`.
|
||||||
|
- Image too large (>5 MB) → skipped with reason. Cap is a constant in `files/scanner.ts`.
|
||||||
|
- DOCX or other unsupported types → skipped silently with extension noted.
|
||||||
|
|
||||||
|
### Concurrent scan guard
|
||||||
|
|
||||||
|
`projects.folderLastScanStatus === 'scanning'` blocks new scan triggers. Manual button shows "Scanning..." disabled; daily auto + mtime on-demand both check the status flag first.
|
||||||
|
|
||||||
|
### Manifest size overflow
|
||||||
|
|
||||||
|
If the agent's pre-injected `<linked_folder>` block would exceed ~3000 tokens, the backend truncates to the top N files by `mtimeMs DESC` and appends an "M more files omitted" hint.
|
||||||
|
|
||||||
|
### Tool call on unlinked project
|
||||||
|
|
||||||
|
`read_project_folder_file` when `folderPath === null` returns `"No folder linked to project {projectId}"`. The agent can recover and answer without folder context.
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
### API (`api/tests/`)
|
||||||
|
|
||||||
|
| File | Coverage |
|
||||||
|
|------|----------|
|
||||||
|
| `test_folder_indexer.py` | `summarize_text` / `summarize_image` happy path, token recording, Langfuse prompt linking |
|
||||||
|
| `test_folder_quota.py` | Pre-flight 402 rejects (max_files + monthly_tokens), atomic increment + `quota_exceeded` mid-stream, monthly reset at `year_month` rollover |
|
||||||
|
| `test_ws_index_session.py` | Session lifecycle, cancel mid-stream, abandoned-on-disconnect (next scan skips already-indexed files via mtime delta), bad batch payload validation |
|
||||||
|
| `test_folder_agent_tool.py` | `read_project_folder_file` happy path, unlinked project, traversal guard (`../`, absolute) |
|
||||||
|
| `test_manifest_injection.py` | `<linked_folder>` block formatting, truncation past 3k tokens, multi-project brief manifest, null projectId skips injection |
|
||||||
|
|
||||||
|
Reuse fixtures in `tests/conftest.py` and WS test helpers (`ws_unified` already covers session lifecycle).
|
||||||
|
|
||||||
|
### Electron / adiuvAI
|
||||||
|
|
||||||
|
No automated test suite currently. Manual smoke checks during development:
|
||||||
|
|
||||||
|
- Link folder → manifest populated → unlink → manifest rows deleted.
|
||||||
|
- Scan a synthetic dir with mixed text/image/binary → only whitelisted indexed.
|
||||||
|
- mtime delta: change one file, rescan only re-indexes that file.
|
||||||
|
- Disconnect WS mid-scan → status flips to `'error'`; next manual rescan re-indexes only the remaining files (mtime delta).
|
||||||
|
|
||||||
|
### Eval (Langfuse)
|
||||||
|
|
||||||
|
Build a test set of 10 representative folders (mix of markdown, code, PDFs, images). Score summary quality (LLM-as-judge) and token efficiency. Link scores to the prompt version per the existing `LOCAL_AGENT_V2_PLAN.md` pattern.
|
||||||
|
|
||||||
|
## Out of scope (this spec)
|
||||||
|
|
||||||
|
- Phase-2 wiki tier (per-folder + per-file structured summaries).
|
||||||
|
- Multi-folder per project.
|
||||||
|
- Web SPA support.
|
||||||
|
- Token-usage display UI (Settings page comes later).
|
||||||
|
- File editing from inside adiuvAI.
|
||||||
|
- Live file watcher (chokidar). Daily + manual + on-demand mtime is enough for now.
|
||||||
|
|
||||||
|
## Open questions (none)
|
||||||
|
|
||||||
|
All resolved during brainstorming.
|
||||||
299
docs/superpowers/specs/2026-05-13-timeline-batch-add-design.md
Normal file
299
docs/superpowers/specs/2026-05-13-timeline-batch-add-design.md
Normal file
@@ -0,0 +1,299 @@
|
|||||||
|
# Timeline Batch Add — Design
|
||||||
|
|
||||||
|
**Date:** 2026-05-13
|
||||||
|
**Status:** Draft, awaiting user review
|
||||||
|
**Scope:** `adiuvAI/` submodule only
|
||||||
|
|
||||||
|
## Problem
|
||||||
|
|
||||||
|
Adding timeline events today goes through `AddEventDialog.tsx` one event at a time. The dialog already supports a sequential "add then add another" loop, but:
|
||||||
|
|
||||||
|
- Each event commits immediately on Enter (no client-side staging).
|
||||||
|
- The project picker appears at the bottom, after type/title/date.
|
||||||
|
- Date entry requires opening a calendar popover — keyboard-hostile.
|
||||||
|
- A user planning a project (kickoff + milestones + activities) clicks through the dialog 5–10 times to seed a project's timeline.
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
|
||||||
|
Single dialog session lets the user pick a project, stage multiple timeline events of mixed types, review, and commit the batch. Fully operable without a mouse.
|
||||||
|
|
||||||
|
## Non-goals
|
||||||
|
|
||||||
|
- New backend endpoint. Re-use `trpc.timelineEvents.create` per event.
|
||||||
|
- Reordering staged events. Order is "as added".
|
||||||
|
- Bulk import (CSV/paste). Out of scope.
|
||||||
|
- Cross-project batch. One batch = one project.
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
Refactor `adiuvAI/src/renderer/components/timeline/AddEventDialog.tsx` in place. Same callsites (`routes/timeline.tsx`, `components/projects/ProjectDetail.tsx`), same props (`open`, `onOpenChange`, `defaultProjectId?`, `onRecordHistory?`).
|
||||||
|
|
||||||
|
Two new shared primitives extracted from this work:
|
||||||
|
|
||||||
|
- `adiuvAI/src/renderer/lib/parseDate.ts` — pure date parser, locale-aware.
|
||||||
|
- `adiuvAI/src/renderer/components/ui/date-field.tsx` — controlled date input with typed entry + popover fallback.
|
||||||
|
|
||||||
|
Existing `EditEventDialog.tsx` migrates to `<DateField>` as part of this work. `TaskFormDialog.tsx` is **out of scope** — it uses `TZDate` plus time-of-day (H/M) selectors, which DateField does not cover. A follow-up pass should add `timezone` + `showTime` props to DateField, then migrate TaskFormDialog.
|
||||||
|
|
||||||
|
## State model
|
||||||
|
|
||||||
|
```ts
|
||||||
|
type StagedEvent = {
|
||||||
|
id: string; // nanoid, local-only key
|
||||||
|
title: string;
|
||||||
|
type: 'milestone' | 'checkpoint' | 'activity';
|
||||||
|
date: Date;
|
||||||
|
endDate?: Date; // activity only
|
||||||
|
};
|
||||||
|
|
||||||
|
type Mode = { kind: 'add' } | { kind: 'edit'; id: string };
|
||||||
|
|
||||||
|
// In AddEventDialog
|
||||||
|
const [projectId, setProjectId] = useState(defaultProjectId ?? '');
|
||||||
|
const [staged, setStaged] = useState<StagedEvent[]>([]);
|
||||||
|
const [mode, setMode] = useState<Mode>({ kind: 'add' });
|
||||||
|
// Form fields:
|
||||||
|
const [title, setTitle] = useState('');
|
||||||
|
const [type, setType] = useState<TimelineEventType>('milestone');
|
||||||
|
const [date, setDate] = useState<Date | undefined>();
|
||||||
|
const [endDate, setEndDate] = useState<Date | undefined>();
|
||||||
|
const [focusedRowId, setFocusedRowId] = useState<string | null>(null);
|
||||||
|
```
|
||||||
|
|
||||||
|
## Layout
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─── Add timeline events ─────────────────┐
|
||||||
|
│ │
|
||||||
|
│ Project [ Search project… ▾ ] │ ← hidden when defaultProjectId set
|
||||||
|
│ │ locked when staged.length > 0
|
||||||
|
│ ┌─ Staged list (scrollable, max ~6) ─┐ │
|
||||||
|
│ │ ✓ Kickoff milestone 15/03 ✕│ │
|
||||||
|
│ │ ✓ Phase 1 checkpoint 22/03 ✕│ │
|
||||||
|
│ └───────────────────────────────────┘ │
|
||||||
|
│ ───────────────────────────────────── │
|
||||||
|
│ ( Milestone | Checkpoint | Activity ) │
|
||||||
|
│ [ Event title… ] │
|
||||||
|
│ [ Date ] [End date ] │ ← end shown only for activity
|
||||||
|
│ │
|
||||||
|
│ [Cancel] [Add ↵] [Save N] │
|
||||||
|
└─────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
States:
|
||||||
|
1. **Fresh open** — empty staged list with hint text, form ready, focus on project picker (or title if `defaultProjectId`).
|
||||||
|
2. **N staged, form ready** — staged list visible, form empty, focus on title.
|
||||||
|
3. **Row focused** — form dimmed (`opacity-50 pointer-events-none`), staged row has focus ring.
|
||||||
|
4. **Editing row** — form populated from row, "Add ↵" button reads "Update ↵", row in list highlighted.
|
||||||
|
|
||||||
|
## Components
|
||||||
|
|
||||||
|
### Shared primitives (new)
|
||||||
|
|
||||||
|
**`lib/parseDate.ts`** — pure functions, no React.
|
||||||
|
|
||||||
|
```ts
|
||||||
|
export function parseDate(
|
||||||
|
input: string,
|
||||||
|
prefs: FormatPrefs,
|
||||||
|
baseDate?: Date,
|
||||||
|
): Date | null;
|
||||||
|
|
||||||
|
export function parseDateRange(
|
||||||
|
input: string,
|
||||||
|
prefs: FormatPrefs,
|
||||||
|
baseDate?: Date,
|
||||||
|
): { from: Date; to?: Date } | null;
|
||||||
|
```
|
||||||
|
|
||||||
|
Accepts:
|
||||||
|
- Keywords: `today`, `tomorrow`, `yesterday` (i18n-aware via current `i18n.language`)
|
||||||
|
- Relative: `+Nd`, `+Nw`, `+Nm`, `-Nd`
|
||||||
|
- Weekday names in current UI language (next occurrence): `mon`/`monday`, `lun`/`lunedì`, etc.
|
||||||
|
- Partial date: `DD/MM` or `MM/DD` (per `prefs.dateFormat`) → current year, year-rollover if past
|
||||||
|
- Full date: `DD/MM/YYYY`, `MM/DD/YYYY`, `YYYY-MM-DD` (per prefs)
|
||||||
|
|
||||||
|
Returns `null` on unparseable. No date library — small regex + native `Date`.
|
||||||
|
|
||||||
|
**`components/ui/date-field.tsx`** — controlled input.
|
||||||
|
|
||||||
|
```ts
|
||||||
|
type DateFieldProps = {
|
||||||
|
value: Date | undefined;
|
||||||
|
onChange: (d: Date | undefined) => void;
|
||||||
|
placeholder?: string;
|
||||||
|
minDate?: Date;
|
||||||
|
autoFocus?: boolean;
|
||||||
|
invalidMessage?: string;
|
||||||
|
className?: string;
|
||||||
|
'aria-label'?: string;
|
||||||
|
id?: string;
|
||||||
|
onCommit?: (d: Date) => void; // fired on Enter after valid parse
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
Internal: text input + calendar icon button → Popover wrapping shadcn `Calendar`. Reads `useFormatPrefs()` internally.
|
||||||
|
|
||||||
|
Behavior:
|
||||||
|
- Display formatted value (via `formatDate(prefs)`) when input not focused and value valid.
|
||||||
|
- Show raw user text while focused.
|
||||||
|
- Parse on blur and on Enter — if valid, call `onChange(date)`; if invalid, set `aria-invalid="true"` and red ring.
|
||||||
|
- Alt+↓ opens popover. Calendar selection commits via `onChange` and closes popover.
|
||||||
|
- `Enter` inside input: `e.preventDefault()`, parse, call `onChange(parsed)`, then call optional `onCommit?: (d: Date) => void` prop synchronously with the parsed value. Parent uses `onCommit` to stage without relying on `useState` flush. If invalid, no `onCommit` call, no propagation.
|
||||||
|
|
||||||
|
### Internal to AddEventDialog (not exported)
|
||||||
|
|
||||||
|
**`<ProjectPickerRow>`** — shadcn `Command` inside `Popover`. Typeable filter. Disabled when `staged.length > 0` (visual: muted, tooltip "Project locked after first event"). Hidden when `defaultProjectId` set.
|
||||||
|
|
||||||
|
**`<StagedList>`** — `<ul role="listbox" aria-label="Staged events">`. Empty state: muted hint `t('timeline.emptyStagedHint')`. Each row `<li role="option" tabIndex={-1}>` with:
|
||||||
|
- Type badge (color from existing palette — chart-1/2/3 mapping by type)
|
||||||
|
- Title (truncate)
|
||||||
|
- Date(s), formatted per prefs
|
||||||
|
- ✕ icon button (hover-visible only) for mouse users; aria-label `t('timeline.removeRow')`
|
||||||
|
|
||||||
|
Roving tabindex managed by `focusedRowId`. List itself has `tabIndex={0}` when no row focused, so Tab reaches it.
|
||||||
|
|
||||||
|
**`<EventForm>`** — wraps:
|
||||||
|
- `ToggleGroup` for type (existing pattern)
|
||||||
|
- `Input` for title (autoFocus when `mode.kind === 'add'`)
|
||||||
|
- `<DateField>` for `date`
|
||||||
|
- `<DateField>` for `endDate`, mounted only when `type === 'activity'`, `minDate` = `date`
|
||||||
|
|
||||||
|
## Keyboard map
|
||||||
|
|
||||||
|
| Context | Key | Action |
|
||||||
|
|----------------------|----------------|--------|
|
||||||
|
| Project picker open | type | filter list |
|
||||||
|
| | ↑/↓ | nav results |
|
||||||
|
| | Enter | select, focus title |
|
||||||
|
| | Esc | close picker |
|
||||||
|
| Form, any field | Tab/Shift+Tab | cycle: project → type → title → date → endDate → footer buttons |
|
||||||
|
| | Enter (valid) | stage event (add) or update row (edit), focus title |
|
||||||
|
| | Ctrl+Enter | save batch (if N ≥ 1) |
|
||||||
|
| | Esc | close dialog (confirm if staged > 0) |
|
||||||
|
| Title field | ↑ (caret at 0) | focus last staged row |
|
||||||
|
| Type toggle | ←/→ | cycle types |
|
||||||
|
| Date field | Alt+↓ | open calendar popover |
|
||||||
|
| | Enter | parse + commit + advance focus |
|
||||||
|
| Staged row | ↑/↓ | move focus |
|
||||||
|
| | Enter | load row → form, mode=edit |
|
||||||
|
| | Del/Backspace | remove row, focus next or form |
|
||||||
|
| | Esc | focus form title |
|
||||||
|
| Footer Save button | Enter/Space | save batch |
|
||||||
|
|
||||||
|
## Data flow
|
||||||
|
|
||||||
|
```
|
||||||
|
type+title+date entered, Enter pressed
|
||||||
|
→ validateForm()
|
||||||
|
→ if mode.add: setStaged([...staged, newEvent]); resetForm(); focusTitle()
|
||||||
|
→ if mode.edit: setStaged(staged.map(e => e.id===mode.id ? newEvent : e)); setMode({kind:'add'}); resetForm(); focusTitle()
|
||||||
|
|
||||||
|
Save N pressed (or Ctrl+Enter)
|
||||||
|
→ for each staged event:
|
||||||
|
results = await Promise.allSettled(
|
||||||
|
staged.map(e => createEvent.mutateAsync({
|
||||||
|
title: e.title,
|
||||||
|
date: e.date.getTime(),
|
||||||
|
endDate: e.endDate?.getTime(),
|
||||||
|
type: e.type,
|
||||||
|
projectId: defaultProjectId || projectId || undefined,
|
||||||
|
}))
|
||||||
|
)
|
||||||
|
→ for each fulfilled: onRecordHistory?.({kind:'create', id, payload:...})
|
||||||
|
→ utils.timelineEvents.list.invalidate() // once, not per event
|
||||||
|
→ if all fulfilled: notify success, close
|
||||||
|
→ if partial: keep rejected rows in staged, mark with error tooltip, notify warning
|
||||||
|
→ if all rejected: notify error, no rows removed
|
||||||
|
```
|
||||||
|
|
||||||
|
## Error handling
|
||||||
|
|
||||||
|
Per-field (inline, no toast):
|
||||||
|
- Empty title → submit disabled, Enter no-op.
|
||||||
|
- Unparseable date → red ring on `DateField`, `aria-invalid="true"`, submit disabled.
|
||||||
|
- Activity `endDate < date` → red ring on end field, message `timeline.endBeforeStart`, submit disabled.
|
||||||
|
- No project selected (picker shown) → submit disabled, picker gets focus ring.
|
||||||
|
|
||||||
|
Batch failure (per data flow above):
|
||||||
|
- All success → toast + close.
|
||||||
|
- Partial → keep failed rows with error tooltip, toast warns count.
|
||||||
|
- All fail → toast error, dialog stays open.
|
||||||
|
|
||||||
|
Edge cases:
|
||||||
|
- Dialog closed mid-batch: fire-and-forget mutations continue server-side; UI suppresses their toasts after close (track via local `closedRef`).
|
||||||
|
- Project deleted between selection and submit → falls into partial-fail path. Acceptable.
|
||||||
|
- `defaultProjectId` for deleted project → already handled by existing callsite contracts.
|
||||||
|
|
||||||
|
## i18n keys (added to all 5 locales)
|
||||||
|
|
||||||
|
```
|
||||||
|
timeline.endBeforeStart "End must be after start"
|
||||||
|
timeline.dateInvalid "Unrecognized date"
|
||||||
|
timeline.batchCreated_one "1 event created"
|
||||||
|
timeline.batchCreated_other "{{count}} events created"
|
||||||
|
timeline.batchPartial "{{ok}} created, {{failed}} failed"
|
||||||
|
timeline.batchFailed "Could not create events"
|
||||||
|
timeline.staged_one "1 event staged"
|
||||||
|
timeline.staged_other "{{count}} events staged"
|
||||||
|
timeline.emptyStagedHint "Type a title, set a date, press Enter"
|
||||||
|
timeline.editRow "Edit"
|
||||||
|
timeline.removeRow "Remove"
|
||||||
|
timeline.projectLocked "Project locked after first event"
|
||||||
|
timeline.confirmCloseStaged "Discard {{count}} staged events?"
|
||||||
|
timeline.saveAll "Save {{count}}"
|
||||||
|
timeline.update "Update"
|
||||||
|
common.add existing — re-use
|
||||||
|
common.cancel existing — re-use
|
||||||
|
```
|
||||||
|
|
||||||
|
Date parser keywords (per locale):
|
||||||
|
```
|
||||||
|
date.keyword.today
|
||||||
|
date.keyword.tomorrow
|
||||||
|
date.keyword.yesterday
|
||||||
|
date.keyword.weekdays array, mon..sun in locale (short + long)
|
||||||
|
```
|
||||||
|
|
||||||
|
## File touch list
|
||||||
|
|
||||||
|
New:
|
||||||
|
- `adiuvAI/src/renderer/lib/parseDate.ts`
|
||||||
|
- `adiuvAI/src/renderer/components/ui/date-field.tsx`
|
||||||
|
|
||||||
|
Modified:
|
||||||
|
- `adiuvAI/src/renderer/components/timeline/AddEventDialog.tsx` — full rewrite to staged-batch model.
|
||||||
|
- `adiuvAI/src/renderer/components/timeline/EditEventDialog.tsx` — swap popover+Calendar for `<DateField>`.
|
||||||
|
- `adiuvAI/src/renderer/locales/{en,it,es,fr,de}/translation.json` — add i18n keys above.
|
||||||
|
|
||||||
|
Untouched:
|
||||||
|
- Backend (`api/`): no schema, no router changes.
|
||||||
|
- tRPC contracts: re-use `timelineEvents.create`.
|
||||||
|
- DB schema: no migration.
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
Repo has no automated test suite (per `adiuvAI/.claude/CLAUDE.md`). Manual verification before merge:
|
||||||
|
|
||||||
|
- [ ] Open from `/timeline`: project picker visible, locks after first staged.
|
||||||
|
- [ ] Open from `ProjectDetail`: project picker hidden, preset used.
|
||||||
|
- [ ] Parse — type and verify: `today`, `tomorrow`, `+3d`, `+1w`, `mon`, `15/03`, `15/03/26`, `2026-03-15`. Repeat with `dateFormat` switched in Settings.
|
||||||
|
- [ ] Switch UI language to IT, type `oggi`, `domani`, `lun` — parse works.
|
||||||
|
- [ ] Stage 3 mixed-type events, Save → all created, history records 3 entries, toast plural correct.
|
||||||
|
- [ ] Stage 2, kill network mid-save → failed row stays with error tooltip, toast warns count.
|
||||||
|
- [ ] Pure keyboard run: open dialog, Tab to project, type+Enter, type title, Tab, type date, Enter (stage), repeat ×3, Ctrl+Enter (save). Mouse never touched.
|
||||||
|
- [ ] ↑ from title moves to last row, Enter loads to form for edit, Esc returns to form.
|
||||||
|
- [ ] Del on focused row removes it, focus advances.
|
||||||
|
- [ ] Esc with staged > 0 shows confirm; cancel keeps dialog, OK closes.
|
||||||
|
- [ ] `EditEventDialog` opens, `DateField` shows existing date formatted, edit and save works.
|
||||||
|
- [ ] Reduced-motion preference respected (no popover spring if user has it).
|
||||||
|
|
||||||
|
## Open questions
|
||||||
|
|
||||||
|
None known at design time. Resolved during brainstorming:
|
||||||
|
- Batch model: stage then commit all.
|
||||||
|
- Project scope: one project per batch, locked after first event.
|
||||||
|
- Date entry: typed input with smart parse, calendar popover as fallback.
|
||||||
|
- Range entry: two fields (start → Tab → end).
|
||||||
|
- Row edit: arrow nav, Enter edit, Del remove.
|
||||||
|
- Components: `DateField` + `parseDate` extracted as shared primitives, migrate `EditEventDialog` in this work. `TaskFormDialog` deferred (needs timezone + time-of-day support on DateField).
|
||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because one or more lines are too long
41409
graphify-out/graph.json
41409
graphify-out/graph.json
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user