Added AI Summary
This commit is contained in:
parent
15bfcfa064
commit
7c407f49f8
|
|
@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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 };
|
||||||
|
}
|
||||||
|
|
@ -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";
|
||||||
|
|
|
||||||
|
|
@ -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>;
|
||||||
Loading…
Reference in New Issue