Compare commits
No commits in common. "bb4e8b55582706c5e888820e9d7dacb4fbeb6122" and "15bfcfa0643aea1289337d8de1197752cbcbd992" have entirely different histories.
bb4e8b5558
...
15bfcfa064
|
|
@ -28,8 +28,9 @@ import {
|
|||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { getBrighterColor, getComplementaryColor } from "@/lib/colors";
|
||||
import { isDarkTheme, useGroupColor } from "@/components/theme/use-dark-theme";
|
||||
import { getBrighterColor, getComplementaryColor, getGroupColor } from "@/lib/colors";
|
||||
import { isDarkTheme, isCyberpunkTheme } from "@/components/theme/use-dark-theme";
|
||||
import { getCyberpunkGroupColor } from "@/lib/colors";
|
||||
import { useBoard } from "@/components/board/board-context";
|
||||
import { useBoardView } from "@/components/board/board-view-provider";
|
||||
import { useHoldOnComplete } from "@/components/hold-on-complete";
|
||||
|
|
@ -121,11 +122,9 @@ export function GroupCard({ group }: { group: GroupDTO }) {
|
|||
// card ever ringing itself while it's the one being moved.)
|
||||
const isDropTarget = isOver && !isDragging;
|
||||
|
||||
// The group's color for the ACTIVE theme: themes with their own palette
|
||||
// (cyberpunk, blueprint, vaporwave, notebook, starfield) retint the card,
|
||||
// every other theme keeps the base GROUP_COLORS. Falls back to the base
|
||||
// color pre-mount so the server render matches.
|
||||
const color = useGroupColor(group.color);
|
||||
const color = isCyberpunkTheme()
|
||||
? getCyberpunkGroupColor(group.color)
|
||||
: getGroupColor(group.color);
|
||||
const borderColor = isDark ? color.dark : color.light;
|
||||
// The card fill is a calm, hand-picked tint of the same stroke color
|
||||
// (`soft` / `softDark` in lib/colors.ts), blended a little into the theme's
|
||||
|
|
|
|||
|
|
@ -21,21 +21,12 @@ import { AddCategoryLane } from "@/components/board/add-category-lane";
|
|||
import { GroupCardOverlay } from "@/components/board/group-card-overlay";
|
||||
import { EmptyState } from "@/components/board/empty-state";
|
||||
import { ViewSwitcher } from "@/components/board/view-switcher";
|
||||
import { SummaryButton } from "@/components/board/summary-button";
|
||||
import { TodoCreateDialog } from "@/components/board/todo-create-dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
import type { CategoryDTO, GroupDTO } from "@/types/board";
|
||||
|
||||
function Board({
|
||||
title,
|
||||
projectId,
|
||||
aiConfigured,
|
||||
}: {
|
||||
title: string;
|
||||
projectId?: string;
|
||||
aiConfigured: boolean;
|
||||
}) {
|
||||
function Board({ title }: { title: string }) {
|
||||
const { categories, reorderLanes, reorderGroups, moveGroup } = useBoard();
|
||||
const [draggedGroup, setDraggedGroup] = useState<GroupDTO | null>(null);
|
||||
const [quickAddOpen, setQuickAddOpen] = useState(false);
|
||||
|
|
@ -163,7 +154,6 @@ function Board({
|
|||
</div>
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center gap-2">
|
||||
{aiConfigured && <SummaryButton projectId={projectId} />}
|
||||
<Tooltip>
|
||||
<TooltipTrigger
|
||||
render={
|
||||
|
|
@ -242,7 +232,7 @@ export function KanbanBoard({
|
|||
projectId={projectId}
|
||||
aiConfigured={aiConfigured}
|
||||
>
|
||||
<Board title={title} projectId={projectId} aiConfigured={aiConfigured} />
|
||||
<Board title={title} />
|
||||
</BoardProvider>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,329 +0,0 @@
|
|||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { Check, Copy, FileText, Loader2, Sparkles } from "lucide-react";
|
||||
|
||||
import { colorModeFromTheme } from "@/components/theme/use-dark-theme";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverDescription,
|
||||
PopoverHeader,
|
||||
PopoverTitle,
|
||||
PopoverTrigger,
|
||||
} from "@/components/ui/popover";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
import { MarkdownPreview } from "@/components/markdown/markdown-widgets";
|
||||
import { summarizeBoard } from "@/lib/actions/summarize";
|
||||
import { toDateKey } from "@/lib/dates";
|
||||
|
||||
const DATE_RANGE_OPTIONS = [
|
||||
{ value: "today", label: "Today" },
|
||||
{ value: "thisWeek", label: "This Week" },
|
||||
{ value: "thisMonth", label: "This Month" },
|
||||
{ value: "lastMonth", label: "Last Month" },
|
||||
{ value: "custom", label: "Custom Range" },
|
||||
] as const;
|
||||
|
||||
const ORGANIZE_BY_OPTIONS = [
|
||||
{ value: "byDate", label: "By Date" },
|
||||
{ value: "byCategory", label: "By Category/Group" },
|
||||
] as const;
|
||||
|
||||
type DateRange = (typeof DATE_RANGE_OPTIONS)[number]["value"];
|
||||
type OrganizeBy = (typeof ORGANIZE_BY_OPTIONS)[number]["value"];
|
||||
|
||||
/**
|
||||
* Board header's "Summary" button: a compact popover with two dropdowns
|
||||
* (date range + how to organize) and a Summarize action, then a
|
||||
* result dialog that renders the AI's markdown recap with a copy-to-
|
||||
* clipboard icon. Scoped to whatever board it's mounted on (Home or a
|
||||
* Project) via `projectId`.
|
||||
*/
|
||||
export function SummaryButton({ projectId }: { projectId?: string }) {
|
||||
const colorMode = colorModeFromTheme();
|
||||
|
||||
// Popover (selection) state.
|
||||
const [popoverOpen, setPopoverOpen] = useState(false);
|
||||
const [dateRange, setDateRange] = useState<DateRange>("today");
|
||||
const [organizeBy, setOrganizeBy] = useState<OrganizeBy>("byDate");
|
||||
// Both default to today so a "Custom Range" pick is immediately usable.
|
||||
const [customStart, setCustomStart] = useState(() => toDateKey(new Date()));
|
||||
const [customEnd, setCustomEnd] = useState(() => toDateKey(new Date()));
|
||||
|
||||
// Result dialog state.
|
||||
const [resultOpen, setResultOpen] = useState(false);
|
||||
const [generating, setGenerating] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [noWork, setNoWork] = useState(false);
|
||||
const [summary, setSummary] = useState<string | null>(null);
|
||||
const [rangeLabel, setRangeLabel] = useState<string | null>(null);
|
||||
const [count, setCount] = useState(0);
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
const customInvalid =
|
||||
dateRange === "custom" && (!customStart || !customEnd || customStart > customEnd);
|
||||
|
||||
async function handleSummarize() {
|
||||
if (generating || customInvalid) return;
|
||||
setPopoverOpen(false);
|
||||
setResultOpen(true);
|
||||
setGenerating(true);
|
||||
setError(null);
|
||||
setNoWork(false);
|
||||
setSummary(null);
|
||||
setRangeLabel(null);
|
||||
setCount(0);
|
||||
setCopied(false);
|
||||
|
||||
try {
|
||||
const result = await summarizeBoard(projectId ?? null, {
|
||||
dateRange,
|
||||
customStart: dateRange === "custom" ? customStart : undefined,
|
||||
customEnd: dateRange === "custom" ? customEnd : undefined,
|
||||
organizeBy,
|
||||
});
|
||||
|
||||
if (result.status === "ok") {
|
||||
setSummary(result.content);
|
||||
setRangeLabel(result.rangeLabel);
|
||||
setCount(result.count);
|
||||
} else if (result.status === "empty") {
|
||||
setRangeLabel(result.rangeLabel);
|
||||
setNoWork(true);
|
||||
} else {
|
||||
throw new Error(result.error);
|
||||
}
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Something went wrong.");
|
||||
} finally {
|
||||
setGenerating(false);
|
||||
}
|
||||
}
|
||||
|
||||
// Starts fresh every time it's reopened -- a summary from a different
|
||||
// range would be stale the moment the dialog closes and reopens.
|
||||
function handleResultOpenChange(next: boolean) {
|
||||
if (!next) {
|
||||
setGenerating(false);
|
||||
setError(null);
|
||||
setNoWork(false);
|
||||
setSummary(null);
|
||||
setRangeLabel(null);
|
||||
setCount(0);
|
||||
setCopied(false);
|
||||
}
|
||||
setResultOpen(next);
|
||||
}
|
||||
|
||||
async function handleCopy() {
|
||||
if (!summary) return;
|
||||
try {
|
||||
await navigator.clipboard.writeText(summary);
|
||||
setCopied(true);
|
||||
toast.success("Summary copied to clipboard.");
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
} catch {
|
||||
toast.error("Couldn't copy to clipboard.");
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Popover open={popoverOpen} onOpenChange={setPopoverOpen}>
|
||||
<PopoverTrigger
|
||||
render={
|
||||
<Button variant="outline" size="sm" className="gap-1.5">
|
||||
<FileText className="size-3.5" />
|
||||
Summary
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
<PopoverContent align="end" className="w-72">
|
||||
<PopoverHeader>
|
||||
<PopoverTitle>Summarize completed work</PopoverTitle>
|
||||
<PopoverDescription>An AI recap of what you got done on this board.</PopoverDescription>
|
||||
</PopoverHeader>
|
||||
|
||||
<div className="space-y-3.5">
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="summary-date-range">Date range</Label>
|
||||
<Select
|
||||
value={dateRange}
|
||||
// Without `items`, <Select.Value> can't look up a label for
|
||||
// the current value until the popup has opened once.
|
||||
items={DATE_RANGE_OPTIONS}
|
||||
onValueChange={(v) => v && setDateRange(v as DateRange)}
|
||||
>
|
||||
<SelectTrigger id="summary-date-range" className="w-full">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{DATE_RANGE_OPTIONS.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{dateRange === "custom" && (
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="summary-custom-start">Start</Label>
|
||||
<Input
|
||||
id="summary-custom-start"
|
||||
type="date"
|
||||
value={customStart}
|
||||
max={customEnd || undefined}
|
||||
onChange={(e) => setCustomStart(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="summary-custom-end">End</Label>
|
||||
<Input
|
||||
id="summary-custom-end"
|
||||
type="date"
|
||||
value={customEnd}
|
||||
min={customStart || undefined}
|
||||
onChange={(e) => setCustomEnd(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="summary-organize-by">Organize by</Label>
|
||||
<Select
|
||||
value={organizeBy}
|
||||
items={ORGANIZE_BY_OPTIONS}
|
||||
onValueChange={(v) => v && setOrganizeBy(v as OrganizeBy)}
|
||||
>
|
||||
<SelectTrigger id="summary-organize-by" className="w-full">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{ORGANIZE_BY_OPTIONS.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{customInvalid && (
|
||||
<p className="text-xs text-destructive">
|
||||
Pick a start date on or before the end date.
|
||||
</p>
|
||||
)}
|
||||
|
||||
<Button
|
||||
className="w-full gap-2"
|
||||
disabled={generating || customInvalid}
|
||||
onClick={handleSummarize}
|
||||
>
|
||||
{generating ? (
|
||||
<Loader2 className="size-4 animate-spin" />
|
||||
) : (
|
||||
<Sparkles className="size-4" />
|
||||
)}
|
||||
Summarize
|
||||
</Button>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
|
||||
<Dialog open={resultOpen} onOpenChange={handleResultOpenChange}>
|
||||
<DialogContent className="flex max-h-[80vh] flex-col sm:max-w-xl" data-color-mode={colorMode}>
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<Sparkles className="size-4 text-primary" />
|
||||
Summary
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
{rangeLabel
|
||||
? `${rangeLabel} · ${count} completed ${count === 1 ? "item" : "items"}`
|
||||
: "An AI recap of what you got done."}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
{generating && (
|
||||
<div className="flex flex-col items-center gap-3 rounded-md border border-dashed p-10">
|
||||
<Loader2 className="size-6 animate-spin text-muted-foreground" />
|
||||
<p className="text-sm text-muted-foreground">Summarizing your completed work…</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!generating && error && (
|
||||
<div className="space-y-3">
|
||||
<p className="text-sm text-destructive">{error}</p>
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button variant="outline" size="sm" onClick={() => setResultOpen(false)}>
|
||||
Close
|
||||
</Button>
|
||||
<Button size="sm" onClick={handleSummarize}>
|
||||
Try again
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!generating && !error && noWork && (
|
||||
<p className="rounded-md border border-dashed p-8 text-center text-sm text-muted-foreground">
|
||||
Nothing was completed in this range yet -- check something off first.
|
||||
</p>
|
||||
)}
|
||||
|
||||
{!generating && !error && !noWork && summary && (
|
||||
<div className="min-h-0 flex-1 overflow-y-auto rounded-md border p-4">
|
||||
<MarkdownPreview source={summary} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{summary && (
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<Tooltip>
|
||||
<TooltipTrigger
|
||||
render={
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
aria-label={copied ? "Copied" : "Copy summary to clipboard"}
|
||||
onClick={handleCopy}
|
||||
>
|
||||
{copied ? <Check className="size-4" /> : <Copy className="size-4" />}
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
<TooltipContent side="top">{copied ? "Copied" : "Copy to clipboard"}</TooltipContent>
|
||||
</Tooltip>
|
||||
<Button variant="outline" size="sm" onClick={() => setResultOpen(false)}>
|
||||
Done
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -3,7 +3,6 @@
|
|||
import { useEffect, useState } from "react";
|
||||
|
||||
import { useTheme, resolveTheme, DARK_SURFACES } from "./theme-provider";
|
||||
import { getGroupColor, getThemedGroupColor, type GroupColor } from "@/lib/colors";
|
||||
|
||||
// The themes whose *surfaces* are dark (see app/globals.css) come from the
|
||||
// provider's single source of truth -- currently Dark, Ocean, Pine, Plum,
|
||||
|
|
@ -49,22 +48,6 @@ export function isCyberpunkTheme(): boolean {
|
|||
return resolveTheme(theme) === "cyberpunk";
|
||||
}
|
||||
|
||||
/**
|
||||
* The group color for the active theme. Themes with their own palette
|
||||
* (cyberpunk, blueprint, vaporwave, notebook, starfield -- see
|
||||
* lib/colors.ts) get theirs; everything else gets the base color. Same
|
||||
* contract as isDarkTheme: reactive to theme switches (derived from the
|
||||
* provider's state, so a setTheme re-renders every consumer in the same
|
||||
* commit) and hydration-safe (until mounted we report the base color,
|
||||
* matching the server render).
|
||||
*/
|
||||
export function useGroupColor(key: string): GroupColor {
|
||||
const mounted = useMounted();
|
||||
const { theme } = useTheme();
|
||||
if (!mounted) return getGroupColor(key);
|
||||
return getThemedGroupColor(resolveTheme(theme), key);
|
||||
}
|
||||
|
||||
/**
|
||||
* The `data-color-mode` value the markdown editor/preview widgets expect.
|
||||
* They only understand light/dark, so the light looks (default, sunset,
|
||||
|
|
|
|||
|
|
@ -1,318 +0,0 @@
|
|||
"use server";
|
||||
|
||||
import { addUTCDays, getWeekStart, startOfUTCDate, toDateKey } from "@/lib/dates";
|
||||
import { callChatCompletion } from "@/lib/ai/chat-completion";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { requireUserId } from "@/lib/auth-helpers";
|
||||
import { SummaryRequestSchema, type SummaryRequest } from "@/lib/validation/summary";
|
||||
|
||||
export type BoardSummaryResult =
|
||||
| { status: "ok"; content: string; rangeLabel: string; count: number }
|
||||
| { status: "empty"; rangeLabel: string }
|
||||
| { status: "error"; error: string };
|
||||
|
||||
// Hard prompt-size caps -- the same philosophy as lib/ai/context.ts: the
|
||||
// whole relevant dataset goes straight into the prompt, truncated only if
|
||||
// a huge custom range would blow the model's context window.
|
||||
const DETAILS_CHAR_CAP = 300;
|
||||
const TOTAL_CHAR_CAP = 40_000;
|
||||
|
||||
interface ResolvedRange {
|
||||
// Half-open UTC interval: [from, to).
|
||||
from: Date;
|
||||
to: Date;
|
||||
// Human label ("Aug 12, 2026", "This week: Aug 10 – Aug 16, 2026", ...)
|
||||
// shown to the user and quoted in the AI prompt.
|
||||
label: string;
|
||||
}
|
||||
|
||||
function formatUtcDay(date: Date, weekday: boolean): string {
|
||||
return date.toLocaleDateString(
|
||||
undefined,
|
||||
{
|
||||
...(weekday ? { weekday: "long" as const } : {}),
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
year: "numeric",
|
||||
// UTC because the boundaries were computed in UTC -- rendering in a
|
||||
// different timezone could shift an edge day.
|
||||
timeZone: "UTC",
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
function monthLabel(year: number, monthIndex: number): string {
|
||||
return new Date(Date.UTC(year, monthIndex, 1)).toLocaleDateString(undefined, {
|
||||
month: "long",
|
||||
year: "numeric",
|
||||
timeZone: "UTC",
|
||||
});
|
||||
}
|
||||
|
||||
/** All boundaries are UTC calendar days -- same convention as lib/dates.ts. */
|
||||
function resolveRange(request: SummaryRequest, now: Date): ResolvedRange {
|
||||
const year = now.getUTCFullYear();
|
||||
const month = now.getUTCMonth(); // 0-based
|
||||
|
||||
switch (request.dateRange) {
|
||||
case "today": {
|
||||
const from = startOfUTCDate(now);
|
||||
return { from, to: addUTCDays(from, 1), label: formatUtcDay(from, true) };
|
||||
}
|
||||
case "thisWeek": {
|
||||
const from = getWeekStart(now);
|
||||
const to = addUTCDays(from, 7);
|
||||
return { from, to, label: `This week (${formatUtcDay(from, false)} – ${formatUtcDay(addUTCDays(to, -1), false)})` };
|
||||
}
|
||||
case "thisMonth": {
|
||||
const from = new Date(Date.UTC(year, month, 1));
|
||||
const to = new Date(Date.UTC(year, month + 1, 1));
|
||||
return { from, to, label: `This month (${monthLabel(year, month)})` };
|
||||
}
|
||||
case "lastMonth": {
|
||||
const from = new Date(Date.UTC(year, month - 1, 1));
|
||||
const to = new Date(Date.UTC(year, month, 1));
|
||||
return { from, to, label: `Last month (${monthLabel(year, month - 1)})` };
|
||||
}
|
||||
case "custom": {
|
||||
// safeParse already guaranteed both dates exist when dateRange is "custom".
|
||||
const from = startOfUTCDate(new Date(`${request.customStart}T00:00:00Z`));
|
||||
const to = addUTCDays(startOfUTCDate(new Date(`${request.customEnd}T00:00:00Z`)), 1);
|
||||
const end = addUTCDays(to, -1);
|
||||
return { from, to, label: formatUtcDay(from, false) === formatUtcDay(end, false) ? formatUtcDay(from, true) : `${formatUtcDay(from, false)} – ${formatUtcDay(end, false)}` };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
interface BoardTodoEntry {
|
||||
title: string;
|
||||
details: string | null;
|
||||
completedAt: Date;
|
||||
groupTitle: string;
|
||||
categoryName: string;
|
||||
}
|
||||
|
||||
interface ScheduledEntry {
|
||||
title: string;
|
||||
occurrenceDate: Date;
|
||||
completedAt: Date;
|
||||
recurring: boolean;
|
||||
}
|
||||
|
||||
interface BoardSummaryData {
|
||||
categories: {
|
||||
name: string;
|
||||
groups: { title: string; archived: boolean; todos: BoardTodoEntry[] }[];
|
||||
}[];
|
||||
scheduled: ScheduledEntry[];
|
||||
}
|
||||
|
||||
function formatDetails(details: string | null): string {
|
||||
const trimmed = details?.trim();
|
||||
if (!trimmed) return "";
|
||||
if (trimmed.length <= DETAILS_CHAR_CAP) return ` — ${trimmed}`;
|
||||
return ` — ${trimmed.slice(0, DETAILS_CHAR_CAP)}… [truncated]`;
|
||||
}
|
||||
|
||||
/** Groups everything by the day it belongs to, in the order the owner asked for. */
|
||||
function formatByDate(data: BoardSummaryData): string {
|
||||
const lines: string[] = [];
|
||||
|
||||
const boardFlat: (BoardTodoEntry & { category: string; group: string })[] =
|
||||
data.categories.flatMap((category) =>
|
||||
category.groups.flatMap((group) =>
|
||||
group.todos.map((todo) => ({ ...todo, category: category.name, group: group.title }))
|
||||
)
|
||||
);
|
||||
|
||||
if (boardFlat.length > 0) {
|
||||
lines.push("Board to-dos, grouped by the day they were completed:");
|
||||
const byDay = new Map<string, typeof boardFlat>();
|
||||
for (const todo of boardFlat) {
|
||||
const key = toDateKey(todo.completedAt);
|
||||
const bucket = byDay.get(key);
|
||||
if (bucket) bucket.push(todo);
|
||||
else byDay.set(key, [todo]);
|
||||
}
|
||||
// Day keys are "YYYY-MM-DD", so lexicographic = chronological -- the
|
||||
// model should see (and keep) oldest day first even though the
|
||||
// underlying data is grouped by category/group.
|
||||
for (const [key, todos] of [...byDay.entries()].sort(([a], [b]) => a.localeCompare(b))) {
|
||||
lines.push(`### ${formatUtcDay(new Date(`${key}T00:00:00Z`), true)}`);
|
||||
for (const todo of todos) {
|
||||
lines.push(`- ${todo.title} (group: ${todo.group}; category: ${todo.category})${formatDetails(todo.details)}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (data.scheduled.length > 0) {
|
||||
lines.push("Scheduled to-dos marked done in this range:");
|
||||
for (const s of data.scheduled) {
|
||||
lines.push(
|
||||
`- ${s.title} (occurrence ${formatUtcDay(s.occurrenceDate, false)}${s.recurring ? ", recurring" : ""}; marked done ${formatUtcDay(s.completedAt, false)})`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
function formatByCategory(data: BoardSummaryData): string {
|
||||
const lines: string[] = [];
|
||||
|
||||
for (const category of data.categories) {
|
||||
const withTodos = category.groups.filter((g) => g.todos.length > 0);
|
||||
if (withTodos.length === 0) continue;
|
||||
lines.push(`## Category: "${category.name}"`);
|
||||
for (const group of withTodos) {
|
||||
lines.push(`### Group: "${group.title}"${group.archived ? " [archived]" : ""}`);
|
||||
for (const todo of group.todos) {
|
||||
lines.push(`- ${todo.title} (completed ${formatUtcDay(todo.completedAt, false)})${formatDetails(todo.details)}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (data.scheduled.length > 0) {
|
||||
lines.push("## Scheduled to-dos");
|
||||
for (const s of data.scheduled) {
|
||||
lines.push(
|
||||
`- ${s.title} (occurrence ${formatUtcDay(s.occurrenceDate, false)}${s.recurring ? ", recurring" : ""}; marked done ${formatUtcDay(s.completedAt, false)})`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
/**
|
||||
* AI summary of everything the user completed on one board (Home or a
|
||||
* Project) inside a date range, organized the way they asked (by day or
|
||||
* by category/group). "Completed" covers both kinds of to-do the app has:
|
||||
* board to-dos whose `completedAt` falls in the range, and scheduled
|
||||
* to-do occurrences marked done in the range. Read-only -- nothing here
|
||||
* is ever saved, so there's no ask/finalize JSON contract, just a plain
|
||||
* markdown answer from the model (see lib/ai/chat-completion.ts).
|
||||
*/
|
||||
export async function summarizeBoard(
|
||||
projectId: string | null,
|
||||
request: SummaryRequest
|
||||
): Promise<BoardSummaryResult> {
|
||||
const userId = await requireUserId();
|
||||
|
||||
const parsed = SummaryRequestSchema.safeParse(request);
|
||||
if (!parsed.success) {
|
||||
return { status: "error", error: parsed.error.issues[0]?.message ?? "Invalid request." };
|
||||
}
|
||||
|
||||
const range = resolveRange(parsed.data, new Date());
|
||||
|
||||
// Scoped to exactly this board: Home means the user's personal
|
||||
// categories; a Project means that project's categories, and only if
|
||||
// this user owns it.
|
||||
const scopeWhere = projectId
|
||||
? { projectId, project: { ownerId: userId } }
|
||||
: { userId, projectId: null };
|
||||
|
||||
const [categories, scheduledCompletions] = await Promise.all([
|
||||
prisma.category.findMany({
|
||||
where: scopeWhere,
|
||||
orderBy: { order: "asc" },
|
||||
// Archived groups are deliberately *not* filtered out (unlike
|
||||
// lib/board.ts): this is a history question, and work completed
|
||||
// before a group was archived should still count.
|
||||
include: {
|
||||
groups: {
|
||||
orderBy: { order: "asc" },
|
||||
include: {
|
||||
todos: {
|
||||
where: { completed: true, completedAt: { gte: range.from, lt: range.to } },
|
||||
orderBy: { completedAt: "asc" },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
prisma.scheduledTodoCompletion.findMany({
|
||||
where: {
|
||||
completedAt: { gte: range.from, lt: range.to },
|
||||
scheduledTodo: scopeWhere,
|
||||
},
|
||||
include: { scheduledTodo: { select: { title: true, rrule: true } } },
|
||||
orderBy: { completedAt: "asc" },
|
||||
}),
|
||||
]);
|
||||
|
||||
const data: BoardSummaryData = {
|
||||
categories: categories
|
||||
.map((category) => ({
|
||||
name: category.name,
|
||||
groups: category.groups.map((group) => ({
|
||||
title: group.title,
|
||||
archived: group.archivedAt !== null,
|
||||
todos: group.todos.map((todo) => ({
|
||||
title: todo.title,
|
||||
details: todo.details,
|
||||
completedAt: todo.completedAt as Date,
|
||||
groupTitle: group.title,
|
||||
categoryName: category.name,
|
||||
})),
|
||||
})),
|
||||
}))
|
||||
.filter((category) => category.groups.some((group) => group.todos.length > 0)),
|
||||
scheduled: scheduledCompletions.map((c) => ({
|
||||
title: c.scheduledTodo.title,
|
||||
occurrenceDate: c.occurrenceDate,
|
||||
completedAt: c.completedAt,
|
||||
recurring: c.scheduledTodo.rrule !== null,
|
||||
})),
|
||||
};
|
||||
|
||||
const count =
|
||||
data.categories.reduce((n, c) => n + c.groups.reduce((m, g) => m + g.todos.length, 0), 0) +
|
||||
data.scheduled.length;
|
||||
|
||||
// Skip the AI call entirely when there's nothing to summarize -- a
|
||||
// clear, instant answer beats a model inventing one.
|
||||
if (count === 0) return { status: "empty", rangeLabel: range.label };
|
||||
|
||||
let dataText = parsed.data.organizeBy === "byDate" ? formatByDate(data) : formatByCategory(data);
|
||||
if (dataText.length > TOTAL_CHAR_CAP) {
|
||||
dataText = `${dataText.slice(0, TOTAL_CHAR_CAP)}\n\n[... additional items omitted for length ...]`;
|
||||
}
|
||||
|
||||
const organizeInstruction =
|
||||
parsed.data.organizeBy === "byDate"
|
||||
? 'organize it by day -- keep one section per day, in the same order the data is grouped, with each day as a "## " heading'
|
||||
: 'organize it by category and group -- one "## " section per category and, inside it, a "### " sub-heading (or bold lead-in) per group';
|
||||
|
||||
const systemPrompt = [
|
||||
"You are summarizing completed work from a to-do organizer app, for the person who owns the data.",
|
||||
"",
|
||||
`Date range being summarized: ${range.label}`,
|
||||
"",
|
||||
"Everything completed in that range is listed below, already grouped the way the owner wants the summary organized:",
|
||||
"",
|
||||
dataText,
|
||||
"",
|
||||
"Write a concise, warm markdown summary of what they got done.",
|
||||
"- Output ONLY the markdown summary itself -- no preamble, no closing remarks, no code fences around it.",
|
||||
`- ${organizeInstruction}. If scheduled to-dos are present, keep them in their own final section.`,
|
||||
"- Open with one or two sentences recapping the overall amount of work in the range.",
|
||||
"- Render completed items as short bullet lines; merge exact duplicates into a single line.",
|
||||
"- Keep the whole summary tight (roughly 30 lines or fewer).",
|
||||
].join("\n");
|
||||
|
||||
const result = await callChatCompletion(
|
||||
[
|
||||
{ role: "system", content: systemPrompt },
|
||||
{ role: "user", content: "Summarize it now." },
|
||||
],
|
||||
// This is the app's biggest prompt (a whole range of completed work),
|
||||
// and the configured provider may be a slow reasoning model -- give it
|
||||
// room to finish rather than the 30s default.
|
||||
{ temperature: 0.3, timeoutMs: 120_000 }
|
||||
);
|
||||
|
||||
if ("error" in result) return { status: "error", error: result.error };
|
||||
return { status: "ok", content: result.content, rangeLabel: range.label, count };
|
||||
}
|
||||
|
|
@ -33,7 +33,7 @@ function extractMessageContent(body: unknown): string | null {
|
|||
*/
|
||||
export async function callChatCompletion(
|
||||
messages: ChatCompletionMessage[],
|
||||
opts?: { temperature?: number; timeoutMs?: number }
|
||||
opts?: { temperature?: number }
|
||||
): Promise<ChatCompletionResult> {
|
||||
const [{ apiUrl, apiKey }, settings] = await Promise.all([
|
||||
getAiCredentials(),
|
||||
|
|
@ -63,7 +63,7 @@ export async function callChatCompletion(
|
|||
messages,
|
||||
temperature: opts?.temperature ?? 0.4,
|
||||
}),
|
||||
signal: AbortSignal.timeout(opts?.timeoutMs ?? 30_000),
|
||||
signal: AbortSignal.timeout(30_000),
|
||||
});
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "Unknown error";
|
||||
|
|
|
|||
|
|
@ -66,80 +66,6 @@ const CYBERPUNK_PALETTE: Record<string, GroupColor> = {
|
|||
pink: { key: "pink", label: "Deep Violet", light: "#8a2be2", dark: "#8a2be2", soft: "#e8d6ff", softDark: "#261236" },
|
||||
};
|
||||
|
||||
// --- Blueprint drafting-ink palette ---
|
||||
// Used ONLY when the "blueprint" theme is active. The cards are white
|
||||
// drafting paper on a cobalt board, so the strokes read as technical pen
|
||||
// inks -- cobalt and ultramarine for the primary lines, with the classic
|
||||
// annotation inks (proof red, sepia, green, cerulean) for the rest. Calm,
|
||||
// even-saturation inks, not neon: this is a drafting table, not a nightclub.
|
||||
|
||||
const BLUEPRINT_PALETTE: Record<string, GroupColor> = {
|
||||
coral: { key: "coral", label: "Proof Red", light: "#b83a4a", dark: "#b83a4a", soft: "#fbe5e7", softDark: "#3a2320" },
|
||||
amber: { key: "amber", label: "Sepia Ink", light: "#a06325", dark: "#a06325", soft: "#f5ecda", softDark: "#3a301c" },
|
||||
lime: { key: "lime", label: "Draft Green", light: "#3d7a46", dark: "#3d7a46", soft: "#e3efe1", softDark: "#25331d" },
|
||||
teal: { key: "teal", label: "Cerulean Ink", light: "#1d6f74", dark: "#1d6f74", soft: "#ddefee", softDark: "#16332f" },
|
||||
sky: { key: "sky", label: "Cobalt Ink", light: "#2456b4", dark: "#2456b4", soft: "#e0e9f8", softDark: "#1a2b40" },
|
||||
indigo: { key: "indigo", label: "Ultramarine", light: "#43489f", dark: "#43489f", soft: "#e4e5f5", softDark: "#23233c" },
|
||||
violet: { key: "violet", label: "Violet Ink", light: "#6d43a8", dark: "#6d43a8", soft: "#ece2f7", softDark: "#2e2136" },
|
||||
pink: { key: "pink", label: "Magenta Ink", light: "#b03586", dark: "#b03586", soft: "#f7e1ef", softDark: "#37202e" },
|
||||
};
|
||||
|
||||
// --- Vaporwave retro palette ---
|
||||
// Used ONLY when the "vaporwave" theme is active. Pastel synthwave sky with
|
||||
// a glossy 90s-desktop gloss, so the strokes are soft retro-neon rather than
|
||||
// full cyberpunk brightness: Miami pink, sunset gold, CRT mint, electric
|
||||
// aqua, chrome blue, laser purple. Saturated enough to read on the pale
|
||||
// card, muted enough to sit in the pastel sky.
|
||||
|
||||
const VAPORWAVE_PALETTE: Record<string, GroupColor> = {
|
||||
coral: { key: "coral", label: "Miami Pink", light: "#e64f9e", dark: "#e64f9e", soft: "#fbe0ef", softDark: "#3d1433" },
|
||||
amber: { key: "amber", label: "Sunset Gold", light: "#e08a1f", dark: "#e08a1f", soft: "#fbeed6", softDark: "#3a301c" },
|
||||
lime: { key: "lime", label: "CRT Mint", light: "#2fb584", dark: "#2fb584", soft: "#dcf3e9", softDark: "#163d14" },
|
||||
teal: { key: "teal", label: "Electric Aqua", light: "#14a9c4", dark: "#14a9c4", soft: "#daf0f5", softDark: "#0f3d3a" },
|
||||
sky: { key: "sky", label: "Chrome Blue", light: "#3f6fe0", dark: "#3f6fe0", soft: "#e0e8fb", softDark: "#1a2b40" },
|
||||
indigo: { key: "indigo", label: "Laser Purple", light: "#7c4fe0", dark: "#7c4fe0", soft: "#e8e1fa", softDark: "#23233c" },
|
||||
violet: { key: "violet", label: "Vapor Magenta", light: "#b455d4", dark: "#b455d4", soft: "#f3e2fa", softDark: "#2e2136" },
|
||||
pink: { key: "pink", label: "Pastel Orchid", light: "#cb6ca8", dark: "#cb6ca8", soft: "#f9e4f1", softDark: "#37202e" },
|
||||
};
|
||||
|
||||
// --- Notebook stationery palette ---
|
||||
// Used ONLY when the "notebook" theme is active. The cards are index cards
|
||||
// on a warm desk, so the strokes read like the pens and markers in the
|
||||
// desk drawer: blue ballpoint, red pencil, green pen, teal marker, purple
|
||||
// pen, rose highlighter. Muted, papery, hand-inked -- the opposite of
|
||||
// glowing; these are inks that dry on paper.
|
||||
|
||||
const NOTEBOOK_PALETTE: Record<string, GroupColor> = {
|
||||
coral: { key: "coral", label: "Red Pencil", light: "#c04a45", dark: "#c04a45", soft: "#fae6e2", softDark: "#3a2320" },
|
||||
amber: { key: "amber", label: "Orange Marker", light: "#d9822b", dark: "#d9822b", soft: "#f9ecd7", softDark: "#3a301c" },
|
||||
lime: { key: "lime", label: "Green Pen", light: "#46905b", dark: "#46905b", soft: "#e3f0e6", softDark: "#25331d" },
|
||||
teal: { key: "teal", label: "Teal Marker", light: "#2a8f8b", dark: "#2a8f8b", soft: "#dcefec", softDark: "#16332f" },
|
||||
sky: { key: "sky", label: "Blue Ballpoint", light: "#2f5cb8", dark: "#2f5cb8", soft: "#e2e9fa", softDark: "#1a2b40" },
|
||||
indigo: { key: "indigo", label: "Purple Pen", light: "#5b4bb0", dark: "#5b4bb0", soft: "#e6e3f6", softDark: "#23233c" },
|
||||
violet: { key: "violet", label: "Violet Ink", light: "#8552b8", dark: "#8552b8", soft: "#ede4f7", softDark: "#2e2136" },
|
||||
pink: { key: "pink", label: "Rose Highlighter", light: "#c25a93", dark: "#c25a93", soft: "#f8e5f0", softDark: "#37202e" },
|
||||
};
|
||||
|
||||
// --- Starfield starlight palette ---
|
||||
// Used ONLY when the "starfield" theme is active. The cards float in a
|
||||
// near-black indigo void, so the strokes read as starlight: bright but
|
||||
// soft, like distant stars and console glows -- star gold, aurora, ion
|
||||
// cyan, nebula violet. Deliberately gentler than cyberpunk's neon; these
|
||||
// are lights seen across a galaxy, not tubes in a night city. softDark
|
||||
// fills are low-light tints so they read as faint nebulae against the
|
||||
// void instead of washed-out pastels.
|
||||
|
||||
const STARFIELD_PALETTE: Record<string, GroupColor> = {
|
||||
coral: { key: "coral", label: "Red Giant", light: "#ff8592", dark: "#ff8592", soft: "#fadfee", softDark: "#3a2026" },
|
||||
amber: { key: "amber", label: "Star Gold", light: "#ffc86b", dark: "#ffc86b", soft: "#eeffe0", softDark: "#3a3018" },
|
||||
lime: { key: "lime", label: "Aurora", light: "#5fd6a0", dark: "#5fd6a0", soft: "#e0fde0", softDark: "#173a2b" },
|
||||
teal: { key: "teal", label: "Ion Cyan", light: "#55c8f0", dark: "#55c8f0", soft: "#dffffa", softDark: "#14303d" },
|
||||
sky: { key: "sky", label: "Polar Blue", light: "#7b9df5", dark: "#7b9df5", soft: "#e0f0ff", softDark: "#1b2542" },
|
||||
indigo: { key: "indigo", label: "Nebula Violet", light: "#a48bfa", dark: "#a48bfa", soft: "#f0e0ff", softDark: "#271f42" },
|
||||
violet: { key: "violet", label: "Nebula Pink", light: "#d18ff0", dark: "#d18ff0", soft: "#ffe0f5", softDark: "#33203a" },
|
||||
pink: { key: "pink", label: "Rose Star", light: "#f591bb", dark: "#f591bb", soft: "#e8d6ff", softDark: "#3a2230" },
|
||||
};
|
||||
|
||||
const GROUP_COLOR_MAP: Record<string, GroupColor> = Object.fromEntries(
|
||||
GROUP_COLORS.map((c) => [c.key, c])
|
||||
);
|
||||
|
|
@ -157,26 +83,6 @@ export function getCyberpunkGroupColor(key: string): GroupColor {
|
|||
return CYBERPUNK_PALETTE[key] ?? CYBERPUNK_PALETTE["coral"];
|
||||
}
|
||||
|
||||
// Every theme-specific palette in one place, keyed by theme name (the
|
||||
// values in components/theme/theme-provider's THEMES). Themes without an
|
||||
// entry keep the base GROUP_COLORS above.
|
||||
const THEME_PALETTES: Partial<Record<string, Record<string, GroupColor>>> = {
|
||||
cyberpunk: CYBERPUNK_PALETTE,
|
||||
blueprint: BLUEPRINT_PALETTE,
|
||||
vaporwave: VAPORWAVE_PALETTE,
|
||||
notebook: NOTEBOOK_PALETTE,
|
||||
starfield: STARFIELD_PALETTE,
|
||||
};
|
||||
|
||||
/** The group color for a given theme: that theme's palette if it has one
|
||||
* (cyberpunk, blueprint, vaporwave, notebook, starfield), otherwise the
|
||||
* base GROUP_COLORS entry. Used by the themed group cards so each
|
||||
* character's board carries the theme's own ink.
|
||||
*/
|
||||
export function getThemedGroupColor(theme: string, key: string): GroupColor {
|
||||
return THEME_PALETTES[theme]?.[key] ?? GROUP_COLOR_MAP[key] ?? GROUP_COLORS[0];
|
||||
}
|
||||
|
||||
// --- Complementary accent (for the to-do progress pie on a Group card) ---
|
||||
//
|
||||
// Derived at runtime by rotating a base color's hue 180° and pulling the
|
||||
|
|
|
|||
|
|
@ -1,43 +0,0 @@
|
|||
import { z } from "zod";
|
||||
|
||||
// "YYYY-MM-DD" -- exactly what an <input type="date"> gives back, so the
|
||||
// client never needs to reformat before sending it. Lexicographic string
|
||||
// comparison is a valid chronological comparison for this shape.
|
||||
const DateStringSchema = z.string().regex(/^\d{4}-\d{2}-\d{2}$/, "Invalid date");
|
||||
|
||||
export const SummaryDateRangeSchema = z.enum([
|
||||
"today",
|
||||
"thisWeek",
|
||||
"thisMonth",
|
||||
"lastMonth",
|
||||
"custom",
|
||||
]);
|
||||
|
||||
export const SummaryOrganizeBySchema = z.enum(["byDate", "byCategory"]);
|
||||
|
||||
export const SummaryRequestSchema = z
|
||||
.object({
|
||||
dateRange: SummaryDateRangeSchema,
|
||||
// Only read when dateRange is "custom" -- enforced conditionally below.
|
||||
customStart: DateStringSchema.optional(),
|
||||
customEnd: DateStringSchema.optional(),
|
||||
organizeBy: SummaryOrganizeBySchema,
|
||||
})
|
||||
.superRefine((value, ctx) => {
|
||||
if (value.dateRange !== "custom") return;
|
||||
if (!value.customStart) {
|
||||
ctx.addIssue({ code: "custom", path: ["customStart"], message: "A start date is required." });
|
||||
}
|
||||
if (!value.customEnd) {
|
||||
ctx.addIssue({ code: "custom", path: ["customEnd"], message: "An end date is required." });
|
||||
}
|
||||
if (value.customStart && value.customEnd && value.customStart > value.customEnd) {
|
||||
ctx.addIssue({
|
||||
code: "custom",
|
||||
path: ["customStart"],
|
||||
message: "The start date must be on or before the end date.",
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
export type SummaryRequest = z.infer<typeof SummaryRequestSchema>;
|
||||
Loading…
Reference in New Issue