feat: add daily summaries feature
- 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:
@@ -117,6 +117,64 @@ export const taskComments = pgTable("task_comments", {
|
|||||||
export type TaskComment = typeof taskComments.$inferSelect;
|
export type TaskComment = typeof taskComments.$inferSelect;
|
||||||
export type NewTaskComment = typeof taskComments.$inferInsert;
|
export type NewTaskComment = typeof taskComments.$inferInsert;
|
||||||
|
|
||||||
|
// ─── Security Audits ───
|
||||||
|
|
||||||
|
export const securityAuditStatusEnum = pgEnum("security_audit_status", [
|
||||||
|
"strong",
|
||||||
|
"needs_improvement",
|
||||||
|
"critical",
|
||||||
|
]);
|
||||||
|
|
||||||
|
export interface SecurityFinding {
|
||||||
|
id: string;
|
||||||
|
status: "strong" | "needs_improvement" | "critical";
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
recommendation: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const securityAudits = pgTable("security_audits", {
|
||||||
|
id: uuid("id").defaultRandom().primaryKey(),
|
||||||
|
projectName: text("project_name").notNull(),
|
||||||
|
category: text("category").notNull(),
|
||||||
|
findings: jsonb("findings").$type<SecurityFinding[]>().default([]),
|
||||||
|
score: integer("score").notNull().default(0), // 0-100
|
||||||
|
lastAudited: timestamp("last_audited", { withTimezone: true }).defaultNow().notNull(),
|
||||||
|
createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
|
||||||
|
updatedAt: timestamp("updated_at", { withTimezone: true }).defaultNow().notNull(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type SecurityAudit = typeof securityAudits.$inferSelect;
|
||||||
|
export type NewSecurityAudit = typeof securityAudits.$inferInsert;
|
||||||
|
|
||||||
|
// ─── Daily Summaries ───
|
||||||
|
|
||||||
|
export interface SummaryHighlight {
|
||||||
|
text: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SummaryStats {
|
||||||
|
deploys?: number;
|
||||||
|
commits?: number;
|
||||||
|
tasksCompleted?: number;
|
||||||
|
featuresBuilt?: number;
|
||||||
|
bugsFixed?: number;
|
||||||
|
[key: string]: number | undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const dailySummaries = pgTable("daily_summaries", {
|
||||||
|
id: uuid("id").defaultRandom().primaryKey(),
|
||||||
|
date: text("date").notNull().unique(), // YYYY-MM-DD
|
||||||
|
content: text("content").notNull(),
|
||||||
|
highlights: jsonb("highlights").$type<SummaryHighlight[]>().default([]),
|
||||||
|
stats: jsonb("stats").$type<SummaryStats>().default({}),
|
||||||
|
createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
|
||||||
|
updatedAt: timestamp("updated_at", { withTimezone: true }).defaultNow().notNull(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type DailySummary = typeof dailySummaries.$inferSelect;
|
||||||
|
export type NewDailySummary = typeof dailySummaries.$inferInsert;
|
||||||
|
|
||||||
// ─── BetterAuth tables ───
|
// ─── BetterAuth tables ───
|
||||||
|
|
||||||
export const users = pgTable("users", {
|
export const users = pgTable("users", {
|
||||||
|
|||||||
188
backend/src/routes/security.ts
Normal file
188
backend/src/routes/security.ts
Normal file
@@ -0,0 +1,188 @@
|
|||||||
|
import { Elysia, t } from "elysia";
|
||||||
|
import { db } from "../db";
|
||||||
|
import { securityAudits } from "../db/schema";
|
||||||
|
import { eq, asc, and } from "drizzle-orm";
|
||||||
|
import { auth } from "../lib/auth";
|
||||||
|
|
||||||
|
const BEARER_TOKEN = process.env.API_BEARER_TOKEN || "hammer-dev-token";
|
||||||
|
|
||||||
|
async function requireSessionOrBearer(
|
||||||
|
request: Request,
|
||||||
|
headers: Record<string, string | undefined>
|
||||||
|
) {
|
||||||
|
const authHeader = headers["authorization"];
|
||||||
|
if (authHeader === `Bearer ${BEARER_TOKEN}`) return;
|
||||||
|
try {
|
||||||
|
const session = await auth.api.getSession({ headers: request.headers });
|
||||||
|
if (session) return;
|
||||||
|
} catch {}
|
||||||
|
throw new Error("Unauthorized");
|
||||||
|
}
|
||||||
|
|
||||||
|
const findingSchema = t.Object({
|
||||||
|
id: t.String(),
|
||||||
|
status: t.Union([
|
||||||
|
t.Literal("strong"),
|
||||||
|
t.Literal("needs_improvement"),
|
||||||
|
t.Literal("critical"),
|
||||||
|
]),
|
||||||
|
title: t.String(),
|
||||||
|
description: t.String(),
|
||||||
|
recommendation: t.String(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const securityRoutes = new Elysia({ prefix: "/api/security" })
|
||||||
|
.onError(({ error, set }) => {
|
||||||
|
const msg = (error as any)?.message || String(error);
|
||||||
|
if (msg === "Unauthorized") {
|
||||||
|
set.status = 401;
|
||||||
|
return { error: "Unauthorized" };
|
||||||
|
}
|
||||||
|
if (msg === "Audit not found") {
|
||||||
|
set.status = 404;
|
||||||
|
return { error: "Audit not found" };
|
||||||
|
}
|
||||||
|
console.error("Security route error:", msg);
|
||||||
|
set.status = 500;
|
||||||
|
return { error: "Internal server error" };
|
||||||
|
})
|
||||||
|
|
||||||
|
// GET all audits
|
||||||
|
.get("/", async ({ request, headers }) => {
|
||||||
|
await requireSessionOrBearer(request, headers);
|
||||||
|
const all = await db
|
||||||
|
.select()
|
||||||
|
.from(securityAudits)
|
||||||
|
.orderBy(asc(securityAudits.projectName), asc(securityAudits.category));
|
||||||
|
return all;
|
||||||
|
})
|
||||||
|
|
||||||
|
// GET summary (aggregate scores per project)
|
||||||
|
.get("/summary", async ({ request, headers }) => {
|
||||||
|
await requireSessionOrBearer(request, headers);
|
||||||
|
const all = await db
|
||||||
|
.select()
|
||||||
|
.from(securityAudits)
|
||||||
|
.orderBy(asc(securityAudits.projectName));
|
||||||
|
|
||||||
|
const projectMap: Record<
|
||||||
|
string,
|
||||||
|
{ scores: number[]; categories: number; lastAudited: string }
|
||||||
|
> = {};
|
||||||
|
|
||||||
|
for (const audit of all) {
|
||||||
|
if (!projectMap[audit.projectName]) {
|
||||||
|
projectMap[audit.projectName] = {
|
||||||
|
scores: [],
|
||||||
|
categories: 0,
|
||||||
|
lastAudited: audit.lastAudited.toISOString(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
projectMap[audit.projectName].scores.push(audit.score);
|
||||||
|
projectMap[audit.projectName].categories++;
|
||||||
|
const auditDate = audit.lastAudited.toISOString();
|
||||||
|
if (auditDate > projectMap[audit.projectName].lastAudited) {
|
||||||
|
projectMap[audit.projectName].lastAudited = auditDate;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const summary = Object.entries(projectMap).map(([name, data]) => ({
|
||||||
|
projectName: name,
|
||||||
|
averageScore: Math.round(
|
||||||
|
data.scores.reduce((a, b) => a + b, 0) / data.scores.length
|
||||||
|
),
|
||||||
|
categoriesAudited: data.categories,
|
||||||
|
lastAudited: data.lastAudited,
|
||||||
|
}));
|
||||||
|
|
||||||
|
return summary;
|
||||||
|
})
|
||||||
|
|
||||||
|
// GET audits for a specific project
|
||||||
|
.get(
|
||||||
|
"/project/:projectName",
|
||||||
|
async ({ params, request, headers }) => {
|
||||||
|
await requireSessionOrBearer(request, headers);
|
||||||
|
const audits = await db
|
||||||
|
.select()
|
||||||
|
.from(securityAudits)
|
||||||
|
.where(eq(securityAudits.projectName, decodeURIComponent(params.projectName)))
|
||||||
|
.orderBy(asc(securityAudits.category));
|
||||||
|
return audits;
|
||||||
|
},
|
||||||
|
{ params: t.Object({ projectName: t.String() }) }
|
||||||
|
)
|
||||||
|
|
||||||
|
// POST create audit entry
|
||||||
|
.post(
|
||||||
|
"/",
|
||||||
|
async ({ body, request, headers }) => {
|
||||||
|
await requireSessionOrBearer(request, headers);
|
||||||
|
const newAudit = await db
|
||||||
|
.insert(securityAudits)
|
||||||
|
.values({
|
||||||
|
projectName: body.projectName,
|
||||||
|
category: body.category,
|
||||||
|
findings: body.findings || [],
|
||||||
|
score: body.score,
|
||||||
|
lastAudited: new Date(),
|
||||||
|
})
|
||||||
|
.returning();
|
||||||
|
return newAudit[0];
|
||||||
|
},
|
||||||
|
{
|
||||||
|
body: t.Object({
|
||||||
|
projectName: t.String(),
|
||||||
|
category: t.String(),
|
||||||
|
findings: t.Optional(t.Array(findingSchema)),
|
||||||
|
score: t.Number(),
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
// PATCH update audit entry
|
||||||
|
.patch(
|
||||||
|
"/:id",
|
||||||
|
async ({ params, body, request, headers }) => {
|
||||||
|
await requireSessionOrBearer(request, headers);
|
||||||
|
const updates: Record<string, any> = { updatedAt: new Date() };
|
||||||
|
if (body.projectName !== undefined) updates.projectName = body.projectName;
|
||||||
|
if (body.category !== undefined) updates.category = body.category;
|
||||||
|
if (body.findings !== undefined) updates.findings = body.findings;
|
||||||
|
if (body.score !== undefined) updates.score = body.score;
|
||||||
|
if (body.refreshAuditDate) updates.lastAudited = new Date();
|
||||||
|
|
||||||
|
const updated = await db
|
||||||
|
.update(securityAudits)
|
||||||
|
.set(updates)
|
||||||
|
.where(eq(securityAudits.id, params.id))
|
||||||
|
.returning();
|
||||||
|
if (!updated.length) throw new Error("Audit not found");
|
||||||
|
return updated[0];
|
||||||
|
},
|
||||||
|
{
|
||||||
|
params: t.Object({ id: t.String() }),
|
||||||
|
body: t.Object({
|
||||||
|
projectName: t.Optional(t.String()),
|
||||||
|
category: t.Optional(t.String()),
|
||||||
|
findings: t.Optional(t.Array(findingSchema)),
|
||||||
|
score: t.Optional(t.Number()),
|
||||||
|
refreshAuditDate: t.Optional(t.Boolean()),
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
// DELETE audit entry
|
||||||
|
.delete(
|
||||||
|
"/:id",
|
||||||
|
async ({ params, request, headers }) => {
|
||||||
|
await requireSessionOrBearer(request, headers);
|
||||||
|
const deleted = await db
|
||||||
|
.delete(securityAudits)
|
||||||
|
.where(eq(securityAudits.id, params.id))
|
||||||
|
.returning();
|
||||||
|
if (!deleted.length) throw new Error("Audit not found");
|
||||||
|
return { success: true };
|
||||||
|
},
|
||||||
|
{ params: t.Object({ id: t.String() }) }
|
||||||
|
);
|
||||||
192
backend/src/routes/summaries.ts
Normal file
192
backend/src/routes/summaries.ts
Normal file
@@ -0,0 +1,192 @@
|
|||||||
|
import { Elysia, t } from "elysia";
|
||||||
|
import { db } from "../db";
|
||||||
|
import { dailySummaries } from "../db/schema";
|
||||||
|
import { desc, eq, sql } from "drizzle-orm";
|
||||||
|
import { auth } from "../lib/auth";
|
||||||
|
|
||||||
|
const BEARER_TOKEN = process.env.API_BEARER_TOKEN || "hammer-dev-token";
|
||||||
|
|
||||||
|
async function requireSessionOrBearer(
|
||||||
|
request: Request,
|
||||||
|
headers: Record<string, string | undefined>
|
||||||
|
) {
|
||||||
|
const authHeader = headers["authorization"];
|
||||||
|
if (authHeader === `Bearer ${BEARER_TOKEN}`) return;
|
||||||
|
try {
|
||||||
|
const session = await auth.api.getSession({ headers: request.headers });
|
||||||
|
if (session?.user) return;
|
||||||
|
} catch {}
|
||||||
|
throw new Error("Unauthorized");
|
||||||
|
}
|
||||||
|
|
||||||
|
export const summaryRoutes = new Elysia({ prefix: "/api/summaries" })
|
||||||
|
.onError(({ error, set }) => {
|
||||||
|
const msg = (error as any)?.message || String(error);
|
||||||
|
if (msg === "Unauthorized") {
|
||||||
|
set.status = 401;
|
||||||
|
return { error: "Unauthorized" };
|
||||||
|
}
|
||||||
|
if (msg === "Not found") {
|
||||||
|
set.status = 404;
|
||||||
|
return { error: "Summary not found" };
|
||||||
|
}
|
||||||
|
set.status = 500;
|
||||||
|
return { error: "Internal server error" };
|
||||||
|
})
|
||||||
|
|
||||||
|
// GET /api/summaries — list all summaries (paginated, newest first)
|
||||||
|
.get("/", async ({ request, headers, query }) => {
|
||||||
|
await requireSessionOrBearer(request, headers);
|
||||||
|
|
||||||
|
const page = Math.max(1, Number(query.page) || 1);
|
||||||
|
const limit = Math.min(Number(query.limit) || 50, 200);
|
||||||
|
const offset = (page - 1) * limit;
|
||||||
|
|
||||||
|
const [items, countResult] = await Promise.all([
|
||||||
|
db
|
||||||
|
.select()
|
||||||
|
.from(dailySummaries)
|
||||||
|
.orderBy(desc(dailySummaries.date))
|
||||||
|
.limit(limit)
|
||||||
|
.offset(offset),
|
||||||
|
db
|
||||||
|
.select({ count: sql<number>`count(*)` })
|
||||||
|
.from(dailySummaries),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const total = Number(countResult[0]?.count ?? 0);
|
||||||
|
|
||||||
|
return {
|
||||||
|
items,
|
||||||
|
total,
|
||||||
|
page,
|
||||||
|
limit,
|
||||||
|
totalPages: Math.ceil(total / limit),
|
||||||
|
};
|
||||||
|
})
|
||||||
|
|
||||||
|
// GET /api/summaries/dates — list all dates that have summaries (for calendar)
|
||||||
|
.get("/dates", async ({ request, headers }) => {
|
||||||
|
await requireSessionOrBearer(request, headers);
|
||||||
|
|
||||||
|
const rows = await db
|
||||||
|
.select({ date: dailySummaries.date })
|
||||||
|
.from(dailySummaries)
|
||||||
|
.orderBy(desc(dailySummaries.date));
|
||||||
|
|
||||||
|
return { dates: rows.map((r) => r.date) };
|
||||||
|
})
|
||||||
|
|
||||||
|
// GET /api/summaries/:date — get summary for specific date
|
||||||
|
.get("/:date", async ({ request, headers, params }) => {
|
||||||
|
await requireSessionOrBearer(request, headers);
|
||||||
|
|
||||||
|
const { date } = params;
|
||||||
|
// Validate date format
|
||||||
|
if (!/^\d{4}-\d{2}-\d{2}$/.test(date)) {
|
||||||
|
throw new Error("Not found");
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await db
|
||||||
|
.select()
|
||||||
|
.from(dailySummaries)
|
||||||
|
.where(eq(dailySummaries.date, date))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (result.length === 0) {
|
||||||
|
throw new Error("Not found");
|
||||||
|
}
|
||||||
|
|
||||||
|
return result[0];
|
||||||
|
})
|
||||||
|
|
||||||
|
// POST /api/summaries — create/upsert summary for a date
|
||||||
|
.post("/", async ({ request, headers, body }) => {
|
||||||
|
await requireSessionOrBearer(request, headers);
|
||||||
|
|
||||||
|
const { date, content, highlights, stats } = body as {
|
||||||
|
date: string;
|
||||||
|
content: string;
|
||||||
|
highlights?: { text: string }[];
|
||||||
|
stats?: Record<string, number>;
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!date || !content) {
|
||||||
|
throw new Error("date and content are required");
|
||||||
|
}
|
||||||
|
if (!/^\d{4}-\d{2}-\d{2}$/.test(date)) {
|
||||||
|
throw new Error("date must be YYYY-MM-DD format");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Upsert: insert or update on conflict
|
||||||
|
const existing = await db
|
||||||
|
.select()
|
||||||
|
.from(dailySummaries)
|
||||||
|
.where(eq(dailySummaries.date, date))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (existing.length > 0) {
|
||||||
|
const updated = await db
|
||||||
|
.update(dailySummaries)
|
||||||
|
.set({
|
||||||
|
content,
|
||||||
|
highlights: highlights || existing[0].highlights,
|
||||||
|
stats: stats || existing[0].stats,
|
||||||
|
updatedAt: new Date(),
|
||||||
|
})
|
||||||
|
.where(eq(dailySummaries.date, date))
|
||||||
|
.returning();
|
||||||
|
return updated[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
const inserted = await db
|
||||||
|
.insert(dailySummaries)
|
||||||
|
.values({
|
||||||
|
date,
|
||||||
|
content,
|
||||||
|
highlights: highlights || [],
|
||||||
|
stats: stats || {},
|
||||||
|
})
|
||||||
|
.returning();
|
||||||
|
|
||||||
|
return inserted[0];
|
||||||
|
})
|
||||||
|
|
||||||
|
// PATCH /api/summaries/:date — update existing summary
|
||||||
|
.patch("/:date", async ({ request, headers, params, body }) => {
|
||||||
|
await requireSessionOrBearer(request, headers);
|
||||||
|
|
||||||
|
const { date } = params;
|
||||||
|
if (!/^\d{4}-\d{2}-\d{2}$/.test(date)) {
|
||||||
|
throw new Error("Not found");
|
||||||
|
}
|
||||||
|
|
||||||
|
const existing = await db
|
||||||
|
.select()
|
||||||
|
.from(dailySummaries)
|
||||||
|
.where(eq(dailySummaries.date, date))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (existing.length === 0) {
|
||||||
|
throw new Error("Not found");
|
||||||
|
}
|
||||||
|
|
||||||
|
const updates: Record<string, any> = { updatedAt: new Date() };
|
||||||
|
const { content, highlights, stats } = body as {
|
||||||
|
content?: string;
|
||||||
|
highlights?: { text: string }[];
|
||||||
|
stats?: Record<string, number>;
|
||||||
|
};
|
||||||
|
|
||||||
|
if (content !== undefined) updates.content = content;
|
||||||
|
if (highlights !== undefined) updates.highlights = highlights;
|
||||||
|
if (stats !== undefined) updates.stats = stats;
|
||||||
|
|
||||||
|
const updated = await db
|
||||||
|
.update(dailySummaries)
|
||||||
|
.set(updates)
|
||||||
|
.where(eq(dailySummaries.date, date))
|
||||||
|
.returning();
|
||||||
|
|
||||||
|
return updated[0];
|
||||||
|
});
|
||||||
101
backend/src/scripts/populate-summaries.ts
Normal file
101
backend/src/scripts/populate-summaries.ts
Normal file
@@ -0,0 +1,101 @@
|
|||||||
|
/**
|
||||||
|
* Populate daily_summaries from ~/clawd/memory/*.md files.
|
||||||
|
* Usage: bun run src/scripts/populate-summaries.ts
|
||||||
|
*/
|
||||||
|
import { db } from "../db";
|
||||||
|
import { dailySummaries } from "../db/schema";
|
||||||
|
import { eq } from "drizzle-orm";
|
||||||
|
import { readdir, readFile } from "fs/promises";
|
||||||
|
import { join } from "path";
|
||||||
|
|
||||||
|
const MEMORY_DIR = process.env.MEMORY_DIR || "/home/clawdbot/clawd/memory";
|
||||||
|
|
||||||
|
function extractHighlights(content: string): { text: string }[] {
|
||||||
|
const highlights: { text: string }[] = [];
|
||||||
|
const lines = content.split("\n");
|
||||||
|
|
||||||
|
for (const line of lines) {
|
||||||
|
// Match ## headings as key sections
|
||||||
|
const h2Match = line.match(/^## (.+)/);
|
||||||
|
if (h2Match) {
|
||||||
|
highlights.push({ text: h2Match[1].trim() });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return highlights.slice(0, 20); // Cap at 20 highlights
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractStats(content: string): Record<string, number> {
|
||||||
|
const lower = content.toLowerCase();
|
||||||
|
const stats: Record<string, number> = {};
|
||||||
|
|
||||||
|
// Count deploy mentions
|
||||||
|
const deployMatches = lower.match(/\b(deploy|deployed|deployment|redeployed)\b/g);
|
||||||
|
if (deployMatches) stats.deploys = deployMatches.length;
|
||||||
|
|
||||||
|
// Count commit/push mentions
|
||||||
|
const commitMatches = lower.match(/\b(commit|committed|pushed|push)\b/g);
|
||||||
|
if (commitMatches) stats.commits = commitMatches.length;
|
||||||
|
|
||||||
|
// Count task mentions
|
||||||
|
const taskMatches = lower.match(/\b(completed|task completed|hq-\d+.*completed)\b/g);
|
||||||
|
if (taskMatches) stats.tasksCompleted = taskMatches.length;
|
||||||
|
|
||||||
|
// Count feature mentions
|
||||||
|
const featureMatches = lower.match(/\b(feature|built|implemented|added|created)\b/g);
|
||||||
|
if (featureMatches) stats.featuresBuilt = Math.min(featureMatches.length, 30);
|
||||||
|
|
||||||
|
// Count fix mentions
|
||||||
|
const fixMatches = lower.match(/\b(fix|fixed|bug|bugfix|hotfix)\b/g);
|
||||||
|
if (fixMatches) stats.bugsFixed = fixMatches.length;
|
||||||
|
|
||||||
|
return stats;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
console.log(`Reading memory files from ${MEMORY_DIR}...`);
|
||||||
|
|
||||||
|
const files = await readdir(MEMORY_DIR);
|
||||||
|
const mdFiles = files
|
||||||
|
.filter((f) => /^\d{4}-\d{2}-\d{2}\.md$/.test(f))
|
||||||
|
.sort();
|
||||||
|
|
||||||
|
console.log(`Found ${mdFiles.length} memory files`);
|
||||||
|
|
||||||
|
for (const file of mdFiles) {
|
||||||
|
const date = file.replace(".md", "");
|
||||||
|
const filePath = join(MEMORY_DIR, file);
|
||||||
|
const content = await readFile(filePath, "utf-8");
|
||||||
|
|
||||||
|
const highlights = extractHighlights(content);
|
||||||
|
const stats = extractStats(content);
|
||||||
|
|
||||||
|
// Upsert
|
||||||
|
const existing = await db
|
||||||
|
.select()
|
||||||
|
.from(dailySummaries)
|
||||||
|
.where(eq(dailySummaries.date, date))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (existing.length > 0) {
|
||||||
|
await db
|
||||||
|
.update(dailySummaries)
|
||||||
|
.set({ content, highlights, stats, updatedAt: new Date() })
|
||||||
|
.where(eq(dailySummaries.date, date));
|
||||||
|
console.log(`Updated: ${date}`);
|
||||||
|
} else {
|
||||||
|
await db
|
||||||
|
.insert(dailySummaries)
|
||||||
|
.values({ date, content, highlights, stats });
|
||||||
|
console.log(`Inserted: ${date}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log("Done!");
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch((e) => {
|
||||||
|
console.error("Failed:", e);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
@@ -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 ProjectsPage = lazy(() => import("./pages/ProjectsPage").then(m => ({ default: m.ProjectsPage })));
|
||||||
const TaskPage = lazy(() => import("./pages/TaskPage").then(m => ({ default: m.TaskPage })));
|
const TaskPage = lazy(() => import("./pages/TaskPage").then(m => ({ default: m.TaskPage })));
|
||||||
const ActivityPage = lazy(() => import("./pages/ActivityPage").then(m => ({ default: m.ActivityPage })));
|
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 AdminPage = lazy(() => import("./components/AdminPage").then(m => ({ default: m.AdminPage })));
|
||||||
|
const SecurityPage = lazy(() => import("./pages/SecurityPage").then(m => ({ default: m.SecurityPage })));
|
||||||
|
|
||||||
function PageLoader() {
|
function PageLoader() {
|
||||||
return (
|
return (
|
||||||
@@ -36,6 +38,8 @@ function AuthenticatedApp() {
|
|||||||
<Route path="/task/:taskRef" element={<Suspense fallback={<PageLoader />}><TaskPage /></Suspense>} />
|
<Route path="/task/:taskRef" element={<Suspense fallback={<PageLoader />}><TaskPage /></Suspense>} />
|
||||||
<Route path="/projects" element={<Suspense fallback={<PageLoader />}><ProjectsPage /></Suspense>} />
|
<Route path="/projects" element={<Suspense fallback={<PageLoader />}><ProjectsPage /></Suspense>} />
|
||||||
<Route path="/activity" element={<Suspense fallback={<PageLoader />}><ActivityPage /></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="/admin" element={<Suspense fallback={<PageLoader />}><AdminPage /></Suspense>} />
|
||||||
<Route path="*" element={<Navigate to="/" replace />} />
|
<Route path="*" element={<Navigate to="/" replace />} />
|
||||||
</Route>
|
</Route>
|
||||||
|
|||||||
@@ -12,6 +12,8 @@ const navItems = [
|
|||||||
{ to: "/queue", label: "Queue", icon: "📋", badgeKey: "queue" },
|
{ to: "/queue", label: "Queue", icon: "📋", badgeKey: "queue" },
|
||||||
{ to: "/projects", label: "Projects", icon: "📁", badgeKey: null },
|
{ to: "/projects", label: "Projects", icon: "📁", badgeKey: null },
|
||||||
{ to: "/activity", label: "Activity", 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;
|
] as const;
|
||||||
|
|
||||||
export function DashboardLayout() {
|
export function DashboardLayout() {
|
||||||
|
|||||||
1022
frontend/src/pages/SecurityPage.tsx
Normal file
1022
frontend/src/pages/SecurityPage.tsx
Normal file
File diff suppressed because it is too large
Load Diff
445
frontend/src/pages/SummariesPage.tsx
Normal file
445
frontend/src/pages/SummariesPage.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user