Compare commits

..

2 Commits

Author SHA1 Message Date
Brian Fertig bb4e8b5558 Updated Theme Colors 2026-08-28 14:50:06 -06:00
Brian Fertig 7c407f49f8 Added AI Summary 2026-08-28 14:25:57 -06:00
8 changed files with 822 additions and 10 deletions

View File

@ -28,9 +28,8 @@ import {
DropdownMenuSeparator, DropdownMenuSeparator,
DropdownMenuTrigger, DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"; } from "@/components/ui/dropdown-menu";
import { getBrighterColor, getComplementaryColor, getGroupColor } from "@/lib/colors"; import { getBrighterColor, getComplementaryColor } from "@/lib/colors";
import { isDarkTheme, isCyberpunkTheme } from "@/components/theme/use-dark-theme"; import { isDarkTheme, useGroupColor } from "@/components/theme/use-dark-theme";
import { getCyberpunkGroupColor } from "@/lib/colors";
import { useBoard } from "@/components/board/board-context"; import { useBoard } from "@/components/board/board-context";
import { useBoardView } from "@/components/board/board-view-provider"; import { useBoardView } from "@/components/board/board-view-provider";
import { useHoldOnComplete } from "@/components/hold-on-complete"; import { useHoldOnComplete } from "@/components/hold-on-complete";
@ -122,9 +121,11 @@ export function GroupCard({ group }: { group: GroupDTO }) {
// card ever ringing itself while it's the one being moved.) // card ever ringing itself while it's the one being moved.)
const isDropTarget = isOver && !isDragging; const isDropTarget = isOver && !isDragging;
const color = isCyberpunkTheme() // The group's color for the ACTIVE theme: themes with their own palette
? getCyberpunkGroupColor(group.color) // (cyberpunk, blueprint, vaporwave, notebook, starfield) retint the card,
: getGroupColor(group.color); // 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 borderColor = isDark ? color.dark : color.light; const borderColor = isDark ? color.dark : color.light;
// The card fill is a calm, hand-picked tint of the same stroke color // 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 // (`soft` / `softDark` in lib/colors.ts), blended a little into the theme's

View File

@ -21,12 +21,21 @@ import { AddCategoryLane } from "@/components/board/add-category-lane";
import { GroupCardOverlay } from "@/components/board/group-card-overlay"; import { GroupCardOverlay } from "@/components/board/group-card-overlay";
import { EmptyState } from "@/components/board/empty-state"; import { EmptyState } from "@/components/board/empty-state";
import { ViewSwitcher } from "@/components/board/view-switcher"; import { ViewSwitcher } from "@/components/board/view-switcher";
import { SummaryButton } from "@/components/board/summary-button";
import { TodoCreateDialog } from "@/components/board/todo-create-dialog"; import { TodoCreateDialog } from "@/components/board/todo-create-dialog";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import type { CategoryDTO, GroupDTO } from "@/types/board"; import type { CategoryDTO, GroupDTO } from "@/types/board";
function Board({ title }: { title: string }) { function Board({
title,
projectId,
aiConfigured,
}: {
title: string;
projectId?: string;
aiConfigured: boolean;
}) {
const { categories, reorderLanes, reorderGroups, moveGroup } = useBoard(); const { categories, reorderLanes, reorderGroups, moveGroup } = useBoard();
const [draggedGroup, setDraggedGroup] = useState<GroupDTO | null>(null); const [draggedGroup, setDraggedGroup] = useState<GroupDTO | null>(null);
const [quickAddOpen, setQuickAddOpen] = useState(false); const [quickAddOpen, setQuickAddOpen] = useState(false);
@ -154,6 +163,7 @@ function Board({ title }: { title: string }) {
</div> </div>
</div> </div>
<div className="flex shrink-0 items-center gap-2"> <div className="flex shrink-0 items-center gap-2">
{aiConfigured && <SummaryButton projectId={projectId} />}
<Tooltip> <Tooltip>
<TooltipTrigger <TooltipTrigger
render={ render={
@ -232,7 +242,7 @@ export function KanbanBoard({
projectId={projectId} projectId={projectId}
aiConfigured={aiConfigured} aiConfigured={aiConfigured}
> >
<Board title={title} /> <Board title={title} projectId={projectId} aiConfigured={aiConfigured} />
</BoardProvider> </BoardProvider>
); );
} }

View File

@ -0,0 +1,329 @@
"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>
</>
);
}

View File

@ -3,6 +3,7 @@
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { useTheme, resolveTheme, DARK_SURFACES } from "./theme-provider"; 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 // The themes whose *surfaces* are dark (see app/globals.css) come from the
// provider's single source of truth -- currently Dark, Ocean, Pine, Plum, // provider's single source of truth -- currently Dark, Ocean, Pine, Plum,
@ -48,6 +49,22 @@ export function isCyberpunkTheme(): boolean {
return resolveTheme(theme) === "cyberpunk"; 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. * The `data-color-mode` value the markdown editor/preview widgets expect.
* They only understand light/dark, so the light looks (default, sunset, * They only understand light/dark, so the light looks (default, sunset,

318
lib/actions/summarize.ts Normal file
View File

@ -0,0 +1,318 @@
"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 };
}

View File

@ -33,7 +33,7 @@ function extractMessageContent(body: unknown): string | null {
*/ */
export async function callChatCompletion( export async function callChatCompletion(
messages: ChatCompletionMessage[], messages: ChatCompletionMessage[],
opts?: { temperature?: number } opts?: { temperature?: number; timeoutMs?: number }
): Promise<ChatCompletionResult> { ): Promise<ChatCompletionResult> {
const [{ apiUrl, apiKey }, settings] = await Promise.all([ const [{ apiUrl, apiKey }, settings] = await Promise.all([
getAiCredentials(), getAiCredentials(),
@ -63,7 +63,7 @@ export async function callChatCompletion(
messages, messages,
temperature: opts?.temperature ?? 0.4, temperature: opts?.temperature ?? 0.4,
}), }),
signal: AbortSignal.timeout(30_000), signal: AbortSignal.timeout(opts?.timeoutMs ?? 30_000),
}); });
} catch (error) { } catch (error) {
const message = error instanceof Error ? error.message : "Unknown error"; const message = error instanceof Error ? error.message : "Unknown error";

View File

@ -66,6 +66,80 @@ const CYBERPUNK_PALETTE: Record<string, GroupColor> = {
pink: { key: "pink", label: "Deep Violet", light: "#8a2be2", dark: "#8a2be2", soft: "#e8d6ff", softDark: "#261236" }, 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( const GROUP_COLOR_MAP: Record<string, GroupColor> = Object.fromEntries(
GROUP_COLORS.map((c) => [c.key, c]) GROUP_COLORS.map((c) => [c.key, c])
); );
@ -83,6 +157,26 @@ export function getCyberpunkGroupColor(key: string): GroupColor {
return CYBERPUNK_PALETTE[key] ?? CYBERPUNK_PALETTE["coral"]; 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) --- // --- 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 // Derived at runtime by rotating a base color's hue 180° and pulling the

43
lib/validation/summary.ts Normal file
View File

@ -0,0 +1,43 @@
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>;