merge: fix/ux-leaf-f-polish into integration/ux-28-gaps (resolve OnboardingFlow+DispatchPanel in layout)
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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 };
|
||||
Reference in New Issue
Block a user