Phase 5: Calendar + Dashboard + Search
This commit is contained in:
@@ -1,86 +0,0 @@
|
|||||||
import { headers } from "next/headers";
|
|
||||||
import { redirect } from "next/navigation";
|
|
||||||
|
|
||||||
export type ChatGPTUser = {
|
|
||||||
displayName: string;
|
|
||||||
email: string;
|
|
||||||
fullName: string | null;
|
|
||||||
};
|
|
||||||
|
|
||||||
const USER_EMAIL_HEADER = "oai-authenticated-user-email";
|
|
||||||
const USER_FULL_NAME_HEADER = "oai-authenticated-user-full-name";
|
|
||||||
const USER_FULL_NAME_ENCODING_HEADER =
|
|
||||||
"oai-authenticated-user-full-name-encoding";
|
|
||||||
const PERCENT_ENCODED_UTF8 = "percent-encoded-utf-8";
|
|
||||||
const SIGN_IN_PATH = "/signin-with-chatgpt";
|
|
||||||
const SIGN_OUT_PATH = "/signout-with-chatgpt";
|
|
||||||
const CALLBACK_PATH = "/callback";
|
|
||||||
|
|
||||||
export async function getChatGPTUser(): Promise<ChatGPTUser | null> {
|
|
||||||
const requestHeaders = await headers();
|
|
||||||
const email = requestHeaders.get(USER_EMAIL_HEADER);
|
|
||||||
if (!email) return null;
|
|
||||||
|
|
||||||
const encodedFullName = requestHeaders.get(USER_FULL_NAME_HEADER);
|
|
||||||
const fullName =
|
|
||||||
encodedFullName &&
|
|
||||||
requestHeaders.get(USER_FULL_NAME_ENCODING_HEADER) === PERCENT_ENCODED_UTF8
|
|
||||||
? safeDecodeURIComponent(encodedFullName)
|
|
||||||
: null;
|
|
||||||
|
|
||||||
return {
|
|
||||||
displayName: fullName ?? email,
|
|
||||||
email,
|
|
||||||
fullName,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function requireChatGPTUser(
|
|
||||||
returnTo: string,
|
|
||||||
): Promise<ChatGPTUser> {
|
|
||||||
const user = await getChatGPTUser();
|
|
||||||
if (user) return user;
|
|
||||||
|
|
||||||
redirect(chatGPTSignInPath(returnTo));
|
|
||||||
}
|
|
||||||
|
|
||||||
export function chatGPTSignInPath(returnTo: string): string {
|
|
||||||
const safeReturnTo = safeRelativeReturnPath(returnTo);
|
|
||||||
return `${SIGN_IN_PATH}?return_to=${encodeURIComponent(safeReturnTo)}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function chatGPTSignOutPath(returnTo = "/"): string {
|
|
||||||
const safeReturnTo = safeRelativeReturnPath(returnTo);
|
|
||||||
return `${SIGN_OUT_PATH}?return_to=${encodeURIComponent(safeReturnTo)}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function safeRelativeReturnPath(value: string): string {
|
|
||||||
if (!value.startsWith("/") || value.startsWith("//")) return "/";
|
|
||||||
|
|
||||||
let url: URL;
|
|
||||||
try {
|
|
||||||
url = new URL(value, "https://app.local");
|
|
||||||
} catch {
|
|
||||||
return "/";
|
|
||||||
}
|
|
||||||
if (url.origin !== "https://app.local") return "/";
|
|
||||||
if (isReservedAuthPath(url.pathname)) return "/";
|
|
||||||
|
|
||||||
return `${url.pathname}${url.search}${url.hash}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function isReservedAuthPath(pathname: string): boolean {
|
|
||||||
return (
|
|
||||||
pathname === SIGN_IN_PATH ||
|
|
||||||
pathname === SIGN_OUT_PATH ||
|
|
||||||
pathname === CALLBACK_PATH
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function safeDecodeURIComponent(value: string): string | null {
|
|
||||||
try {
|
|
||||||
return decodeURIComponent(value);
|
|
||||||
} catch {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
File diff suppressed because one or more lines are too long
@@ -1,34 +0,0 @@
|
|||||||
import type { Metadata } from "next";
|
|
||||||
import { Geist, Geist_Mono } from "next/font/google";
|
|
||||||
import "./globals.css";
|
|
||||||
|
|
||||||
const geistSans = Geist({
|
|
||||||
variable: "--font-geist-sans",
|
|
||||||
subsets: ["latin"],
|
|
||||||
});
|
|
||||||
|
|
||||||
const geistMono = Geist_Mono({
|
|
||||||
variable: "--font-geist-mono",
|
|
||||||
subsets: ["latin"],
|
|
||||||
});
|
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
|
||||||
title: "Project E — Your personal operating system",
|
|
||||||
description: "Tasks, habits, projects, notes, reports, and agents in one calm workspace.",
|
|
||||||
};
|
|
||||||
|
|
||||||
export default function RootLayout({
|
|
||||||
children,
|
|
||||||
}: Readonly<{
|
|
||||||
children: React.ReactNode;
|
|
||||||
}>) {
|
|
||||||
return (
|
|
||||||
<html lang="en">
|
|
||||||
<body
|
|
||||||
className={`${geistSans.variable} ${geistMono.variable} antialiased`}
|
|
||||||
>
|
|
||||||
{children}
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
import ProjectEApp from "./project-e-app";
|
|
||||||
|
|
||||||
export default function Home() {
|
|
||||||
return <ProjectEApp />;
|
|
||||||
}
|
|
||||||
@@ -1,235 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import {
|
|
||||||
Activity,
|
|
||||||
BarChart3,
|
|
||||||
Bell,
|
|
||||||
Bot,
|
|
||||||
CalendarDays,
|
|
||||||
Check,
|
|
||||||
CheckCircle2,
|
|
||||||
ChevronDown,
|
|
||||||
ChevronRight,
|
|
||||||
Circle,
|
|
||||||
Clock3,
|
|
||||||
Command,
|
|
||||||
FileBarChart,
|
|
||||||
Flame,
|
|
||||||
FolderKanban,
|
|
||||||
GripVertical,
|
|
||||||
LayoutDashboard,
|
|
||||||
ListTodo,
|
|
||||||
Menu,
|
|
||||||
MoreHorizontal,
|
|
||||||
NotebookPen,
|
|
||||||
Pause,
|
|
||||||
Play,
|
|
||||||
Plus,
|
|
||||||
Search,
|
|
||||||
Settings,
|
|
||||||
Sparkles,
|
|
||||||
Target,
|
|
||||||
TimerReset,
|
|
||||||
TrendingUp,
|
|
||||||
X,
|
|
||||||
type LucideIcon,
|
|
||||||
} from "lucide-react";
|
|
||||||
import { useEffect, useMemo, useState } from "react";
|
|
||||||
|
|
||||||
type View = "Dashboard" | "Tasks" | "Habits" | "Projects" | "Notes" | "Reports" | "Calendar" | "Analytics" | "Agent Activity" | "Settings";
|
|
||||||
type Task = { id: number; title: string; project: string; time: string; priority: "Urgent" | "High" | "Normal"; done: boolean; status: "To do" | "In progress" | "Done"; domain: "Personal" | "Work" | "OTS" };
|
|
||||||
|
|
||||||
const nav: { label: View; icon: LucideIcon }[] = [
|
|
||||||
{ label: "Dashboard", icon: LayoutDashboard }, { label: "Tasks", icon: ListTodo },
|
|
||||||
{ label: "Habits", icon: Flame }, { label: "Projects", icon: FolderKanban },
|
|
||||||
{ label: "Notes", icon: NotebookPen }, { label: "Reports", icon: FileBarChart },
|
|
||||||
{ label: "Calendar", icon: CalendarDays }, { label: "Analytics", icon: BarChart3 },
|
|
||||||
{ label: "Agent Activity", icon: Bot }, { label: "Settings", icon: Settings },
|
|
||||||
];
|
|
||||||
|
|
||||||
const initialTasks: Task[] = [
|
|
||||||
{ id: 1, title: "Finalize Q3 product brief", project: "Project E", time: "9:00 AM", priority: "Urgent", done: false, status: "In progress", domain: "Work" },
|
|
||||||
{ id: 2, title: "Review OTS storefront copy", project: "OTS Growth", time: "11:30 AM", priority: "High", done: false, status: "To do", domain: "OTS" },
|
|
||||||
{ id: 3, title: "30 minute strength session", project: "Personal OS", time: "4:00 PM", priority: "Normal", done: false, status: "To do", domain: "Personal" },
|
|
||||||
{ id: 4, title: "Map MCP permission model", project: "Project E", time: "Yesterday", priority: "High", done: true, status: "Done", domain: "Work" },
|
|
||||||
{ id: 5, title: "Archive June campaign assets", project: "OTS Growth", time: "Jul 15", priority: "Normal", done: false, status: "In progress", domain: "OTS" },
|
|
||||||
{ id: 6, title: "Plan long weekend itinerary", project: "Personal OS", time: "Jul 17", priority: "Normal", done: false, status: "To do", domain: "Personal" },
|
|
||||||
];
|
|
||||||
|
|
||||||
const habits = [
|
|
||||||
{ id: 1, icon: "↟", name: "Morning walk", streak: 18, color: "mint", done: true },
|
|
||||||
{ id: 2, icon: "◒", name: "Read 20 pages", streak: 12, color: "blue", done: false },
|
|
||||||
{ id: 3, icon: "✦", name: "Daily reflection", streak: 7, color: "amber", done: false },
|
|
||||||
{ id: 4, icon: "⌁", name: "No screens after 10", streak: 4, color: "coral", done: false },
|
|
||||||
];
|
|
||||||
|
|
||||||
const projects = [
|
|
||||||
{ name: "Project E", domain: "Work", progress: 72, due: "Sep 30", tasks: "18 / 25", color: "#356bff", icon: "E" },
|
|
||||||
{ name: "OTS Growth", domain: "OTS", progress: 54, due: "Aug 12", tasks: "13 / 24", color: "#ea7658", icon: "O" },
|
|
||||||
{ name: "Personal OS", domain: "Personal", progress: 81, due: "Ongoing", tasks: "17 / 21", color: "#299667", icon: "P" },
|
|
||||||
];
|
|
||||||
|
|
||||||
const week = [
|
|
||||||
{ day: "M", value: 58 }, { day: "T", value: 78 }, { day: "W", value: 66 },
|
|
||||||
{ day: "T", value: 91 }, { day: "F", value: 74 }, { day: "S", value: 42 }, { day: "S", value: 67 },
|
|
||||||
];
|
|
||||||
|
|
||||||
function Logo() {
|
|
||||||
return <div className="brand"><span className="brand-mark">E</span><span>Project E</span></div>;
|
|
||||||
}
|
|
||||||
|
|
||||||
function Progress({ value, color = "var(--green)" }: { value: number; color?: string }) {
|
|
||||||
return <div className="progress" aria-label={`${value}% complete`}><span style={{ width: `${value}%`, background: color }} /></div>;
|
|
||||||
}
|
|
||||||
|
|
||||||
function Dashboard({ tasks, onToggleTask, habitDone, onToggleHabit, onNavigate }: { tasks: Task[]; onToggleTask: (id: number) => void; habitDone: Record<number, boolean>; onToggleHabit: (id: number) => void; onNavigate: (view: View) => void }) {
|
|
||||||
const done = tasks.filter(t => t.done).length;
|
|
||||||
return <>
|
|
||||||
<section className="hero-grid">
|
|
||||||
<article className="focus-card panel">
|
|
||||||
<div><p className="eyebrow"><Sparkles size={14} /> Focus score</p><h2>Good momentum.</h2><p>You’re ahead of last week. One focused block will close your highest-impact task.</p></div>
|
|
||||||
<div className="score-ring" aria-label="Focus score 82 out of 100"><strong>82</strong><span>/ 100</span></div>
|
|
||||||
<button className="primary-button"><Play size={15} fill="currentColor" /> Start focus session</button>
|
|
||||||
</article>
|
|
||||||
<article className="metric panel"><span className="metric-icon blue"><CheckCircle2 /></span><div><small>Tasks complete</small><strong>{done + 11}<span>/ 18</span></strong><em><TrendingUp size={13} /> 18% this week</em></div></article>
|
|
||||||
<article className="metric panel"><span className="metric-icon coral"><Flame /></span><div><small>Best streak</small><strong>18 <span>days</span></strong><em>Morning walk</em></div></article>
|
|
||||||
<article className="metric panel"><span className="metric-icon mint"><Clock3 /></span><div><small>Focused time</small><strong>14.5 <span>hrs</span></strong><em><TrendingUp size={13} /> 2.1h vs last week</em></div></article>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section className="main-grid">
|
|
||||||
<article className="panel today-panel">
|
|
||||||
<header className="panel-header"><div><p className="eyebrow">Monday, July 13</p><h2>Today’s plan</h2></div><button className="text-button" onClick={() => onNavigate("Tasks")}>All tasks <ChevronRight size={15} /></button></header>
|
|
||||||
<div className="task-list">
|
|
||||||
{tasks.slice(0, 4).map(task => <div className={`task-row ${task.done ? "is-done" : ""}`} key={task.id}>
|
|
||||||
<button className="check-button" onClick={() => onToggleTask(task.id)} aria-label={`${task.done ? "Reopen" : "Complete"} ${task.title}`}>{task.done ? <Check size={15} /> : <Circle size={17} />}</button>
|
|
||||||
<div className="task-copy"><strong>{task.title}</strong><span><i className={`dot ${task.domain.toLowerCase()}`} />{task.project} · {task.time}</span></div>
|
|
||||||
<span className={`priority ${task.priority.toLowerCase()}`}>{task.priority}</span><button className="icon-button" aria-label={`More options for ${task.title}`}><MoreHorizontal size={17} /></button>
|
|
||||||
</div>)}
|
|
||||||
</div>
|
|
||||||
<button className="add-row"><Plus size={16} /> Add a task</button>
|
|
||||||
</article>
|
|
||||||
|
|
||||||
<article className="panel habits-panel">
|
|
||||||
<header className="panel-header"><div><p className="eyebrow">Daily rhythm</p><h2>Habits</h2></div><span className="completion-badge">{Object.values(habitDone).filter(Boolean).length}/4 done</span></header>
|
|
||||||
<div className="habit-list">
|
|
||||||
{habits.map(habit => { const isDone = habitDone[habit.id]; return <button className={`habit-row ${isDone ? "is-done" : ""}`} key={habit.id} onClick={() => onToggleHabit(habit.id)}>
|
|
||||||
<span className={`habit-icon ${habit.color}`}>{habit.icon}</span><span className="habit-copy"><strong>{habit.name}</strong><small><Flame size={12} fill="currentColor" /> {habit.streak} day streak</small></span><span className="habit-check">{isDone && <Check size={15} />}</span>
|
|
||||||
</button>})}
|
|
||||||
</div>
|
|
||||||
</article>
|
|
||||||
|
|
||||||
<article className="panel week-panel">
|
|
||||||
<header className="panel-header"><div><p className="eyebrow">July 7–13</p><h2>Weekly pulse</h2></div><button className="icon-button" aria-label="Weekly pulse options"><MoreHorizontal size={18} /></button></header>
|
|
||||||
<div className="chart-summary"><div><strong>76%</strong><span>completion rate</span></div><em><TrendingUp size={14} /> +8% vs last week</em></div>
|
|
||||||
<div className="bar-chart" aria-label="Weekly completion chart">{week.map((d, i) => <div className="bar-wrap" key={`${d.day}-${i}`}><span className={i === 3 ? "active" : ""} style={{ height: `${d.value}%` }} /><small>{d.day}</small></div>)}</div>
|
|
||||||
</article>
|
|
||||||
|
|
||||||
<article className="panel projects-panel">
|
|
||||||
<header className="panel-header"><div><p className="eyebrow">Across your life</p><h2>Active projects</h2></div><button className="text-button" onClick={() => onNavigate("Projects")}>View all <ChevronRight size={15} /></button></header>
|
|
||||||
<div className="project-list">{projects.map(p => <button className="project-row" key={p.name} onClick={() => onNavigate("Projects")}><span className="project-icon" style={{ background: p.color }}>{p.icon}</span><span className="project-copy"><span><strong>{p.name}</strong><small>{p.tasks} tasks</small></span><Progress value={p.progress} color={p.color} /></span><strong className="project-percent">{p.progress}%</strong></button>)}</div>
|
|
||||||
</article>
|
|
||||||
|
|
||||||
<article className="panel agent-panel">
|
|
||||||
<div className="agent-glow"><Bot size={25} /></div><div><p className="eyebrow">Agent workspace</p><h2>Hermes finished a task</h2><p>“Project health brief” is ready to review, with 3 risks and 5 next actions.</p></div><button className="secondary-button" onClick={() => onNavigate("Agent Activity")}>Review result <ChevronRight size={15} /></button>
|
|
||||||
</article>
|
|
||||||
</section>
|
|
||||||
</>;
|
|
||||||
}
|
|
||||||
|
|
||||||
function TasksView({ tasks, onToggle }: { tasks: Task[]; onToggle: (id: number) => void }) {
|
|
||||||
return <section className="view-stack"><div className="view-toolbar"><div className="segmented"><button className="active">Board</button><button>List</button></div><div className="toolbar-actions"><button className="secondary-button"><Search size={15} /> Filter</button><button className="primary-button"><Plus size={15} /> New task</button></div></div><div className="kanban">{(["To do", "In progress", "Done"] as const).map(status => <div className="kanban-column" key={status}><header><span><i className={`status-dot status-${status.replace(" ", "-").toLowerCase()}`} />{status}</span><small>{tasks.filter(t => t.status === status).length}</small><Plus size={15} /></header><div className="kanban-cards">{tasks.filter(t => t.status === status).map(task => <article className={`kanban-card ${task.done ? "is-done" : ""}`} key={task.id}><div className="kanban-top"><GripVertical size={15} /><span className={`priority ${task.priority.toLowerCase()}`}>{task.priority}</span><MoreHorizontal size={16} /></div><button onClick={() => onToggle(task.id)}><span className="task-check">{task.done && <Check size={13} />}</span><strong>{task.title}</strong></button><p>{task.project}</p><footer><span><CalendarDays size={13} /> {task.time}</span><span className={`domain-pill ${task.domain.toLowerCase()}`}>{task.domain}</span></footer></article>)}</div><button className="add-card"><Plus size={15} /> Add task</button></div>)}</div></section>;
|
|
||||||
}
|
|
||||||
|
|
||||||
function HabitsView({ habitDone, onToggle }: { habitDone: Record<number, boolean>; onToggle: (id: number) => void }) {
|
|
||||||
return <section className="view-stack"><div className="habit-score-banner panel"><div><p className="eyebrow">Your rhythm</p><h2>Consistency is compounding</h2><p>You completed 86% of scheduled habits in the past 30 days.</p></div><div className="score-ring small"><strong>86</strong><span>score</span></div></div><div className="habit-grid">{habits.map(h => <article className="habit-card panel" key={h.id}><div className="habit-card-top"><span className={`habit-icon large ${h.color}`}>{h.icon}</span><button className="icon-button"><MoreHorizontal size={18} /></button></div><h3>{h.name}</h3><p>Every day · Personal</p><div className="streak-line"><Flame size={18} fill="currentColor" /><strong>{h.streak}</strong><span>day streak</span></div><div className="mini-heat">{Array.from({ length: 28 }).map((_, i) => <i className={i % 7 === 5 || i > 24 ? "low" : i % 5 === 0 ? "mid" : "high"} key={i} />)}</div><button className={`habit-complete ${habitDone[h.id] ? "done" : ""}`} onClick={() => onToggle(h.id)}>{habitDone[h.id] ? <><Check size={16} /> Completed today</> : <><Circle size={16} /> Mark complete</>}</button></article>)}</div></section>;
|
|
||||||
}
|
|
||||||
|
|
||||||
function ProjectsView() {
|
|
||||||
return <section className="view-stack"><div className="view-toolbar"><div className="filter-pills"><button className="active">Active <span>3</span></button><button>Paused <span>1</span></button><button>Archived <span>6</span></button></div><button className="primary-button"><Plus size={15} /> New project</button></div><div className="projects-grid">{projects.map((p, index) => <article className="project-card panel" key={p.name}><header><span className="project-icon large" style={{ background: p.color }}>{p.icon}</span><span className={`domain-pill ${p.domain.toLowerCase()}`}>{p.domain}</span><button className="icon-button"><MoreHorizontal size={18} /></button></header><h2>{p.name}</h2><p>{index === 0 ? "A calm, agent-native system for every commitment." : index === 1 ? "Make the storefront and campaigns work as one growth engine." : "Build routines and systems that create more spacious days."}</p><div className="project-meta"><span><strong>{p.progress}%</strong> complete</span><span>{p.tasks} tasks</span></div><Progress value={p.progress} color={p.color} /><footer><span><CalendarDays size={14} /> {p.due}</span><span className="avatar-stack"><i>M</i><i>H</i></span></footer></article>)}</div></section>;
|
|
||||||
}
|
|
||||||
|
|
||||||
function NotesView() {
|
|
||||||
const notes = ["Project E — product principles", "July operating review", "OTS campaign concepts", "Reading notes: Slow Productivity"];
|
|
||||||
const [selected, setSelected] = useState(notes[0]);
|
|
||||||
return <section className="notes-shell panel"><aside><header><strong>Notes</strong><button className="icon-button"><Plus size={17} /></button></header><label className="notes-search"><Search size={14} /><input placeholder="Search notes…" /></label>{notes.map((n, i) => <button key={n} className={selected === n ? "active" : ""} onClick={() => setSelected(n)}><strong>{n}</strong><span>{i === 0 ? "Updated 18m ago" : `${i + 1} days ago`}</span></button>)}</aside><article className="note-editor"><header><div className="breadcrumb">Notes <ChevronRight size={13} /> <span>Work</span></div><div><button className="icon-button"><Bot size={17} /></button><button className="icon-button"><MoreHorizontal size={17} /></button></div></header><div className="note-body"><p className="eyebrow"># project-e · work · strategy</p><h1>{selected}</h1><p className="note-lede">A personal operating system should reduce cognitive weight, not become another system to maintain.</p><h2>North star</h2><p>Project E brings tasks, habits, projects, and knowledge into one calm surface. The model stays flexible; views do the organizing.</p><blockquote><Sparkles size={18} /> Design for the moment a person returns after a chaotic week. The system should make the next step obvious.</blockquote><h2>Working principles</h2><ul><li><span>01</span> Three equal pillars, never one buried inside another.</li><li><span>02</span> Capture quickly, organize progressively.</li><li><span>03</span> External agents operate through explicit permissions.</li></ul></div></article></section>;
|
|
||||||
}
|
|
||||||
|
|
||||||
function ReportsView() {
|
|
||||||
const items = [{ icon: BarChart3, title: "Weekly Summary", sub: "Jul 7–13", color: "blue" }, { icon: CalendarDays, title: "Monthly Review", sub: "June 2026", color: "mint" }, { icon: Target, title: "Project Health", sub: "Project E", color: "coral" }, { icon: Flame, title: "Habit Analysis", sub: "Last 30 days", color: "amber" }];
|
|
||||||
return <section className="view-stack"><div className="report-hero panel"><div><p className="eyebrow">Reports</p><h2>Turn activity into perspective.</h2><p>Generate a structured review from your actual work, habits, and time.</p></div><button className="primary-button"><Sparkles size={15} /> Generate report</button></div><div className="report-grid">{items.map(({ icon: Icon, ...r }) => <article className="report-card panel" key={r.title}><span className={`metric-icon ${r.color}`}><Icon /></span><div><h3>{r.title}</h3><p>{r.sub}</p></div><span className="report-status">Ready</span><button className="text-button">Open <ChevronRight size={14} /></button></article>)}</div><article className="panel recent-table"><header className="panel-header"><h2>Recent reports</h2><button className="text-button">View archive</button></header>{["Project E weekly health", "June time audit", "OTS campaign retrospective"].map((name, i) => <div className="table-row" key={name}><span className="file-icon"><FileBarChart size={17} /></span><strong>{name}</strong><span>{i === 0 ? "Weekly" : i === 1 ? "Time audit" : "Custom"}</span><span>Jul {13 - i * 4}</span><button className="icon-button"><MoreHorizontal size={17} /></button></div>)}</article></section>;
|
|
||||||
}
|
|
||||||
|
|
||||||
function CalendarView() {
|
|
||||||
const days = Array.from({ length: 35 }, (_, i) => i - 1);
|
|
||||||
return <section className="calendar-shell panel"><header><div><button className="secondary-button">Today</button><button className="icon-button">‹</button><button className="icon-button">›</button><h2>July 2026</h2></div><div className="segmented"><button className="active">Month</button><button>Week</button><button>Day</button></div></header><div className="calendar-grid">{["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"].map(d => <strong className="weekday" key={d}>{d}</strong>)}{days.map((d, i) => <div className={`calendar-day ${d === 13 ? "today" : ""} ${d < 1 || d > 31 ? "muted" : ""}`} key={i}><span>{d < 1 ? 29 + d : d > 31 ? d - 31 : d}</span>{d === 13 && <><em className="event work">9:00 Product brief</em><em className="event ots">11:30 OTS copy</em><em className="event personal">4:00 Strength</em></>}{d === 15 && <em className="event work">Project milestone</em>}{d === 17 && <em className="event personal">Trip planning</em>}</div>)}</div></section>;
|
|
||||||
}
|
|
||||||
|
|
||||||
function AnalyticsView() {
|
|
||||||
return <section className="view-stack"><div className="analytics-metrics"><article className="panel"><small>Completion rate</small><strong>76%</strong><span className="up"><TrendingUp size={14} /> 8.2%</span></article><article className="panel"><small>Habit consistency</small><strong>86%</strong><span className="up"><TrendingUp size={14} /> 4.1%</span></article><article className="panel"><small>Deep work</small><strong>14.5h</strong><span className="up"><TrendingUp size={14} /> 2.1h</span></article><article className="panel"><small>Active streaks</small><strong>4</strong><span>Best: 18 days</span></article></div><div className="analytics-grid"><article className="panel trend-card"><header className="panel-header"><div><p className="eyebrow">12 week view</p><h2>Productivity trend</h2></div><div className="segmented"><button className="active">Tasks</button><button>Habits</button></div></header><div className="area-chart">{[35, 47, 42, 58, 54, 69, 63, 77, 72, 88, 80, 92].map((v, i) => <i key={i} style={{ height: `${v}%` }}><span>{v}</span></i>)}</div></article><article className="panel distribution-card"><p className="eyebrow">Time distribution</p><h2>Where time went</h2><div className="donut"><div><strong>14.5h</strong><span>tracked</span></div></div><ul><li><i className="work" />Work <strong>48%</strong></li><li><i className="ots" />OTS <strong>31%</strong></li><li><i className="personal" />Personal <strong>21%</strong></li></ul></article></div></section>;
|
|
||||||
}
|
|
||||||
|
|
||||||
function AgentView() {
|
|
||||||
const events = [{ type: "done", icon: Check, title: "Completed “Project health brief”", agent: "Hermes", time: "12 minutes ago", text: "Created a report with 3 risks, 5 actions, and a milestone forecast." }, { type: "working", icon: Activity, title: "Analyzing OTS campaign performance", agent: "Hermes", time: "Started 28 minutes ago", text: "Reviewing 14 tasks and 3 linked notes." }, { type: "created", icon: Plus, title: "Created note “July research synthesis”", agent: "Claude", time: "Yesterday, 4:18 PM", text: "Generated from 8 source notes with backlinks preserved." }];
|
|
||||||
return <section className="agent-layout"><article className="panel agent-summary"><div className="agent-avatar"><Bot size={28} /></div><div><p className="eyebrow">Active agent</p><h2>Hermes</h2><p>Planning and analysis partner · Full access</p></div><span className="online"><i /> Online</span><div className="agent-stats"><span><strong>24</strong>actions</span><span><strong>8</strong>tasks</span><span><strong>4</strong>reports</span></div><button className="secondary-button">Open workspace</button></article><article className="panel activity-feed"><header className="panel-header"><div><p className="eyebrow">Audit trail</p><h2>Agent activity</h2></div><button className="secondary-button">All agents <ChevronDown size={14} /></button></header>{events.map(({ icon: Icon, ...event }) => <div className="activity-row" key={event.title}><span className={`activity-icon ${event.type}`}><Icon size={16} /></span><div><strong>{event.title}</strong><p>{event.text}</p><span>{event.agent} · {event.time}</span></div><button className="text-button">Details</button></div>)}</article></section>;
|
|
||||||
}
|
|
||||||
|
|
||||||
function SettingsView() {
|
|
||||||
return <section className="settings-layout"><aside className="panel"><button className="active">Appearance</button><button>Domains</button><button>Keyboard shortcuts</button><button>Agents & permissions</button><button>Webhooks</button><button>Import & export</button></aside><article className="panel settings-content"><p className="eyebrow">Preferences</p><h2>Appearance</h2><p>Make Project E feel right for the way you work.</p><div className="setting-row"><div><strong>Color mode</strong><span>Use a light or dark interface.</span></div><div className="segmented"><button className="active">Light</button><button>Dark</button><button>System</button></div></div><div className="setting-row"><div><strong>Accent color</strong><span>Used for actions and progress.</span></div><div className="swatches"><button className="active blue" aria-label="Blue" /><button className="green" aria-label="Green" /><button className="coral" aria-label="Coral" /><button className="violet" aria-label="Violet" /></div></div><div className="setting-row"><div><strong>Density</strong><span>Control information spacing.</span></div><div className="segmented"><button>Compact</button><button className="active">Comfortable</button><button>Spacious</button></div></div><div className="setting-row"><div><strong>Reduced motion</strong><span>Minimize interface animation.</span></div><button className="switch" aria-label="Toggle reduced motion"><span /></button></div></article></section>;
|
|
||||||
}
|
|
||||||
|
|
||||||
function CommandPalette({ onClose, onNavigate }: { onClose: () => void; onNavigate: (v: View) => void }) {
|
|
||||||
const [query, setQuery] = useState("");
|
|
||||||
const results = nav.filter(n => n.label.toLowerCase().includes(query.toLowerCase()));
|
|
||||||
return <div className="modal-backdrop" onMouseDown={onClose}><div className="command-modal" role="dialog" aria-modal="true" aria-label="Command palette" onMouseDown={e => e.stopPropagation()}><label><Search size={19} /><input autoFocus value={query} onChange={e => setQuery(e.target.value)} placeholder="Search Project E or run a command…" /><kbd>esc</kbd></label><div className="command-results"><p>Jump to</p>{results.map(({ label, icon: Icon }) => <button key={label} onClick={() => { onNavigate(label); onClose(); }}><Icon size={17} /><span>{label}</span><kbd>↵</kbd></button>)}</div><footer><span><kbd>↑</kbd><kbd>↓</kbd> navigate</span><span><kbd>↵</kbd> open</span></footer></div></div>;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function ProjectEApp() {
|
|
||||||
const [view, setView] = useState<View>("Dashboard");
|
|
||||||
const [tasks, setTasks] = useState(initialTasks);
|
|
||||||
const [habitDone, setHabitDone] = useState<Record<number, boolean>>({ 1: true, 2: false, 3: false, 4: false });
|
|
||||||
const [domain, setDomain] = useState("All domains");
|
|
||||||
const [commandOpen, setCommandOpen] = useState(false);
|
|
||||||
const [mobileNav, setMobileNav] = useState(false);
|
|
||||||
const [timerActive, setTimerActive] = useState(false);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const onKey = (event: KeyboardEvent) => {
|
|
||||||
if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === "k") { event.preventDefault(); setCommandOpen(true); }
|
|
||||||
if (event.key === "Escape") { setCommandOpen(false); setMobileNav(false); }
|
|
||||||
};
|
|
||||||
window.addEventListener("keydown", onKey); return () => window.removeEventListener("keydown", onKey);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const filteredTasks = useMemo(() => domain === "All domains" ? tasks : tasks.filter(t => t.domain === domain), [tasks, domain]);
|
|
||||||
const toggleTask = (id: number) => setTasks(current => current.map(t => t.id === id ? { ...t, done: !t.done, status: !t.done ? "Done" : "To do" } : t));
|
|
||||||
const navigate = (next: View) => { setView(next); setMobileNav(false); window.scrollTo({ top: 0, behavior: "smooth" }); };
|
|
||||||
|
|
||||||
let content;
|
|
||||||
if (view === "Dashboard") content = <Dashboard tasks={filteredTasks} onToggleTask={toggleTask} habitDone={habitDone} onToggleHabit={id => setHabitDone(v => ({ ...v, [id]: !v[id] }))} onNavigate={navigate} />;
|
|
||||||
else if (view === "Tasks") content = <TasksView tasks={filteredTasks} onToggle={toggleTask} />;
|
|
||||||
else if (view === "Habits") content = <HabitsView habitDone={habitDone} onToggle={id => setHabitDone(v => ({ ...v, [id]: !v[id] }))} />;
|
|
||||||
else if (view === "Projects") content = <ProjectsView />;
|
|
||||||
else if (view === "Notes") content = <NotesView />;
|
|
||||||
else if (view === "Reports") content = <ReportsView />;
|
|
||||||
else if (view === "Calendar") content = <CalendarView />;
|
|
||||||
else if (view === "Analytics") content = <AnalyticsView />;
|
|
||||||
else if (view === "Agent Activity") content = <AgentView />;
|
|
||||||
else content = <SettingsView />;
|
|
||||||
|
|
||||||
return <div className="app-shell">
|
|
||||||
<a href="#main" className="skip-link">Skip to content</a>
|
|
||||||
<aside className={`sidebar ${mobileNav ? "open" : ""}`}>
|
|
||||||
<div className="sidebar-top"><Logo /><button className="close-mobile icon-button" onClick={() => setMobileNav(false)} aria-label="Close navigation"><X size={19} /></button></div>
|
|
||||||
<nav aria-label="Main navigation">{nav.slice(0, 8).map(({ label, icon: Icon }) => <button key={label} className={view === label ? "active" : ""} onClick={() => navigate(label)}><Icon size={18} /><span>{label}</span>{label === "Tasks" && <em>6</em>}</button>)}</nav>
|
|
||||||
<div className="sidebar-section"><p>Workspace</p>{nav.slice(8).map(({ label, icon: Icon }) => <button key={label} className={view === label ? "active" : ""} onClick={() => navigate(label)}><Icon size={18} /><span>{label}</span>{label === "Agent Activity" && <i className="live-dot" />}</button>)}</div>
|
|
||||||
<button className={`focus-mini ${timerActive ? "active" : ""}`} onClick={() => setTimerActive(v => !v)}><span className="focus-mini-icon">{timerActive ? <Pause size={17} fill="currentColor" /> : <TimerReset size={18} />}</span><span><strong>{timerActive ? "24:32" : "Focus timer"}</strong><small>{timerActive ? "Project E brief" : "Ready when you are"}</small></span><ChevronRight size={15} /></button>
|
|
||||||
<div className="profile"><span className="profile-avatar">ME</span><span><strong>Matt</strong><small>Personal workspace</small></span><MoreHorizontal size={17} /></div>
|
|
||||||
</aside>
|
|
||||||
{mobileNav && <button className="nav-scrim" aria-label="Close navigation" onClick={() => setMobileNav(false)} />}
|
|
||||||
<div className="workspace">
|
|
||||||
<header className="topbar"><button className="mobile-menu icon-button" onClick={() => setMobileNav(true)} aria-label="Open navigation"><Menu size={20} /></button><button className="command-trigger" onClick={() => setCommandOpen(true)}><Search size={16} /><span>Search or jump to…</span><kbd><Command size={11} /> K</kbd></button><div className="top-actions"><label className="domain-select"><span className={`dot ${domain === "Work" ? "work" : domain === "OTS" ? "ots" : domain === "Personal" ? "personal" : "all"}`} /><select value={domain} onChange={e => setDomain(e.target.value)} aria-label="Filter by domain"><option>All domains</option><option>Personal</option><option>Work</option><option>OTS</option></select><ChevronDown size={13} /></label><button className="icon-button notification" aria-label="Notifications"><Bell size={18} /><i /></button><button className="quick-add" onClick={() => setCommandOpen(true)}><Plus size={17} /> <span>Quick add</span></button></div></header>
|
|
||||||
<main id="main"><header className="page-heading"><div><p className="eyebrow">{view === "Dashboard" ? "Your day, at a glance" : "Project E"}</p><h1>{view === "Dashboard" ? "Good morning, Matt." : view}</h1><p>{view === "Dashboard" ? "Monday, July 13 · You have 3 priorities and 4 habits today." : view === "Tasks" ? "Move work forward without losing the thread." : view === "Habits" ? "Small actions, visible momentum." : view === "Projects" ? "Every outcome has a home." : view === "Notes" ? "Connect ideas to the work they shape." : view === "Reports" ? "Step back and see what changed." : view === "Calendar" ? "Your commitments, in time." : view === "Analytics" ? "Patterns behind your progress." : view === "Agent Activity" ? "Every agent action, visible and reversible." : "Tune Project E to fit your work."}</p></div>{view !== "Dashboard" && view !== "Settings" && <button className="primary-button"><Plus size={15} /> New {view === "Agent Activity" ? "agent" : view.replace(/s$/, "").toLowerCase()}</button>}</header>{content}</main>
|
|
||||||
</div>
|
|
||||||
{commandOpen && <CommandPalette onClose={() => setCommandOpen(false)} onNavigate={navigate} />}
|
|
||||||
</div>;
|
|
||||||
}
|
|
||||||
@@ -1,15 +1,16 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useEffect, useState, useMemo, Suspense } from 'react';
|
import { useEffect, useState, useMemo, useCallback, Suspense } from 'react';
|
||||||
import { Filter } from 'lucide-react';
|
import { Filter, ChevronLeft, ChevronRight, CalendarDays, Calendar as CalendarIcon } from 'lucide-react';
|
||||||
import dynamic from 'next/dynamic';
|
import dynamic from 'next/dynamic';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
import { Badge } from '@/components/ui/badge';
|
import { Badge } from '@/components/ui/badge';
|
||||||
import { Checkbox } from '@/components/ui/checkbox';
|
import { Checkbox } from '@/components/ui/checkbox';
|
||||||
import { Label } from '@/components/ui/label';
|
import { Label } from '@/components/ui/label';
|
||||||
|
import { useRouter } from 'next/navigation';
|
||||||
|
|
||||||
// Lazy load react-big-calendar (~60KB + date-fns)
|
// Lazy load react-big-calendar
|
||||||
const BigCalendar = dynamic(
|
const BigCalendar = dynamic(
|
||||||
() => import('@/components/calendar/big-calendar-wrapper').then((m) => m.BigCalendarWrapper),
|
() => import('@/components/calendar/big-calendar-wrapper').then((m) => m.BigCalendarWrapper),
|
||||||
{
|
{
|
||||||
@@ -27,24 +28,48 @@ interface CalendarEvent {
|
|||||||
title: string;
|
title: string;
|
||||||
start: Date;
|
start: Date;
|
||||||
end: Date;
|
end: Date;
|
||||||
type: 'task' | 'project' | 'milestone';
|
type: 'task' | 'habit' | 'project' | 'milestone';
|
||||||
domain: string;
|
entityType: string;
|
||||||
|
entityId: string;
|
||||||
color: string;
|
color: string;
|
||||||
|
domainId: string;
|
||||||
href: string;
|
href: string;
|
||||||
|
priority?: string;
|
||||||
|
difficulty?: string;
|
||||||
|
status?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function CalendarPage() {
|
export default function CalendarPage() {
|
||||||
|
const router = useRouter();
|
||||||
const [events, setEvents] = useState<CalendarEvent[]>([]);
|
const [events, setEvents] = useState<CalendarEvent[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [currentDate, setCurrentDate] = useState(new Date());
|
||||||
|
const [view, setView] = useState<'month' | 'week' | 'day'>('month');
|
||||||
const [showTasks, setShowTasks] = useState(true);
|
const [showTasks, setShowTasks] = useState(true);
|
||||||
|
const [showHabits, setShowHabits] = useState(true);
|
||||||
const [showProjects, setShowProjects] = useState(true);
|
const [showProjects, setShowProjects] = useState(true);
|
||||||
const [showMilestones, setShowMilestones] = useState(true);
|
const [showMilestones, setShowMilestones] = useState(true);
|
||||||
const [selectedDomains, setSelectedDomains] = useState<string[]>([]);
|
const [selectedDomains, setSelectedDomains] = useState<string[]>([]);
|
||||||
const [domainOptions, setDomainOptions] = useState<{id: string; name: string}[]>([]);
|
const [domainOptions, setDomainOptions] = useState<{id: string; name: string; color: string | null}[]>([]);
|
||||||
|
const [currentDomainId, setCurrentDomainId] = useState<string | null>(null);
|
||||||
|
|
||||||
|
// Auto-switch to day view on mobile
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchEvents();
|
const checkWidth = () => {
|
||||||
|
if (window.innerWidth < 640 && view !== 'day') {
|
||||||
|
setView('day');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
checkWidth();
|
||||||
|
window.addEventListener('resize', checkWidth);
|
||||||
|
return () => window.removeEventListener('resize', checkWidth);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Get current domain from URL or default
|
||||||
|
useEffect(() => {
|
||||||
|
const pathParts = window.location.pathname.split('/');
|
||||||
|
// Try to find domain from sidebar or use first domain
|
||||||
fetchDomains();
|
fetchDomains();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
@@ -54,136 +79,182 @@ export default function CalendarPage() {
|
|||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
setDomainOptions(data.items || []);
|
setDomainOptions(data.items || []);
|
||||||
|
if (data.items?.length > 0 && !currentDomainId) {
|
||||||
|
setCurrentDomainId(data.items[0].id);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} catch {}
|
} catch {}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function fetchEvents() {
|
const fetchEvents = useCallback(async (domainId: string, from: Date, to: Date) => {
|
||||||
|
if (!domainId) return;
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
try {
|
try {
|
||||||
const [tasksResponse, projectsResponse, milestonesResponse] = await Promise.all([
|
const params = new URLSearchParams({
|
||||||
fetch('/api/tasks?perPage=500'),
|
from: from.toISOString(),
|
||||||
fetch('/api/projects?perPage=500'),
|
to: to.toISOString(),
|
||||||
fetch('/api/milestones?perPage=500'),
|
types: ['task', 'habit', 'project', 'milestone'].join(','),
|
||||||
]);
|
});
|
||||||
|
const res = await fetch(`/api/domains/${domainId}/calendar/events?${params}`);
|
||||||
if (!tasksResponse.ok || !projectsResponse.ok || !milestonesResponse.ok) {
|
if (!res.ok) throw new Error('Failed to fetch events');
|
||||||
throw new Error('One or more calendar sources could not be loaded.');
|
const data = await res.json();
|
||||||
}
|
const calendarEvents: CalendarEvent[] = (data.events || []).map((e: any) => ({
|
||||||
|
...e,
|
||||||
const [tasksData, projectsData, milestonesData] = await Promise.all([
|
start: new Date(e.start),
|
||||||
tasksResponse.json(),
|
end: new Date(e.end),
|
||||||
projectsResponse.json(),
|
}));
|
||||||
milestonesResponse.json(),
|
|
||||||
]);
|
|
||||||
|
|
||||||
const calendarEvents: CalendarEvent[] = [];
|
|
||||||
|
|
||||||
// Add tasks
|
|
||||||
if (tasksData.items) {
|
|
||||||
for (const task of tasksData.items) {
|
|
||||||
if (task.due_date) {
|
|
||||||
const date = new Date(task.due_date);
|
|
||||||
calendarEvents.push({
|
|
||||||
id: `task-${task.id}`,
|
|
||||||
title: task.title,
|
|
||||||
start: date,
|
|
||||||
end: date,
|
|
||||||
type: 'task',
|
|
||||||
domain: task.domain ?? 'personal',
|
|
||||||
color: '#3b82f6',
|
|
||||||
href: '/tasks',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add projects
|
|
||||||
if (projectsData.items) {
|
|
||||||
for (const project of projectsData.items) {
|
|
||||||
if (project.due_date) {
|
|
||||||
const date = new Date(project.due_date);
|
|
||||||
calendarEvents.push({
|
|
||||||
id: `project-${project.id}`,
|
|
||||||
title: project.name,
|
|
||||||
start: date,
|
|
||||||
end: date,
|
|
||||||
type: 'project',
|
|
||||||
domain: project.domain ?? 'personal',
|
|
||||||
color: '#8b5cf6',
|
|
||||||
href: `/projects/${project.id}`,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add milestones
|
|
||||||
if (milestonesData.items) {
|
|
||||||
for (const milestone of milestonesData.items) {
|
|
||||||
if (milestone.due_date) {
|
|
||||||
const date = new Date(milestone.due_date);
|
|
||||||
calendarEvents.push({
|
|
||||||
id: `milestone-${milestone.id}`,
|
|
||||||
title: milestone.name || milestone.title,
|
|
||||||
start: date,
|
|
||||||
end: date,
|
|
||||||
type: 'milestone',
|
|
||||||
domain: milestone.domain ?? 'work',
|
|
||||||
color: '#f59e0b',
|
|
||||||
href: milestone.project_id ? `/projects/${milestone.project_id}` : '/projects',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
setEvents(calendarEvents);
|
setEvents(calendarEvents);
|
||||||
} catch (error) {
|
} catch (err) {
|
||||||
console.error('Failed to fetch calendar events:', error);
|
console.error('Failed to fetch calendar events:', err);
|
||||||
setError('Calendar events could not be loaded. Please try again.');
|
setError('Calendar events could not be loaded. Please try again.');
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Fetch events when domain or date range changes
|
||||||
|
useEffect(() => {
|
||||||
|
if (!currentDomainId) return;
|
||||||
|
const range = getViewRange(currentDate, view);
|
||||||
|
fetchEvents(currentDomainId, range.from, range.to);
|
||||||
|
}, [currentDomainId, currentDate, view, fetchEvents]);
|
||||||
|
|
||||||
|
// Calendar keyboard shortcuts
|
||||||
|
useEffect(() => {
|
||||||
|
const handleKeyDown = (e: KeyboardEvent) => {
|
||||||
|
const target = e.target as HTMLElement;
|
||||||
|
if (
|
||||||
|
target.tagName === 'INPUT' ||
|
||||||
|
target.tagName === 'TEXTAREA' ||
|
||||||
|
target.tagName === 'SELECT' ||
|
||||||
|
target.isContentEditable
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
switch (e.key.toLowerCase()) {
|
||||||
|
case 't':
|
||||||
|
navigate('today');
|
||||||
|
e.preventDefault();
|
||||||
|
break;
|
||||||
|
case 'm':
|
||||||
|
setView('month');
|
||||||
|
e.preventDefault();
|
||||||
|
break;
|
||||||
|
case 'w':
|
||||||
|
setView('week');
|
||||||
|
e.preventDefault();
|
||||||
|
break;
|
||||||
|
case 'd':
|
||||||
|
setView('day');
|
||||||
|
e.preventDefault();
|
||||||
|
break;
|
||||||
|
case 'arrowleft':
|
||||||
|
navigate('prev');
|
||||||
|
e.preventDefault();
|
||||||
|
break;
|
||||||
|
case 'arrowright':
|
||||||
|
navigate('next');
|
||||||
|
e.preventDefault();
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
document.addEventListener('keydown', handleKeyDown);
|
||||||
|
return () => document.removeEventListener('keydown', handleKeyDown);
|
||||||
|
}, [currentDate, view]);
|
||||||
|
|
||||||
|
function getViewRange(date: Date, v: string): { from: Date; to: Date } {
|
||||||
|
const from = new Date(date);
|
||||||
|
const to = new Date(date);
|
||||||
|
switch (v) {
|
||||||
|
case 'month':
|
||||||
|
from.setDate(1);
|
||||||
|
from.setHours(0, 0, 0, 0);
|
||||||
|
to.setMonth(to.getMonth() + 1, 0);
|
||||||
|
to.setHours(23, 59, 59, 999);
|
||||||
|
// Add buffer for week overlap
|
||||||
|
from.setDate(from.getDate() - 7);
|
||||||
|
to.setDate(to.getDate() + 7);
|
||||||
|
break;
|
||||||
|
case 'week': {
|
||||||
|
const day = from.getDay();
|
||||||
|
from.setDate(from.getDate() - day);
|
||||||
|
from.setHours(0, 0, 0, 0);
|
||||||
|
to.setDate(to.getDate() + (6 - day));
|
||||||
|
to.setHours(23, 59, 59, 999);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'day':
|
||||||
|
from.setHours(0, 0, 0, 0);
|
||||||
|
to.setHours(23, 59, 59, 999);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
return { from, to };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const navigate = (direction: 'prev' | 'next' | 'today') => {
|
||||||
|
const d = new Date(currentDate);
|
||||||
|
switch (direction) {
|
||||||
|
case 'prev':
|
||||||
|
if (view === 'month') d.setMonth(d.getMonth() - 1);
|
||||||
|
else if (view === 'week') d.setDate(d.getDate() - 7);
|
||||||
|
else d.setDate(d.getDate() - 1);
|
||||||
|
break;
|
||||||
|
case 'next':
|
||||||
|
if (view === 'month') d.setMonth(d.getMonth() + 1);
|
||||||
|
else if (view === 'week') d.setDate(d.getDate() + 7);
|
||||||
|
else d.setDate(d.getDate() + 1);
|
||||||
|
break;
|
||||||
|
case 'today':
|
||||||
|
d.setTime(Date.now());
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
setCurrentDate(d);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleEventDrop = async (event: CalendarEvent, newStart: Date) => {
|
||||||
|
if (event.entityType !== 'task') return;
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/domains/${currentDomainId}/tasks/${event.entityId}/schedule`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ dueDate: newStart.toISOString() }),
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error('Failed to reschedule');
|
||||||
|
// Refresh events
|
||||||
|
const range = getViewRange(currentDate, view);
|
||||||
|
fetchEvents(currentDomainId!, range.from, range.to);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to reschedule task:', err);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const filteredEvents = useMemo(() => {
|
const filteredEvents = useMemo(() => {
|
||||||
return events.filter((event) => {
|
return events.filter((event) => {
|
||||||
// Filter by type
|
|
||||||
if (event.type === 'task' && !showTasks) return false;
|
if (event.type === 'task' && !showTasks) return false;
|
||||||
|
if (event.type === 'habit' && !showHabits) return false;
|
||||||
if (event.type === 'project' && !showProjects) return false;
|
if (event.type === 'project' && !showProjects) return false;
|
||||||
if (event.type === 'milestone' && !showMilestones) return false;
|
if (event.type === 'milestone' && !showMilestones) return false;
|
||||||
|
if (selectedDomains.length > 0 && !selectedDomains.includes(event.domainId)) return false;
|
||||||
// Filter by domain
|
|
||||||
if (selectedDomains.length > 0 && !selectedDomains.includes(event.domain)) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
});
|
});
|
||||||
}, [events, showTasks, showProjects, showMilestones, selectedDomains]);
|
}, [events, showTasks, showHabits, showProjects, showMilestones, selectedDomains]);
|
||||||
|
|
||||||
function toggleDomain(domain: string) {
|
const toggleDomain = (domainId: string) => {
|
||||||
setSelectedDomains((prev) =>
|
setSelectedDomains((prev) =>
|
||||||
prev.includes(domain) ? prev.filter((d) => d !== domain) : [...prev, domain]
|
prev.includes(domainId) ? prev.filter((d) => d !== domainId) : [...prev, domainId]
|
||||||
);
|
);
|
||||||
}
|
};
|
||||||
|
|
||||||
if (loading) {
|
const formatTitle = () => {
|
||||||
return (
|
const opts: Intl.DateTimeFormatOptions = {};
|
||||||
<div className="flex items-center justify-center py-20">
|
if (view === 'month') { opts.month = 'long'; opts.year = 'numeric'; }
|
||||||
<p className="text-muted-foreground">Loading calendar...</p>
|
else if (view === 'week') { opts.month = 'short'; opts.day = 'numeric'; }
|
||||||
</div>
|
else { opts.weekday = 'long'; opts.month = 'long'; opts.day = 'numeric'; opts.year = 'numeric'; }
|
||||||
);
|
return currentDate.toLocaleDateString('en-US', opts);
|
||||||
}
|
};
|
||||||
|
|
||||||
if (error) {
|
|
||||||
return (
|
|
||||||
<div className="space-y-4 py-20 text-center">
|
|
||||||
<p className="text-muted-foreground" role="alert">{error}</p>
|
|
||||||
<Button onClick={fetchEvents}>Retry</Button>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
@@ -194,6 +265,36 @@ export default function CalendarPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Toolbar */}
|
||||||
|
<div className="mb-4 flex flex-wrap items-center gap-2">
|
||||||
|
<Button variant="outline" size="sm" onClick={() => navigate('today')}>
|
||||||
|
<CalendarIcon className="mr-1 h-4 w-4" />
|
||||||
|
Today
|
||||||
|
</Button>
|
||||||
|
<div className="flex">
|
||||||
|
<Button variant="ghost" size="icon" onClick={() => navigate('prev')}>
|
||||||
|
<ChevronLeft className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
<Button variant="ghost" size="icon" onClick={() => navigate('next')}>
|
||||||
|
<ChevronRight className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<h2 className="min-w-[180px] text-lg font-semibold">{formatTitle()}</h2>
|
||||||
|
<div className="ml-auto flex rounded-lg border">
|
||||||
|
{(['month', 'week', 'day'] as const).map((v) => (
|
||||||
|
<Button
|
||||||
|
key={v}
|
||||||
|
variant={view === v ? 'default' : 'ghost'}
|
||||||
|
size="sm"
|
||||||
|
className="rounded-none capitalize"
|
||||||
|
onClick={() => setView(v)}
|
||||||
|
>
|
||||||
|
{v}
|
||||||
|
</Button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-1 gap-6 lg:grid-cols-[250px_1fr]">
|
<div className="grid grid-cols-1 gap-6 lg:grid-cols-[250px_1fr]">
|
||||||
{/* Filters sidebar */}
|
{/* Filters sidebar */}
|
||||||
<Card>
|
<Card>
|
||||||
@@ -206,36 +307,31 @@ export default function CalendarPage() {
|
|||||||
<CardContent className="space-y-6">
|
<CardContent className="space-y-6">
|
||||||
{/* Entity types */}
|
{/* Entity types */}
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<h2 className="text-sm font-semibold">Show</h2>
|
<h3 className="text-sm font-semibold">Show</h3>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<div className="flex items-center space-x-2">
|
<div className="flex items-center space-x-2">
|
||||||
<Checkbox
|
<Checkbox id="tasks" checked={showTasks} onCheckedChange={(c) => setShowTasks(c === true)} />
|
||||||
id="tasks"
|
|
||||||
checked={showTasks}
|
|
||||||
onCheckedChange={(checked) => setShowTasks(checked === true)}
|
|
||||||
/>
|
|
||||||
<Label htmlFor="tasks" className="flex items-center gap-2">
|
<Label htmlFor="tasks" className="flex items-center gap-2">
|
||||||
<div className="h-3 w-3 rounded" style={{ backgroundColor: '#3b82f6' }} />
|
<div className="h-3 w-3 rounded" style={{ backgroundColor: '#3b82f6' }} />
|
||||||
Tasks
|
Tasks
|
||||||
</Label>
|
</Label>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center space-x-2">
|
<div className="flex items-center space-x-2">
|
||||||
<Checkbox
|
<Checkbox id="habits" checked={showHabits} onCheckedChange={(c) => setShowHabits(c === true)} />
|
||||||
id="projects"
|
<Label htmlFor="habits" className="flex items-center gap-2">
|
||||||
checked={showProjects}
|
<div className="h-3 w-3 rounded" style={{ backgroundColor: '#22c55e' }} />
|
||||||
onCheckedChange={(checked) => setShowProjects(checked === true)}
|
Habits
|
||||||
/>
|
</Label>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<Checkbox id="projects" checked={showProjects} onCheckedChange={(c) => setShowProjects(c === true)} />
|
||||||
<Label htmlFor="projects" className="flex items-center gap-2">
|
<Label htmlFor="projects" className="flex items-center gap-2">
|
||||||
<div className="h-3 w-3 rounded" style={{ backgroundColor: '#8b5cf6' }} />
|
<div className="h-3 w-3 rounded" style={{ backgroundColor: '#8b5cf6' }} />
|
||||||
Projects
|
Projects
|
||||||
</Label>
|
</Label>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center space-x-2">
|
<div className="flex items-center space-x-2">
|
||||||
<Checkbox
|
<Checkbox id="milestones" checked={showMilestones} onCheckedChange={(c) => setShowMilestones(c === true)} />
|
||||||
id="milestones"
|
|
||||||
checked={showMilestones}
|
|
||||||
onCheckedChange={(checked) => setShowMilestones(checked === true)}
|
|
||||||
/>
|
|
||||||
<Label htmlFor="milestones" className="flex items-center gap-2">
|
<Label htmlFor="milestones" className="flex items-center gap-2">
|
||||||
<div className="h-3 w-3 rounded" style={{ backgroundColor: '#f59e0b' }} />
|
<div className="h-3 w-3 rounded" style={{ backgroundColor: '#f59e0b' }} />
|
||||||
Milestones
|
Milestones
|
||||||
@@ -246,9 +342,9 @@ export default function CalendarPage() {
|
|||||||
|
|
||||||
{/* Domains */}
|
{/* Domains */}
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<h2 className="text-sm font-semibold">Domains</h2>
|
<h3 className="text-sm font-semibold">Domains</h3>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
{(domainOptions.length > 0 ? domainOptions : []).map((domain) => (
|
{domainOptions.map((domain) => (
|
||||||
<div key={domain.id} className="flex items-center space-x-2">
|
<div key={domain.id} className="flex items-center space-x-2">
|
||||||
<Checkbox
|
<Checkbox
|
||||||
id={domain.id}
|
id={domain.id}
|
||||||
@@ -262,12 +358,7 @@ export default function CalendarPage() {
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
{selectedDomains.length > 0 && (
|
{selectedDomains.length > 0 && (
|
||||||
<Button
|
<Button variant="ghost" size="sm" onClick={() => setSelectedDomains([])} className="text-xs">
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
onClick={() => setSelectedDomains([])}
|
|
||||||
className="text-xs"
|
|
||||||
>
|
|
||||||
Clear filters
|
Clear filters
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
@@ -275,9 +366,10 @@ export default function CalendarPage() {
|
|||||||
|
|
||||||
{/* Legend */}
|
{/* Legend */}
|
||||||
<div className="space-y-2 border-t pt-4">
|
<div className="space-y-2 border-t pt-4">
|
||||||
<h2 className="text-sm font-semibold">Legend</h2>
|
<h3 className="text-sm font-semibold">Legend</h3>
|
||||||
<div className="space-y-1 text-xs text-muted-foreground">
|
<div className="space-y-1 text-xs text-muted-foreground">
|
||||||
<p>• Tasks show on due date</p>
|
<p>• Tasks show on due date (color = priority)</p>
|
||||||
|
<p>• Habits show daily (color = difficulty)</p>
|
||||||
<p>• Projects show on deadline</p>
|
<p>• Projects show on deadline</p>
|
||||||
<p>• Milestones show on due date</p>
|
<p>• Milestones show on due date</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -288,15 +380,34 @@ export default function CalendarPage() {
|
|||||||
{/* Calendar */}
|
{/* Calendar */}
|
||||||
<Card>
|
<Card>
|
||||||
<CardContent className="p-4">
|
<CardContent className="p-4">
|
||||||
<Suspense
|
{error ? (
|
||||||
fallback={
|
<div className="flex flex-col items-center gap-4 py-20">
|
||||||
<div className="flex h-[600px] items-center justify-center rounded-lg border bg-muted/30">
|
<p className="text-muted-foreground" role="alert">{error}</p>
|
||||||
<div className="animate-pulse text-sm text-muted-foreground">Loading calendar...</div>
|
<Button onClick={() => {
|
||||||
</div>
|
const range = getViewRange(currentDate, view);
|
||||||
}
|
if (currentDomainId) fetchEvents(currentDomainId, range.from, range.to);
|
||||||
>
|
}}>
|
||||||
<BigCalendar events={filteredEvents} />
|
Retry
|
||||||
</Suspense>
|
</Button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<Suspense
|
||||||
|
fallback={
|
||||||
|
<div className="flex h-[600px] items-center justify-center rounded-lg border bg-muted/30">
|
||||||
|
<div className="animate-pulse text-sm text-muted-foreground">Loading calendar...</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<BigCalendar
|
||||||
|
events={filteredEvents}
|
||||||
|
onEventDrop={handleEventDrop}
|
||||||
|
defaultView={view}
|
||||||
|
date={currentDate}
|
||||||
|
onNavigate={setCurrentDate}
|
||||||
|
onViewChange={(v: string) => setView(v as 'month' | 'week' | 'day')}
|
||||||
|
/>
|
||||||
|
</Suspense>
|
||||||
|
)}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,10 +1,12 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import React, { Suspense } from 'react';
|
import React, { Suspense, useEffect, useState } from 'react';
|
||||||
import dynamic from 'next/dynamic';
|
import dynamic from 'next/dynamic';
|
||||||
import { useDashboardStore } from '@/lib/stores/use-dashboard-store';
|
import { useDashboardStore } from '@/lib/stores/use-dashboard-store';
|
||||||
import { WidgetErrorBoundary } from '@/components/widget-error-boundary';
|
import { WidgetErrorBoundary } from '@/components/widget-error-boundary';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Settings2, LayoutGrid } from 'lucide-react';
|
||||||
|
import { useRouter } from 'next/navigation';
|
||||||
|
|
||||||
// Lazy load react-grid-layout (client-only, ~45KB)
|
// Lazy load react-grid-layout (client-only, ~45KB)
|
||||||
const ResponsiveGridLayout = dynamic(
|
const ResponsiveGridLayout = dynamic(
|
||||||
@@ -21,84 +23,15 @@ const ResponsiveGridLayout = dynamic(
|
|||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
// Lazy load individual widgets — each is code-split into its own chunk
|
// Lazy load individual widgets
|
||||||
const TodayTasksWidget = dynamic(
|
const TodayTasksWidget = dynamic(() => import('@/components/dashboard/widgets/today-tasks-widget').then((m) => m.TodayTasksWidget), { ssr: false, loading: () => <WidgetSkeleton /> });
|
||||||
() =>
|
const HabitChecklistWidget = dynamic(() => import('@/components/dashboard/widgets/habit-checklist-widget').then((m) => m.HabitChecklistWidget), { ssr: false, loading: () => <WidgetSkeleton /> });
|
||||||
import('@/components/dashboard/widgets/today-tasks-widget').then((m) => m.TodayTasksWidget),
|
const WeeklyStatsWidget = dynamic(() => import('@/components/dashboard/widgets/weekly-stats-widget').then((m) => m.WeeklyStatsWidget), { ssr: false, loading: () => <WidgetSkeleton /> });
|
||||||
{
|
const ProjectProgressWidget = dynamic(() => import('@/components/dashboard/widgets/project-progress-widget').then((m) => m.ProjectProgressWidget), { ssr: false, loading: () => <WidgetSkeleton /> });
|
||||||
ssr: false,
|
const UpcomingCalendarWidget = dynamic(() => import('@/components/dashboard/widgets/upcoming-calendar-widget').then((m) => m.UpcomingCalendarWidget), { ssr: false, loading: () => <WidgetSkeleton /> });
|
||||||
loading: () => <WidgetSkeleton />,
|
const RecentNotesWidget = dynamic(() => import('@/components/dashboard/widgets/recent-notes-widget').then((m) => m.RecentNotesWidget), { ssr: false, loading: () => <WidgetSkeleton /> });
|
||||||
}
|
const ActivityFeedWidget = dynamic(() => import('@/components/dashboard/widgets/activity-feed-widget').then((m) => m.ActivityFeedWidget), { ssr: false, loading: () => <WidgetSkeleton /> });
|
||||||
);
|
const QuickCaptureWidget = dynamic(() => import('@/components/dashboard/widgets/quick-capture-widget').then((m) => m.QuickCaptureWidget), { ssr: false, loading: () => <WidgetSkeleton /> });
|
||||||
|
|
||||||
const HabitChecklistWidget = dynamic(
|
|
||||||
() =>
|
|
||||||
import('@/components/dashboard/widgets/habit-checklist-widget').then(
|
|
||||||
(m) => m.HabitChecklistWidget
|
|
||||||
),
|
|
||||||
{
|
|
||||||
ssr: false,
|
|
||||||
loading: () => <WidgetSkeleton />,
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
const WeeklyStatsWidget = dynamic(
|
|
||||||
() =>
|
|
||||||
import('@/components/dashboard/widgets/weekly-stats-widget').then((m) => m.WeeklyStatsWidget),
|
|
||||||
{
|
|
||||||
ssr: false,
|
|
||||||
loading: () => <WidgetSkeleton />,
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
const ProjectProgressWidget = dynamic(
|
|
||||||
() =>
|
|
||||||
import('@/components/dashboard/widgets/project-progress-widget').then(
|
|
||||||
(m) => m.ProjectProgressWidget
|
|
||||||
),
|
|
||||||
{
|
|
||||||
ssr: false,
|
|
||||||
loading: () => <WidgetSkeleton />,
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
const HabitStreaksWidget = dynamic(
|
|
||||||
() =>
|
|
||||||
import('@/components/dashboard/widgets/habit-streaks-widget').then((m) => m.HabitStreaksWidget),
|
|
||||||
{
|
|
||||||
ssr: false,
|
|
||||||
loading: () => <WidgetSkeleton />,
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
const CalendarMiniWidget = dynamic(
|
|
||||||
() =>
|
|
||||||
import('@/components/dashboard/widgets/calendar-mini-widget').then((m) => m.CalendarMiniWidget),
|
|
||||||
{
|
|
||||||
ssr: false,
|
|
||||||
loading: () => <WidgetSkeleton />,
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
const QuickAddWidget = dynamic(
|
|
||||||
() =>
|
|
||||||
import('@/components/dashboard/widgets/quick-add-widget').then((m) => m.QuickAddWidget),
|
|
||||||
{
|
|
||||||
ssr: false,
|
|
||||||
loading: () => <WidgetSkeleton />,
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
const RecentActivityWidget = dynamic(
|
|
||||||
() =>
|
|
||||||
import('@/components/dashboard/widgets/recent-activity-widget').then(
|
|
||||||
(m) => m.RecentActivityWidget
|
|
||||||
),
|
|
||||||
{
|
|
||||||
ssr: false,
|
|
||||||
loading: () => <WidgetSkeleton />,
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
function WidgetSkeleton() {
|
function WidgetSkeleton() {
|
||||||
return (
|
return (
|
||||||
@@ -118,15 +51,29 @@ const widgetComponents: Record<string, React.ComponentType> = {
|
|||||||
'habit-checklist': HabitChecklistWidget,
|
'habit-checklist': HabitChecklistWidget,
|
||||||
'weekly-stats': WeeklyStatsWidget,
|
'weekly-stats': WeeklyStatsWidget,
|
||||||
'project-progress': ProjectProgressWidget,
|
'project-progress': ProjectProgressWidget,
|
||||||
'habit-streaks': HabitStreaksWidget,
|
'upcoming-calendar': UpcomingCalendarWidget,
|
||||||
'calendar-mini': CalendarMiniWidget,
|
'recent-notes': RecentNotesWidget,
|
||||||
'quick-add': QuickAddWidget,
|
'activity-feed': ActivityFeedWidget,
|
||||||
'recent-activity': RecentActivityWidget,
|
'quick-capture': QuickCaptureWidget,
|
||||||
|
};
|
||||||
|
|
||||||
|
const widgetLabels: Record<string, string> = {
|
||||||
|
'today-tasks': "Today's Tasks",
|
||||||
|
'habit-checklist': 'Habit Checklist',
|
||||||
|
'weekly-stats': 'Weekly Stats',
|
||||||
|
'project-progress': 'Project Progress',
|
||||||
|
'upcoming-calendar': 'Upcoming Calendar',
|
||||||
|
'recent-notes': 'Recent Notes',
|
||||||
|
'activity-feed': 'Activity Feed',
|
||||||
|
'quick-capture': 'Quick Capture',
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function DashboardPage() {
|
export default function DashboardPage() {
|
||||||
const { widgets, setWidgets } = useDashboardStore();
|
const { widgets, setWidgets, addWidget, removeWidget } = useDashboardStore();
|
||||||
const [layoutAnnouncement, setLayoutAnnouncement] = React.useState('');
|
const [layoutAnnouncement, setLayoutAnnouncement] = React.useState('');
|
||||||
|
const [editMode, setEditMode] = React.useState(false);
|
||||||
|
const [showConfig, setShowConfig] = React.useState(false);
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
const layout = widgets.map((w) => ({
|
const layout = widgets.map((w) => ({
|
||||||
i: w.id,
|
i: w.id,
|
||||||
@@ -140,81 +87,98 @@ export default function DashboardPage() {
|
|||||||
const updated = widgets.map((w) => {
|
const updated = widgets.map((w) => {
|
||||||
const layoutItem = newLayout.find((l) => l.i === w.id);
|
const layoutItem = newLayout.find((l) => l.i === w.id);
|
||||||
if (layoutItem) {
|
if (layoutItem) {
|
||||||
return {
|
return { ...w, x: layoutItem.x, y: layoutItem.y, w: layoutItem.w, h: layoutItem.h };
|
||||||
...w,
|
|
||||||
x: layoutItem.x,
|
|
||||||
y: layoutItem.y,
|
|
||||||
w: layoutItem.w,
|
|
||||||
h: layoutItem.h,
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
return w;
|
return w;
|
||||||
});
|
});
|
||||||
setWidgets(updated);
|
setWidgets(updated);
|
||||||
}
|
}
|
||||||
|
|
||||||
function moveWidget(id: string, direction: -1 | 1) {
|
const availableWidgets = Object.keys(widgetComponents).filter((id) => !widgets.find((w) => w.id === id));
|
||||||
const ordered = [...widgets].sort((a, b) => a.y - b.y || a.x - b.x);
|
|
||||||
const index = ordered.findIndex((widget) => widget.id === id);
|
|
||||||
const targetIndex = index + direction;
|
|
||||||
if (index < 0 || targetIndex < 0 || targetIndex >= ordered.length) return;
|
|
||||||
|
|
||||||
const current = ordered[index];
|
function addNewWidget(widgetId: string) {
|
||||||
const target = ordered[targetIndex];
|
addWidget({
|
||||||
setWidgets(widgets.map((widget) => {
|
id: widgetId,
|
||||||
if (widget.id === current.id) return { ...widget, x: target.x, y: target.y };
|
type: widgetLabels[widgetId] || widgetId,
|
||||||
if (widget.id === target.id) return { ...widget, x: current.x, y: current.y };
|
x: 0,
|
||||||
return widget;
|
y: widgets.length,
|
||||||
}));
|
w: 4,
|
||||||
setLayoutAnnouncement(`${current.type} moved ${direction < 0 ? 'earlier' : 'later'} on the dashboard.`);
|
h: 3,
|
||||||
}
|
visible: true,
|
||||||
|
});
|
||||||
function resizeWidget(id: string, direction: -1 | 1) {
|
|
||||||
const widget = widgets.find((item) => item.id === id);
|
|
||||||
if (!widget) return;
|
|
||||||
const width = Math.max(2, Math.min(12, widget.w + direction));
|
|
||||||
if (width === widget.w) return;
|
|
||||||
setWidgets(widgets.map((item) => item.id === id ? { ...item, w: width } : item));
|
|
||||||
setLayoutAnnouncement(`${widget.type} is now ${width} columns wide.`);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<div className="mb-6">
|
<div className="mb-6 flex items-center justify-between">
|
||||||
<h1 className="text-2xl font-bold">Dashboard</h1>
|
<div>
|
||||||
<p className="mt-1 text-muted-foreground">Your day, at a glance.</p>
|
<h1 className="text-2xl font-bold">Dashboard</h1>
|
||||||
|
<p className="mt-1 text-muted-foreground">Your day, at a glance.</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Button
|
||||||
|
variant={editMode ? 'default' : 'outline'}
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setEditMode(!editMode)}
|
||||||
|
>
|
||||||
|
<LayoutGrid className="mr-1 h-4 w-4" />
|
||||||
|
{editMode ? 'Done' : 'Edit'}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setShowConfig(!showConfig)}
|
||||||
|
>
|
||||||
|
<Settings2 className="mr-1 h-4 w-4" />
|
||||||
|
Configure
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<details className="mb-4 rounded-lg border bg-card p-3">
|
{/* Widget configuration panel */}
|
||||||
<summary className="cursor-pointer text-sm font-medium">Customize dashboard layout</summary>
|
{showConfig && (
|
||||||
<p className="mt-2 text-sm text-muted-foreground">
|
<div className="mb-6 rounded-lg border bg-card p-4">
|
||||||
Use these controls to reorder or resize widgets without dragging.
|
<h3 className="mb-3 text-sm font-semibold">Add Widgets</h3>
|
||||||
</p>
|
<div className="flex flex-wrap gap-2">
|
||||||
<div className="mt-3 space-y-2">
|
{availableWidgets.length === 0 ? (
|
||||||
{[...widgets].sort((a, b) => a.y - b.y || a.x - b.x).map((widget, index, ordered) => (
|
<p className="text-sm text-muted-foreground">All widgets are already on your dashboard.</p>
|
||||||
<div key={widget.id} className="flex items-center justify-between gap-3 rounded-md bg-muted/50 px-3 py-2">
|
) : (
|
||||||
<span className="text-sm">{widget.type}</span>
|
availableWidgets.map((id) => (
|
||||||
<div className="flex gap-2">
|
<Button
|
||||||
<Button variant="outline" size="sm" onClick={() => moveWidget(widget.id, -1)} disabled={index === 0}>
|
key={id}
|
||||||
Move earlier
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => addNewWidget(id)}
|
||||||
|
>
|
||||||
|
+ {widgetLabels[id] || id}
|
||||||
</Button>
|
</Button>
|
||||||
<Button variant="outline" size="sm" onClick={() => moveWidget(widget.id, 1)} disabled={index === ordered.length - 1}>
|
))
|
||||||
Move later
|
)}
|
||||||
</Button>
|
</div>
|
||||||
<Button variant="outline" size="sm" onClick={() => resizeWidget(widget.id, -1)} disabled={widget.w <= 2}>
|
<div className="mt-4">
|
||||||
Narrower
|
<h3 className="mb-3 text-sm font-semibold">Active Widgets</h3>
|
||||||
</Button>
|
<div className="space-y-2">
|
||||||
<Button variant="outline" size="sm" onClick={() => resizeWidget(widget.id, 1)} disabled={widget.w >= 12}>
|
{widgets.map((w) => (
|
||||||
Wider
|
<div key={w.id} className="flex items-center justify-between rounded-md bg-muted/50 px-3 py-2">
|
||||||
</Button>
|
<span className="text-sm">{widgetLabels[w.id] || w.type}</span>
|
||||||
</div>
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="text-destructive"
|
||||||
|
onClick={() => removeWidget(w.id)}
|
||||||
|
>
|
||||||
|
Remove
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
</div>
|
</div>
|
||||||
))}
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</details>
|
)}
|
||||||
|
|
||||||
<p className="sr-only" aria-live="polite">{layoutAnnouncement}</p>
|
<p className="sr-only" aria-live="polite">{layoutAnnouncement}</p>
|
||||||
|
|
||||||
<ResponsiveGridLayout layout={layout} onLayoutChange={handleLayoutChange}>
|
<ResponsiveGridLayout layout={layout} onLayoutChange={handleLayoutChange} isDraggable={editMode} isResizable={editMode}>
|
||||||
{widgets.map((widget) => {
|
{widgets.map((widget) => {
|
||||||
const WidgetComponent = widgetComponents[widget.id];
|
const WidgetComponent = widgetComponents[widget.id];
|
||||||
if (!WidgetComponent) return null;
|
if (!WidgetComponent) return null;
|
||||||
@@ -223,7 +187,7 @@ export default function DashboardPage() {
|
|||||||
<div key={widget.id}>
|
<div key={widget.id}>
|
||||||
<WidgetErrorBoundary widgetName={widget.type}>
|
<WidgetErrorBoundary widgetName={widget.type}>
|
||||||
<div className="h-full rounded-lg border bg-card p-4 shadow-sm">
|
<div className="h-full rounded-lg border bg-card p-4 shadow-sm">
|
||||||
<div className="widget-drag-handle">
|
<div className={editMode ? 'widget-drag-handle' : ''}>
|
||||||
<Suspense fallback={<WidgetSkeleton />}>
|
<Suspense fallback={<WidgetSkeleton />}>
|
||||||
<WidgetComponent />
|
<WidgetComponent />
|
||||||
</Suspense>
|
</Suspense>
|
||||||
|
|||||||
@@ -0,0 +1,291 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useEffect, useState, useMemo, useCallback, Suspense } from 'react';
|
||||||
|
import { useRouter, useSearchParams } from 'next/navigation';
|
||||||
|
import { Search, Calendar, ListTodo, BookOpen, FolderKanban, Hash, ExternalLink, Clock, Filter, X } from 'lucide-react';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Input } from '@/components/ui/input';
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
|
import { Badge } from '@/components/ui/badge';
|
||||||
|
import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||||
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||||
|
|
||||||
|
interface SearchResult {
|
||||||
|
id: string;
|
||||||
|
type: 'task' | 'note' | 'project' | 'habit' | 'domain';
|
||||||
|
title: string;
|
||||||
|
snippet: string;
|
||||||
|
score: number;
|
||||||
|
workspaceId: string;
|
||||||
|
link: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const typeIcons: Record<string, React.ReactNode> = {
|
||||||
|
task: <ListTodo className="h-4 w-4" />,
|
||||||
|
note: <BookOpen className="h-4 w-4" />,
|
||||||
|
project: <FolderKanban className="h-4 w-4" />,
|
||||||
|
habit: <Hash className="h-4 w-4" />,
|
||||||
|
domain: <Calendar className="h-4 w-4" />,
|
||||||
|
};
|
||||||
|
|
||||||
|
const typeColors: Record<string, string> = {
|
||||||
|
task: 'bg-blue-500/10 text-blue-600',
|
||||||
|
note: 'bg-green-500/10 text-green-600',
|
||||||
|
project: 'bg-purple-500/10 text-purple-600',
|
||||||
|
habit: 'bg-orange-500/10 text-orange-600',
|
||||||
|
domain: 'bg-gray-500/10 text-gray-600',
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function SearchPage() {
|
||||||
|
return (
|
||||||
|
<Suspense fallback={<div className="flex h-96 items-center justify-center"><div className="animate-pulse text-sm text-muted-foreground">Loading search...</div></div>}>
|
||||||
|
<SearchPageContent />
|
||||||
|
</Suspense>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SearchPageContent() {
|
||||||
|
const router = useRouter();
|
||||||
|
const searchParams = useSearchParams();
|
||||||
|
const initialQuery = searchParams.get('q') || '';
|
||||||
|
|
||||||
|
const [query, setQuery] = useState(initialQuery);
|
||||||
|
const [results, setResults] = useState<SearchResult[]>([]);
|
||||||
|
const [totalCount, setTotalCount] = useState(0);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [selectedTypes, setSelectedTypes] = useState<string[]>(['task', 'note', 'project', 'habit', 'domain']);
|
||||||
|
const [selectedDomain, setSelectedDomain] = useState<string>('all');
|
||||||
|
const [recentSearches, setRecentSearches] = useState<string[]>([]);
|
||||||
|
|
||||||
|
// Load recent searches from localStorage
|
||||||
|
useEffect(() => {
|
||||||
|
try {
|
||||||
|
const stored = localStorage.getItem('project-e-recent-searches');
|
||||||
|
if (stored) setRecentSearches(JSON.parse(stored));
|
||||||
|
} catch {}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const saveRecentSearch = useCallback((q: string) => {
|
||||||
|
const updated = [q, ...recentSearches.filter(s => s !== q)].slice(0, 10);
|
||||||
|
setRecentSearches(updated);
|
||||||
|
try {
|
||||||
|
localStorage.setItem('project-e-recent-searches', JSON.stringify(updated));
|
||||||
|
} catch {}
|
||||||
|
}, [recentSearches]);
|
||||||
|
|
||||||
|
const doSearch = useCallback(async (q: string) => {
|
||||||
|
if (!q.trim()) {
|
||||||
|
setResults([]);
|
||||||
|
setTotalCount(0);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const params = new URLSearchParams({ q });
|
||||||
|
if (selectedTypes.length < 5) params.set('types', selectedTypes.join(','));
|
||||||
|
if (selectedDomain !== 'all') params.set('domain', selectedDomain);
|
||||||
|
|
||||||
|
const res = await fetch(`/api/search?${params}`);
|
||||||
|
if (!res.ok) throw new Error('Search failed');
|
||||||
|
|
||||||
|
const data = await res.json();
|
||||||
|
setResults(data.results || []);
|
||||||
|
setTotalCount(data.totalCount || 0);
|
||||||
|
saveRecentSearch(q);
|
||||||
|
} catch (err) {
|
||||||
|
setError('Search failed. Please try again.');
|
||||||
|
console.error('Search error:', err);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, [selectedTypes, selectedDomain, saveRecentSearch]);
|
||||||
|
|
||||||
|
// Initial search from URL param
|
||||||
|
useEffect(() => {
|
||||||
|
if (initialQuery) doSearch(initialQuery);
|
||||||
|
}, []); // eslint-disable-line react-hooks/exhaustive-deps
|
||||||
|
|
||||||
|
const handleSearch = (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
doSearch(query);
|
||||||
|
router.replace(`/search?q=${encodeURIComponent(query)}`);
|
||||||
|
};
|
||||||
|
|
||||||
|
const groupedResults = useMemo(() => {
|
||||||
|
const groups: Record<string, SearchResult[]> = {
|
||||||
|
task: [], note: [], project: [], habit: [], domain: [],
|
||||||
|
};
|
||||||
|
for (const r of results) {
|
||||||
|
if (groups[r.type]) groups[r.type].push(r);
|
||||||
|
}
|
||||||
|
return Object.entries(groups).filter(([, items]) => items.length > 0);
|
||||||
|
}, [results]);
|
||||||
|
|
||||||
|
const toggleType = (type: string) => {
|
||||||
|
setSelectedTypes(prev =>
|
||||||
|
prev.includes(type) ? prev.filter(t => t !== type) : [...prev, type]
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div className="mb-6">
|
||||||
|
<h1 className="text-2xl font-bold">Search</h1>
|
||||||
|
<p className="mt-1 text-muted-foreground">Find anything across your workspace.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Search bar */}
|
||||||
|
<form onSubmit={handleSearch} className="mb-6">
|
||||||
|
<div className="relative">
|
||||||
|
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
|
||||||
|
<Input
|
||||||
|
value={query}
|
||||||
|
onChange={(e) => setQuery(e.target.value)}
|
||||||
|
placeholder="Search tasks, notes, projects, habits..."
|
||||||
|
className="pl-10 pr-20"
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
size="sm"
|
||||||
|
className="absolute right-1 top-1/2 -translate-y-1/2"
|
||||||
|
disabled={loading || !query.trim()}
|
||||||
|
>
|
||||||
|
{loading ? 'Searching...' : 'Search'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
{/* Filters */}
|
||||||
|
<div className="mb-6 flex flex-wrap items-center gap-3">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Filter className="h-4 w-4 text-muted-foreground" />
|
||||||
|
<span className="text-sm text-muted-foreground">Filter:</span>
|
||||||
|
</div>
|
||||||
|
{['task', 'note', 'project', 'habit', 'domain'].map(type => (
|
||||||
|
<Badge
|
||||||
|
key={type}
|
||||||
|
variant={selectedTypes.includes(type) ? 'default' : 'outline'}
|
||||||
|
className="cursor-pointer capitalize"
|
||||||
|
onClick={() => toggleType(type)}
|
||||||
|
>
|
||||||
|
{typeIcons[type]}
|
||||||
|
<span className="ml-1">{type}s</span>
|
||||||
|
</Badge>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Results */}
|
||||||
|
{error && (
|
||||||
|
<Card className="mb-6 border-destructive">
|
||||||
|
<CardContent className="p-4 text-sm text-destructive">{error}</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!loading && !error && query && results.length === 0 && (
|
||||||
|
<div className="py-12 text-center">
|
||||||
|
<Search className="mx-auto mb-4 h-12 w-12 text-muted-foreground/50" />
|
||||||
|
<h3 className="text-lg font-medium">No results found</h3>
|
||||||
|
<p className="mt-1 text-sm text-muted-foreground">
|
||||||
|
Try different keywords or adjust your filters.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!query && !loading && (
|
||||||
|
<div className="py-12 text-center">
|
||||||
|
<Search className="mx-auto mb-4 h-12 w-12 text-muted-foreground/50" />
|
||||||
|
<h3 className="text-lg font-medium">Search your workspace</h3>
|
||||||
|
<p className="mt-1 text-sm text-muted-foreground">
|
||||||
|
Type a query above to search across tasks, notes, projects, habits, and domains.
|
||||||
|
</p>
|
||||||
|
{recentSearches.length > 0 && (
|
||||||
|
<div className="mt-6">
|
||||||
|
<h4 className="mb-2 text-sm font-medium text-muted-foreground">Recent searches</h4>
|
||||||
|
<div className="flex flex-wrap justify-center gap-2">
|
||||||
|
{recentSearches.map((s, i) => (
|
||||||
|
<Badge
|
||||||
|
key={i}
|
||||||
|
variant="secondary"
|
||||||
|
className="cursor-pointer"
|
||||||
|
onClick={() => { setQuery(s); doSearch(s); }}
|
||||||
|
>
|
||||||
|
<Clock className="mr-1 h-3 w-3" />
|
||||||
|
{s}
|
||||||
|
</Badge>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{loading && (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{Array.from({ length: 3 }).map((_, i) => (
|
||||||
|
<Card key={i}>
|
||||||
|
<CardContent className="p-4">
|
||||||
|
<div className="mb-2 h-4 w-48 animate-pulse rounded bg-muted" />
|
||||||
|
<div className="h-3 w-full animate-pulse rounded bg-muted/50" />
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!loading && results.length > 0 && (
|
||||||
|
<div>
|
||||||
|
<p className="mb-4 text-sm text-muted-foreground">
|
||||||
|
Found {totalCount} result{totalCount !== 1 ? 's' : ''} for “{query}”
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="space-y-6">
|
||||||
|
{groupedResults.map(([type, items]) => (
|
||||||
|
<div key={type}>
|
||||||
|
<h3 className="mb-3 flex items-center gap-2 text-sm font-semibold capitalize">
|
||||||
|
{typeIcons[type]}
|
||||||
|
{type}s
|
||||||
|
<Badge variant="secondary" className="ml-1 text-xs">{items.length}</Badge>
|
||||||
|
</h3>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{items.map((result) => (
|
||||||
|
<Card
|
||||||
|
key={`${result.type}-${result.id}`}
|
||||||
|
className="cursor-pointer transition-colors hover:bg-accent/50"
|
||||||
|
onClick={() => router.push(result.link)}
|
||||||
|
>
|
||||||
|
<CardContent className="flex items-start gap-3 p-3">
|
||||||
|
<div className={`mt-0.5 rounded p-1.5 ${typeColors[result.type] || 'bg-gray-500/10'}`}>
|
||||||
|
{typeIcons[result.type]}
|
||||||
|
</div>
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="truncate text-sm font-medium">{result.title}</span>
|
||||||
|
<Badge variant="outline" className="shrink-0 text-[10px] capitalize">
|
||||||
|
{result.type}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
{result.snippet && (
|
||||||
|
<p
|
||||||
|
className="mt-1 text-xs text-muted-foreground line-clamp-2"
|
||||||
|
dangerouslySetInnerHTML={{ __html: result.snippet }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<ExternalLink className="mt-1 h-3 w-3 shrink-0 text-muted-foreground" />
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,217 @@
|
|||||||
|
// AGENT INSTRUCTION: After every write (INSERT/UPDATE/DELETE), you MUST:
|
||||||
|
// 1. Insert activity feed entry
|
||||||
|
// 2. Call pg.notify('project_e_events', JSON.stringify({ type, action, id, workspace_id }))
|
||||||
|
// See AGENTS.md for full rules.
|
||||||
|
|
||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { withAuth, requireWorkspaceAccess, createErrorResponse } from '@/lib/auth';
|
||||||
|
import { db, tasks, habits, habitCompletions, projects, sections, domains } from '@project-e/db';
|
||||||
|
import { and, asc, between, eq, gte, isNull, lte, sql } from 'drizzle-orm';
|
||||||
|
|
||||||
|
type RouteContext = { params: Promise<{ domainId: string }> };
|
||||||
|
|
||||||
|
interface CalendarEvent {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
start: string;
|
||||||
|
end: string;
|
||||||
|
type: 'task' | 'habit' | 'project' | 'milestone';
|
||||||
|
entityType: string;
|
||||||
|
entityId: string;
|
||||||
|
color: string;
|
||||||
|
domainId: string;
|
||||||
|
href: string;
|
||||||
|
priority?: string;
|
||||||
|
difficulty?: string;
|
||||||
|
status?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// GET /api/domains/[domainId]/calendar/events?from=&to=
|
||||||
|
// Returns all events (tasks with due_date, habits scheduled for date range, project target dates)
|
||||||
|
// joined with domain for color/title
|
||||||
|
export const GET = withAuth<RouteContext>(async (request: NextRequest, user, context) => {
|
||||||
|
const { domainId } = await context!.params;
|
||||||
|
await requireWorkspaceAccess(domainId);
|
||||||
|
|
||||||
|
const { searchParams } = new URL(request.url);
|
||||||
|
const from = searchParams.get('from');
|
||||||
|
const to = searchParams.get('to');
|
||||||
|
const types = searchParams.get('types')?.split(',').filter(Boolean) || ['task', 'habit', 'project', 'milestone'];
|
||||||
|
|
||||||
|
if (!from || !to) {
|
||||||
|
return createErrorResponse('VALIDATION_ERROR', 'from and to query params are required (ISO dates)', 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
const fromDate = new Date(from);
|
||||||
|
const toDate = new Date(to);
|
||||||
|
|
||||||
|
// Get domain for color
|
||||||
|
const [domain] = await db.select({ color: domains.color, name: domains.name })
|
||||||
|
.from(domains)
|
||||||
|
.where(eq(domains.id, domainId))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
const domainColor = domain?.color || '#3b82f6';
|
||||||
|
const events: CalendarEvent[] = [];
|
||||||
|
|
||||||
|
// 1. Tasks with due_date in range
|
||||||
|
if (types.includes('task')) {
|
||||||
|
const taskRows = await db.select()
|
||||||
|
.from(tasks)
|
||||||
|
.where(and(
|
||||||
|
eq(tasks.domainId, domainId),
|
||||||
|
isNull(tasks.deletedAt),
|
||||||
|
gte(tasks.dueDate, fromDate),
|
||||||
|
lte(tasks.dueDate, toDate),
|
||||||
|
))
|
||||||
|
.orderBy(asc(tasks.dueDate));
|
||||||
|
|
||||||
|
for (const task of taskRows) {
|
||||||
|
if (!task.dueDate) continue;
|
||||||
|
const color = task.priority === 'urgent' ? '#ef4444'
|
||||||
|
: task.priority === 'high' ? '#f97316'
|
||||||
|
: task.priority === 'medium' ? '#3b82f6'
|
||||||
|
: '#6b7280';
|
||||||
|
events.push({
|
||||||
|
id: `task-${task.id}`,
|
||||||
|
title: task.title,
|
||||||
|
start: task.dueDate.toISOString(),
|
||||||
|
end: task.dueDate.toISOString(),
|
||||||
|
type: 'task',
|
||||||
|
entityType: 'task',
|
||||||
|
entityId: task.id,
|
||||||
|
color,
|
||||||
|
domainId,
|
||||||
|
href: `/tasks/${task.id}`,
|
||||||
|
priority: task.priority,
|
||||||
|
status: task.status,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Habits — check if they have completions in range (scheduled habits)
|
||||||
|
if (types.includes('habit')) {
|
||||||
|
const habitRows = await db.select()
|
||||||
|
.from(habits)
|
||||||
|
.where(and(
|
||||||
|
eq(habits.domainId, domainId),
|
||||||
|
eq(habits.active, true),
|
||||||
|
isNull(habits.deletedAt),
|
||||||
|
));
|
||||||
|
|
||||||
|
for (const habit of habitRows) {
|
||||||
|
const color = habit.difficulty === 'hard' ? '#ef4444'
|
||||||
|
: habit.difficulty === 'medium' ? '#f97316'
|
||||||
|
: '#22c55e';
|
||||||
|
|
||||||
|
// Check if habit has completions in range
|
||||||
|
const completions = await db.select({ date: habitCompletions.date })
|
||||||
|
.from(habitCompletions)
|
||||||
|
.where(and(
|
||||||
|
eq(habitCompletions.habitId, habit.id),
|
||||||
|
gte(habitCompletions.date, fromDate),
|
||||||
|
lte(habitCompletions.date, toDate),
|
||||||
|
));
|
||||||
|
|
||||||
|
const completedDates = new Set(completions.map(c => c.date.toISOString().split('T')[0]));
|
||||||
|
|
||||||
|
// Generate events for each day in range (for daily habits)
|
||||||
|
// For weekly/custom, just show the habit as a recurring event
|
||||||
|
const current = new Date(fromDate);
|
||||||
|
while (current <= toDate) {
|
||||||
|
const dayOfWeek = current.getDay();
|
||||||
|
const skipDays = (habit.skipDays || []) as number[];
|
||||||
|
const dateStr = current.toISOString().split('T')[0];
|
||||||
|
|
||||||
|
if (!skipDays.includes(dayOfWeek)) {
|
||||||
|
const isCompleted = completedDates.has(dateStr);
|
||||||
|
events.push({
|
||||||
|
id: `habit-${habit.id}-${dateStr}`,
|
||||||
|
title: `${isCompleted ? '✅ ' : '○ '}${habit.name}`,
|
||||||
|
start: current.toISOString(),
|
||||||
|
end: current.toISOString(),
|
||||||
|
type: 'habit',
|
||||||
|
entityType: 'habit',
|
||||||
|
entityId: habit.id,
|
||||||
|
color,
|
||||||
|
domainId,
|
||||||
|
href: '/habits',
|
||||||
|
difficulty: habit.difficulty,
|
||||||
|
status: isCompleted ? 'completed' : 'pending',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
current.setDate(current.getDate() + 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Projects with target_date in range
|
||||||
|
if (types.includes('project')) {
|
||||||
|
const projectRows = await db.select()
|
||||||
|
.from(projects)
|
||||||
|
.where(and(
|
||||||
|
eq(projects.domainId, domainId),
|
||||||
|
isNull(projects.deletedAt),
|
||||||
|
gte(projects.targetDate, fromDate),
|
||||||
|
lte(projects.targetDate, toDate),
|
||||||
|
))
|
||||||
|
.orderBy(asc(projects.targetDate));
|
||||||
|
|
||||||
|
for (const project of projectRows) {
|
||||||
|
if (!project.targetDate) continue;
|
||||||
|
events.push({
|
||||||
|
id: `project-${project.id}`,
|
||||||
|
title: `📁 ${project.name}`,
|
||||||
|
start: project.targetDate.toISOString(),
|
||||||
|
end: project.targetDate.toISOString(),
|
||||||
|
type: 'project',
|
||||||
|
entityType: 'project',
|
||||||
|
entityId: project.id,
|
||||||
|
color: project.color || '#8b5cf6',
|
||||||
|
domainId,
|
||||||
|
href: `/projects/${project.id}`,
|
||||||
|
status: project.status,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. Sections (milestones) with target_date in range
|
||||||
|
if (types.includes('milestone')) {
|
||||||
|
const milestoneRows = await db.select({
|
||||||
|
id: sections.id,
|
||||||
|
name: sections.name,
|
||||||
|
targetDate: sections.targetDate,
|
||||||
|
projectId: sections.projectId,
|
||||||
|
status: sections.status,
|
||||||
|
kind: sections.kind,
|
||||||
|
})
|
||||||
|
.from(sections)
|
||||||
|
.innerJoin(projects, eq(sections.projectId, projects.id))
|
||||||
|
.where(and(
|
||||||
|
eq(projects.domainId, domainId),
|
||||||
|
eq(sections.kind, 'milestone'),
|
||||||
|
gte(sections.targetDate, fromDate),
|
||||||
|
lte(sections.targetDate, toDate),
|
||||||
|
))
|
||||||
|
.orderBy(asc(sections.targetDate));
|
||||||
|
|
||||||
|
for (const milestone of milestoneRows) {
|
||||||
|
if (!milestone.targetDate) continue;
|
||||||
|
events.push({
|
||||||
|
id: `milestone-${milestone.id}`,
|
||||||
|
title: `🏁 ${milestone.name}`,
|
||||||
|
start: milestone.targetDate.toISOString(),
|
||||||
|
end: milestone.targetDate.toISOString(),
|
||||||
|
type: 'milestone',
|
||||||
|
entityType: 'section',
|
||||||
|
entityId: milestone.id,
|
||||||
|
color: '#f59e0b',
|
||||||
|
domainId,
|
||||||
|
href: `/projects/${milestone.projectId}`,
|
||||||
|
status: milestone.status,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({ events });
|
||||||
|
});
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
// AGENT INSTRUCTION: After every write (INSERT/UPDATE/DELETE), you MUST:
|
||||||
|
// 1. Insert activity feed entry
|
||||||
|
// 2. Call pg.notify('project_e_events', JSON.stringify({ type, action, id, workspace_id }))
|
||||||
|
// See AGENTS.md for full rules.
|
||||||
|
|
||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { withAuth, requireWorkspaceAccess, createErrorResponse, ApiError } from '@/lib/auth';
|
||||||
|
import { recordActivity } from '@/lib/activity';
|
||||||
|
import { db, domains } from '@project-e/db';
|
||||||
|
import { eq } from 'drizzle-orm';
|
||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
type RouteContext = { params: Promise<{ domainId: string }> };
|
||||||
|
|
||||||
|
const layoutItemSchema = z.object({
|
||||||
|
widgetId: z.string(),
|
||||||
|
order: z.number().int(),
|
||||||
|
enabled: z.boolean(),
|
||||||
|
config: z.record(z.string(), z.unknown()).optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const updateLayoutSchema = z.object({
|
||||||
|
layout: z.array(layoutItemSchema),
|
||||||
|
});
|
||||||
|
|
||||||
|
// PUT /api/domains/[domainId]/dashboard/layout — Update dashboard layout
|
||||||
|
export const PUT = withAuth<RouteContext>(async (request: NextRequest, user, context) => {
|
||||||
|
const { domainId } = await context!.params;
|
||||||
|
await requireWorkspaceAccess(domainId);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const body = await request.json();
|
||||||
|
const data = updateLayoutSchema.parse(body);
|
||||||
|
|
||||||
|
const [domain] = await db.select({ id: domains.id, customFields: domains.customFields })
|
||||||
|
.from(domains)
|
||||||
|
.where(eq(domains.id, domainId))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (!domain) {
|
||||||
|
return createErrorResponse('NOT_FOUND', 'Domain not found', 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Store layout in domain's custom_fields
|
||||||
|
const existingFields = (domain.customFields as Record<string, unknown>) || {};
|
||||||
|
await db.update(domains)
|
||||||
|
.set({
|
||||||
|
customFields: { ...existingFields, dashboard_layout: data.layout },
|
||||||
|
updatedAt: new Date(),
|
||||||
|
})
|
||||||
|
.where(eq(domains.id, domainId));
|
||||||
|
|
||||||
|
await recordActivity({
|
||||||
|
actor: user.name,
|
||||||
|
action: 'updated',
|
||||||
|
entityType: 'domain',
|
||||||
|
entityId: domainId,
|
||||||
|
changes: { dashboardLayout: data.layout },
|
||||||
|
workspaceId: domainId,
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json({ layout: data.layout });
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof z.ZodError) {
|
||||||
|
return createErrorResponse('VALIDATION_ERROR', 'Invalid input', 400, error.issues);
|
||||||
|
}
|
||||||
|
if (error instanceof ApiError) {
|
||||||
|
return createErrorResponse(error.code, error.message, error.status);
|
||||||
|
}
|
||||||
|
console.error('[dashboard/layout PUT] error:', error);
|
||||||
|
return createErrorResponse('INTERNAL_ERROR', 'Failed to update dashboard layout', 500);
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -0,0 +1,106 @@
|
|||||||
|
// AGENT INSTRUCTION: After every write (INSERT/UPDATE/DELETE), you MUST:
|
||||||
|
// 1. Insert activity feed entry
|
||||||
|
// 2. Call pg.notify('project_e_events', JSON.stringify({ type, action, id, workspace_id }))
|
||||||
|
// See AGENTS.md for full rules.
|
||||||
|
|
||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { withAuth, requireWorkspaceAccess, createErrorResponse, ApiError } from '@/lib/auth';
|
||||||
|
import { recordActivity } from '@/lib/activity';
|
||||||
|
import { db, domains } from '@project-e/db';
|
||||||
|
import { eq } from 'drizzle-orm';
|
||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
type RouteContext = { params: Promise<{ domainId: string }> };
|
||||||
|
|
||||||
|
const layoutItemSchema = z.object({
|
||||||
|
widgetId: z.string(),
|
||||||
|
order: z.number().int(),
|
||||||
|
enabled: z.boolean(),
|
||||||
|
config: z.record(z.string(), z.unknown()).optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const updateLayoutSchema = z.object({
|
||||||
|
layout: z.array(layoutItemSchema),
|
||||||
|
});
|
||||||
|
|
||||||
|
// GET /api/domains/[domainId]/dashboard — Returns layout (widget order) + widget data
|
||||||
|
export const GET = withAuth<RouteContext>(async (request: NextRequest, user, context) => {
|
||||||
|
const { domainId } = await context!.params;
|
||||||
|
await requireWorkspaceAccess(domainId);
|
||||||
|
|
||||||
|
// Verify domain exists
|
||||||
|
const [domain] = await db.select({ id: domains.id, name: domains.name, color: domains.color, customFields: domains.customFields })
|
||||||
|
.from(domains)
|
||||||
|
.where(eq(domains.id, domainId))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (!domain) {
|
||||||
|
return createErrorResponse('NOT_FOUND', 'Domain not found', 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Dashboard layout is stored in the domain's custom_fields as jsonb
|
||||||
|
// We use a convention: dashboard_layout key in custom_fields
|
||||||
|
const storedLayout = (domain.customFields as Record<string, unknown>)?.dashboard_layout as Array<{ widgetId: string; order: number; enabled: boolean; config?: Record<string, unknown> }> | undefined;
|
||||||
|
|
||||||
|
const defaultLayout = [
|
||||||
|
{ widgetId: 'today-tasks', order: 0, enabled: true },
|
||||||
|
{ widgetId: 'habit-checklist', order: 1, enabled: true },
|
||||||
|
{ widgetId: 'weekly-stats', order: 2, enabled: true },
|
||||||
|
{ widgetId: 'project-progress', order: 3, enabled: true },
|
||||||
|
{ widgetId: 'upcoming-calendar', order: 4, enabled: true },
|
||||||
|
{ widgetId: 'recent-notes', order: 5, enabled: true },
|
||||||
|
{ widgetId: 'activity-feed', order: 6, enabled: true },
|
||||||
|
{ widgetId: 'quick-capture', order: 7, enabled: true },
|
||||||
|
];
|
||||||
|
|
||||||
|
return NextResponse.json({ layout: storedLayout || defaultLayout });
|
||||||
|
});
|
||||||
|
|
||||||
|
// PUT /api/domains/[domainId]/dashboard/layout — Update dashboard layout
|
||||||
|
export const PUT = withAuth<RouteContext>(async (request: NextRequest, user, context) => {
|
||||||
|
const { domainId } = await context!.params;
|
||||||
|
await requireWorkspaceAccess(domainId);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const body = await request.json();
|
||||||
|
const data = updateLayoutSchema.parse(body);
|
||||||
|
|
||||||
|
const [domain] = await db.select({ id: domains.id, customFields: domains.customFields })
|
||||||
|
.from(domains)
|
||||||
|
.where(eq(domains.id, domainId))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (!domain) {
|
||||||
|
return createErrorResponse('NOT_FOUND', 'Domain not found', 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Store layout in domain's custom_fields
|
||||||
|
const existingFields = (domain.customFields as Record<string, unknown>) || {};
|
||||||
|
await db.update(domains)
|
||||||
|
.set({
|
||||||
|
customFields: { ...existingFields, dashboard_layout: data.layout },
|
||||||
|
updatedAt: new Date(),
|
||||||
|
})
|
||||||
|
.where(eq(domains.id, domainId));
|
||||||
|
|
||||||
|
await recordActivity({
|
||||||
|
actor: user.name,
|
||||||
|
action: 'updated',
|
||||||
|
entityType: 'domain',
|
||||||
|
entityId: domainId,
|
||||||
|
changes: { dashboardLayout: data.layout },
|
||||||
|
workspaceId: domainId,
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json({ layout: data.layout });
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof z.ZodError) {
|
||||||
|
return createErrorResponse('VALIDATION_ERROR', 'Invalid input', 400, error.issues);
|
||||||
|
}
|
||||||
|
if (error instanceof ApiError) {
|
||||||
|
return createErrorResponse(error.code, error.message, error.status);
|
||||||
|
}
|
||||||
|
console.error('[dashboard PUT] error:', error);
|
||||||
|
return createErrorResponse('INTERNAL_ERROR', 'Failed to update dashboard layout', 500);
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
// AGENT INSTRUCTION: After every write (INSERT/UPDATE/DELETE), you MUST:
|
||||||
|
// 1. Insert activity feed entry
|
||||||
|
// 2. Call pg.notify('project_e_events', JSON.stringify({ type, action, id, workspace_id }))
|
||||||
|
// See AGENTS.md for full rules.
|
||||||
|
|
||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { withAuth, requireWorkspaceAccess } from '@/lib/auth';
|
||||||
|
import { db, activityFeed } from '@project-e/db';
|
||||||
|
import { and, desc, eq } from 'drizzle-orm';
|
||||||
|
|
||||||
|
type RouteContext = { params: Promise<{ domainId: string }> };
|
||||||
|
|
||||||
|
// GET /api/domains/[domainId]/dashboard/widgets/activity-feed
|
||||||
|
export const GET = withAuth<RouteContext>(async (request: NextRequest, user, context) => {
|
||||||
|
const { domainId } = await context!.params;
|
||||||
|
await requireWorkspaceAccess(domainId);
|
||||||
|
|
||||||
|
const items = await db.select()
|
||||||
|
.from(activityFeed)
|
||||||
|
.where(eq(activityFeed.workspaceId, domainId))
|
||||||
|
.orderBy(desc(activityFeed.createdAt))
|
||||||
|
.limit(20);
|
||||||
|
|
||||||
|
return NextResponse.json({ items });
|
||||||
|
});
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
// AGENT INSTRUCTION: After every write (INSERT/UPDATE/DELETE), you MUST:
|
||||||
|
// 1. Insert activity feed entry
|
||||||
|
// 2. Call pg.notify('project_e_events', JSON.stringify({ type, action, id, workspace_id }))
|
||||||
|
// See AGENTS.md for full rules.
|
||||||
|
|
||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { withAuth, requireWorkspaceAccess } from '@/lib/auth';
|
||||||
|
import { db, habits, habitCompletions } from '@project-e/db';
|
||||||
|
import { and, eq, gte, isNull, lte, sql } from 'drizzle-orm';
|
||||||
|
|
||||||
|
type RouteContext = { params: Promise<{ domainId: string }> };
|
||||||
|
|
||||||
|
// GET /api/domains/[domainId]/dashboard/widgets/habit-checklist
|
||||||
|
export const GET = withAuth<RouteContext>(async (request: NextRequest, user, context) => {
|
||||||
|
const { domainId } = await context!.params;
|
||||||
|
await requireWorkspaceAccess(domainId);
|
||||||
|
|
||||||
|
const today = new Date();
|
||||||
|
today.setHours(0, 0, 0, 0);
|
||||||
|
const tomorrow = new Date(today);
|
||||||
|
tomorrow.setDate(tomorrow.getDate() + 1);
|
||||||
|
|
||||||
|
const activeHabits = await db.select()
|
||||||
|
.from(habits)
|
||||||
|
.where(and(
|
||||||
|
eq(habits.domainId, domainId),
|
||||||
|
eq(habits.active, true),
|
||||||
|
isNull(habits.deletedAt),
|
||||||
|
));
|
||||||
|
|
||||||
|
// Check which habits are completed today
|
||||||
|
const items = [];
|
||||||
|
for (const habit of activeHabits) {
|
||||||
|
const [completion] = await db.select({ count: sql<number>`count(*)` })
|
||||||
|
.from(habitCompletions)
|
||||||
|
.where(and(
|
||||||
|
eq(habitCompletions.habitId, habit.id),
|
||||||
|
gte(habitCompletions.date, today),
|
||||||
|
lte(habitCompletions.date, tomorrow),
|
||||||
|
));
|
||||||
|
|
||||||
|
const completed = Number(completion?.count || 0) > 0;
|
||||||
|
items.push({
|
||||||
|
...habit,
|
||||||
|
completedToday: completed,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({ items });
|
||||||
|
});
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
// AGENT INSTRUCTION: After every write (INSERT/UPDATE/DELETE), you MUST:
|
||||||
|
// 1. Insert activity feed entry
|
||||||
|
// 2. Call pg.notify('project_e_events', JSON.stringify({ type, action, id, workspace_id }))
|
||||||
|
// See AGENTS.md for full rules.
|
||||||
|
|
||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { withAuth, requireWorkspaceAccess } from '@/lib/auth';
|
||||||
|
import { db, projects, tasks } from '@project-e/db';
|
||||||
|
import { and, eq, inArray, isNull, sql } from 'drizzle-orm';
|
||||||
|
|
||||||
|
type RouteContext = { params: Promise<{ domainId: string }> };
|
||||||
|
|
||||||
|
// GET /api/domains/[domainId]/dashboard/widgets/project-progress
|
||||||
|
export const GET = withAuth<RouteContext>(async (request: NextRequest, user, context) => {
|
||||||
|
const { domainId } = await context!.params;
|
||||||
|
await requireWorkspaceAccess(domainId);
|
||||||
|
|
||||||
|
const activeProjects = await db.select()
|
||||||
|
.from(projects)
|
||||||
|
.where(and(
|
||||||
|
eq(projects.domainId, domainId),
|
||||||
|
inArray(projects.status, ['active', 'paused']),
|
||||||
|
isNull(projects.deletedAt),
|
||||||
|
));
|
||||||
|
|
||||||
|
// Compute progress for each project
|
||||||
|
const items = [];
|
||||||
|
for (const project of activeProjects) {
|
||||||
|
const [totalResult] = await db.select({ count: sql<number>`count(*)` })
|
||||||
|
.from(tasks)
|
||||||
|
.where(and(eq(tasks.projectId, project.id), isNull(tasks.deletedAt)));
|
||||||
|
|
||||||
|
const [completedResult] = await db.select({ count: sql<number>`count(*)` })
|
||||||
|
.from(tasks)
|
||||||
|
.where(and(eq(tasks.projectId, project.id), eq(tasks.status, 'done'), isNull(tasks.deletedAt)));
|
||||||
|
|
||||||
|
const total = Number(totalResult?.count || 0);
|
||||||
|
const completed = Number(completedResult?.count || 0);
|
||||||
|
const progress = total > 0 ? Math.round((completed / total) * 100) : 0;
|
||||||
|
|
||||||
|
items.push({
|
||||||
|
id: project.id,
|
||||||
|
name: project.name,
|
||||||
|
status: project.status,
|
||||||
|
color: project.color,
|
||||||
|
targetDate: project.targetDate,
|
||||||
|
taskCount: total,
|
||||||
|
completedCount: completed,
|
||||||
|
progress,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({ items });
|
||||||
|
});
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
// AGENT INSTRUCTION: After every write (INSERT/UPDATE/DELETE), you MUST:
|
||||||
|
// 1. Insert activity feed entry
|
||||||
|
// 2. Call pg.notify('project_e_events', JSON.stringify({ type, action, id, workspace_id }))
|
||||||
|
// See AGENTS.md for full rules.
|
||||||
|
|
||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { withAuth, requireWorkspaceAccess } from '@/lib/auth';
|
||||||
|
import { db, notes } from '@project-e/db';
|
||||||
|
import { and, desc, eq, isNull } from 'drizzle-orm';
|
||||||
|
|
||||||
|
type RouteContext = { params: Promise<{ domainId: string }> };
|
||||||
|
|
||||||
|
// GET /api/domains/[domainId]/dashboard/widgets/recent-notes
|
||||||
|
export const GET = withAuth<RouteContext>(async (request: NextRequest, user, context) => {
|
||||||
|
const { domainId } = await context!.params;
|
||||||
|
await requireWorkspaceAccess(domainId);
|
||||||
|
|
||||||
|
const items = await db.select({
|
||||||
|
id: notes.id,
|
||||||
|
title: notes.title,
|
||||||
|
updatedAt: notes.updatedAt,
|
||||||
|
isPinned: notes.isPinned,
|
||||||
|
})
|
||||||
|
.from(notes)
|
||||||
|
.where(and(
|
||||||
|
eq(notes.domainId, domainId),
|
||||||
|
eq(notes.isArchived, false),
|
||||||
|
isNull(notes.deletedAt),
|
||||||
|
))
|
||||||
|
.orderBy(desc(notes.updatedAt))
|
||||||
|
.limit(5);
|
||||||
|
|
||||||
|
return NextResponse.json({ items });
|
||||||
|
});
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
// AGENT INSTRUCTION: After every write (INSERT/UPDATE/DELETE), you MUST:
|
||||||
|
// 1. Insert activity feed entry
|
||||||
|
// 2. Call pg.notify('project_e_events', JSON.stringify({ type, action, id, workspace_id }))
|
||||||
|
// See AGENTS.md for full rules.
|
||||||
|
|
||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { withAuth, requireWorkspaceAccess } from '@/lib/auth';
|
||||||
|
import { db, tasks, habits, habitCompletions, projects, notes, activityFeed, domains } from '@project-e/db';
|
||||||
|
import { and, asc, desc, eq, gte, inArray, isNull, lte, sql } from 'drizzle-orm';
|
||||||
|
|
||||||
|
type RouteContext = { params: Promise<{ domainId: string }> };
|
||||||
|
|
||||||
|
// GET /api/domains/[domainId]/dashboard/widgets/today-tasks
|
||||||
|
export const GET = withAuth<RouteContext>(async (request: NextRequest, user, context) => {
|
||||||
|
const { domainId } = await context!.params;
|
||||||
|
await requireWorkspaceAccess(domainId);
|
||||||
|
|
||||||
|
const today = new Date();
|
||||||
|
today.setHours(0, 0, 0, 0);
|
||||||
|
const tomorrow = new Date(today);
|
||||||
|
tomorrow.setDate(tomorrow.getDate() + 1);
|
||||||
|
|
||||||
|
const items = await db.select()
|
||||||
|
.from(tasks)
|
||||||
|
.where(and(
|
||||||
|
eq(tasks.domainId, domainId),
|
||||||
|
isNull(tasks.deletedAt),
|
||||||
|
gte(tasks.dueDate, today),
|
||||||
|
lte(tasks.dueDate, tomorrow),
|
||||||
|
))
|
||||||
|
.orderBy(asc(tasks.priority))
|
||||||
|
.limit(10);
|
||||||
|
|
||||||
|
return NextResponse.json({ items });
|
||||||
|
});
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
// AGENT INSTRUCTION: After every write (INSERT/UPDATE/DELETE), you MUST:
|
||||||
|
// 1. Insert activity feed entry
|
||||||
|
// 2. Call pg.notify('project_e_events', JSON.stringify({ type, action, id, workspace_id }))
|
||||||
|
// See AGENTS.md for full rules.
|
||||||
|
|
||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { withAuth, requireWorkspaceAccess } from '@/lib/auth';
|
||||||
|
import { db, tasks, projects, sections } from '@project-e/db';
|
||||||
|
import { and, asc, eq, gte, isNull, lte, sql } from 'drizzle-orm';
|
||||||
|
|
||||||
|
type RouteContext = { params: Promise<{ domainId: string }> };
|
||||||
|
|
||||||
|
// GET /api/domains/[domainId]/dashboard/widgets/upcoming-calendar
|
||||||
|
export const GET = withAuth<RouteContext>(async (request: NextRequest, user, context) => {
|
||||||
|
const { domainId } = await context!.params;
|
||||||
|
await requireWorkspaceAccess(domainId);
|
||||||
|
|
||||||
|
const today = new Date();
|
||||||
|
today.setHours(0, 0, 0, 0);
|
||||||
|
const nextWeek = new Date(today);
|
||||||
|
nextWeek.setDate(nextWeek.getDate() + 7);
|
||||||
|
|
||||||
|
// Tasks due in next 7 days
|
||||||
|
const upcomingTasks = await db.select({
|
||||||
|
id: tasks.id,
|
||||||
|
title: tasks.title,
|
||||||
|
dueDate: tasks.dueDate,
|
||||||
|
priority: tasks.priority,
|
||||||
|
status: tasks.status,
|
||||||
|
})
|
||||||
|
.from(tasks)
|
||||||
|
.where(and(
|
||||||
|
eq(tasks.domainId, domainId),
|
||||||
|
isNull(tasks.deletedAt),
|
||||||
|
gte(tasks.dueDate, today),
|
||||||
|
lte(tasks.dueDate, nextWeek),
|
||||||
|
))
|
||||||
|
.orderBy(asc(tasks.dueDate))
|
||||||
|
.limit(10);
|
||||||
|
|
||||||
|
// Projects with target dates in next 7 days
|
||||||
|
const upcomingProjects = await db.select({
|
||||||
|
id: projects.id,
|
||||||
|
name: projects.name,
|
||||||
|
targetDate: projects.targetDate,
|
||||||
|
status: projects.status,
|
||||||
|
color: projects.color,
|
||||||
|
})
|
||||||
|
.from(projects)
|
||||||
|
.where(and(
|
||||||
|
eq(projects.domainId, domainId),
|
||||||
|
isNull(projects.deletedAt),
|
||||||
|
gte(projects.targetDate, today),
|
||||||
|
lte(projects.targetDate, nextWeek),
|
||||||
|
))
|
||||||
|
.orderBy(asc(projects.targetDate))
|
||||||
|
.limit(5);
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
tasks: upcomingTasks,
|
||||||
|
projects: upcomingProjects,
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
// AGENT INSTRUCTION: After every write (INSERT/UPDATE/DELETE), you MUST:
|
||||||
|
// 1. Insert activity feed entry
|
||||||
|
// 2. Call pg.notify('project_e_events', JSON.stringify({ type, action, id, workspace_id }))
|
||||||
|
// See AGENTS.md for full rules.
|
||||||
|
|
||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { withAuth, requireWorkspaceAccess } from '@/lib/auth';
|
||||||
|
import { db, tasks, habits, habitCompletions } from '@project-e/db';
|
||||||
|
import { and, eq, gte, isNull, lte, sql } from 'drizzle-orm';
|
||||||
|
|
||||||
|
type RouteContext = { params: Promise<{ domainId: string }> };
|
||||||
|
|
||||||
|
// GET /api/domains/[domainId]/dashboard/widgets/weekly-stats
|
||||||
|
export const GET = withAuth<RouteContext>(async (request: NextRequest, user, context) => {
|
||||||
|
const { domainId } = await context!.params;
|
||||||
|
await requireWorkspaceAccess(domainId);
|
||||||
|
|
||||||
|
const now = new Date();
|
||||||
|
const weekStart = new Date(now);
|
||||||
|
weekStart.setDate(weekStart.getDate() - weekStart.getDay());
|
||||||
|
weekStart.setHours(0, 0, 0, 0);
|
||||||
|
const weekEnd = new Date(weekStart);
|
||||||
|
weekEnd.setDate(weekEnd.getDate() + 7);
|
||||||
|
|
||||||
|
// Task completions this week
|
||||||
|
const [taskCompletions] = await db.select({ count: sql<number>`count(*)` })
|
||||||
|
.from(tasks)
|
||||||
|
.where(and(
|
||||||
|
eq(tasks.domainId, domainId),
|
||||||
|
eq(tasks.status, 'done'),
|
||||||
|
gte(tasks.completedAt, weekStart),
|
||||||
|
lte(tasks.completedAt, weekEnd),
|
||||||
|
isNull(tasks.deletedAt),
|
||||||
|
));
|
||||||
|
|
||||||
|
// Habit completions this week
|
||||||
|
const [habitCompletionsCount] = await db.select({ count: sql<number>`count(*)` })
|
||||||
|
.from(habitCompletions)
|
||||||
|
.innerJoin(habits, eq(habitCompletions.habitId, habits.id))
|
||||||
|
.where(and(
|
||||||
|
eq(habits.domainId, domainId),
|
||||||
|
gte(habitCompletions.date, weekStart),
|
||||||
|
lte(habitCompletions.date, weekEnd),
|
||||||
|
isNull(habits.deletedAt),
|
||||||
|
));
|
||||||
|
|
||||||
|
// Streak counts
|
||||||
|
const activeHabits = await db.select({ id: habits.id, streakCount: habits.streakCount, bestStreak: habits.bestStreak })
|
||||||
|
.from(habits)
|
||||||
|
.where(and(
|
||||||
|
eq(habits.domainId, domainId),
|
||||||
|
eq(habits.active, true),
|
||||||
|
isNull(habits.deletedAt),
|
||||||
|
));
|
||||||
|
|
||||||
|
const totalStreak = activeHabits.reduce((sum, h) => sum + (h.streakCount || 0), 0);
|
||||||
|
const bestStreak = Math.max(...activeHabits.map(h => h.bestStreak || 0), 0);
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
taskCompletions: Number(taskCompletions?.count || 0),
|
||||||
|
habitCompletions: Number(habitCompletionsCount?.count || 0),
|
||||||
|
totalStreak,
|
||||||
|
bestStreak,
|
||||||
|
activeHabits: activeHabits.length,
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
// AGENT INSTRUCTION: After every write (INSERT/UPDATE/DELETE), you MUST:
|
||||||
|
// 1. Insert activity feed entry
|
||||||
|
// 2. Call pg.notify('project_e_events', JSON.stringify({ type, action, id, workspace_id }))
|
||||||
|
// See AGENTS.md for full rules.
|
||||||
|
|
||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { withAuth, requireWorkspaceAccess, createErrorResponse, ApiError } from '@/lib/auth';
|
||||||
|
import { recordActivity } from '@/lib/activity';
|
||||||
|
import { db, tasks } from '@project-e/db';
|
||||||
|
import { and, eq, isNull } from 'drizzle-orm';
|
||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
const scheduleSchema = z.object({
|
||||||
|
dueDate: z.string().datetime().nullable(),
|
||||||
|
});
|
||||||
|
|
||||||
|
type RouteContext = { params: Promise<{ domainId: string; id: string }> };
|
||||||
|
|
||||||
|
// PATCH /api/domains/[domainId]/tasks/[id]/schedule — Reschedule a task via drag
|
||||||
|
export const PATCH = withAuth<RouteContext>(async (request: NextRequest, user, context) => {
|
||||||
|
const { domainId, id } = await context!.params;
|
||||||
|
await requireWorkspaceAccess(domainId);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const body = await request.json();
|
||||||
|
const data = scheduleSchema.parse(body);
|
||||||
|
|
||||||
|
// Verify task exists
|
||||||
|
const [existing] = await db.select()
|
||||||
|
.from(tasks)
|
||||||
|
.where(and(eq(tasks.id, id), eq(tasks.domainId, domainId), isNull(tasks.deletedAt)))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (!existing) {
|
||||||
|
return createErrorResponse('NOT_FOUND', 'Task not found', 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
const [updated] = await db.update(tasks)
|
||||||
|
.set({
|
||||||
|
dueDate: data.dueDate ? new Date(data.dueDate) : null,
|
||||||
|
updatedAt: new Date(),
|
||||||
|
})
|
||||||
|
.where(eq(tasks.id, id))
|
||||||
|
.returning();
|
||||||
|
|
||||||
|
// Record activity
|
||||||
|
await recordActivity({
|
||||||
|
actor: user.name,
|
||||||
|
action: 'updated',
|
||||||
|
entityType: 'task',
|
||||||
|
entityId: id,
|
||||||
|
changes: { dueDate: data.dueDate, previousDueDate: existing.dueDate?.toISOString() || null },
|
||||||
|
workspaceId: domainId,
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json(updated);
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof z.ZodError) {
|
||||||
|
return createErrorResponse('VALIDATION_ERROR', 'Invalid input', 400, error.issues);
|
||||||
|
}
|
||||||
|
if (error instanceof ApiError) {
|
||||||
|
return createErrorResponse(error.code, error.message, error.status);
|
||||||
|
}
|
||||||
|
console.error('[schedule PATCH] error:', error);
|
||||||
|
return createErrorResponse('INTERNAL_ERROR', 'Failed to reschedule task', 500);
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -4,73 +4,39 @@
|
|||||||
// See AGENTS.md for full rules.
|
// See AGENTS.md for full rules.
|
||||||
|
|
||||||
import { NextRequest, NextResponse } from 'next/server';
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
import { withAuth } from '@/lib/auth';
|
import { withAuth, createErrorResponse } from '@/lib/auth';
|
||||||
import { createPocketBaseClient } from '@/lib/pocketbase';
|
import { searchEntities } from '@/lib/search-service';
|
||||||
|
|
||||||
// GET /api/search — Cross-entity full-text search
|
// GET /api/search?q=&type=&domain=&limit=&offset=
|
||||||
//
|
// Full-text search across all entity types using PostgreSQL tsvector/tsquery
|
||||||
// Implementation note: the underlying data layer (`lib/database.ts`) uses a
|
export const GET = withAuth(async (request: NextRequest, user) => {
|
||||||
// JavaScript filter parser that only supports `=, !=, <=, >=, <, >` — it does
|
|
||||||
// NOT understand PocketBase's `~` (contains) or `||` (or) operators. To make
|
|
||||||
// search actually return results we fetch each collection's full list and
|
|
||||||
// filter in-process with a case-insensitive substring match on the searchable
|
|
||||||
// fields. This is fine at the current data scale and avoids the silent
|
|
||||||
// zero-result bug.
|
|
||||||
export const GET = withAuth(async (request: NextRequest, _user) => {
|
|
||||||
const { searchParams } = new URL(request.url);
|
const { searchParams } = new URL(request.url);
|
||||||
const query = (searchParams.get('q') || '').trim();
|
const q = (searchParams.get('q') || '').trim();
|
||||||
const types = (
|
const types = searchParams.get('types')?.split(',').filter(Boolean) || ['task', 'note', 'project', 'habit', 'domain'];
|
||||||
searchParams.get('types')?.split(',') || ['tasks', 'habits', 'projects', 'notes', 'reports']
|
const domain = searchParams.get('domain') || undefined;
|
||||||
).filter((t) =>
|
const limit = Math.max(1, Math.min(50, parseInt(searchParams.get('limit') || '20')));
|
||||||
['tasks', 'habits', 'projects', 'notes', 'reports'].includes(t)
|
const offset = Math.max(0, parseInt(searchParams.get('offset') || '0'));
|
||||||
);
|
|
||||||
const limit = Math.max(1, Math.min(50, parseInt(searchParams.get('limit') || '10')));
|
|
||||||
|
|
||||||
if (!query) {
|
if (!q) {
|
||||||
return NextResponse.json({ results: [] });
|
return NextResponse.json({ results: [], totalCount: 0 });
|
||||||
}
|
}
|
||||||
|
|
||||||
const needle = query.toLowerCase();
|
try {
|
||||||
const pb = createPocketBaseClient();
|
const { results, totalCount } = await searchEntities({
|
||||||
const results: Array<{ type: string; items: unknown[] }> = [];
|
query: q,
|
||||||
|
types,
|
||||||
|
domainId: domain,
|
||||||
|
limit,
|
||||||
|
offset,
|
||||||
|
});
|
||||||
|
|
||||||
type Searchable = Record<string, unknown> & { id: string };
|
return NextResponse.json({
|
||||||
const matches = (record: Searchable, fields: string[]): boolean => {
|
results,
|
||||||
for (const f of fields) {
|
totalCount,
|
||||||
const value = record[f];
|
query: q,
|
||||||
if (typeof value === 'string' && value.toLowerCase().includes(needle)) {
|
});
|
||||||
return true;
|
} catch (error) {
|
||||||
}
|
console.error('[search GET] error:', error);
|
||||||
}
|
return createErrorResponse('INTERNAL_ERROR', 'Search failed', 500);
|
||||||
return false;
|
|
||||||
};
|
|
||||||
|
|
||||||
const searchableFields: Record<string, string[]> = {
|
|
||||||
tasks: ['title', 'description'],
|
|
||||||
habits: ['name', 'description'],
|
|
||||||
projects: ['name', 'description'],
|
|
||||||
notes: ['title', 'content'],
|
|
||||||
reports: ['title', 'content'],
|
|
||||||
};
|
|
||||||
|
|
||||||
for (const type of types) {
|
|
||||||
try {
|
|
||||||
const items = (await pb.collection(type).getFullList()) as Searchable[];
|
|
||||||
const filtered = items
|
|
||||||
.filter((record) => matches(record, searchableFields[type] || []))
|
|
||||||
.slice(0, limit)
|
|
||||||
.map((record) => ({ id: record.id, title: getTitle(record, type) }));
|
|
||||||
results.push({ type, items: filtered });
|
|
||||||
} catch {
|
|
||||||
// Skip collections that fail (e.g. missing or inaccessible)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return NextResponse.json({ results });
|
|
||||||
});
|
});
|
||||||
|
|
||||||
function getTitle(record: Record<string, unknown>, type: string): string {
|
|
||||||
const title = record.title ?? record.name;
|
|
||||||
if (typeof title === 'string' && title.length > 0) return title;
|
|
||||||
return `Untitled ${type.slice(0, -1)}`;
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,7 +1,10 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { Calendar, dateFnsLocalizer } from 'react-big-calendar';
|
import { useCallback, useMemo } from 'react';
|
||||||
|
import { Calendar, dateFnsLocalizer, Views } from 'react-big-calendar';
|
||||||
|
import withDragAndDrop from 'react-big-calendar/lib/addons/dragAndDrop';
|
||||||
import 'react-big-calendar/lib/css/react-big-calendar.css';
|
import 'react-big-calendar/lib/css/react-big-calendar.css';
|
||||||
|
import 'react-big-calendar/lib/addons/dragAndDrop/styles.css';
|
||||||
import { format, parse, startOfWeek, getDay } from 'date-fns';
|
import { format, parse, startOfWeek, getDay } from 'date-fns';
|
||||||
import { enUS } from 'date-fns/locale/en-US';
|
import { enUS } from 'date-fns/locale/en-US';
|
||||||
import { useRouter } from 'next/navigation';
|
import { useRouter } from 'next/navigation';
|
||||||
@@ -18,50 +21,125 @@ const localizer = dateFnsLocalizer({
|
|||||||
locales,
|
locales,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const DragAndDropCalendar = withDragAndDrop(Calendar as any) as any;
|
||||||
|
|
||||||
interface CalendarEvent {
|
interface CalendarEvent {
|
||||||
id: string;
|
id: string;
|
||||||
title: string;
|
title: string;
|
||||||
start: Date;
|
start: Date;
|
||||||
end: Date;
|
end: Date;
|
||||||
type: 'task' | 'project' | 'milestone';
|
type: 'task' | 'habit' | 'project' | 'milestone';
|
||||||
domain: string;
|
entityType: string;
|
||||||
|
entityId: string;
|
||||||
color: string;
|
color: string;
|
||||||
|
domainId: string;
|
||||||
href: string;
|
href: string;
|
||||||
|
priority?: string;
|
||||||
|
difficulty?: string;
|
||||||
|
status?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface BigCalendarWrapperProps {
|
interface BigCalendarWrapperProps {
|
||||||
events: CalendarEvent[];
|
events: CalendarEvent[];
|
||||||
|
onEventDrop?: (event: CalendarEvent, newStart: Date) => void;
|
||||||
|
defaultView?: string;
|
||||||
|
date?: Date;
|
||||||
|
onNavigate?: (date: Date) => void;
|
||||||
|
onViewChange?: (view: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
function eventStyleGetter(event: CalendarEvent) {
|
function eventStyleGetter(event: CalendarEvent) {
|
||||||
return {
|
const style: React.CSSProperties = {
|
||||||
style: {
|
backgroundColor: event.color,
|
||||||
backgroundColor: event.color,
|
borderRadius: '4px',
|
||||||
borderRadius: '4px',
|
opacity: 0.85,
|
||||||
opacity: 0.8,
|
color: '#fff',
|
||||||
color: 'white',
|
border: '0px',
|
||||||
border: '0px',
|
fontSize: '12px',
|
||||||
fontSize: '12px',
|
display: 'block',
|
||||||
},
|
|
||||||
};
|
};
|
||||||
|
|
||||||
|
if (event.status === 'completed' || event.status === 'done') {
|
||||||
|
style.opacity = 0.5;
|
||||||
|
style.textDecoration = 'line-through';
|
||||||
|
}
|
||||||
|
|
||||||
|
return { style };
|
||||||
}
|
}
|
||||||
|
|
||||||
export function BigCalendarWrapper({ events }: BigCalendarWrapperProps) {
|
export function BigCalendarWrapper({
|
||||||
|
events,
|
||||||
|
onEventDrop,
|
||||||
|
defaultView = 'month',
|
||||||
|
date,
|
||||||
|
onNavigate,
|
||||||
|
onViewChange,
|
||||||
|
}: BigCalendarWrapperProps) {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
||||||
|
const handleSelectEvent = useCallback(
|
||||||
|
(event: CalendarEvent) => {
|
||||||
|
router.push(event.href);
|
||||||
|
},
|
||||||
|
[router]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleEventDrop = useCallback(
|
||||||
|
({ event, start }: { event: CalendarEvent; start: Date }) => {
|
||||||
|
if (onEventDrop) {
|
||||||
|
onEventDrop(event, start);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[onEventDrop]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleNavigate = useCallback(
|
||||||
|
(newDate: Date) => {
|
||||||
|
if (onNavigate) onNavigate(newDate);
|
||||||
|
},
|
||||||
|
[onNavigate]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleViewChange = useCallback(
|
||||||
|
(newView: string) => {
|
||||||
|
if (onViewChange) onViewChange(newView);
|
||||||
|
},
|
||||||
|
[onViewChange]
|
||||||
|
);
|
||||||
|
|
||||||
|
const minTime = useMemo(() => {
|
||||||
|
const d = new Date();
|
||||||
|
d.setHours(0, 0, 0, 0);
|
||||||
|
return d;
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const maxTime = useMemo(() => {
|
||||||
|
const d = new Date();
|
||||||
|
d.setHours(23, 59, 59, 999);
|
||||||
|
return d;
|
||||||
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Calendar
|
<DragAndDropCalendar
|
||||||
localizer={localizer}
|
localizer={localizer}
|
||||||
events={events}
|
events={events}
|
||||||
startAccessor="start"
|
startAccessor="start"
|
||||||
endAccessor="end"
|
endAccessor="end"
|
||||||
style={{ height: 600 }}
|
style={{ height: 600 }}
|
||||||
eventPropGetter={eventStyleGetter}
|
eventPropGetter={eventStyleGetter}
|
||||||
onSelectEvent={(event) => router.push(event.href)}
|
onSelectEvent={handleSelectEvent}
|
||||||
views={['month', 'week', 'day']}
|
views={['month', 'week', 'day']}
|
||||||
defaultView="month"
|
defaultView={defaultView}
|
||||||
|
date={date}
|
||||||
|
onNavigate={handleNavigate}
|
||||||
|
onView={handleViewChange}
|
||||||
popup
|
popup
|
||||||
toolbar
|
onEventDrop={handleEventDrop}
|
||||||
|
resizable
|
||||||
|
step={60}
|
||||||
|
timeslots={1}
|
||||||
|
min={minTime}
|
||||||
|
max={maxTime}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,41 +1,108 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { ResponsiveGridLayout, useContainerWidth, verticalCompactor } from 'react-grid-layout';
|
import { useMemo } from 'react';
|
||||||
import type { Layout } from 'react-grid-layout';
|
import dynamic from 'next/dynamic';
|
||||||
import 'react-grid-layout/css/styles.css';
|
import 'react-grid-layout/css/styles.css';
|
||||||
import 'react-resizable/css/styles.css';
|
import 'react-resizable/css/styles.css';
|
||||||
|
|
||||||
|
// react-grid-layout needs WidthProvider for responsive behavior
|
||||||
|
// Dynamic import to avoid SSR issues
|
||||||
|
const ReactGridLayout = dynamic(
|
||||||
|
() => import('react-grid-layout').then((mod) => {
|
||||||
|
// react-grid-layout v2 exports GridLayout as default
|
||||||
|
// WidthProvider is a named export
|
||||||
|
const GridLayout = (mod as any).default || mod;
|
||||||
|
const WidthProvider = (mod as any).WidthProvider;
|
||||||
|
if (WidthProvider) {
|
||||||
|
return WidthProvider(GridLayout);
|
||||||
|
}
|
||||||
|
return GridLayout;
|
||||||
|
}),
|
||||||
|
{ ssr: false }
|
||||||
|
);
|
||||||
|
|
||||||
|
interface LayoutItem {
|
||||||
|
i: string;
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
w: number;
|
||||||
|
h: number;
|
||||||
|
minW?: number;
|
||||||
|
minH?: number;
|
||||||
|
maxW?: number;
|
||||||
|
maxH?: number;
|
||||||
|
static?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
interface ResponsiveGridProps {
|
interface ResponsiveGridProps {
|
||||||
layout: Layout;
|
layout: LayoutItem[];
|
||||||
onLayoutChange: (newLayout: Layout) => void;
|
onLayoutChange: (newLayout: LayoutItem[]) => void;
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
|
isDraggable?: boolean;
|
||||||
|
isResizable?: boolean;
|
||||||
|
className?: string;
|
||||||
|
compactType?: 'vertical' | 'horizontal' | null;
|
||||||
|
preventCollision?: boolean;
|
||||||
|
rowHeight?: number;
|
||||||
|
cols?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function ResponsiveGrid({
|
export default function ResponsiveGrid({
|
||||||
layout,
|
layout,
|
||||||
onLayoutChange,
|
onLayoutChange,
|
||||||
children,
|
children,
|
||||||
|
isDraggable = true,
|
||||||
|
isResizable = true,
|
||||||
|
className = '',
|
||||||
|
compactType = 'vertical',
|
||||||
|
preventCollision = false,
|
||||||
|
rowHeight = 200,
|
||||||
|
cols = 12,
|
||||||
}: ResponsiveGridProps) {
|
}: ResponsiveGridProps) {
|
||||||
const { width, containerRef, mounted } = useContainerWidth();
|
// Build responsive layouts: same layout for all breakpoints
|
||||||
|
const responsiveLayouts = useMemo(() => {
|
||||||
|
// Desktop: 12 columns
|
||||||
|
const lg = layout.map((item) => ({ ...item }));
|
||||||
|
// Tablet: 8 columns — scale widths proportionally
|
||||||
|
const md = layout.map((item) => ({
|
||||||
|
...item,
|
||||||
|
w: Math.max(1, Math.min(8, Math.round(item.w * (8 / 12)))),
|
||||||
|
}));
|
||||||
|
// Mobile: 4 columns — stack widgets
|
||||||
|
const sm = layout.map((item, idx) => ({
|
||||||
|
...item,
|
||||||
|
x: 0,
|
||||||
|
y: idx,
|
||||||
|
w: 4,
|
||||||
|
h: Math.max(2, item.h),
|
||||||
|
}));
|
||||||
|
return { lg, md, sm, xs: sm, xxs: sm };
|
||||||
|
}, [layout]);
|
||||||
|
|
||||||
|
const handleLayoutChange = (newLayout: LayoutItem[]) => {
|
||||||
|
onLayoutChange(newLayout);
|
||||||
|
};
|
||||||
|
|
||||||
|
const GridComponent = ReactGridLayout as any;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div ref={containerRef}>
|
<div className={`w-full ${className}`}>
|
||||||
{mounted && (
|
<GridComponent
|
||||||
<ResponsiveGridLayout
|
layouts={responsiveLayouts}
|
||||||
className="layout"
|
onLayoutChange={handleLayoutChange}
|
||||||
width={width}
|
isDraggable={isDraggable}
|
||||||
layouts={{ lg: layout }}
|
isResizable={isResizable}
|
||||||
breakpoints={{ lg: 1200, md: 996, sm: 768, xs: 480, xxs: 0 }}
|
compactType={compactType}
|
||||||
cols={{ lg: 12, md: 10, sm: 6, xs: 4, xxs: 2 }}
|
preventCollision={preventCollision}
|
||||||
rowHeight={80}
|
rowHeight={rowHeight}
|
||||||
onLayoutChange={(_layout, _layouts) => onLayoutChange(_layout)}
|
cols={{ lg: 12, md: 8, sm: 4, xs: 4, xxs: 4 }}
|
||||||
dragConfig={{ handle: '.widget-drag-handle' }}
|
breakpoints={{ lg: 1200, md: 996, sm: 768, xs: 480, xxs: 0 }}
|
||||||
compactor={verticalCompactor}
|
draggableHandle=".widget-drag-handle"
|
||||||
resizeConfig={{ enabled: true }}
|
margin={[16, 16]}
|
||||||
>
|
containerPadding={[0, 0]}
|
||||||
{children}
|
>
|
||||||
</ResponsiveGridLayout>
|
{children}
|
||||||
)}
|
</GridComponent>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,90 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { Activity, Clock, User, Plus, CheckCircle2, XCircle } from 'lucide-react';
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
|
import { Badge } from '@/components/ui/badge';
|
||||||
|
|
||||||
|
interface ActivityItem {
|
||||||
|
id: string;
|
||||||
|
actor: string;
|
||||||
|
action: string;
|
||||||
|
entity_type: string;
|
||||||
|
entity_id: string;
|
||||||
|
changes: Record<string, unknown> | null;
|
||||||
|
created_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ActivityFeedWidget() {
|
||||||
|
const [activities, setActivities] = useState<ActivityItem[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchActivities();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
async function fetchActivities() {
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/agent-activity?perPage=20&sort=-created');
|
||||||
|
if (res.ok) {
|
||||||
|
const data = await res.json();
|
||||||
|
setActivities(data.items || []);
|
||||||
|
}
|
||||||
|
} catch {} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getActionIcon(action: string) {
|
||||||
|
switch (action) {
|
||||||
|
case 'created': return <Plus className="h-3 w-3 text-green-500" />;
|
||||||
|
case 'completed': return <CheckCircle2 className="h-3 w-3 text-green-500" />;
|
||||||
|
case 'deleted': return <XCircle className="h-3 w-3 text-red-500" />;
|
||||||
|
default: return <Clock className="h-3 w-3 text-blue-500" />;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function timeAgo(dateStr: string) {
|
||||||
|
const diff = Date.now() - new Date(dateStr).getTime();
|
||||||
|
const mins = Math.floor(diff / 60000);
|
||||||
|
if (mins < 1) return 'just now';
|
||||||
|
if (mins < 60) return `${mins}m ago`;
|
||||||
|
const hours = Math.floor(mins / 60);
|
||||||
|
if (hours < 24) return `${hours}h ago`;
|
||||||
|
return `${Math.floor(hours / 24)}d ago`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card className="h-full border-0 shadow-none">
|
||||||
|
<CardHeader className="p-0 pb-3">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<CardTitle className="flex items-center gap-2 text-base">
|
||||||
|
<Activity className="h-4 w-4" aria-hidden="true" />
|
||||||
|
Activity Feed
|
||||||
|
</CardTitle>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="p-0">
|
||||||
|
{loading ? (
|
||||||
|
<p className="text-sm text-muted-foreground">Loading...</p>
|
||||||
|
) : activities.length === 0 ? (
|
||||||
|
<p className="text-sm text-muted-foreground">No recent activity</p>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
{activities.slice(0, 10).map((item) => (
|
||||||
|
<div key={item.id} className="flex items-start gap-2 rounded-md p-1.5 text-xs">
|
||||||
|
<span className="mt-0.5 shrink-0">{getActionIcon(item.action)}</span>
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<span className="font-medium">{item.actor}</span>{' '}
|
||||||
|
<span className="text-muted-foreground">{item.action}</span>{' '}
|
||||||
|
<Badge variant="outline" className="text-[10px]">{item.entity_type}</Badge>
|
||||||
|
</div>
|
||||||
|
<span className="shrink-0 text-muted-foreground">{timeAgo(item.created_at)}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { Plus, Send } from 'lucide-react';
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Input } from '@/components/ui/input';
|
||||||
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||||
|
import { useRouter } from 'next/navigation';
|
||||||
|
|
||||||
|
export function QuickCaptureWidget() {
|
||||||
|
const [type, setType] = useState('task');
|
||||||
|
const [title, setTitle] = useState('');
|
||||||
|
const [submitting, setSubmitting] = useState(false);
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
async function handleSubmit(e: React.FormEvent) {
|
||||||
|
e.preventDefault();
|
||||||
|
if (!title.trim()) return;
|
||||||
|
|
||||||
|
setSubmitting(true);
|
||||||
|
try {
|
||||||
|
const endpoint = type === 'task' ? '/api/tasks'
|
||||||
|
: type === 'habit' ? '/api/habits'
|
||||||
|
: '/api/notes';
|
||||||
|
|
||||||
|
const body: Record<string, unknown> = { title: title.trim() };
|
||||||
|
if (type === 'task') {
|
||||||
|
body.status = 'todo';
|
||||||
|
body.priority = 'medium';
|
||||||
|
}
|
||||||
|
if (type === 'habit') {
|
||||||
|
body.name = title.trim();
|
||||||
|
delete body.title;
|
||||||
|
body.frequency = 'daily';
|
||||||
|
body.difficulty = 'medium';
|
||||||
|
}
|
||||||
|
|
||||||
|
const res = await fetch(endpoint, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (res.ok) {
|
||||||
|
setTitle('');
|
||||||
|
router.refresh();
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Quick capture failed:', err);
|
||||||
|
} finally {
|
||||||
|
setSubmitting(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card className="h-full border-0 shadow-none">
|
||||||
|
<CardHeader className="p-0 pb-3">
|
||||||
|
<CardTitle className="flex items-center gap-2 text-base">
|
||||||
|
<Plus className="h-4 w-4" aria-hidden="true" />
|
||||||
|
Quick Capture
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="p-0">
|
||||||
|
<form onSubmit={handleSubmit} className="flex gap-2">
|
||||||
|
<Select value={type} onValueChange={setType}>
|
||||||
|
<SelectTrigger className="w-24">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="task">Task</SelectItem>
|
||||||
|
<SelectItem value="habit">Habit</SelectItem>
|
||||||
|
<SelectItem value="note">Note</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<Input
|
||||||
|
value={title}
|
||||||
|
onChange={(e) => setTitle(e.target.value)}
|
||||||
|
placeholder="Quick add..."
|
||||||
|
className="flex-1"
|
||||||
|
/>
|
||||||
|
<Button type="submit" size="icon" disabled={submitting || !title.trim()}>
|
||||||
|
<Send className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { BookOpen, FileText } from 'lucide-react';
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
|
import { useRouter } from 'next/navigation';
|
||||||
|
|
||||||
|
interface Note {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
updated_at: string;
|
||||||
|
is_pinned: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function RecentNotesWidget() {
|
||||||
|
const [notes, setNotes] = useState<Note[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchNotes();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
async function fetchNotes() {
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/notes?perPage=5&sort=-updated');
|
||||||
|
if (res.ok) {
|
||||||
|
const data = await res.json();
|
||||||
|
setNotes(data.items || []);
|
||||||
|
}
|
||||||
|
} catch {} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card className="h-full border-0 shadow-none">
|
||||||
|
<CardHeader className="p-0 pb-3">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<CardTitle className="flex items-center gap-2 text-base">
|
||||||
|
<BookOpen className="h-4 w-4" aria-hidden="true" />
|
||||||
|
Recent Notes
|
||||||
|
</CardTitle>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="p-0">
|
||||||
|
{loading ? (
|
||||||
|
<p className="text-sm text-muted-foreground">Loading...</p>
|
||||||
|
) : notes.length === 0 ? (
|
||||||
|
<p className="text-sm text-muted-foreground">No notes yet</p>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{notes.map((note) => (
|
||||||
|
<div
|
||||||
|
key={note.id}
|
||||||
|
className="flex cursor-pointer items-center gap-2 rounded-md p-1.5 transition-colors hover:bg-accent/50"
|
||||||
|
onClick={() => router.push('/notes')}
|
||||||
|
>
|
||||||
|
<FileText className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
|
||||||
|
<span className="flex-1 truncate text-sm">
|
||||||
|
{note.is_pinned && '📌 '}
|
||||||
|
{note.title}
|
||||||
|
</span>
|
||||||
|
<span className="shrink-0 text-xs text-muted-foreground">
|
||||||
|
{new Date(note.updated_at).toLocaleDateString()}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { Calendar, ListTodo } from 'lucide-react';
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
|
import { Badge } from '@/components/ui/badge';
|
||||||
|
import { useRouter } from 'next/navigation';
|
||||||
|
|
||||||
|
interface UpcomingItem {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
due_date: string;
|
||||||
|
priority?: string;
|
||||||
|
status?: string;
|
||||||
|
name?: string;
|
||||||
|
target_date?: string;
|
||||||
|
color?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function UpcomingCalendarWidget() {
|
||||||
|
const [tasks, setTasks] = useState<UpcomingItem[]>([]);
|
||||||
|
const [projects, setProjects] = useState<UpcomingItem[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchUpcoming();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
async function fetchUpcoming() {
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/tasks?perPage=10&sort=due_date');
|
||||||
|
if (res.ok) {
|
||||||
|
const data = await res.json();
|
||||||
|
const now = new Date();
|
||||||
|
const nextWeek = new Date(now.getTime() + 7 * 24 * 60 * 60 * 1000);
|
||||||
|
const upcoming = (data.items || []).filter((t: any) => {
|
||||||
|
if (!t.due_date) return false;
|
||||||
|
const d = new Date(t.due_date);
|
||||||
|
return d >= now && d <= nextWeek;
|
||||||
|
});
|
||||||
|
setTasks(upcoming);
|
||||||
|
}
|
||||||
|
} catch {} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDate(dateStr: string) {
|
||||||
|
const d = new Date(dateStr);
|
||||||
|
const today = new Date();
|
||||||
|
const tomorrow = new Date(today);
|
||||||
|
tomorrow.setDate(tomorrow.getDate() + 1);
|
||||||
|
|
||||||
|
if (d.toDateString() === today.toDateString()) return 'Today';
|
||||||
|
if (d.toDateString() === tomorrow.toDateString()) return 'Tomorrow';
|
||||||
|
return d.toLocaleDateString('en-US', { weekday: 'short', month: 'short', day: 'numeric' });
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card className="h-full border-0 shadow-none">
|
||||||
|
<CardHeader className="p-0 pb-3">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<CardTitle className="flex items-center gap-2 text-base">
|
||||||
|
<Calendar className="h-4 w-4" aria-hidden="true" />
|
||||||
|
Upcoming
|
||||||
|
</CardTitle>
|
||||||
|
<Badge variant="secondary" className="text-xs">{tasks.length} due</Badge>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="p-0">
|
||||||
|
{loading ? (
|
||||||
|
<p className="text-sm text-muted-foreground">Loading...</p>
|
||||||
|
) : tasks.length === 0 ? (
|
||||||
|
<p className="text-sm text-muted-foreground">No upcoming due dates</p>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{tasks.slice(0, 7).map((task) => (
|
||||||
|
<div
|
||||||
|
key={task.id}
|
||||||
|
className="flex cursor-pointer items-center gap-2 rounded-md p-1.5 transition-colors hover:bg-accent/50"
|
||||||
|
onClick={() => router.push('/tasks')}
|
||||||
|
>
|
||||||
|
<ListTodo className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
|
||||||
|
<span className="flex-1 truncate text-sm">{task.title}</span>
|
||||||
|
<span className="shrink-0 text-xs text-muted-foreground">
|
||||||
|
{formatDate(task.due_date!)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -12,6 +12,7 @@ import {
|
|||||||
NotebookPen,
|
NotebookPen,
|
||||||
Share2,
|
Share2,
|
||||||
CalendarDays,
|
CalendarDays,
|
||||||
|
Search,
|
||||||
Bot,
|
Bot,
|
||||||
Settings,
|
Settings,
|
||||||
ChevronLeft,
|
ChevronLeft,
|
||||||
@@ -42,6 +43,7 @@ const navItems = [
|
|||||||
{ href: '/notes', label: 'Notes', icon: NotebookPen },
|
{ href: '/notes', label: 'Notes', icon: NotebookPen },
|
||||||
{ href: '/graph', label: 'Graph', icon: Share2 },
|
{ href: '/graph', label: 'Graph', icon: Share2 },
|
||||||
{ href: '/calendar', label: 'Calendar', icon: CalendarDays },
|
{ href: '/calendar', label: 'Calendar', icon: CalendarDays },
|
||||||
|
{ href: '/search', label: 'Search', icon: Search },
|
||||||
];
|
];
|
||||||
|
|
||||||
const workspaceItems = [
|
const workspaceItems = [
|
||||||
|
|||||||
@@ -46,6 +46,7 @@ export function useKeyboardShortcuts() {
|
|||||||
if (key === 'g') { router.push('/graph'); e.preventDefault(); return; }
|
if (key === 'g') { router.push('/graph'); e.preventDefault(); return; }
|
||||||
if (key === 'r') { router.push('/reports'); e.preventDefault(); return; }
|
if (key === 'r') { router.push('/reports'); e.preventDefault(); return; }
|
||||||
if (key === 'c') { router.push('/calendar'); e.preventDefault(); return; }
|
if (key === 'c') { router.push('/calendar'); e.preventDefault(); return; }
|
||||||
|
if (key === 's') { router.push('/search'); e.preventDefault(); return; }
|
||||||
if (key === 'a') { router.push('/analytics'); e.preventDefault(); return; }
|
if (key === 'a') { router.push('/analytics'); e.preventDefault(); return; }
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,152 @@
|
|||||||
|
import { db, sql } from '@project-e/db';
|
||||||
|
|
||||||
|
export interface SearchResult {
|
||||||
|
id: string;
|
||||||
|
type: 'task' | 'note' | 'project' | 'habit' | 'domain';
|
||||||
|
title: string;
|
||||||
|
snippet: string;
|
||||||
|
score: number;
|
||||||
|
workspaceId: string;
|
||||||
|
link: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SearchOptions {
|
||||||
|
query: string;
|
||||||
|
types?: string[];
|
||||||
|
domainId?: string;
|
||||||
|
limit?: number;
|
||||||
|
offset?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface EntityConfig {
|
||||||
|
table: string;
|
||||||
|
titleColumn: string;
|
||||||
|
contentColumn: string | null;
|
||||||
|
linkPrefix: string;
|
||||||
|
workspaceColumn: string;
|
||||||
|
deletedColumn: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const entityConfigs: Record<string, EntityConfig> = {
|
||||||
|
task: {
|
||||||
|
table: 'tasks',
|
||||||
|
titleColumn: 'title',
|
||||||
|
contentColumn: 'description',
|
||||||
|
linkPrefix: '/tasks',
|
||||||
|
workspaceColumn: 'domain_id',
|
||||||
|
deletedColumn: 'deleted_at',
|
||||||
|
},
|
||||||
|
note: {
|
||||||
|
table: 'notes',
|
||||||
|
titleColumn: 'title',
|
||||||
|
contentColumn: 'content',
|
||||||
|
linkPrefix: '/notes',
|
||||||
|
workspaceColumn: 'domain_id',
|
||||||
|
deletedColumn: 'deleted_at',
|
||||||
|
},
|
||||||
|
project: {
|
||||||
|
table: 'projects',
|
||||||
|
titleColumn: 'name',
|
||||||
|
contentColumn: 'description',
|
||||||
|
linkPrefix: '/projects',
|
||||||
|
workspaceColumn: 'domain_id',
|
||||||
|
deletedColumn: 'deleted_at',
|
||||||
|
},
|
||||||
|
habit: {
|
||||||
|
table: 'habits',
|
||||||
|
titleColumn: 'name',
|
||||||
|
contentColumn: 'description',
|
||||||
|
linkPrefix: '/habits',
|
||||||
|
workspaceColumn: 'domain_id',
|
||||||
|
deletedColumn: 'deleted_at',
|
||||||
|
},
|
||||||
|
domain: {
|
||||||
|
table: 'domains',
|
||||||
|
titleColumn: 'name',
|
||||||
|
contentColumn: null,
|
||||||
|
linkPrefix: '/settings',
|
||||||
|
workspaceColumn: 'id',
|
||||||
|
deletedColumn: null,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Full-text search across all entity types using PostgreSQL tsvector/tsquery.
|
||||||
|
* Uses websearch_to_tsquery for user-friendly query syntax.
|
||||||
|
* Generates snippets via ts_headline for highlighted matches.
|
||||||
|
*/
|
||||||
|
export async function searchEntities(options: SearchOptions): Promise<{
|
||||||
|
results: SearchResult[];
|
||||||
|
totalCount: number;
|
||||||
|
}> {
|
||||||
|
const { query, types = ['task', 'note', 'project', 'habit', 'domain'], domainId, limit = 20, offset = 0 } = options;
|
||||||
|
|
||||||
|
if (!query.trim()) {
|
||||||
|
return { results: [], totalCount: 0 };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sanitize query for websearch
|
||||||
|
const sanitized = query.replace(/['"\\]/g, '').trim();
|
||||||
|
if (!sanitized) {
|
||||||
|
return { results: [], totalCount: 0 };
|
||||||
|
}
|
||||||
|
|
||||||
|
const results: SearchResult[] = [];
|
||||||
|
|
||||||
|
for (const type of types) {
|
||||||
|
const config = entityConfigs[type];
|
||||||
|
if (!config) continue;
|
||||||
|
|
||||||
|
const { table, titleColumn, contentColumn, linkPrefix, workspaceColumn, deletedColumn } = config;
|
||||||
|
|
||||||
|
// Build conditions
|
||||||
|
const conditions: string[] = [`search_vector @@ websearch_to_tsquery('english', '${sanitized.replace(/'/g, "''")}')`];
|
||||||
|
if (domainId && workspaceColumn !== 'id') {
|
||||||
|
conditions.push(`${workspaceColumn} = '${domainId}'::uuid`);
|
||||||
|
}
|
||||||
|
if (deletedColumn) {
|
||||||
|
conditions.push(`${deletedColumn} IS NULL`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const whereClause = conditions.join(' AND ');
|
||||||
|
|
||||||
|
// Use the content column for headline if available, otherwise use title
|
||||||
|
const headlineColumn = contentColumn || titleColumn;
|
||||||
|
|
||||||
|
const queryStr = `
|
||||||
|
SELECT
|
||||||
|
id,
|
||||||
|
${titleColumn} AS title,
|
||||||
|
ts_headline('english', ${headlineColumn}, websearch_to_tsquery('english', '${sanitized.replace(/'/g, "''")}'),
|
||||||
|
'MaxWords=30, MinWords=15, ShortWord=3, HighlightAll=FALSE, StartSel=<mark>, StopSel=</mark>, FragmentDelimiter=...'
|
||||||
|
) AS snippet,
|
||||||
|
ts_rank(search_vector, websearch_to_tsquery('english', '${sanitized.replace(/'/g, "''")}')) AS score,
|
||||||
|
${workspaceColumn} AS workspace_id
|
||||||
|
FROM ${table}
|
||||||
|
WHERE ${whereClause}
|
||||||
|
ORDER BY score DESC
|
||||||
|
LIMIT 50
|
||||||
|
`;
|
||||||
|
|
||||||
|
const rows: any[] = await sql.unsafe(queryStr);
|
||||||
|
|
||||||
|
for (const row of rows) {
|
||||||
|
results.push({
|
||||||
|
id: String(row.id),
|
||||||
|
type: type as SearchResult['type'],
|
||||||
|
title: String(row.title || ''),
|
||||||
|
snippet: String(row.snippet || ''),
|
||||||
|
score: Number(row.score || 0),
|
||||||
|
workspaceId: String(row.workspace_id || ''),
|
||||||
|
link: `${linkPrefix}/${row.id}`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sort by score descending, then apply pagination
|
||||||
|
results.sort((a, b) => b.score - a.score);
|
||||||
|
const totalCount = results.length;
|
||||||
|
const paginated = results.slice(offset, offset + limit);
|
||||||
|
|
||||||
|
return { results: paginated, totalCount };
|
||||||
|
}
|
||||||
@@ -23,14 +23,14 @@ interface DashboardState {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const defaultWidgets: WidgetConfig[] = [
|
const defaultWidgets: WidgetConfig[] = [
|
||||||
{ id: 'today-tasks', type: 'TodayTasks', x: 0, y: 0, w: 6, h: 4, visible: true },
|
{ id: 'today-tasks', type: "Today's Tasks", x: 0, y: 0, w: 6, h: 4, visible: true },
|
||||||
{ id: 'habit-checklist', type: 'HabitChecklist', x: 6, y: 0, w: 3, h: 4, visible: true },
|
{ id: 'habit-checklist', type: 'Habit Checklist', x: 6, y: 0, w: 3, h: 4, visible: true },
|
||||||
{ id: 'weekly-stats', type: 'WeeklyStats', x: 9, y: 0, w: 3, h: 4, visible: true },
|
{ id: 'weekly-stats', type: 'Weekly Stats', x: 9, y: 0, w: 3, h: 4, visible: true },
|
||||||
{ id: 'project-progress', type: 'ProjectProgress', x: 0, y: 4, w: 4, h: 3, visible: true },
|
{ id: 'project-progress', type: 'Project Progress', x: 0, y: 4, w: 4, h: 3, visible: true },
|
||||||
{ id: 'habit-streaks', type: 'HabitStreaks', x: 4, y: 4, w: 4, h: 3, visible: true },
|
{ id: 'upcoming-calendar', type: 'Upcoming Calendar', x: 4, y: 4, w: 4, h: 3, visible: true },
|
||||||
{ id: 'calendar-mini', type: 'CalendarMini', x: 8, y: 4, w: 4, h: 3, visible: true },
|
{ id: 'recent-notes', type: 'Recent Notes', x: 8, y: 4, w: 4, h: 3, visible: true },
|
||||||
{ id: 'quick-add', type: 'QuickAdd', x: 0, y: 7, w: 3, h: 3, visible: true },
|
{ id: 'activity-feed', type: 'Activity Feed', x: 0, y: 7, w: 6, h: 3, visible: true },
|
||||||
{ id: 'recent-activity', type: 'RecentActivity', x: 3, y: 7, w: 9, h: 3, visible: true },
|
{ id: 'quick-capture', type: 'Quick Capture', x: 6, y: 7, w: 3, h: 3, visible: true },
|
||||||
];
|
];
|
||||||
|
|
||||||
export const useDashboardStore = create<DashboardState>()(
|
export const useDashboardStore = create<DashboardState>()(
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,10 @@
|
|||||||
|
ALTER TABLE "domains" ADD COLUMN "search_vector" "tsvector";--> statement-breakpoint
|
||||||
|
ALTER TABLE "habits" ADD COLUMN "search_vector" "tsvector";--> statement-breakpoint
|
||||||
|
ALTER TABLE "notes" ADD COLUMN "search_vector" "tsvector";--> statement-breakpoint
|
||||||
|
ALTER TABLE "projects" ADD COLUMN "search_vector" "tsvector";--> statement-breakpoint
|
||||||
|
ALTER TABLE "tasks" ADD COLUMN "search_vector" "tsvector";--> statement-breakpoint
|
||||||
|
CREATE INDEX "domains_search_idx" ON "domains" USING gin ("search_vector");--> statement-breakpoint
|
||||||
|
CREATE INDEX "habits_search_idx" ON "habits" USING gin ("search_vector");--> statement-breakpoint
|
||||||
|
CREATE INDEX "notes_search_idx" ON "notes" USING gin ("search_vector");--> statement-breakpoint
|
||||||
|
CREATE INDEX "projects_search_idx" ON "projects" USING gin ("search_vector");--> statement-breakpoint
|
||||||
|
CREATE INDEX "tasks_search_idx" ON "tasks" USING gin ("search_vector");
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
ALTER TABLE "domains" ADD COLUMN "custom_fields" jsonb DEFAULT '{}'::jsonb;--> statement-breakpoint
|
||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -8,6 +8,20 @@
|
|||||||
"when": 1785317538917,
|
"when": 1785317538917,
|
||||||
"tag": "0000_outstanding_zuras",
|
"tag": "0000_outstanding_zuras",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 1,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1785323079216,
|
||||||
|
"tag": "0001_warm_eternity",
|
||||||
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 2,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1785324000000,
|
||||||
|
"tag": "0002_steep_black_widow",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
Generated
+4114
File diff suppressed because it is too large
Load Diff
@@ -24,6 +24,7 @@
|
|||||||
"@playwright/test": "^1.61.1",
|
"@playwright/test": "^1.61.1",
|
||||||
"@swc/jest": "^0.2.39",
|
"@swc/jest": "^0.2.39",
|
||||||
"drizzle-kit": "^0.31.10",
|
"drizzle-kit": "^0.31.10",
|
||||||
|
"jest": "^30.4.2",
|
||||||
"tsx": "^4.23.1",
|
"tsx": "^4.23.1",
|
||||||
"turbo": "^2.5.0",
|
"turbo": "^2.5.0",
|
||||||
"typescript": "^5.9.3"
|
"typescript": "^5.9.3"
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import {
|
import {
|
||||||
boolean,
|
boolean,
|
||||||
|
customType,
|
||||||
index,
|
index,
|
||||||
integer,
|
integer,
|
||||||
jsonb,
|
jsonb,
|
||||||
@@ -13,6 +14,13 @@ import {
|
|||||||
uuid,
|
uuid,
|
||||||
} from 'drizzle-orm/pg-core';
|
} from 'drizzle-orm/pg-core';
|
||||||
|
|
||||||
|
// ── Custom tsvector type for full-text search ─────────────────────────────────
|
||||||
|
export const tsvector = customType<{ data: string }>({
|
||||||
|
dataType() {
|
||||||
|
return 'tsvector';
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
// ── Enums ──────────────────────────────────────────────────────────────────────
|
// ── Enums ──────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export const taskStatusEnum = pgEnum('task_status', ['todo', 'in_progress', 'done', 'cancelled']);
|
export const taskStatusEnum = pgEnum('task_status', ['todo', 'in_progress', 'done', 'cancelled']);
|
||||||
@@ -51,12 +59,15 @@ export const domains = pgTable(
|
|||||||
icon: text('icon'),
|
icon: text('icon'),
|
||||||
parentId: uuid('parent_id').references((): any => domains.id, { onDelete: 'set null' }),
|
parentId: uuid('parent_id').references((): any => domains.id, { onDelete: 'set null' }),
|
||||||
sortOrder: integer('sort_order').default(0),
|
sortOrder: integer('sort_order').default(0),
|
||||||
|
customFields: jsonb('custom_fields').$type<Record<string, unknown>>().default({}),
|
||||||
|
searchVector: tsvector('search_vector'),
|
||||||
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
|
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
|
||||||
updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull(),
|
updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull(),
|
||||||
},
|
},
|
||||||
(table) => [
|
(table) => [
|
||||||
index('domains_parent_id_idx').on(table.parentId),
|
index('domains_parent_id_idx').on(table.parentId),
|
||||||
index('domains_slug_idx').on(table.slug),
|
index('domains_slug_idx').on(table.slug),
|
||||||
|
index('domains_search_idx').using('gin', table.searchVector),
|
||||||
]
|
]
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -94,6 +105,7 @@ export const projects = pgTable(
|
|||||||
color: text('color'),
|
color: text('color'),
|
||||||
icon: text('icon'),
|
icon: text('icon'),
|
||||||
targetDate: timestamp('target_date', { withTimezone: true }),
|
targetDate: timestamp('target_date', { withTimezone: true }),
|
||||||
|
searchVector: tsvector('search_vector'),
|
||||||
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
|
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
|
||||||
updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull(),
|
updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull(),
|
||||||
deletedAt: timestamp('deleted_at', { withTimezone: true }),
|
deletedAt: timestamp('deleted_at', { withTimezone: true }),
|
||||||
@@ -102,6 +114,7 @@ export const projects = pgTable(
|
|||||||
index('projects_domain_id_idx').on(table.domainId),
|
index('projects_domain_id_idx').on(table.domainId),
|
||||||
index('projects_status_idx').on(table.status),
|
index('projects_status_idx').on(table.status),
|
||||||
index('projects_deleted_at_idx').on(table.deletedAt),
|
index('projects_deleted_at_idx').on(table.deletedAt),
|
||||||
|
index('projects_search_idx').using('gin', table.searchVector),
|
||||||
]
|
]
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -152,6 +165,7 @@ export const tasks = pgTable(
|
|||||||
recurrenceRule: text('recurrence_rule'),
|
recurrenceRule: text('recurrence_rule'),
|
||||||
order: integer('order').default(0),
|
order: integer('order').default(0),
|
||||||
customFields: jsonb('custom_fields').$type<Record<string, unknown>>().default({}),
|
customFields: jsonb('custom_fields').$type<Record<string, unknown>>().default({}),
|
||||||
|
searchVector: tsvector('search_vector'),
|
||||||
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
|
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
|
||||||
updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull(),
|
updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull(),
|
||||||
deletedAt: timestamp('deleted_at', { withTimezone: true }),
|
deletedAt: timestamp('deleted_at', { withTimezone: true }),
|
||||||
@@ -166,6 +180,7 @@ export const tasks = pgTable(
|
|||||||
index('tasks_due_date_idx').on(table.dueDate),
|
index('tasks_due_date_idx').on(table.dueDate),
|
||||||
index('tasks_order_idx').on(table.order),
|
index('tasks_order_idx').on(table.order),
|
||||||
index('tasks_deleted_at_idx').on(table.deletedAt),
|
index('tasks_deleted_at_idx').on(table.deletedAt),
|
||||||
|
index('tasks_search_idx').using('gin', table.searchVector),
|
||||||
]
|
]
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -226,6 +241,7 @@ export const habits = pgTable(
|
|||||||
bestStreak: integer('best_streak').default(0),
|
bestStreak: integer('best_streak').default(0),
|
||||||
moodTracking: boolean('mood_tracking').default(false),
|
moodTracking: boolean('mood_tracking').default(false),
|
||||||
active: boolean('active').default(true),
|
active: boolean('active').default(true),
|
||||||
|
searchVector: tsvector('search_vector'),
|
||||||
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
|
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
|
||||||
updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull(),
|
updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull(),
|
||||||
deletedAt: timestamp('deleted_at', { withTimezone: true }),
|
deletedAt: timestamp('deleted_at', { withTimezone: true }),
|
||||||
@@ -234,6 +250,7 @@ export const habits = pgTable(
|
|||||||
index('habits_domain_id_idx').on(table.domainId),
|
index('habits_domain_id_idx').on(table.domainId),
|
||||||
index('habits_active_idx').on(table.active),
|
index('habits_active_idx').on(table.active),
|
||||||
index('habits_deleted_at_idx').on(table.deletedAt),
|
index('habits_deleted_at_idx').on(table.deletedAt),
|
||||||
|
index('habits_search_idx').using('gin', table.searchVector),
|
||||||
]
|
]
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -289,6 +306,7 @@ export const notes = pgTable(
|
|||||||
.references((): any => domains.id, { onDelete: 'cascade' }),
|
.references((): any => domains.id, { onDelete: 'cascade' }),
|
||||||
isPinned: boolean('is_pinned').default(false),
|
isPinned: boolean('is_pinned').default(false),
|
||||||
isArchived: boolean('is_archived').default(false),
|
isArchived: boolean('is_archived').default(false),
|
||||||
|
searchVector: tsvector('search_vector'),
|
||||||
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
|
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
|
||||||
updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull(),
|
updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull(),
|
||||||
deletedAt: timestamp('deleted_at', { withTimezone: true }),
|
deletedAt: timestamp('deleted_at', { withTimezone: true }),
|
||||||
@@ -298,6 +316,7 @@ export const notes = pgTable(
|
|||||||
index('notes_is_pinned_idx').on(table.isPinned),
|
index('notes_is_pinned_idx').on(table.isPinned),
|
||||||
index('notes_is_archived_idx').on(table.isArchived),
|
index('notes_is_archived_idx').on(table.isArchived),
|
||||||
index('notes_deleted_at_idx').on(table.deletedAt),
|
index('notes_deleted_at_idx').on(table.deletedAt),
|
||||||
|
index('notes_search_idx').using('gin', table.searchVector),
|
||||||
]
|
]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user