319 lines
12 KiB
TypeScript
319 lines
12 KiB
TypeScript
"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 };
|
||
}
|