330 lines
11 KiB
TypeScript
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>
|
|
</>
|
|
);
|
|
}
|