"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 }; }