diff --git a/components/board/kanban-board.tsx b/components/board/kanban-board.tsx index 60d0b76..9e0aa3e 100644 --- a/components/board/kanban-board.tsx +++ b/components/board/kanban-board.tsx @@ -21,12 +21,21 @@ 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 }: { title: string }) { +function Board({ + title, + projectId, + aiConfigured, +}: { + title: string; + projectId?: string; + aiConfigured: boolean; +}) { const { categories, reorderLanes, reorderGroups, moveGroup } = useBoard(); const [draggedGroup, setDraggedGroup] = useState(null); const [quickAddOpen, setQuickAddOpen] = useState(false); @@ -154,6 +163,7 @@ function Board({ title }: { title: string }) {
+ {aiConfigured && } - + ); } diff --git a/components/board/summary-button.tsx b/components/board/summary-button.tsx new file mode 100644 index 0000000..f2e30d2 --- /dev/null +++ b/components/board/summary-button.tsx @@ -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("today"); + const [organizeBy, setOrganizeBy] = useState("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(null); + const [noWork, setNoWork] = useState(false); + const [summary, setSummary] = useState(null); + const [rangeLabel, setRangeLabel] = useState(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 ( + <> + + + + Summary + + } + /> + + + Summarize completed work + An AI recap of what you got done on this board. + + +
+
+ + +
+ + {dateRange === "custom" && ( +
+
+ + setCustomStart(e.target.value)} + /> +
+
+ + setCustomEnd(e.target.value)} + /> +
+
+ )} + +
+ + +
+ + {customInvalid && ( +

+ Pick a start date on or before the end date. +

+ )} + + +
+
+
+ + + + + + + Summary + + + {rangeLabel + ? `${rangeLabel} · ${count} completed ${count === 1 ? "item" : "items"}` + : "An AI recap of what you got done."} + + + + {generating && ( +
+ +

Summarizing your completed work…

+
+ )} + + {!generating && error && ( +
+

{error}

+
+ + +
+
+ )} + + {!generating && !error && noWork && ( +

+ Nothing was completed in this range yet -- check something off first. +

+ )} + + {!generating && !error && !noWork && summary && ( +
+ +
+ )} + + {summary && ( +
+ + + {copied ? : } + + } + /> + {copied ? "Copied" : "Copy to clipboard"} + + +
+ )} +
+
+ + ); +} diff --git a/lib/actions/summarize.ts b/lib/actions/summarize.ts new file mode 100644 index 0000000..133fd6c --- /dev/null +++ b/lib/actions/summarize.ts @@ -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(); + 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 { + 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 }; +} diff --git a/lib/ai/chat-completion.ts b/lib/ai/chat-completion.ts index e63b674..b5a9961 100644 --- a/lib/ai/chat-completion.ts +++ b/lib/ai/chat-completion.ts @@ -33,7 +33,7 @@ function extractMessageContent(body: unknown): string | null { */ export async function callChatCompletion( messages: ChatCompletionMessage[], - opts?: { temperature?: number } + opts?: { temperature?: number; timeoutMs?: number } ): Promise { 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(30_000), + signal: AbortSignal.timeout(opts?.timeoutMs ?? 30_000), }); } catch (error) { const message = error instanceof Error ? error.message : "Unknown error"; diff --git a/lib/validation/summary.ts b/lib/validation/summary.ts new file mode 100644 index 0000000..11209fc --- /dev/null +++ b/lib/validation/summary.ts @@ -0,0 +1,43 @@ +import { z } from "zod"; + +// "YYYY-MM-DD" -- exactly what an 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;