feat: add daily summaries feature
All checks were successful
CI/CD / test (push) Successful in 19s
CI/CD / deploy (push) Successful in 2s

- Backend: daily_summaries table, API routes (GET/POST/PATCH) at /api/summaries
- Frontend: SummariesPage with calendar view, markdown rendering, stats bar, highlights
- Sidebar nav: added Summaries link between Activity and Chat
- Data population script for importing from memory files
- Bearer token + session auth support
This commit is contained in:
2026-01-30 04:42:10 +00:00
parent b5066a0d33
commit d5693a7624
8 changed files with 2012 additions and 0 deletions

View File

@@ -12,7 +12,9 @@ const QueuePage = lazy(() => import("./pages/QueuePage").then(m => ({ default: m
const ProjectsPage = lazy(() => import("./pages/ProjectsPage").then(m => ({ default: m.ProjectsPage })));
const TaskPage = lazy(() => import("./pages/TaskPage").then(m => ({ default: m.TaskPage })));
const ActivityPage = lazy(() => import("./pages/ActivityPage").then(m => ({ default: m.ActivityPage })));
const SummariesPage = lazy(() => import("./pages/SummariesPage").then(m => ({ default: m.SummariesPage })));
const AdminPage = lazy(() => import("./components/AdminPage").then(m => ({ default: m.AdminPage })));
const SecurityPage = lazy(() => import("./pages/SecurityPage").then(m => ({ default: m.SecurityPage })));
function PageLoader() {
return (
@@ -36,6 +38,8 @@ function AuthenticatedApp() {
<Route path="/task/:taskRef" element={<Suspense fallback={<PageLoader />}><TaskPage /></Suspense>} />
<Route path="/projects" element={<Suspense fallback={<PageLoader />}><ProjectsPage /></Suspense>} />
<Route path="/activity" element={<Suspense fallback={<PageLoader />}><ActivityPage /></Suspense>} />
<Route path="/summaries" element={<Suspense fallback={<PageLoader />}><SummariesPage /></Suspense>} />
<Route path="/security" element={<Suspense fallback={<PageLoader />}><SecurityPage /></Suspense>} />
<Route path="/admin" element={<Suspense fallback={<PageLoader />}><AdminPage /></Suspense>} />
<Route path="*" element={<Navigate to="/" replace />} />
</Route>

View File

@@ -12,6 +12,8 @@ const navItems = [
{ to: "/queue", label: "Queue", icon: "📋", badgeKey: "queue" },
{ to: "/projects", label: "Projects", icon: "📁", badgeKey: null },
{ to: "/activity", label: "Activity", icon: "📝", badgeKey: null },
{ to: "/summaries", label: "Summaries", icon: "📅", badgeKey: null },
{ to: "/security", label: "Security", icon: "🛡️", badgeKey: null },
] as const;
export function DashboardLayout() {

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,445 @@
import { useState, useEffect, useCallback, useMemo } from "react";
import ReactMarkdown from "react-markdown";
import remarkGfm from "remark-gfm";
interface SummaryStats {
deploys?: number;
commits?: number;
tasksCompleted?: number;
featuresBuilt?: number;
bugsFixed?: number;
[key: string]: number | undefined;
}
interface SummaryHighlight {
text: string;
}
interface DailySummary {
id: string;
date: string;
content: string;
highlights: SummaryHighlight[];
stats: SummaryStats;
createdAt: string;
updatedAt: string;
}
const STAT_ICONS: Record<string, string> = {
deploys: "🚀",
commits: "📦",
tasksCompleted: "✅",
featuresBuilt: "🛠️",
bugsFixed: "🐛",
};
const STAT_LABELS: Record<string, string> = {
deploys: "Deploys",
commits: "Commits",
tasksCompleted: "Tasks",
featuresBuilt: "Features",
bugsFixed: "Fixes",
};
function formatDateDisplay(dateStr: string): string {
const d = new Date(dateStr + "T12:00:00Z");
return d.toLocaleDateString("en-US", {
weekday: "long",
year: "numeric",
month: "long",
day: "numeric",
});
}
function formatShort(dateStr: string): string {
const d = new Date(dateStr + "T12:00:00Z");
return d.toLocaleDateString("en-US", { month: "short", day: "numeric" });
}
function getDaysInMonth(year: number, month: number): number {
return new Date(year, month + 1, 0).getDate();
}
function getFirstDayOfMonth(year: number, month: number): number {
return new Date(year, month, 1).getDay();
}
function toDateStr(y: number, m: number, d: number): string {
return `${y}-${String(m + 1).padStart(2, "0")}-${String(d).padStart(2, "0")}`;
}
function todayStr(): string {
const d = new Date();
return toDateStr(d.getFullYear(), d.getMonth(), d.getDate());
}
function addDays(dateStr: string, days: number): string {
const d = new Date(dateStr + "T12:00:00Z");
d.setUTCDate(d.getUTCDate() + days);
return toDateStr(d.getUTCFullYear(), d.getUTCMonth(), d.getUTCDate());
}
export function SummariesPage() {
const [summaryDates, setSummaryDates] = useState<Set<string>>(new Set());
const [selectedDate, setSelectedDate] = useState<string>(todayStr());
const [summary, setSummary] = useState<DailySummary | null>(null);
const [loading, setLoading] = useState(true);
const [loadingSummary, setLoadingSummary] = useState(false);
const [calMonth, setCalMonth] = useState(() => {
const d = new Date();
return { year: d.getFullYear(), month: d.getMonth() };
});
// Fetch all dates with summaries
const fetchDates = useCallback(async () => {
try {
const res = await fetch("/api/summaries/dates", { credentials: "include" });
if (!res.ok) throw new Error("Failed to fetch dates");
const data = await res.json();
setSummaryDates(new Set(data.dates));
} catch (e) {
console.error("Failed to fetch summary dates:", e);
} finally {
setLoading(false);
}
}, []);
// Fetch summary for selected date
const fetchSummary = useCallback(async (date: string) => {
setLoadingSummary(true);
setSummary(null);
try {
const res = await fetch(`/api/summaries/${date}`, { credentials: "include" });
if (res.status === 404) {
setSummary(null);
return;
}
if (!res.ok) throw new Error("Failed to fetch summary");
const data = await res.json();
setSummary(data);
} catch (e) {
console.error("Failed to fetch summary:", e);
setSummary(null);
} finally {
setLoadingSummary(false);
}
}, []);
useEffect(() => {
fetchDates();
}, [fetchDates]);
useEffect(() => {
if (selectedDate) {
fetchSummary(selectedDate);
}
}, [selectedDate, fetchSummary]);
// Calendar data
const daysInMonth = getDaysInMonth(calMonth.year, calMonth.month);
const firstDay = getFirstDayOfMonth(calMonth.year, calMonth.month);
const monthLabel = new Date(calMonth.year, calMonth.month, 1).toLocaleDateString("en-US", {
month: "long",
year: "numeric",
});
const calDays = useMemo(() => {
const days: (number | null)[] = [];
for (let i = 0; i < firstDay; i++) days.push(null);
for (let d = 1; d <= daysInMonth; d++) days.push(d);
return days;
}, [firstDay, daysInMonth]);
const prevMonth = () =>
setCalMonth((p) => (p.month === 0 ? { year: p.year - 1, month: 11 } : { year: p.year, month: p.month - 1 }));
const nextMonth = () =>
setCalMonth((p) => (p.month === 11 ? { year: p.year + 1, month: 0 } : { year: p.year, month: p.month + 1 }));
const goToday = () => {
const d = new Date();
setCalMonth({ year: d.getFullYear(), month: d.getMonth() });
setSelectedDate(todayStr());
};
const prevDay = () => {
const dates = Array.from(summaryDates).sort().reverse();
const idx = dates.indexOf(selectedDate);
if (idx >= 0 && idx < dates.length - 1) setSelectedDate(dates[idx + 1]);
else if (idx === -1) {
const prev = dates.find((d) => d < selectedDate);
if (prev) setSelectedDate(prev);
else setSelectedDate(addDays(selectedDate, -1));
} else {
setSelectedDate(addDays(selectedDate, -1));
}
};
const nextDay = () => {
const dates = Array.from(summaryDates).sort();
const idx = dates.indexOf(selectedDate);
if (idx >= 0 && idx < dates.length - 1) setSelectedDate(dates[idx + 1]);
else if (idx === -1) {
const next = dates.find((d) => d > selectedDate);
if (next) setSelectedDate(next);
else setSelectedDate(addDays(selectedDate, 1));
} else {
setSelectedDate(addDays(selectedDate, 1));
}
};
const today = todayStr();
const statsEntries = summary
? Object.entries(summary.stats).filter(([, v]) => v !== undefined && v > 0)
: [];
if (loading) {
return (
<div className="min-h-screen flex items-center justify-center text-gray-400 dark:text-gray-500">
Loading summaries...
</div>
);
}
return (
<div className="min-h-screen">
{/* Header */}
<header className="bg-white dark:bg-gray-900 border-b border-gray-200 dark:border-gray-800 sticky top-14 md:top-0 z-30">
<div className="max-w-5xl mx-auto px-4 sm:px-6 py-4">
<div className="flex items-center justify-between">
<div>
<h1 className="text-xl font-bold text-gray-900 dark:text-gray-100">📅 Daily Summaries</h1>
<p className="text-sm text-gray-400 dark:text-gray-500">
{summaryDates.size} days logged
</p>
</div>
<button
onClick={goToday}
className="text-xs font-medium bg-amber-500 hover:bg-amber-600 text-white px-3 py-1.5 rounded-lg transition"
>
Today
</button>
</div>
</div>
</header>
<div className="max-w-5xl mx-auto px-4 sm:px-6 py-6">
<div className="grid grid-cols-1 lg:grid-cols-[300px_1fr] gap-6">
{/* Calendar sidebar */}
<div className="space-y-4">
{/* Calendar widget */}
<div className="bg-white dark:bg-gray-900 rounded-xl border border-gray-200 dark:border-gray-800 p-4">
<div className="flex items-center justify-between mb-4">
<button
onClick={prevMonth}
className="p-1 rounded hover:bg-gray-100 dark:hover:bg-gray-800 text-gray-500 dark:text-gray-400 transition"
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" />
</svg>
</button>
<h3 className="text-sm font-semibold text-gray-900 dark:text-gray-100">{monthLabel}</h3>
<button
onClick={nextMonth}
className="p-1 rounded hover:bg-gray-100 dark:hover:bg-gray-800 text-gray-500 dark:text-gray-400 transition"
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
</svg>
</button>
</div>
{/* Day headers */}
<div className="grid grid-cols-7 gap-1 mb-1">
{["Su", "Mo", "Tu", "We", "Th", "Fr", "Sa"].map((d) => (
<div key={d} className="text-[10px] font-medium text-gray-400 dark:text-gray-500 text-center py-1">
{d}
</div>
))}
</div>
{/* Day cells */}
<div className="grid grid-cols-7 gap-1">
{calDays.map((day, i) => {
if (day === null) return <div key={`empty-${i}`} />;
const dateStr = toDateStr(calMonth.year, calMonth.month, day);
const hasSummary = summaryDates.has(dateStr);
const isSelected = dateStr === selectedDate;
const isToday = dateStr === today;
return (
<button
key={dateStr}
onClick={() => setSelectedDate(dateStr)}
className={`
relative w-full aspect-square flex items-center justify-center rounded-lg text-xs font-medium transition
${isSelected
? "bg-amber-500 text-white"
: isToday
? "bg-amber-100 dark:bg-amber-900/30 text-amber-700 dark:text-amber-400"
: hasSummary
? "bg-gray-100 dark:bg-gray-800 text-gray-900 dark:text-gray-100 hover:bg-gray-200 dark:hover:bg-gray-700"
: "text-gray-400 dark:text-gray-600 hover:bg-gray-50 dark:hover:bg-gray-800/50"
}
`}
>
{day}
{hasSummary && !isSelected && (
<span className="absolute bottom-0.5 left-1/2 -translate-x-1/2 w-1 h-1 rounded-full bg-amber-500" />
)}
</button>
);
})}
</div>
</div>
{/* Recent summaries list */}
<div className="bg-white dark:bg-gray-900 rounded-xl border border-gray-200 dark:border-gray-800 p-4">
<h3 className="text-sm font-semibold text-gray-900 dark:text-gray-100 mb-3">Recent Days</h3>
<div className="space-y-1">
{Array.from(summaryDates)
.sort()
.reverse()
.slice(0, 10)
.map((date) => (
<button
key={date}
onClick={() => {
setSelectedDate(date);
const d = new Date(date + "T12:00:00Z");
setCalMonth({ year: d.getUTCFullYear(), month: d.getUTCMonth() });
}}
className={`w-full text-left px-3 py-2 rounded-lg text-sm transition ${
date === selectedDate
? "bg-amber-500/20 text-amber-700 dark:text-amber-400 font-medium"
: "text-gray-600 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-800"
}`}
>
{formatShort(date)}
<span className="text-xs text-gray-400 dark:text-gray-500 ml-2">
{new Date(date + "T12:00:00Z").toLocaleDateString("en-US", { weekday: "short" })}
</span>
</button>
))}
{summaryDates.size === 0 && (
<p className="text-xs text-gray-400 dark:text-gray-500 py-2">No summaries yet</p>
)}
</div>
</div>
</div>
{/* Summary content */}
<div>
{/* Date navigation */}
<div className="flex items-center justify-between mb-4">
<button
onClick={prevDay}
className="flex items-center gap-1 text-sm text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-200 transition"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" />
</svg>
Prev
</button>
<h2 className="text-lg font-bold text-gray-900 dark:text-gray-100">
{formatDateDisplay(selectedDate)}
</h2>
<button
onClick={nextDay}
className="flex items-center gap-1 text-sm text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-200 transition"
>
Next
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
</svg>
</button>
</div>
{loadingSummary ? (
<div className="flex items-center justify-center py-20 text-gray-400 dark:text-gray-500">
<div className="flex items-center gap-2">
<span className="w-2 h-2 bg-amber-400 rounded-full animate-bounce" style={{ animationDelay: "0ms" }} />
<span className="w-2 h-2 bg-amber-400 rounded-full animate-bounce" style={{ animationDelay: "150ms" }} />
<span className="w-2 h-2 bg-amber-400 rounded-full animate-bounce" style={{ animationDelay: "300ms" }} />
</div>
</div>
) : summary ? (
<div className="space-y-4">
{/* Stats bar */}
{statsEntries.length > 0 && (
<div className="bg-white dark:bg-gray-900 rounded-xl border border-gray-200 dark:border-gray-800 p-4">
<div className="flex flex-wrap gap-4">
{statsEntries.map(([key, value]) => (
<div key={key} className="flex items-center gap-2">
<span className="text-lg">{STAT_ICONS[key] || "📊"}</span>
<div>
<p className="text-lg font-bold text-gray-900 dark:text-gray-100">{value}</p>
<p className="text-[10px] text-gray-400 dark:text-gray-500 uppercase tracking-wider">
{STAT_LABELS[key] || key}
</p>
</div>
</div>
))}
</div>
</div>
)}
{/* Highlights */}
{summary.highlights && summary.highlights.length > 0 && (
<div className="bg-amber-50 dark:bg-amber-900/20 rounded-xl border border-amber-200 dark:border-amber-800/50 p-4">
<h3 className="text-sm font-semibold text-amber-800 dark:text-amber-400 mb-2">
Key Accomplishments
</h3>
<ul className="space-y-1.5">
{summary.highlights.map((h, i) => (
<li key={i} className="flex items-start gap-2 text-sm text-amber-900 dark:text-amber-200">
<span className="text-amber-500 mt-0.5"></span>
<span>{h.text}</span>
</li>
))}
</ul>
</div>
)}
{/* Full markdown content */}
<div className="bg-white dark:bg-gray-900 rounded-xl border border-gray-200 dark:border-gray-800 p-6">
<div className="prose prose-sm dark:prose-invert max-w-none prose-headings:text-gray-900 dark:prose-headings:text-gray-100 prose-a:text-amber-600 dark:prose-a:text-amber-400 prose-code:text-amber-700 dark:prose-code:text-amber-300 prose-code:bg-gray-100 dark:prose-code:bg-gray-800 prose-code:px-1 prose-code:py-0.5 prose-code:rounded prose-pre:bg-gray-100 dark:prose-pre:bg-gray-800">
<ReactMarkdown remarkPlugins={[remarkGfm]}>
{summary.content}
</ReactMarkdown>
</div>
</div>
</div>
) : (
/* Empty state */
<div className="bg-white dark:bg-gray-900 rounded-xl border border-gray-200 dark:border-gray-800 p-12 text-center">
<div className="text-4xl mb-3">📭</div>
<h3 className="text-lg font-semibold text-gray-900 dark:text-gray-100 mb-1">
No summary for this day
</h3>
<p className="text-sm text-gray-400 dark:text-gray-500">
{selectedDate === today
? "Today's summary hasn't been created yet. Check back later!"
: "No work was logged for this date."}
</p>
{summaryDates.size > 0 && (
<button
onClick={() => {
const nearest = Array.from(summaryDates).sort().reverse()[0];
if (nearest) {
setSelectedDate(nearest);
const d = new Date(nearest + "T12:00:00Z");
setCalMonth({ year: d.getUTCFullYear(), month: d.getUTCMonth() });
}
}}
className="mt-4 text-sm text-amber-600 dark:text-amber-400 hover:underline"
>
Jump to latest summary
</button>
)}
</div>
)}
</div>
</div>
</div>
</div>
);
}