Organize/components/board/summary-button.tsx

330 lines
11 KiB
TypeScript

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