feat: update task user stories and enhance task dialogs with timezone support

This commit is contained in:
Roberto Musso
2026-02-24 16:56:54 +01:00
parent 7a1aec0d9f
commit e70982c8b6
8 changed files with 206 additions and 72 deletions

View File

@@ -1,4 +1,4 @@
## Your Task US-021 ## Your Task US-022
1. Read the full app PRD at `prd-main.md` (in the same directory as this file) 1. Read the full app PRD at `prd-main.md` (in the same directory as this file)
2. Read the PRD at `prd.json` (in the same directory as this file) 2. Read the PRD at `prd.json` (in the same directory as this file)
@@ -23,18 +23,17 @@ APPEND to progress.txt (never replace, always append):
## USER REQUEST ## USER REQUEST
{ {
"id": "US-021", "id": "US-022",
"title": "@ProjectAgent with project action tools", "title": "LanceDB vector store setup and note embedding pipeline",
"description": "As a user, I want the AI to answer project-specific questions and take actions like adding tasks, summarizing the project, and suggesting checkpoints.", "description": "As a developer, I need notes embedded into LanceDB so that semantic search across all project notes is possible.",
"acceptanceCriteria": [ "acceptanceCriteria": [
"read_project_notes tool: fetches all notes for the scoped projectId from SQLite and returns combined content to the model", "vectordb (LanceDB Node.js binding) installed and initialized in main process only; vector DB stored at app.getPath('userData')/vectors/",
"add_task tool: creates a task in the project via tasks.create and confirms with 'Task added: [title]' in the chat response", "After notes.create or notes.update, note content is embedded via the GitHub Copilot SDK embeddings endpoint and upserted in LanceDB with metadata: { noteId, projectId, content }",
"get_summary tool: calls the SDK to generate a 2-3 sentence summary of the project based on its notes and tasks, then calls projects.update to persist the result in project.aiSummary", "On first app startup, a migration routine checks if the 'notes' LanceDB table exists; if not, embeds all existing SQLite notes and populates LanceDB",
"suggest_checkpoints tool: returns a JSON array of { title: string, date: string } proposed checkpoints based on date-anchored commitments found in notes", "Embedding errors are caught and logged to console (console.error) but do not reject the notes.update/create promise",
"@Orchestrator routes project-context messages to @ProjectAgent",
"Typecheck passes" "Typecheck passes"
], ],
"priority": 21, "priority": 22,
"passes": false, "passes": false,
"notes": "" "notes": ""
} }

View File

