diff --git a/apps/web/app/(dashboard)/dashboard/page.tsx b/apps/web/app/(dashboard)/dashboard/page.tsx index 5d2a557..a8fe1af 100644 --- a/apps/web/app/(dashboard)/dashboard/page.tsx +++ b/apps/web/app/(dashboard)/dashboard/page.tsx @@ -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 = { '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() {

Dashboard

-

Your day, at a glance.

+

Your day, at a glance.{domainFilter ? " (Filtered: " + domainFilter + ")" : ""}

}> + + + ); +} diff --git a/apps/web/app/(dashboard)/habits/page.tsx b/apps/web/app/(dashboard)/habits/page.tsx index 56fed7b..43cf3d3 100644 --- a/apps/web/app/(dashboard)/habits/page.tsx +++ b/apps/web/app/(dashboard)/habits/page.tsx @@ -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() {
)} + {/* Analytics */} +
+ +
+
-
+
{children}
@@ -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" /> +
); diff --git a/apps/web/app/(dashboard)/notes/page.tsx b/apps/web/app/(dashboard)/notes/page.tsx index 33f1816..aaa18ec 100644 --- a/apps/web/app/(dashboard)/notes/page.tsx +++ b/apps/web/app/(dashboard)/notes/page.tsx @@ -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() { ))} )} + { + createNoteWithContent(content); + }} />
+ {/* Timeline */} + + ([]); + const [completions, setCompletions] = useState([]); + 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(); + 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 ( + + ); + } + + return ( +
+
+

Analytics (30 days)

+ +
+ + {loading ? ( +

Loading analytics...

+ ) : ( +
+ + + + + Daily Completions + + + + {completions.length === 0 ? ( +

No data yet

+ ) : ( + + + + v.slice(5)} /> + + + + + + )} +
+
+ + + + + + Top Streaks + + + + {streaks.length === 0 ? ( +

No streaks yet

+ ) : ( + + + + + + + + + + )} +
+
+
+ )} +
+ ); +} + +function daysAgo(n: number): string { + const d = new Date(); + d.setDate(d.getDate() - n); + return d.toISOString().split('T')[0]; +} diff --git a/apps/web/components/notes/note-templates.tsx b/apps/web/components/notes/note-templates.tsx new file mode 100644 index 0000000..83c16a9 --- /dev/null +++ b/apps/web/components/notes/note-templates.tsx @@ -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: '

Meeting: [Title]

Date: [Date]

Attendees:

Agenda

Notes

Action Items

', + }, + { + name: 'Daily journal', + icon: 'NotebookText', + content: '

[Date]

What I did today

What I learned

What I\'m grateful for

', + }, + { + name: 'Brain dump', + icon: 'BrainCircuit', + content: '

Brain Dump — [Date]

Everything on my mind right now:

Priorities

', + }, +]; + +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(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 = { + FileText: , + NotebookText: , + BrainCircuit: , + }; + + return ( + <> + + + + + + Choose a template + + Start with a pre-formatted note template. + + + +
+ {templates.map((template) => ( + + ))} +
+ + + Templates are stored locally in your browser. + +
+
+ + ); +} diff --git a/apps/web/components/onboarding/onboarding-flow.tsx b/apps/web/components/onboarding/onboarding-flow.tsx new file mode 100644 index 0000000..43de0fd --- /dev/null +++ b/apps/web/components/onboarding/onboarding-flow.tsx @@ -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: , + content: ( +
+ + setDomainName(e.target.value)} + placeholder="e.g. Personal, Work, Side Project" + autoFocus + /> + +
+ ), + }, + { + title: 'Create your first task', + description: 'Tasks are the building blocks of your workflow. Create one to get started.', + icon: , + content: ( +
+

+ Use the + button in the top bar or press{' '} + C on the Tasks page to create a new task. +

+ +
+ ), + }, + { + title: 'Add a habit', + description: 'Build streaks and track progress on things you do regularly.', + icon: , + content: ( +
+

+ Head to the Habits page and click New habit to start tracking something daily or weekly. +

+ +
+ ), + }, + ]; + + const current = steps[step]; + + return ( + { if (!v) handleDismiss(); }}> + + +
+ {current.icon} +
+ {current.title} + {current.description} +
+
+
+ + {current.content} + + +
+ {steps.map((_, i) => ( +
+ ))} +
+ + + + + ); +} diff --git a/apps/web/components/projects/project-timeline.tsx b/apps/web/components/projects/project-timeline.tsx new file mode 100644 index 0000000..ce80134 --- /dev/null +++ b/apps/web/components/projects/project-timeline.tsx @@ -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 = { + 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 = { + 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 ( +
+

Timeline

+
+ {/* Header row */} +
+ Section +
+
+ {Array.from({ length: Math.min(totalDays, 60) }).map((_, i) => ( +
+ ))} +
+
+
+ + {/* Section rows */} +
+ {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 ( +
+
+ + {section.kind === 'milestone' ? 'M' : 'S'} + + {section.name} +
+
+
+
+
+ ); + })} +
+ + {/* Project target date marker */} + {projectTargetDate && ( +
+ Target date + {new Date(projectTargetDate).toLocaleDateString()} +
+ )} +
+
+ ); +} diff --git a/apps/web/components/tasks/task-create-dialog.tsx b/apps/web/components/tasks/task-create-dialog.tsx index 26866d8..83264b6 100644 --- a/apps/web/components/tasks/task-create-dialog.tsx +++ b/apps/web/components/tasks/task-create-dialog.tsx @@ -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({ Create a new task to track your work.
+ { + setTitle(t.title); + setDescription(t.description); + setPriority(t.priority); + setStatus(t.status); + }} /> +
void; +} + +export function TaskTemplates({ onSelect }: TaskTemplatesProps) { + const [templates] = useState(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 ( +
+ + +
+ ); +} + +export type { TaskTemplate };