Added AI Summary

This commit is contained in:
Brian Fertig 2026-08-28 14:25:57 -06:00
parent 15bfcfa064
commit 7c407f49f8
5 changed files with 704 additions and 4 deletions

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>
</>
);
}

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

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