@@ -1,9 +1,19 @@
import { useEffect, useRef } from 'react'; import { useEffect, useRef } from 'react';
import { Crepe, CrepeFeature } from '@milkdown/crepe'; import { Crepe, CrepeFeature } from '@milkdown/crepe';
import { upload, uploadConfig } from '@milkdown/plugin-upload';
import '@milkdown/crepe/theme/common/style.css'; import '@milkdown/crepe/theme/common/style.css';
import '@milkdown/crepe/theme/nord.css'; import '@milkdown/crepe/theme/nord.css';
function fileToDataUrl(file: File): Promise<string> {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => resolve(reader.result as string);
reader.onerror = reject;
reader.readAsDataURL(file);
});
}
interface MilkdownEditorProps { interface MilkdownEditorProps {
initialContent: string; initialContent: string;
onChange: (markdown: string) => void; onChange: (markdown: string) => void;
@@ -25,9 +35,35 @@ export function MilkdownEditor({ initialContent, onChange }: MilkdownEditorProps
[CrepeFeature.Placeholder]: { [CrepeFeature.Placeholder]: {
text: 'Start writing...', text: 'Start writing...',
}, },
[CrepeFeature.ImageBlock]: {
onUpload: fileToDataUrl,
inlineOnUpload: fileToDataUrl,
blockOnUpload: fileToDataUrl,
},
}, },
}); });
// Add upload plugin to handle Ctrl+V and drag-drop of image files
crepe.editor
.config((ctx) => {
ctx.update(uploadConfig.key, (prev) => ({
...prev,
uploader: async (files: FileList, schema: import('@milkdown/prose/model').Schema) => {
const results: import('@milkdown/prose/model').Node[] = [];
for (let i = 0; i < files.length; i++) {
const file = files.item(i);
if (!file?.type.includes('image')) continue;
const src = await fileToDataUrl(file);
const node = schema.nodes.image?.createAndFill({ src, alt: file.name });
if (node) results.push(node);
}
return results;
},
enableHtmlFileUploader: true,
}));
})
.use(upload);
crepe.on((listener) => { crepe.on((listener) => {
listener.markdownUpdated((_ctx, markdown, prevMarkdown) => { listener.markdownUpdated((_ctx, markdown, prevMarkdown) => {
if (markdown !== prevMarkdown) { if (markdown !== prevMarkdown) {

View File

@@ -5,6 +5,7 @@ import { Badge } from '@/components/ui/badge';
import { TaskRow, type TaskItem } from '@/components/tasks/TaskRow'; import { TaskRow, type TaskItem } from '@/components/tasks/TaskRow';
import { NewTaskDialog } from '@/components/tasks/NewTaskDialog'; import { NewTaskDialog } from '@/components/tasks/NewTaskDialog';
import { EditTaskDialog } from '@/components/tasks/EditTaskDialog'; import { EditTaskDialog } from '@/components/tasks/EditTaskDialog';
import { TaskDetailDialog } from '@/components/tasks/TaskDetailDialog';
const COLUMNS = [ const COLUMNS = [
{ id: 'todo', label: 'To Do' }, { id: 'todo', label: 'To Do' },
@@ -32,8 +33,9 @@ export function KanbanBoard({ projectId, newTaskOpen, onNewTaskOpenChange }: Kan
onSuccess: () => void utils.tasks.list.invalidate(), onSuccess: () => void utils.tasks.list.invalidate(),
}); });
// Edit task dialog state // Edit / view task dialog state
const [editTask, setEditTask] = useState<TaskItem | null>(null); const [editTask, setEditTask] = useState<TaskItem | null>(null);
const [viewTask, setViewTask] = useState<TaskItem | null>(null);
// Group tasks by status // Group tasks by status
const columns = useMemo(() => { const columns = useMemo(() => {
@@ -119,6 +121,7 @@ export function KanbanBoard({ projectId, newTaskOpen, onNewTaskOpenChange }: Kan
onToggle={handleToggle} onToggle={handleToggle}
onEdit={setEditTask} onEdit={setEditTask}
onDelete={(id) => deleteTask.mutate({ id })} onDelete={(id) => deleteTask.mutate({ id })}
onClick={setViewTask}
hideBreadcrumb hideBreadcrumb
/> />
</div> </div>
@@ -144,6 +147,13 @@ export function KanbanBoard({ projectId, newTaskOpen, onNewTaskOpenChange }: Kan
open={!!editTask} open={!!editTask}
onOpenChange={(open) => { if (!open) setEditTask(null); }} onOpenChange={(open) => { if (!open) setEditTask(null); }}
/> />
<TaskDetailDialog
task={viewTask}
open={!!viewTask}
onOpenChange={(open) => { if (!open) setViewTask(null); }}
onEdit={(task) => { setViewTask(null); setEditTask(task); }}
onDelete={(id) => { deleteTask.mutate({ id }); setViewTask(null); }}
/>
</> </>
); );
} }

View File

@@ -1,5 +1,5 @@
import { Fragment, useMemo, useState } from 'react'; import { Fragment, useMemo, useState } from 'react';
import { Sparkles, FileText, CheckCircle2, Milestone, Plus, SquareDashed } from 'lucide-react'; import { Sparkles, FileText, CheckCircle2, Milestone, Plus } from 'lucide-react';
import { format } from 'date-fns'; import { format } from 'date-fns';
import { useNavigate } from '@tanstack/react-router'; import { useNavigate } from '@tanstack/react-router';
import { trpc } from '@/lib/trpc'; import { trpc } from '@/lib/trpc';
@@ -266,7 +266,7 @@ export function ProjectDetail({ projectId }: ProjectDetailProps) {
} }
> >
<ItemMedia variant="icon"> <ItemMedia variant="icon">
<SquareDashed /> <FileText />
</ItemMedia> </ItemMedia>
<ItemContent> <ItemContent>
<ItemTitle>{note.title}</ItemTitle> <ItemTitle>{note.title}</ItemTitle>

View File

@@ -1,5 +1,6 @@
import { useState, useEffect } from 'react'; import { useState, useEffect } from 'react';
import { format } from 'date-fns'; import { format } from 'date-fns';
import { TZDate } from 'react-day-picker';
import { Calendar as CalendarIcon, X, UserPlus, Check } from 'lucide-react'; import { Calendar as CalendarIcon, X, UserPlus, Check } from 'lucide-react';
import { trpc } from '@/lib/trpc'; import { trpc } from '@/lib/trpc';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
@@ -27,6 +28,9 @@ import { ScrollArea } from '@/components/ui/scroll-area';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import type { TaskItem } from './TaskRow'; import type { TaskItem } from './TaskRow';
const HOURS = Array.from({ length: 24 }, (_, i) => String(i).padStart(2, '0'));
const MINUTES = Array.from({ length: 60 }, (_, i) => String(i).padStart(2, '0'));
function parseAssigneesLocal(raw: string | null): string[] { function parseAssigneesLocal(raw: string | null): string[] {
if (!raw) return []; if (!raw) return [];
try { try {
@@ -47,8 +51,10 @@ export function EditTaskDialog({ task, open, onOpenChange }: EditTaskDialogProps
const [description, setDescription] = useState(''); const [description, setDescription] = useState('');
const [priority, setPriority] = useState('medium'); const [priority, setPriority] = useState('medium');
const [status, setStatus] = useState('todo'); const [status, setStatus] = useState('todo');
const [dueDate, setDueDate] = useState<Date | undefined>(); const timezone = Intl.DateTimeFormat().resolvedOptions().timeZone;
const [dueTime, setDueTime] = useState(''); const [dueDate, setDueDate] = useState<TZDate | undefined>();
const [dueHour, setDueHour] = useState('');
const [dueMinute, setDueMinute] = useState('');
const [projectId, setProjectId] = useState(''); const [projectId, setProjectId] = useState('');
const [assignees, setAssignees] = useState<string[]>([]); const [assignees, setAssignees] = useState<string[]>([]);
const [assigneeInput, setAssigneeInput] = useState(''); const [assigneeInput, setAssigneeInput] = useState('');
@@ -62,14 +68,14 @@ export function EditTaskDialog({ task, open, onOpenChange }: EditTaskDialogProps
setPriority(task.priority ?? 'medium'); setPriority(task.priority ?? 'medium');
setStatus(task.status ?? 'todo'); setStatus(task.status ?? 'todo');
if (task.dueDate) { if (task.dueDate) {
const d = new Date(task.dueDate); const d = new TZDate(task.dueDate, timezone);
setDueDate(d); setDueDate(d);
setDueTime( setDueHour(String(d.getHours()).padStart(2, '0'));
`${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`, setDueMinute(String(d.getMinutes()).padStart(2, '0'));
);
} else { } else {
setDueDate(undefined); setDueDate(undefined);
setDueTime(''); setDueHour('');
setDueMinute('');
} }
setProjectId(task.projectId ?? ''); setProjectId(task.projectId ?? '');
setAssignees(parseAssigneesLocal(task.assignee)); setAssignees(parseAssigneesLocal(task.assignee));
@@ -111,14 +117,16 @@ export function EditTaskDialog({ task, open, onOpenChange }: EditTaskDialogProps
let resolvedDueDate: number | undefined; let resolvedDueDate: number | undefined;
if (dueDate) { if (dueDate) {
const d = new Date(dueDate); const h = dueHour !== '' ? parseInt(dueHour, 10) : 0;
if (dueTime) { const m = dueMinute !== '' ? parseInt(dueMinute, 10) : 0;
const parts = dueTime.split(':'); const tzDate = new TZDate(
const h = parseInt(parts[0] ?? '0', 10); dueDate.getFullYear(),
const m = parseInt(parts[1] ?? '0', 10); dueDate.getMonth(),
d.setHours(h, m, 0, 0); dueDate.getDate(),
} h, m, 0, 0,
resolvedDueDate = d.getTime(); timezone,
);
resolvedDueDate = tzDate.getTime();
} }
updateTask.mutate({ updateTask.mutate({
@@ -194,29 +202,62 @@ export function EditTaskDialog({ task, open, onOpenChange }: EditTaskDialogProps
)} )}
> >
<CalendarIcon className="mr-2 h-4 w-4" /> <CalendarIcon className="mr-2 h-4 w-4" />
{dueDate ? format(dueDate, 'PPP') : 'Pick a due date'} {dueDate
? `${format(dueDate, 'PPP')}${dueHour !== '' && dueMinute !== '' ? ` ${dueHour}:${dueMinute}` : ''}`
: 'Pick a due date'}
</Button> </Button>
</PopoverTrigger> </PopoverTrigger>
<PopoverContent className="w-auto p-0" align="start"> <PopoverContent className="w-auto p-0" align="start">
<Calendar <Calendar
mode="single" mode="single"
selected={dueDate} selected={dueDate}
onSelect={setDueDate} onSelect={(d) => setDueDate(d as TZDate | undefined)}
timeZone={timezone}
/> />
<div className="border-t px-3 py-2"> <div className="border-t px-3 py-2 flex flex-col gap-2">
<label className="text-xs text-muted-foreground mb-1 block">Time (optional)</label> <div>
<Input <label className="text-xs text-muted-foreground mb-1 block">Time (optional, 24h)</label>
type="time" <div className="flex items-center gap-1.5">
value={dueTime} <Select value={dueHour} onValueChange={setDueHour}>
onChange={(e) => setDueTime(e.target.value)} <SelectTrigger className="h-8 w-20 text-sm">
className="h-8 text-sm" <SelectValue placeholder="HH" />
/> </SelectTrigger>
<SelectContent>
{HOURS.map((h) => (
<SelectItem key={h} value={h}>{h}</SelectItem>
))}
</SelectContent>
</Select>
<span className="text-muted-foreground text-sm">:</span>
<Select value={dueMinute} onValueChange={setDueMinute}>
<SelectTrigger className="h-8 w-20 text-sm">
<SelectValue placeholder="MM" />
</SelectTrigger>
<SelectContent>
{MINUTES.map((m) => (
<SelectItem key={m} value={m}>{m}</SelectItem>
))}
</SelectContent>
</Select>
{(dueHour !== '' || dueMinute !== '') && (
<Button
type="button"
variant="ghost"
size="sm"
className="h-8 px-2 text-xs"
onClick={() => { setDueHour(''); setDueMinute(''); }}
>
Clear
</Button>
)}
</div>
</div>
</div> </div>
</PopoverContent> </PopoverContent>
</Popover> </Popover>
{dueDate && dueTime && ( {dueDate && dueHour !== '' && dueMinute !== '' && (
<p className="text-xs text-muted-foreground pl-1"> <p className="text-xs text-muted-foreground pl-1">
Due: {format(dueDate, 'PPP')} at {dueTime} Due: {format(dueDate, 'PPP')} at {dueHour}:{dueMinute}
</p> </p>
)} )}
</div> </div>

View File

@@ -1,5 +1,6 @@
import { useState, useMemo } from 'react'; import { useState, useMemo } from 'react';
import { format } from 'date-fns'; import { format } from 'date-fns';
import { TZDate } from 'react-day-picker';
import { Calendar as CalendarIcon, X, UserPlus, Check, Plus } from 'lucide-react'; import { Calendar as CalendarIcon, X, UserPlus, Check, Plus } from 'lucide-react';
import { trpc } from '@/lib/trpc'; import { trpc } from '@/lib/trpc';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
@@ -26,6 +27,9 @@ import { Separator } from '@/components/ui/separator';
import { ScrollArea } from '@/components/ui/scroll-area'; import { ScrollArea } from '@/components/ui/scroll-area';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
const HOURS = Array.from({ length: 24 }, (_, i) => String(i).padStart(2, '0'));
const MINUTES = ['00', '05', '10', '15', '20', '25', '30', '35', '40', '45', '50', '55'];
const NO_CLIENT = '__no_client__'; const NO_CLIENT = '__no_client__';
interface NewTaskDialogProps { interface NewTaskDialogProps {
@@ -40,8 +44,10 @@ export function NewTaskDialog({ open, onOpenChange, defaultProjectId, defaultSta
const [description, setDescription] = useState(''); const [description, setDescription] = useState('');
const [priority, setPriority] = useState('medium'); const [priority, setPriority] = useState('medium');
const [status, setStatus] = useState(defaultStatus ?? 'todo'); const [status, setStatus] = useState(defaultStatus ?? 'todo');
const [dueDate, setDueDate] = useState<Date | undefined>(); const [dueDate, setDueDate] = useState<TZDate | undefined>();
const [dueTime, setDueTime] = useState(''); const [dueHour, setDueHour] = useState('');
const [dueMinute, setDueMinute] = useState('');
const timezone = Intl.DateTimeFormat().resolvedOptions().timeZone;
const [projectId, setProjectId] = useState(defaultProjectId ?? ''); const [projectId, setProjectId] = useState(defaultProjectId ?? '');
// Multi-assignee state // Multi-assignee state
@@ -96,7 +102,8 @@ export function NewTaskDialog({ open, onOpenChange, defaultProjectId, defaultSta
setPriority('medium'); setPriority('medium');
setStatus(defaultStatus ?? 'todo'); setStatus(defaultStatus ?? 'todo');
setDueDate(undefined); setDueDate(undefined);
setDueTime(''); setDueHour('');
setDueMinute('');
setProjectId(defaultProjectId ?? ''); setProjectId(defaultProjectId ?? '');
setAssignees([]); setAssignees([]);
setAssigneeInput(''); setAssigneeInput('');
@@ -171,17 +178,19 @@ export function NewTaskDialog({ open, onOpenChange, defaultProjectId, defaultSta
e.preventDefault(); e.preventDefault();
if (!title.trim()) return; if (!title.trim()) return;
// Resolve dueDate + optional time // Resolve dueDate + optional time in the selected timezone
let resolvedDueDate: number | undefined; let resolvedDueDate: number | undefined;
if (dueDate) { if (dueDate) {
const d = new Date(dueDate); const h = dueHour !== '' ? parseInt(dueHour, 10) : 0;
if (dueTime) { const m = dueMinute !== '' ? parseInt(dueMinute, 10) : 0;
const parts = dueTime.split(':'); const tzDate = new TZDate(
const h = parseInt(parts[0] ?? '0', 10); dueDate.getFullYear(),
const m = parseInt(parts[1] ?? '0', 10); dueDate.getMonth(),
d.setHours(h, m, 0, 0); dueDate.getDate(),
} h, m, 0, 0,
resolvedDueDate = d.getTime(); timezone,
);
resolvedDueDate = tzDate.getTime();
} }
// If creating a new project inline, do that first // If creating a new project inline, do that first
@@ -268,7 +277,7 @@ export function NewTaskDialog({ open, onOpenChange, defaultProjectId, defaultSta
> >
<CalendarIcon className="mr-2 h-4 w-4" /> <CalendarIcon className="mr-2 h-4 w-4" />
{dueDate {dueDate
? format(dueDate, dueTime ? 'PPP' : 'PPP') ? `${format(dueDate, 'PPP')}${dueHour !== '' && dueMinute !== '' ? ` ${dueHour}:${dueMinute}` : ''}`
: 'Pick a due date'} : 'Pick a due date'}
</Button> </Button>
</PopoverTrigger> </PopoverTrigger>
@@ -276,22 +285,54 @@ export function NewTaskDialog({ open, onOpenChange, defaultProjectId, defaultSta
<Calendar <Calendar
mode="single" mode="single"
selected={dueDate} selected={dueDate}
onSelect={setDueDate} onSelect={(d) => setDueDate(d as TZDate | undefined)}
timeZone={timezone}
/> />
<div className="border-t px-3 py-2"> <div className="border-t px-3 py-2 flex flex-col gap-2">
<label className="text-xs text-muted-foreground mb-1 block">Time (optional)</label> {/* Time row */}
<Input <div>
type="time" <label className="text-xs text-muted-foreground mb-1 block">Time (optional, 24h)</label>
value={dueTime} <div className="flex items-center gap-1.5">
onChange={(e) => setDueTime(e.target.value)} <Select value={dueHour} onValueChange={setDueHour}>
className="h-8 text-sm" <SelectTrigger className="h-8 w-20 text-sm">
/> <SelectValue placeholder="HH" />
</SelectTrigger>
<SelectContent>
{HOURS.map((h) => (
<SelectItem key={h} value={h}>{h}</SelectItem>
))}
</SelectContent>
</Select>
<span className="text-muted-foreground text-sm">:</span>
<Select value={dueMinute} onValueChange={setDueMinute}>
<SelectTrigger className="h-8 w-20 text-sm">
<SelectValue placeholder="MM" />
</SelectTrigger>
<SelectContent>
{MINUTES.map((m) => (
<SelectItem key={m} value={m}>{m}</SelectItem>
))}
</SelectContent>
</Select>
{(dueHour !== '' || dueMinute !== '') && (
<Button
type="button"
variant="ghost"
size="sm"
className="h-8 px-2 text-xs"
onClick={() => { setDueHour(''); setDueMinute(''); }}
>
Clear
</Button>
)}
</div>
</div>
</div> </div>
</PopoverContent> </PopoverContent>
</Popover> </Popover>
{dueDate && dueTime && ( {dueDate && dueHour !== '' && dueMinute !== '' && (
<p className="text-xs text-muted-foreground pl-1"> <p className="text-xs text-muted-foreground pl-1">
Due: {format(dueDate, 'PPP')} at {dueTime} Due: {format(dueDate, 'PPP')} at {dueHour}:{dueMinute}
</p> </p>
)} )}
</div> </div>

View File

@@ -29,7 +29,11 @@ import { parseAssignees, type TaskItem } from './TaskRow';
function formatDate(timestamp: number): string { function formatDate(timestamp: number): string {
const d = new Date(timestamp); const d = new Date(timestamp);
const months = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec']; const months = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
return `${months[d.getMonth()]} ${String(d.getDate()).padStart(2, '0')}, ${d.getFullYear()}`; const date = `${months[d.getMonth()]} ${String(d.getDate()).padStart(2, '0')}, ${d.getFullYear()}`;
if (d.getHours() === 0 && d.getMinutes() === 0) return date;
const h = String(d.getHours()).padStart(2, '0');
const m = String(d.getMinutes()).padStart(2, '0');
return `${date} ${h}:${m}`;
} }
function relativeTime(timestamp: number): string { function relativeTime(timestamp: number): string {

View File

@@ -16,6 +16,7 @@ import {
AlertDialogTitle, AlertDialogTitle,
AlertDialogTrigger, AlertDialogTrigger,
} from '@/components/ui/alert-dialog'; } from '@/components/ui/alert-dialog';
import { ScrollArea } from '@/components/ui/scroll-area';
import { trpc } from '@/lib/trpc'; import { trpc } from '@/lib/trpc';
import { MilkdownEditor } from '@/components/notes/MilkdownEditor'; import { MilkdownEditor } from '@/components/notes/MilkdownEditor';
@@ -187,13 +188,15 @@ function NoteDetailPage() {
</div> </div>
{/* Editor */} {/* Editor */}
<div className="flex-1 min-h-0 px-4 py-4 flex flex-col"> <ScrollArea className="flex-1 min-h-0">
<MilkdownEditor <div className="px-4 py-4">
key={noteId} <MilkdownEditor
initialContent={note.content} key={noteId}
onChange={handleContentChange} initialContent={note.content}
/> onChange={handleContentChange}
</div> />
</div>
</ScrollArea>
</div> </div>
); );
} }