merge: fix/ux-leaf-f-polish into integration/ux-28-gaps (resolve OnboardingFlow+DispatchPanel in layout)

This commit is contained in:
2026-07-29 20:34:52 +00:00
11 changed files with 698 additions and 4 deletions
+12 -3
View File
@@ -6,7 +6,7 @@ import { useDashboardStore } from '@/lib/stores/use-dashboard-store';
import { WidgetErrorBoundary } from '@/components/widget-error-boundary';
import { Button } from '@/components/ui/button';
import { Settings2, LayoutGrid } from 'lucide-react';
import { useRouter } from 'next/navigation';
import { useRouter, useSearchParams } from 'next/navigation';
// Lazy load react-grid-layout (client-only, ~45KB)
const ResponsiveGridLayout = dynamic(
@@ -68,12 +68,14 @@ const widgetLabels: Record<string, string> = {
'quick-capture': 'Quick Capture',
};
export default function DashboardPage() {
function DashboardPage() {
const { widgets, setWidgets, addWidget, removeWidget } = useDashboardStore();
const [layoutAnnouncement, setLayoutAnnouncement] = React.useState('');
const [editMode, setEditMode] = React.useState(false);
const [showConfig, setShowConfig] = React.useState(false);
const router = useRouter();
const searchParams = useSearchParams();
const domainFilter = searchParams.get('domain');
const layout = widgets.map((w) => ({
i: w.id,
@@ -113,7 +115,7 @@ export default function DashboardPage() {
<div className="mb-6 flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold">Dashboard</h1>
<p className="mt-1 text-muted-foreground">Your day, at a glance.</p>
<p className="mt-1 text-muted-foreground">Your day, at a glance.{domainFilter ? " (Filtered: " + domainFilter + ")" : ""}</p>
</div>
<div className="flex items-center gap-2">
<Button
@@ -201,3 +203,10 @@ export default function DashboardPage() {
</div>
);
}
export default function DashboardPageWrapper() {
return (
<Suspense fallback={<div className="py-12 text-center text-muted-foreground">Loading dashboard...</div>}>
<DashboardPage />
</Suspense>
);
}
+6
View File
@@ -24,6 +24,7 @@ import { HabitCreateDialog } from "@/components/habits/habit-create-dialog";
import { HabitEditDialog } from "@/components/habits/habit-edit-dialog";
import { HabitCompletionDialog } from "@/components/habits/habit-completion-dialog";
import { HabitCalendarHeatmap } from "@/components/habits/habit-calendar-heatmap";
import { HabitAnalytics } from "@/components/habits/habit-analytics";
import { toast } from "sonner";
interface Habit {
@@ -247,6 +248,11 @@ export default function HabitsPage() {
</div>
)}
{/* Analytics */}
<div className="mt-4">
<HabitAnalytics domainId={domainId || ""} habits={habits} />
</div>
<HabitCreateDialog
open={createOpen}
onOpenChange={setCreateOpen}
+3 -1
View File
@@ -5,6 +5,7 @@ import { KeyboardShortcutsProvider } from '@/components/keyboard-shortcuts-provi
import { WebVitalsTracker } from '@/components/web-vitals-tracker';
import { MobileBottomNav } from '@/components/mobile-bottom-nav';
import { DispatchPanel } from '@/components/agents/dispatch-panel';
import { OnboardingFlow } from '@/components/onboarding/onboarding-flow';
export default function DashboardLayout({ children }: { children: React.ReactNode }) {
return (
@@ -18,7 +19,7 @@ export default function DashboardLayout({ children }: { children: React.ReactNod
<Sidebar />
<div className="flex flex-1 flex-col pb-16 md:pb-0">
<TopBar />
<main id="main-content" className="flex-1 overflow-auto p-6" tabIndex={-1}>
<main id="main-content" className="flex-1 overflow-auto p-4 md:p-6" tabIndex={-1}>
{children}
</main>
</div>
@@ -32,6 +33,7 @@ export default function DashboardLayout({ children }: { children: React.ReactNod
triggerClassName="h-12 w-12 rounded-full shadow-lg md:h-auto md:w-auto md:rounded-md md:px-4 md:py-2"
/>
</div>
<OnboardingFlow />
<div className="sr-only" aria-live="polite" aria-atomic="true" id="a11y-announcer" />
</KeyboardShortcutsProvider>
);
+26
View File
@@ -5,6 +5,7 @@ import { Plus, FileText, Link2, GitBranch, Trash2, Pin, Archive, Search, PinOff,
import dynamic from 'next/dynamic';
import { toast } from 'sonner';
import { Button } from '@/components/ui/button';
import { NoteTemplates } from '@/components/notes/note-templates';
import { Card } from '@/components/ui/card';
import { ScrollArea } from '@/components/ui/scroll-area';
import { Badge } from '@/components/ui/badge';
@@ -162,6 +163,28 @@ export default function NotesPage() {
}
}
async function createNoteWithContent(content: string) {
if (!domainId) return;
try {
const response = await fetch("/api/domains/" + domainId + "/notes", {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
title: 'Untitled note',
content,
}),
});
if (!response.ok) throw new Error('Unable to create note.');
const newNote = await response.json();
setNotes((current) => [newNote, ...current]);
setSelectedNote(newNote);
toast.success('Note created from template');
} catch (error) {
console.error('Failed to create note:', error);
toast.error('Unable to create note');
}
}
async function createNote() {
if (!domainId) return;
try {
@@ -339,6 +362,9 @@ export default function NotesPage() {
))}
</select>
)}
<NoteTemplates onCreateFromTemplate={(content) => {
createNoteWithContent(content);
}} />
<Button onClick={createNote}>
<Plus className="mr-2 h-4 w-4" />
New note
@@ -23,6 +23,7 @@ import {
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
import { SectionDialog } from "@/components/projects/section-dialog";
import { ProjectTimeline } from "@/components/projects/project-timeline";
import Link from "next/link";
import { toast } from "sonner";
@@ -331,6 +332,9 @@ export default function ProjectDetailPage() {
</div>
</div>
{/* Timeline */}
<ProjectTimeline sections={project.sections} projectTargetDate={project.targetDate} />
<SectionDialog
open={sectionDialogOpen}
onOpenChange={setSectionDialogOpen}
@@ -0,0 +1,142 @@
'use client';
import { useState, useEffect } from 'react';
import { BarChart, Bar, XAxis, YAxis, Tooltip, ResponsiveContainer, LineChart, Line, CartesianGrid } from 'recharts';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { BarChart3, TrendingUp } from 'lucide-react';
interface HabitAnalyticsProps {
domainId: string;
habits: { id: string; name: string }[];
}
interface StreakItem {
habitId: string;
habitName: string;
currentStreak: number;
bestStreak: number;
}
interface CompletionDay {
date: string;
count: number;
}
export function HabitAnalytics({ domainId, habits }: HabitAnalyticsProps) {
const [open, setOpen] = useState(false);
const [streaks, setStreaks] = useState<StreakItem[]>([]);
const [completions, setCompletions] = useState<CompletionDay[]>([]);
const [loading, setLoading] = useState(false);
useEffect(() => {
if (!open) return;
setLoading(true);
Promise.all([
fetch('/api/habits/streaks').then((r) => r.json()),
...habits.map((h) =>
fetch(
'/api/domains/' + domainId + '/habits/' + h.id + '/completions?from=' + daysAgo(30) + '&order=asc&limit=365'
).then((r) => r.json())
),
])
.then(([streaksData, ...completionsData]) => {
setStreaks((streaksData.streaks || []).slice(0, 5));
const dateMap = new Map<string, number>();
for (const data of completionsData) {
for (const item of data.items || []) {
const d = item.date?.split('T')[0];
if (d) dateMap.set(d, (dateMap.get(d) || 0) + 1);
}
}
const sorted = Array.from(dateMap.entries())
.sort(([a], [b]) => a.localeCompare(b))
.map(([date, count]) => ({ date, count }));
setCompletions(sorted);
})
.catch(() => {})
.finally(() => setLoading(false));
}, [open, domainId, habits]);
if (!open) {
return (
<Button variant="outline" size="sm" onClick={() => setOpen(true)}>
<BarChart3 className="mr-2 h-4 w-4" />
Show analytics
</Button>
);
}
return (
<div className="space-y-4">
<div className="flex items-center justify-between">
<h3 className="text-sm font-semibold">Analytics (30 days)</h3>
<Button variant="ghost" size="sm" onClick={() => setOpen(false)}>
Hide
</Button>
</div>
{loading ? (
<p className="text-sm text-muted-foreground">Loading analytics...</p>
) : (
<div className="grid gap-4 sm:grid-cols-2">
<Card>
<CardHeader className="pb-2">
<CardTitle className="flex items-center gap-2 text-sm">
<TrendingUp className="h-4 w-4 text-primary" />
Daily Completions
</CardTitle>
</CardHeader>
<CardContent>
{completions.length === 0 ? (
<p className="py-4 text-center text-xs text-muted-foreground">No data yet</p>
) : (
<ResponsiveContainer width="100%" height={160}>
<LineChart data={completions}>
<CartesianGrid strokeDasharray="3 3" stroke="hsl(var(--border))" />
<XAxis dataKey="date" tick={{ fontSize: 10 }} tickFormatter={(v) => v.slice(5)} />
<YAxis allowDecimals={false} tick={{ fontSize: 10 }} />
<Tooltip />
<Line type="monotone" dataKey="count" stroke="hsl(var(--primary))" strokeWidth={2} dot={false} />
</LineChart>
</ResponsiveContainer>
)}
</CardContent>
</Card>
<Card>
<CardHeader className="pb-2">
<CardTitle className="flex items-center gap-2 text-sm">
<BarChart3 className="h-4 w-4 text-primary" />
Top Streaks
</CardTitle>
</CardHeader>
<CardContent>
{streaks.length === 0 ? (
<p className="py-4 text-center text-xs text-muted-foreground">No streaks yet</p>
) : (
<ResponsiveContainer width="100%" height={160}>
<BarChart data={streaks} layout="vertical">
<CartesianGrid strokeDasharray="3 3" stroke="hsl(var(--border))" />
<XAxis type="number" tick={{ fontSize: 10 }} />
<YAxis type="category" dataKey="habitName" width={80} tick={{ fontSize: 10 }} />
<Tooltip />
<Bar dataKey="bestStreak" fill="hsl(var(--primary))" radius={[0, 4, 4, 0]} />
</BarChart>
</ResponsiveContainer>
)}
</CardContent>
</Card>
</div>
)}
</div>
);
}
function daysAgo(n: number): string {
const d = new Date();
d.setDate(d.getDate() - n);
return d.toISOString().split('T')[0];
}
@@ -0,0 +1,124 @@
'use client';
import { useState, useCallback } from 'react';
import { Button } from '@/components/ui/button';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { toast } from 'sonner';
import { FileText, NotebookText, BrainCircuit } from 'lucide-react';
const TEMPLATES_KEY = 'pe_note_templates';
interface NoteTemplate {
name: string;
icon: string;
content: string;
}
const DEFAULT_TEMPLATES: NoteTemplate[] = [
{
name: 'Meeting notes',
icon: 'FileText',
content: '<h2>Meeting: [Title]</h2><p><strong>Date:</strong> [Date]</p><p><strong>Attendees:</strong></p><ul><li></li></ul><h3>Agenda</h3><ol><li></li></ol><h3>Notes</h3><p></p><h3>Action Items</h3><ul><li></li></ul>',
},
{
name: 'Daily journal',
icon: 'NotebookText',
content: '<h2>[Date]</h2><h3>What I did today</h3><p></p><h3>What I learned</h3><p></p><h3>What I\'m grateful for</h3><ul><li></li></ul>',
},
{
name: 'Brain dump',
icon: 'BrainCircuit',
content: '<h2>Brain Dump — [Date]</h2><p>Everything on my mind right now:</p><ul><li></li></ul><h3>Priorities</h3><ol><li></li></ol>',
},
];
function getTemplates(): NoteTemplate[] {
if (typeof window === 'undefined') return DEFAULT_TEMPLATES;
try {
const stored = localStorage.getItem(TEMPLATES_KEY);
if (stored) return JSON.parse(stored);
} catch {}
return DEFAULT_TEMPLATES;
}
interface NoteTemplatesProps {
onCreateFromTemplate: (content: string) => void;
}
export function NoteTemplates({ onCreateFromTemplate }: NoteTemplatesProps) {
const [open, setOpen] = useState(false);
const [templates, setTemplates] = useState<NoteTemplate[]>(getTemplates);
const handleSelect = useCallback(
(template: NoteTemplate) => {
const content = template.content
.replace(/\[Date\]/g, new Date().toLocaleDateString())
.replace(/\[Title\]/g, template.name);
onCreateFromTemplate(content);
setOpen(false);
toast.success('Note created from template');
},
[onCreateFromTemplate]
);
const iconMap: Record<string, React.ReactNode> = {
FileText: <FileText className="h-5 w-5" />,
NotebookText: <NotebookText className="h-5 w-5" />,
BrainCircuit: <BrainCircuit className="h-5 w-5" />,
};
return (
<>
<Button variant="outline" onClick={() => setOpen(true)}>
<FileText className="mr-2 h-4 w-4" />
+ From template
</Button>
<Dialog open={open} onOpenChange={setOpen}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>Choose a template</DialogTitle>
<DialogDescription>
Start with a pre-formatted note template.
</DialogDescription>
</DialogHeader>
<div className="space-y-2">
{templates.map((template) => (
<button
key={template.name}
onClick={() => handleSelect(template)}
className="flex w-full items-center gap-3 rounded-lg border p-3 text-left transition-colors hover:bg-accent"
>
<span className="flex h-10 w-10 shrink-0 items-center justify-center rounded-md bg-muted text-muted-foreground">
{iconMap[template.icon] || <FileText className="h-5 w-5" />}
</span>
<div>
<p className="text-sm font-medium">{template.name}</p>
<p className="text-xs text-muted-foreground">
{template.name === 'Meeting notes'
? 'Structured meeting notes with agenda and action items'
: template.name === 'Daily journal'
? 'Daily reflection with accomplishments and learnings'
: 'Free-form thought capture'}
</p>
</div>
</button>
))}
</div>
<DialogFooter className="text-xs text-muted-foreground">
Templates are stored locally in your browser.
</DialogFooter>
</DialogContent>
</Dialog>
</>
);
}
@@ -0,0 +1,151 @@
'use client';
import { useState, useEffect } from 'react';
import {
Sheet,
SheetContent,
SheetDescription,
SheetFooter,
SheetHeader,
SheetTitle,
} from '@/components/ui/sheet';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { toast } from 'sonner';
import { Rocket, ListTodo, Flame } from 'lucide-react';
const ONBOARDED_KEY = 'pe_onboarded';
interface OnboardingFlowProps {
onComplete?: () => void;
}
export function OnboardingFlow({ onComplete }: OnboardingFlowProps) {
const [open, setOpen] = useState(false);
const [step, setStep] = useState(0);
const [domainName, setDomainName] = useState('');
useEffect(() => {
if (typeof window === 'undefined') return;
const onboarded = localStorage.getItem(ONBOARDED_KEY);
if (!onboarded) {
setOpen(true);
}
}, []);
function handleDismiss() {
localStorage.setItem(ONBOARDED_KEY, 'true');
setOpen(false);
onComplete?.();
}
async function handleCreateDomain() {
if (!domainName.trim()) {
toast.error('Please enter a domain name');
return;
}
try {
const res = await fetch('/api/domains', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name: domainName.trim() }),
});
if (!res.ok) throw new Error('Failed to create domain');
toast.success('Domain created!');
setStep(1);
} catch {
toast.error('Failed to create domain');
}
}
const steps = [
{
title: 'Pick your primary domain',
description: 'A domain is your workspace — a container for tasks, habits, and projects.',
icon: <Rocket className="h-8 w-8 text-primary" />,
content: (
<div className="space-y-3">
<Label htmlFor="domain-name">Domain name</Label>
<Input
id="domain-name"
value={domainName}
onChange={(e) => setDomainName(e.target.value)}
placeholder="e.g. Personal, Work, Side Project"
autoFocus
/>
<Button onClick={handleCreateDomain} className="w-full">
Create domain
</Button>
</div>
),
},
{
title: 'Create your first task',
description: 'Tasks are the building blocks of your workflow. Create one to get started.',
icon: <ListTodo className="h-8 w-8 text-primary" />,
content: (
<div className="space-y-3">
<p className="text-sm text-muted-foreground">
Use the <kbd className="rounded border bg-muted px-1 font-mono text-xs">+</kbd> button in the top bar or press{' '}
<kbd className="rounded border bg-muted px-1 font-mono text-xs">C</kbd> on the Tasks page to create a new task.
</p>
<Button onClick={() => setStep(2)} className="w-full">
Got it, next step
</Button>
</div>
),
},
{
title: 'Add a habit',
description: 'Build streaks and track progress on things you do regularly.',
icon: <Flame className="h-8 w-8 text-primary" />,
content: (
<div className="space-y-3">
<p className="text-sm text-muted-foreground">
Head to the Habits page and click <strong>New habit</strong> to start tracking something daily or weekly.
</p>
<Button onClick={handleDismiss} className="w-full">
Done start using Project E
</Button>
</div>
),
},
];
const current = steps[step];
return (
<Sheet open={open} onOpenChange={(v) => { if (!v) handleDismiss(); }}>
<SheetContent side="bottom" className="sm:max-w-md sm:mx-auto sm:rounded-t-xl">
<SheetHeader className="mb-4">
<div className="flex items-center gap-3">
{current.icon}
<div>
<SheetTitle>{current.title}</SheetTitle>
<SheetDescription>{current.description}</SheetDescription>
</div>
</div>
</SheetHeader>
{current.content}
<SheetFooter className="mt-6 flex items-center justify-between">
<div className="flex gap-1">
{steps.map((_, i) => (
<div
key={i}
className={`h-1.5 w-6 rounded-full transition-colors ${
i === step ? 'bg-primary' : 'bg-muted'
}`}
/>
))}
</div>
<Button variant="ghost" size="sm" onClick={handleDismiss}>
Skip onboarding
</Button>
</SheetFooter>
</SheetContent>
</Sheet>
);
}
@@ -0,0 +1,126 @@
'use client';
import { useMemo } from 'react';
interface Section {
id: string;
name: string;
kind: 'section' | 'milestone';
status: 'planned' | 'in_progress' | 'complete';
targetDate: string | null;
sortOrder: number;
}
interface ProjectTimelineProps {
sections: Section[];
projectTargetDate: string | null;
}
const statusColors: Record<string, string> = {
planned: 'bg-gray-200 dark:bg-gray-700',
in_progress: 'bg-blue-400 dark:bg-blue-600',
complete: 'bg-green-400 dark:bg-green-600',
};
const kindBadge: Record<string, string> = {
section: 'bg-muted text-muted-foreground',
milestone: 'bg-amber-100 text-amber-800 dark:bg-amber-900 dark:text-amber-200',
};
export function ProjectTimeline({ sections, projectTargetDate }: ProjectTimelineProps) {
const { startDate, totalDays } = useMemo(() => {
if (sections.length === 0) return { startDate: new Date(), totalDays: 30 };
const dates = sections
.filter((s) => s.targetDate)
.map((s) => new Date(s.targetDate!));
if (projectTargetDate) dates.push(new Date(projectTargetDate));
if (dates.length === 0) {
// No dates at all — show a default 30-day window
const now = new Date();
return { startDate: now, totalDays: 30 };
}
const minDate = new Date(Math.min(...dates.map((d) => d.getTime())));
const maxDate = new Date(Math.max(...dates.map((d) => d.getTime())));
const diff = Math.max((maxDate.getTime() - minDate.getTime()) / (1000 * 60 * 60 * 24), 14);
return { startDate: minDate, totalDays: Math.ceil(diff) };
}, [sections, projectTargetDate]);
if (sections.length === 0) return null;
return (
<div className="mt-6">
<h3 className="mb-3 text-sm font-semibold">Timeline</h3>
<div className="rounded-lg border bg-card p-4">
{/* Header row */}
<div className="mb-2 flex items-center gap-2 text-xs text-muted-foreground">
<span className="w-40 shrink-0">Section</span>
<div className="relative flex-1 h-4">
<div className="absolute inset-0 flex">
{Array.from({ length: Math.min(totalDays, 60) }).map((_, i) => (
<div
key={i}
className="flex-1 border-r border-border/30"
style={i % 7 === 0 ? { borderRightWidth: 2 } : {}}
/>
))}
</div>
</div>
</div>
{/* Section rows */}
<div className="space-y-2">
{sections.map((section) => {
if (!section.targetDate) return null;
const sectionDate = new Date(section.targetDate);
const dayOffset = Math.max(
0,
Math.round((sectionDate.getTime() - startDate.getTime()) / (1000 * 60 * 60 * 24))
);
const barWidth = Math.max(8, Math.min(100, (1 / Math.max(totalDays, 1)) * 100));
return (
<div key={section.id} className="flex items-center gap-2">
<div className="flex w-40 shrink-0 items-center gap-2">
<span
className={`inline-flex items-center rounded px-1.5 py-0.5 text-[10px] font-medium ${
kindBadge[section.kind] || kindBadge.section
}`}
>
{section.kind === 'milestone' ? 'M' : 'S'}
</span>
<span className="truncate text-sm">{section.name}</span>
</div>
<div className="relative flex-1 h-6">
<div
className={`absolute top-1 h-4 rounded ${
statusColors[section.status] || statusColors.planned
}`}
style={{
left: `${(dayOffset / Math.max(totalDays, 1)) * 100}%`,
width: `${barWidth}%`,
minWidth: 8,
}}
title={`${section.name}${section.status}${sectionDate.toLocaleDateString()}`}
/>
</div>
</div>
);
})}
</div>
{/* Project target date marker */}
{projectTargetDate && (
<div className="mt-3 flex items-center gap-2 border-t pt-2 text-xs text-muted-foreground">
<span className="w-40 shrink-0">Target date</span>
<span>{new Date(projectTargetDate).toLocaleDateString()}</span>
</div>
)}
</div>
</div>
);
}
@@ -21,6 +21,7 @@ import {
SelectValue,
} from '@/components/ui/select';
import { toast } from 'sonner';
import { TaskTemplates } from '@/components/tasks/task-templates';
interface TaskCreateDialogProps {
open: boolean;
@@ -127,6 +128,13 @@ export function TaskCreateDialog({
<DialogDescription>Create a new task to track your work.</DialogDescription>
</DialogHeader>
<form className="space-y-4" onSubmit={handleSubmit}>
<TaskTemplates onSelect={(t) => {
setTitle(t.title);
setDescription(t.description);
setPriority(t.priority);
setStatus(t.status);
}} />
<div className="space-y-2">
<Label htmlFor="task-title">Title *</Label>
<Input
@@ -0,0 +1,96 @@
'use client';
import { useState, useCallback } from 'react';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { Label } from '@/components/ui/label';
import { toast } from 'sonner';
const TEMPLATES_KEY = 'pe_task_templates';
interface TaskTemplate {
name: string;
title: string;
description: string;
priority: 'low' | 'medium' | 'high' | 'urgent';
status: 'todo' | 'in_progress' | 'done' | 'cancelled';
}
const DEFAULT_TEMPLATES: TaskTemplate[] = [
{
name: 'Bug fix',
title: 'Fix: ',
description: '## Steps to reproduce\n1. \n\n## Expected behavior\n\n## Actual behavior\n\n## Environment\n- \n',
priority: 'high',
status: 'todo',
},
{
name: 'Feature work',
title: 'Feature: ',
description: '## Description\n\n## Acceptance criteria\n- [ ] \n\n## Notes\n',
priority: 'medium',
status: 'todo',
},
{
name: 'Quick meeting',
title: 'Meeting: ',
description: '## Attendees\n\n## Agenda\n1. \n\n## Notes\n\n## Action items\n- [ ] \n',
priority: 'medium',
status: 'todo',
},
];
function getTemplates(): TaskTemplate[] {
if (typeof window === 'undefined') return DEFAULT_TEMPLATES;
try {
const stored = localStorage.getItem(TEMPLATES_KEY);
if (stored) return JSON.parse(stored);
} catch {}
return DEFAULT_TEMPLATES;
}
interface TaskTemplatesProps {
onSelect: (template: TaskTemplate) => void;
}
export function TaskTemplates({ onSelect }: TaskTemplatesProps) {
const [templates] = useState<TaskTemplate[]>(getTemplates);
const handleChange = useCallback(
(value: string) => {
const template = templates.find((t) => t.name === value);
if (template) {
onSelect(template);
toast.success('Template applied');
}
},
[templates, onSelect]
);
if (templates.length === 0) return null;
return (
<div className="space-y-2">
<Label htmlFor="task-template">From template</Label>
<Select onValueChange={handleChange}>
<SelectTrigger id="task-template">
<SelectValue placeholder="Choose a template..." />
</SelectTrigger>
<SelectContent>
{templates.map((t) => (
<SelectItem key={t.name} value={t.name}>
{t.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
);
}
export type { TaskTemplate };