feat: update task user stories and enhance task dialogs with timezone support
This commit is contained in:
@@ -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": ""
|
||||||
}
|
}
|
||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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); }}
|
||||||
|
/>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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">
|
|
||||||
<label className="text-xs text-muted-foreground mb-1 block">Time (optional)</label>
|
|
||||||
<Input
|
|
||||||
type="time"
|
|
||||||
value={dueTime}
|
|
||||||
onChange={(e) => setDueTime(e.target.value)}
|
|
||||||
className="h-8 text-sm"
|
|
||||||
/>
|
/>
|
||||||
|
<div className="border-t px-3 py-2 flex flex-col gap-2">
|
||||||
|
<div>
|
||||||
|
<label className="text-xs text-muted-foreground mb-1 block">Time (optional, 24h)</label>
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
<Select value={dueHour} onValueChange={setDueHour}>
|
||||||
|
<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>
|
||||||
|
|||||||
@@ -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">
|
|
||||||
<label className="text-xs text-muted-foreground mb-1 block">Time (optional)</label>
|
|
||||||
<Input
|
|
||||||
type="time"
|
|
||||||
value={dueTime}
|
|
||||||
onChange={(e) => setDueTime(e.target.value)}
|
|
||||||
className="h-8 text-sm"
|
|
||||||
/>
|
/>
|
||||||
|
<div className="border-t px-3 py-2 flex flex-col gap-2">
|
||||||
|
{/* Time row */}
|
||||||
|
<div>
|
||||||
|
<label className="text-xs text-muted-foreground mb-1 block">Time (optional, 24h)</label>
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
<Select value={dueHour} onValueChange={setDueHour}>
|
||||||
|
<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>
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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">
|
||||||
|
<div className="px-4 py-4">
|
||||||
<MilkdownEditor
|
<MilkdownEditor
|
||||||
key={noteId}
|
key={noteId}
|
||||||
initialContent={note.content}
|
initialContent={note.content}
|
||||||
onChange={handleContentChange}
|
onChange={handleContentChange}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
</ScrollArea>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user