Compare commits

...

13 Commits

Author SHA1 Message Date
cbfeb6db70 Add debug endpoint for todos DB diagnostics
Some checks failed
CI/CD / test (push) Successful in 23s
CI/CD / deploy (push) Failing after 1s
2026-01-30 05:02:06 +00:00
fd823e2d75 Add SQL init fallback for todos table creation 2026-01-30 04:59:17 +00:00
602e1ed75b fix: force db:push with yes pipe + add error logging to summaries 2026-01-30 04:48:22 +00:00
fe18fc12f9 feat: add security audit seed data with real findings from code review
- Added seed-security.ts with comprehensive audit data for all 5 projects
- Real findings from actual code inspection: auth, CORS, rate limiting,
  error handling, dependencies, TLS certs, infrastructure
- 35 audit entries across Hammer Dashboard, Network App, Todo App, nKode,
  and Infrastructure
- Fixed unused deleteAudit import warning in SecurityPage
2026-01-30 04:45:52 +00:00
dd2c80224e feat: add personal todos feature
- New todos table in DB schema (title, description, priority, category, due date, completion)
- Full CRUD + toggle API routes at /api/todos
- Categories support with filtering
- Bulk import endpoint for migration
- New TodosPage with inline editing, priority badges, due date display
- Add Todos to sidebar navigation
- Dark mode support throughout
2026-01-30 04:44:34 +00:00
d5693a7624 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
2026-01-30 04:42:10 +00:00
b5066a0d33 feat: remove chat section from dashboard
- Remove Chat from sidebar navigation
- Remove /chat route from App.tsx
- Delete ChatPage component, gateway.ts client lib
- Delete backend chat routes and gateway-relay WebSocket code
- No other features depended on removed code
2026-01-30 04:40:51 +00:00
504215439e feat: unified activity feed with comments + progress notes
- New /api/activity endpoint returning combined timeline of progress notes
  and comments across all tasks, sorted chronologically
- Activity page now fetches from unified endpoint instead of extracting
  from task data client-side
- Type filter (progress/comment) and status filter on Activity page
- Comment entries show author avatars and type badges
- 30s auto-refresh on activity feed
2026-01-30 00:06:41 +00:00
b7ff8437e4 feat: task comments/discussion system
- New task_comments table (separate from progress notes)
- Backend: GET/POST/DELETE /api/tasks/:id/comments with session + bearer auth
- TaskComments component on TaskPage (full-page view) with markdown support,
  author avatars, delete own comments, 30s polling
- CompactComments in TaskDetailPanel (side panel) with last 3 + expand
- Comment API functions in frontend lib/api.ts
2026-01-30 00:04:38 +00:00
46002e0854 ci: trigger pipeline with secrets configured 2026-01-29 23:12:56 +00:00
d01a155c95 fix: single-line curl in deploy step (act runner escaping issue) 2026-01-29 23:08:40 +00:00
b8e490f635 feat: add deploy-to-Dokploy step in CI/CD pipeline
- Deploy job runs after tests pass, only on push to main
- Uses Dokploy compose.deploy API with secrets for URL, token, compose ID
- PRs only run tests (no deploy)
2026-01-29 22:54:46 +00:00
268ee5d0b2 feat: add unit tests and Gitea Actions CI pipeline
- Extract pure utility functions to lib/utils.ts for testability
- Add 28 unit tests covering: computeNextDueDate, resetSubtasks,
  parseTaskIdentifier, validators, statusSortOrder
- Add Gitea Actions workflow (.gitea/workflows/ci.yml) that runs
  tests and type checking on push/PR to main
- Refactor tasks.ts to use extracted utils
2026-01-29 22:42:59 +00:00
32 changed files with 4759 additions and 1628 deletions

43
.gitea/workflows/ci.yml Normal file
View File

@@ -0,0 +1,43 @@
name: CI/CD
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
test:
runs-on: ubuntu-latest
defaults:
run:
working-directory: backend
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Bun
uses: oven-sh/setup-bun@v2
with:
bun-version: latest
- name: Install dependencies
run: bun install --frozen-lockfile
- name: Run tests
run: bun test --bail
- name: Type check
run: bun x tsc --noEmit
deploy:
needs: test
runs-on: ubuntu-latest
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
steps:
- name: Deploy to Dokploy
run: |
curl -fsSL -X POST "${{ secrets.DOKPLOY_URL }}/api/compose.deploy" -H "Content-Type: application/json" -H "x-api-key: ${{ secrets.DOKPLOY_API_TOKEN }}" -d '{"composeId": "${{ secrets.DOKPLOY_COMPOSE_ID }}"}'
echo "Deploy triggered on Dokploy"

View File

@@ -10,4 +10,6 @@ COPY . .
# Generate migrations and run # Generate migrations and run
EXPOSE 3100 EXPOSE 3100
CMD ["sh", "-c", "bun run db:push && bun run start"] RUN apt-get update && apt-get install -y postgresql-client && rm -rf /var/lib/apt/lists/*
COPY init-todos.sql /app/init-todos.sql
CMD ["sh", "-c", "echo 'Running init SQL...' && psql \"$DATABASE_URL\" -f /app/init-todos.sql 2>&1 && echo 'Init SQL done' && echo 'Running db:push...' && yes | bun run db:push 2>&1; echo 'db:push exit code:' $? && echo 'Starting server...' && bun run start"]

21
backend/init-todos.sql Normal file
View File

@@ -0,0 +1,21 @@
-- Create todo_priority enum if not exists
DO $$ BEGIN
CREATE TYPE todo_priority AS ENUM ('high', 'medium', 'low', 'none');
EXCEPTION WHEN duplicate_object THEN null;
END $$;
-- Create todos table if not exists
CREATE TABLE IF NOT EXISTS todos (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id TEXT NOT NULL,
title TEXT NOT NULL,
description TEXT,
is_completed BOOLEAN NOT NULL DEFAULT false,
priority todo_priority NOT NULL DEFAULT 'none',
category TEXT,
due_date TIMESTAMPTZ,
completed_at TIMESTAMPTZ,
sort_order INTEGER NOT NULL DEFAULT 0,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);

View File

@@ -7,7 +7,10 @@
"db:generate": "drizzle-kit generate", "db:generate": "drizzle-kit generate",
"db:migrate": "drizzle-kit migrate", "db:migrate": "drizzle-kit migrate",
"db:push": "drizzle-kit push", "db:push": "drizzle-kit push",
"db:studio": "drizzle-kit studio" "db:studio": "drizzle-kit studio",
"test": "bun test",
"test:ci": "bun test --bail",
"seed:security": "bun run src/seed-security.ts"
}, },
"dependencies": { "dependencies": {
"@elysiajs/cors": "^1.2.0", "@elysiajs/cors": "^1.2.0",

View File

@@ -103,6 +103,105 @@ export const tasks = pgTable("tasks", {
export type Task = typeof tasks.$inferSelect; export type Task = typeof tasks.$inferSelect;
export type NewTask = typeof tasks.$inferInsert; export type NewTask = typeof tasks.$inferInsert;
// ─── Comments ───
export const taskComments = pgTable("task_comments", {
id: uuid("id").defaultRandom().primaryKey(),
taskId: uuid("task_id").notNull().references(() => tasks.id, { onDelete: "cascade" }),
authorId: text("author_id"), // BetterAuth user ID, or "hammer" for API, null for anonymous
authorName: text("author_name").notNull(),
content: text("content").notNull(),
createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
});
export type TaskComment = typeof taskComments.$inferSelect;
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;
// ─── Personal Todos ───
export const todoPriorityEnum = pgEnum("todo_priority", [
"high",
"medium",
"low",
"none",
]);
export const todos = pgTable("todos", {
id: uuid("id").defaultRandom().primaryKey(),
userId: text("user_id").notNull(),
title: text("title").notNull(),
description: text("description"),
isCompleted: boolean("is_completed").notNull().default(false),
priority: todoPriorityEnum("priority").notNull().default("none"),
category: text("category"),
dueDate: timestamp("due_date", { withTimezone: true }),
completedAt: timestamp("completed_at", { withTimezone: true }),
sortOrder: integer("sort_order").notNull().default(0),
createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
updatedAt: timestamp("updated_at", { withTimezone: true }).defaultNow().notNull(),
});
export type Todo = typeof todos.$inferSelect;
export type NewTodo = typeof todos.$inferInsert;
// ─── BetterAuth tables ─── // ─── BetterAuth tables ───
export const users = pgTable("users", { export const users = pgTable("users", {

View File

@@ -3,7 +3,11 @@ import { cors } from "@elysiajs/cors";
import { taskRoutes } from "./routes/tasks"; import { taskRoutes } from "./routes/tasks";
import { adminRoutes } from "./routes/admin"; import { adminRoutes } from "./routes/admin";
import { projectRoutes } from "./routes/projects"; import { projectRoutes } from "./routes/projects";
import { chatRoutes } from "./routes/chat"; import { commentRoutes } from "./routes/comments";
import { activityRoutes } from "./routes/activity";
import { summaryRoutes } from "./routes/summaries";
import { securityRoutes } from "./routes/security";
import { todoRoutes } from "./routes/todos";
import { auth } from "./lib/auth"; import { auth } from "./lib/auth";
import { db } from "./db"; import { db } from "./db";
import { tasks, users } from "./db/schema"; import { tasks, users } from "./db/schema";
@@ -115,9 +119,13 @@ const app = new Elysia()
}) })
.use(taskRoutes) .use(taskRoutes)
.use(commentRoutes)
.use(activityRoutes)
.use(projectRoutes) .use(projectRoutes)
.use(adminRoutes) .use(adminRoutes)
.use(chatRoutes) .use(securityRoutes)
.use(summaryRoutes)
.use(todoRoutes)
// Current user info (role, etc.) // Current user info (role, etc.)
.get("/api/me", async ({ request }) => { .get("/api/me", async ({ request }) => {

View File

@@ -1,283 +0,0 @@
/**
* Gateway WebSocket Relay
*
* Maintains a single persistent WebSocket connection to the Clawdbot gateway.
* Dashboard clients connect through the backend (authenticated via BetterAuth),
* and messages are relayed bidirectionally.
*
* Architecture:
* Browser ←WSS→ Dashboard Backend ←WSS→ Clawdbot Gateway
* (BetterAuth) (relay) (token auth)
*/
const GATEWAY_URL = process.env.GATEWAY_WS_URL || "wss://ws.hammer.donovankelly.xyz";
const GATEWAY_TOKEN = process.env.GATEWAY_WS_TOKEN || "";
type GatewayState = "disconnected" | "connecting" | "connected";
type MessageHandler = (msg: any) => void;
let reqCounter = 0;
function nextReqId() {
return `relay-${++reqCounter}`;
}
class GatewayConnection {
private ws: WebSocket | null = null;
private state: GatewayState = "disconnected";
private pendingRequests = new Map<string, { resolve: (v: any) => void; reject: (e: any) => void; timer: ReturnType<typeof setTimeout> }>();
private eventListeners = new Set<MessageHandler>();
private reconnectTimer: ReturnType<typeof setTimeout> | null = null;
private shouldReconnect = true;
private connectSent = false;
private tickTimer: ReturnType<typeof setInterval> | null = null;
constructor() {
this.connect();
}
private connect() {
if (this.state === "connecting") return;
this.state = "connecting";
this.connectSent = false;
if (!GATEWAY_TOKEN) {
console.warn("[gateway-relay] No GATEWAY_WS_TOKEN set, chat relay disabled");
this.state = "disconnected";
return;
}
console.log(`[gateway-relay] Connecting to ${GATEWAY_URL}...`);
try {
this.ws = new WebSocket(GATEWAY_URL);
} catch (e) {
console.error("[gateway-relay] Failed to create WebSocket:", e);
this.state = "disconnected";
this.scheduleReconnect();
return;
}
this.ws.addEventListener("open", () => {
console.log("[gateway-relay] WebSocket open, sending handshake...");
this.sendConnect();
});
this.ws.addEventListener("message", (event) => {
try {
const msg = JSON.parse(String(event.data));
// Handle connect.challenge — gateway may send this before we connect
if (msg.type === "event" && msg.event === "connect.challenge") {
console.log("[gateway-relay] Received connect challenge");
// Token auth doesn't need signing; send connect if not yet sent
if (!this.connectSent) {
this.sendConnect();
}
return;
}
this.handleMessage(msg);
} catch (e) {
console.error("[gateway-relay] Failed to parse message:", e);
}
});
this.ws.addEventListener("close", () => {
console.log("[gateway-relay] Disconnected");
this.state = "disconnected";
this.ws = null;
this.connectSent = false;
if (this.tickTimer) {
clearInterval(this.tickTimer);
this.tickTimer = null;
}
// Reject all pending requests
for (const [id, pending] of this.pendingRequests) {
clearTimeout(pending.timer);
pending.reject(new Error("Connection closed"));
this.pendingRequests.delete(id);
}
if (this.shouldReconnect) {
this.scheduleReconnect();
}
});
this.ws.addEventListener("error", () => {
console.error("[gateway-relay] WebSocket error");
});
}
private sendConnect() {
if (this.connectSent) return;
this.connectSent = true;
const connectId = nextReqId();
this.sendRaw({
type: "req",
id: connectId,
method: "connect",
params: {
minProtocol: 3,
maxProtocol: 3,
client: {
id: "dashboard-relay",
displayName: "Hammer Dashboard",
version: "1.0.0",
platform: "server",
mode: "webchat",
instanceId: `relay-${process.pid}-${Date.now()}`,
},
role: "operator",
scopes: ["operator.read", "operator.write"],
caps: [],
commands: [],
permissions: {},
auth: {
token: GATEWAY_TOKEN,
},
},
});
// Wait for handshake response
this.pendingRequests.set(connectId, {
resolve: (payload) => {
console.log("[gateway-relay] Connected to gateway, protocol:", payload?.protocol);
this.state = "connected";
// Start tick keepalive (gateway expects periodic ticks)
const tickInterval = payload?.policy?.tickIntervalMs || 15000;
this.tickTimer = setInterval(() => {
this.sendRaw({ type: "tick" });
}, tickInterval);
},
reject: (err) => {
console.error("[gateway-relay] Handshake failed:", err);
this.state = "disconnected";
this.ws?.close();
},
timer: setTimeout(() => {
if (this.pendingRequests.has(connectId)) {
this.pendingRequests.delete(connectId);
console.error("[gateway-relay] Handshake timeout");
this.state = "disconnected";
this.ws?.close();
}
}, 15000),
});
}
private scheduleReconnect() {
if (this.reconnectTimer) return;
this.reconnectTimer = setTimeout(() => {
this.reconnectTimer = null;
if (this.shouldReconnect && this.state === "disconnected") {
this.connect();
}
}, 5000);
}
private sendRaw(msg: any) {
if (this.ws?.readyState === WebSocket.OPEN) {
this.ws.send(JSON.stringify(msg));
}
}
private handleMessage(msg: any) {
if (msg.type === "res") {
const pending = this.pendingRequests.get(msg.id);
if (pending) {
clearTimeout(pending.timer);
this.pendingRequests.delete(msg.id);
if (msg.ok !== false) {
pending.resolve(msg.payload ?? msg.result ?? {});
} else {
pending.reject(new Error(msg.error?.message || msg.error || "Request failed"));
}
}
} else if (msg.type === "event") {
// Forward events to all listeners
for (const listener of this.eventListeners) {
try {
listener(msg);
} catch (e) {
console.error("[gateway-relay] Event listener error:", e);
}
}
}
// Ignore tick responses and other frame types
}
isConnected(): boolean {
return this.state === "connected";
}
async request(method: string, params?: any): Promise<any> {
if (!this.isConnected()) {
throw new Error("Gateway not connected");
}
return new Promise((resolve, reject) => {
const id = nextReqId();
const timer = setTimeout(() => {
if (this.pendingRequests.has(id)) {
this.pendingRequests.delete(id);
reject(new Error("Request timeout"));
}
}, 120000);
this.pendingRequests.set(id, { resolve, reject, timer });
this.sendRaw({ type: "req", id, method, params });
});
}
onEvent(handler: MessageHandler): () => void {
this.eventListeners.add(handler);
return () => this.eventListeners.delete(handler);
}
destroy() {
this.shouldReconnect = false;
if (this.reconnectTimer) clearTimeout(this.reconnectTimer);
if (this.tickTimer) clearInterval(this.tickTimer);
if (this.ws) {
try { this.ws.close(); } catch {}
}
this.ws = null;
this.state = "disconnected";
}
}
// Singleton gateway connection
export const gateway = new GatewayConnection();
/**
* Send a chat message to the gateway
*/
export async function chatSend(sessionKey: string, message: string): Promise<any> {
return gateway.request("chat.send", {
sessionKey,
message,
idempotencyKey: `dash-${Date.now()}-${Math.random().toString(36).slice(2)}`,
});
}
/**
* Get chat history from the gateway
*/
export async function chatHistory(sessionKey: string, limit = 50): Promise<any> {
return gateway.request("chat.history", { sessionKey, limit });
}
/**
* Abort an in-progress chat response
*/
export async function chatAbort(sessionKey: string): Promise<any> {
return gateway.request("chat.abort", { sessionKey });
}
/**
* List sessions from the gateway
*/
export async function sessionsList(limit = 50): Promise<any> {
return gateway.request("sessions.list", { limit });
}

View File

@@ -0,0 +1,242 @@
import { describe, test, expect, beforeAll } from "bun:test";
import {
computeNextDueDate,
resetSubtasks,
parseTaskIdentifier,
isValidTaskStatus,
isValidTaskPriority,
isValidTaskSource,
isValidRecurrenceFrequency,
statusSortOrder,
} from "./utils";
import type { Subtask } from "../db/schema";
// ── computeNextDueDate ──────────────────────────────────────────────
describe("computeNextDueDate", () => {
test("daily adds 1 day from now when no fromDate", () => {
const before = new Date();
const result = computeNextDueDate("daily");
const after = new Date();
// Should be roughly 1 day from now
const diffMs = result.getTime() - before.getTime();
const oneDayMs = 24 * 60 * 60 * 1000;
expect(diffMs).toBeGreaterThanOrEqual(oneDayMs - 1000);
expect(diffMs).toBeLessThanOrEqual(oneDayMs + 1000);
});
test("weekly adds 7 days", () => {
const before = new Date();
const result = computeNextDueDate("weekly");
const diffMs = result.getTime() - before.getTime();
const sevenDaysMs = 7 * 24 * 60 * 60 * 1000;
expect(diffMs).toBeGreaterThanOrEqual(sevenDaysMs - 1000);
expect(diffMs).toBeLessThanOrEqual(sevenDaysMs + 1000);
});
test("biweekly adds 14 days", () => {
const before = new Date();
const result = computeNextDueDate("biweekly");
const diffMs = result.getTime() - before.getTime();
const fourteenDaysMs = 14 * 24 * 60 * 60 * 1000;
expect(diffMs).toBeGreaterThanOrEqual(fourteenDaysMs - 1000);
expect(diffMs).toBeLessThanOrEqual(fourteenDaysMs + 1000);
});
test("monthly adds approximately 1 month", () => {
const before = new Date();
const result = computeNextDueDate("monthly");
// Should be roughly 28-31 days from now
const diffDays = (result.getTime() - before.getTime()) / (24 * 60 * 60 * 1000);
expect(diffDays).toBeGreaterThanOrEqual(27);
expect(diffDays).toBeLessThanOrEqual(32);
});
test("uses fromDate when it is in the future", () => {
const futureDate = new Date();
futureDate.setDate(futureDate.getDate() + 10); // 10 days from now
const result = computeNextDueDate("daily", futureDate);
// Should be futureDate + 1 day
const expected = new Date(futureDate);
expected.setDate(expected.getDate() + 1);
expect(result.getDate()).toBe(expected.getDate());
});
test("ignores fromDate when it is in the past", () => {
const pastDate = new Date("2020-01-01");
const before = new Date();
const result = computeNextDueDate("daily", pastDate);
// Should be ~1 day from now, not from 2020
expect(result.getFullYear()).toBeGreaterThanOrEqual(before.getFullYear());
});
test("handles null fromDate", () => {
const before = new Date();
const result = computeNextDueDate("weekly", null);
const diffMs = result.getTime() - before.getTime();
const sevenDaysMs = 7 * 24 * 60 * 60 * 1000;
expect(diffMs).toBeGreaterThanOrEqual(sevenDaysMs - 1000);
});
});
// ── resetSubtasks ───────────────────────────────────────────────────
describe("resetSubtasks", () => {
test("resets all subtasks to uncompleted", () => {
const subtasks: Subtask[] = [
{ id: "st-1", title: "Do thing", completed: true, completedAt: "2025-01-01T00:00:00Z", createdAt: "2024-12-01T00:00:00Z" },
{ id: "st-2", title: "Do other thing", completed: false, createdAt: "2024-12-01T00:00:00Z" },
{ id: "st-3", title: "Done thing", completed: true, completedAt: "2025-01-15T00:00:00Z", createdAt: "2024-12-15T00:00:00Z" },
];
const result = resetSubtasks(subtasks);
expect(result).toHaveLength(3);
for (const s of result) {
expect(s.completed).toBe(false);
expect(s.completedAt).toBeUndefined();
}
});
test("preserves other fields", () => {
const subtasks: Subtask[] = [
{ id: "st-1", title: "My task", completed: true, completedAt: "2025-01-01T00:00:00Z", createdAt: "2024-12-01T00:00:00Z" },
];
const result = resetSubtasks(subtasks);
expect(result[0].id).toBe("st-1");
expect(result[0].title).toBe("My task");
expect(result[0].createdAt).toBe("2024-12-01T00:00:00Z");
});
test("handles empty array", () => {
expect(resetSubtasks([])).toEqual([]);
});
});
// ── parseTaskIdentifier ─────────────────────────────────────────────
describe("parseTaskIdentifier", () => {
test("parses plain number", () => {
const result = parseTaskIdentifier("42");
expect(result).toEqual({ type: "number", value: 42 });
});
test("parses HQ- prefixed number", () => {
const result = parseTaskIdentifier("HQ-7");
expect(result).toEqual({ type: "number", value: 7 });
});
test("parses hq- prefixed number (case insensitive)", () => {
const result = parseTaskIdentifier("hq-15");
expect(result).toEqual({ type: "number", value: 15 });
});
test("parses UUID", () => {
const uuid = "550e8400-e29b-41d4-a716-446655440000";
const result = parseTaskIdentifier(uuid);
expect(result).toEqual({ type: "uuid", value: uuid });
});
test("treats non-numeric strings as UUID", () => {
const result = parseTaskIdentifier("abc123");
expect(result).toEqual({ type: "uuid", value: "abc123" });
});
test("treats mixed number-string as UUID", () => {
const result = parseTaskIdentifier("12abc");
expect(result).toEqual({ type: "uuid", value: "12abc" });
});
});
// ── Validators ──────────────────────────────────────────────────────
describe("isValidTaskStatus", () => {
test("accepts valid statuses", () => {
expect(isValidTaskStatus("active")).toBe(true);
expect(isValidTaskStatus("queued")).toBe(true);
expect(isValidTaskStatus("blocked")).toBe(true);
expect(isValidTaskStatus("completed")).toBe(true);
expect(isValidTaskStatus("cancelled")).toBe(true);
});
test("rejects invalid statuses", () => {
expect(isValidTaskStatus("done")).toBe(false);
expect(isValidTaskStatus("")).toBe(false);
expect(isValidTaskStatus("ACTIVE")).toBe(false);
});
});
describe("isValidTaskPriority", () => {
test("accepts valid priorities", () => {
expect(isValidTaskPriority("critical")).toBe(true);
expect(isValidTaskPriority("high")).toBe(true);
expect(isValidTaskPriority("medium")).toBe(true);
expect(isValidTaskPriority("low")).toBe(true);
});
test("rejects invalid priorities", () => {
expect(isValidTaskPriority("urgent")).toBe(false);
expect(isValidTaskPriority("")).toBe(false);
});
});
describe("isValidTaskSource", () => {
test("accepts valid sources", () => {
expect(isValidTaskSource("donovan")).toBe(true);
expect(isValidTaskSource("hammer")).toBe(true);
expect(isValidTaskSource("heartbeat")).toBe(true);
expect(isValidTaskSource("cron")).toBe(true);
expect(isValidTaskSource("other")).toBe(true);
expect(isValidTaskSource("david")).toBe(true);
});
test("rejects invalid sources", () => {
expect(isValidTaskSource("system")).toBe(false);
expect(isValidTaskSource("")).toBe(false);
});
});
describe("isValidRecurrenceFrequency", () => {
test("accepts valid frequencies", () => {
expect(isValidRecurrenceFrequency("daily")).toBe(true);
expect(isValidRecurrenceFrequency("weekly")).toBe(true);
expect(isValidRecurrenceFrequency("biweekly")).toBe(true);
expect(isValidRecurrenceFrequency("monthly")).toBe(true);
});
test("rejects invalid frequencies", () => {
expect(isValidRecurrenceFrequency("yearly")).toBe(false);
expect(isValidRecurrenceFrequency("")).toBe(false);
});
});
// ── statusSortOrder ─────────────────────────────────────────────────
describe("statusSortOrder", () => {
test("active sorts first", () => {
expect(statusSortOrder("active")).toBe(0);
});
test("cancelled sorts last among known statuses", () => {
expect(statusSortOrder("cancelled")).toBe(4);
});
test("maintains correct ordering", () => {
const statuses = ["cancelled", "active", "blocked", "queued", "completed"];
const sorted = [...statuses].sort(
(a, b) => statusSortOrder(a) - statusSortOrder(b)
);
expect(sorted).toEqual([
"active",
"queued",
"blocked",
"completed",
"cancelled",
]);
});
test("unknown status gets highest sort value", () => {
expect(statusSortOrder("unknown")).toBe(5);
expect(statusSortOrder("unknown")).toBeGreaterThan(
statusSortOrder("cancelled")
);
});
});

101
backend/src/lib/utils.ts Normal file
View File

@@ -0,0 +1,101 @@
/**
* Pure utility functions extracted for testability.
* No database or external dependencies.
*/
import type { RecurrenceFrequency, Subtask } from "../db/schema";
/**
* Compute the next due date for a recurring task based on frequency.
*/
export function computeNextDueDate(
frequency: RecurrenceFrequency,
fromDate?: Date | null
): Date {
const base =
fromDate && fromDate > new Date() ? new Date(fromDate) : new Date();
switch (frequency) {
case "daily":
base.setDate(base.getDate() + 1);
break;
case "weekly":
base.setDate(base.getDate() + 7);
break;
case "biweekly":
base.setDate(base.getDate() + 14);
break;
case "monthly":
base.setMonth(base.getMonth() + 1);
break;
}
return base;
}
/**
* Reset subtasks for a new recurrence instance (uncheck all).
*/
export function resetSubtasks(subtasks: Subtask[]): Subtask[] {
return subtasks.map((s) => ({
...s,
completed: false,
completedAt: undefined,
}));
}
/**
* Parse a task identifier - could be a UUID, a number, or "HQ-<number>".
* Returns { type: "number", value: number } or { type: "uuid", value: string }.
*/
export function parseTaskIdentifier(idOrNumber: string):
| { type: "number"; value: number }
| { type: "uuid"; value: string } {
const cleaned = idOrNumber.replace(/^HQ-/i, "");
const asNumber = parseInt(cleaned, 10);
if (!isNaN(asNumber) && String(asNumber) === cleaned) {
return { type: "number", value: asNumber };
}
return { type: "uuid", value: cleaned };
}
/**
* Validate that a status string is a valid task status.
*/
export function isValidTaskStatus(status: string): boolean {
return ["active", "queued", "blocked", "completed", "cancelled"].includes(status);
}
/**
* Validate that a priority string is a valid task priority.
*/
export function isValidTaskPriority(priority: string): boolean {
return ["critical", "high", "medium", "low"].includes(priority);
}
/**
* Validate that a source string is a valid task source.
*/
export function isValidTaskSource(source: string): boolean {
return ["donovan", "david", "hammer", "heartbeat", "cron", "other"].includes(source);
}
/**
* Validate a recurrence frequency string.
*/
export function isValidRecurrenceFrequency(freq: string): boolean {
return ["daily", "weekly", "biweekly", "monthly"].includes(freq);
}
/**
* Sort status values by priority order (active first).
* Returns a numeric sort key.
*/
export function statusSortOrder(status: string): number {
const order: Record<string, number> = {
active: 0,
queued: 1,
blocked: 2,
completed: 3,
cancelled: 4,
};
return order[status] ?? 5;
}

View File

@@ -0,0 +1,105 @@
import { Elysia, t } from "elysia";
import { db } from "../db";
import { tasks, taskComments } from "../db/schema";
import { desc, 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 interface ActivityFeedItem {
type: "progress" | "comment";
timestamp: string;
taskId: string;
taskNumber: number | null;
taskTitle: string;
taskStatus: string;
// For progress notes
note?: string;
// For comments
commentId?: string;
authorName?: string;
authorId?: string | null;
content?: string;
}
export const activityRoutes = new Elysia({ prefix: "/api/activity" })
.onError(({ error, set }) => {
const msg = (error as any)?.message || String(error);
if (msg === "Unauthorized") {
set.status = 401;
return { error: "Unauthorized" };
}
set.status = 500;
return { error: "Internal server error" };
})
// GET /api/activity — unified feed of progress notes + comments
.get("/", async ({ request, headers, query }) => {
await requireSessionOrBearer(request, headers);
const limit = Math.min(Number(query.limit) || 50, 200);
// Fetch all tasks with progress notes
const allTasks = await db.select().from(tasks);
// Collect progress note items
const items: ActivityFeedItem[] = [];
for (const task of allTasks) {
const notes = (task.progressNotes || []) as { timestamp: string; note: string }[];
for (const note of notes) {
items.push({
type: "progress",
timestamp: note.timestamp,
taskId: task.id,
taskNumber: task.taskNumber,
taskTitle: task.title,
taskStatus: task.status,
note: note.note,
});
}
}
// Fetch all comments
const allComments = await db
.select()
.from(taskComments)
.orderBy(desc(taskComments.createdAt));
// Build task lookup for comment items
const taskMap = new Map(allTasks.map(t => [t.id, t]));
for (const comment of allComments) {
const task = taskMap.get(comment.taskId);
if (!task) continue;
items.push({
type: "comment",
timestamp: comment.createdAt.toISOString(),
taskId: task.id,
taskNumber: task.taskNumber,
taskTitle: task.title,
taskStatus: task.status,
commentId: comment.id,
authorName: comment.authorName,
authorId: comment.authorId,
content: comment.content,
});
}
// Sort by timestamp descending, take limit
items.sort((a, b) => new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime());
return {
items: items.slice(0, limit),
total: items.length,
};
});

View File

@@ -1,269 +0,0 @@
/**
* Chat routes - WebSocket relay + REST fallback for dashboard chat
*
* WebSocket: /api/chat/ws - Real-time bidirectional relay to gateway
* REST: /api/chat/send, /api/chat/history, /api/chat/sessions - Fallback endpoints
*/
import { Elysia } from "elysia";
import { auth } from "../lib/auth";
import { gateway, chatSend, chatHistory, chatAbort, sessionsList } from "../lib/gateway-relay";
// Track active WebSocket client connections
const activeClients = new Map<string, {
ws: any;
userId: string;
sessionKeys: Set<string>;
}>();
export const chatRoutes = new Elysia()
// WebSocket endpoint for real-time chat relay
.ws("/api/chat/ws", {
open(ws) {
const clientId = `client-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
(ws.data as any).__clientId = clientId;
(ws.data as any).__authenticated = false;
console.log(`[chat-ws] Client connected: ${clientId}`);
},
async message(ws, rawMsg) {
const clientId = (ws.data as any).__clientId || "unknown";
let msg: any;
try {
msg = typeof rawMsg === "string" ? JSON.parse(rawMsg) : rawMsg;
} catch {
ws.send(JSON.stringify({ type: "error", error: "Invalid JSON" }));
return;
}
// First message must be auth
if (!(ws.data as any).__authenticated) {
if (msg.type !== "auth") {
ws.send(JSON.stringify({ type: "error", error: "Must authenticate first" }));
ws.close();
return;
}
// Validate session cookie or token
const session = await validateAuth(msg);
if (!session) {
ws.send(JSON.stringify({ type: "error", error: "Authentication failed" }));
ws.close();
return;
}
(ws.data as any).__authenticated = true;
(ws.data as any).__userId = session.user.id;
(ws.data as any).__userName = session.user.name || session.user.email;
// Register client
activeClients.set(clientId, {
ws,
userId: session.user.id,
sessionKeys: new Set(),
});
ws.send(JSON.stringify({
type: "auth_ok",
user: { id: session.user.id, name: session.user.name },
gatewayConnected: gateway.isConnected(),
}));
console.log(`[chat-ws] Client authenticated: ${clientId} (${session.user.name || session.user.email})`);
return;
}
// Handle authenticated messages
try {
await handleClientMessage(clientId, ws, msg);
} catch (e: any) {
ws.send(JSON.stringify({
type: "error",
id: msg.id,
error: e.message || "Internal error",
}));
}
},
close(ws) {
const clientId = (ws.data as any).__clientId || "unknown";
activeClients.delete(clientId);
console.log(`[chat-ws] Client disconnected: ${clientId}`);
},
})
// REST: Send a chat message
.post("/api/chat/send", async ({ request, body }) => {
const session = await auth.api.getSession({ headers: request.headers });
if (!session?.user) {
return new Response(JSON.stringify({ error: "Unauthorized" }), { status: 401 });
}
const { sessionKey, message } = body as { sessionKey: string; message: string };
if (!sessionKey || !message) {
return new Response(JSON.stringify({ error: "sessionKey and message required" }), { status: 400 });
}
if (!gateway.isConnected()) {
return new Response(JSON.stringify({ error: "Gateway not connected" }), { status: 503 });
}
try {
const result = await chatSend(sessionKey, message);
return { ok: true, result };
} catch (e: any) {
return new Response(JSON.stringify({ error: e.message }), { status: 500 });
}
})
// REST: Get chat history
.get("/api/chat/history/:sessionKey", async ({ request, params }) => {
const session = await auth.api.getSession({ headers: request.headers });
if (!session?.user) {
return new Response(JSON.stringify({ error: "Unauthorized" }), { status: 401 });
}
if (!gateway.isConnected()) {
return new Response(JSON.stringify({ error: "Gateway not connected" }), { status: 503 });
}
try {
const result = await chatHistory(params.sessionKey);
return { ok: true, ...result };
} catch (e: any) {
return new Response(JSON.stringify({ error: e.message }), { status: 500 });
}
})
// REST: List sessions
.get("/api/chat/sessions", async ({ request }) => {
const session = await auth.api.getSession({ headers: request.headers });
if (!session?.user) {
return new Response(JSON.stringify({ error: "Unauthorized" }), { status: 401 });
}
if (!gateway.isConnected()) {
return new Response(JSON.stringify({ error: "Gateway not connected" }), { status: 503 });
}
try {
const result = await sessionsList();
return { ok: true, ...result };
} catch (e: any) {
return new Response(JSON.stringify({ error: e.message }), { status: 500 });
}
})
// REST: Gateway connection status
.get("/api/chat/status", async ({ request }) => {
const session = await auth.api.getSession({ headers: request.headers });
if (!session?.user) {
return new Response(JSON.stringify({ error: "Unauthorized" }), { status: 401 });
}
return {
gatewayConnected: gateway.isConnected(),
activeClients: activeClients.size,
};
});
// Validate auth from WebSocket auth message
async function validateAuth(msg: any): Promise<any> {
// Support cookie-based auth (pass cookie string)
if (msg.cookie) {
try {
// Create a fake request with the cookie header for BetterAuth
const headers = new Headers();
headers.set("cookie", msg.cookie);
const session = await auth.api.getSession({ headers });
return session;
} catch {
return null;
}
}
// Support bearer token auth
if (msg.token) {
try {
const headers = new Headers();
headers.set("authorization", `Bearer ${msg.token}`);
const session = await auth.api.getSession({ headers });
return session;
} catch {
return null;
}
}
return null;
}
// Handle messages from authenticated WebSocket clients
async function handleClientMessage(clientId: string, ws: any, msg: any) {
const client = activeClients.get(clientId);
if (!client) return;
switch (msg.type) {
case "chat.send": {
const { sessionKey, message } = msg;
if (!sessionKey || !message) {
ws.send(JSON.stringify({ type: "error", id: msg.id, error: "sessionKey and message required" }));
return;
}
client.sessionKeys.add(sessionKey);
const result = await chatSend(sessionKey, message);
ws.send(JSON.stringify({ type: "res", id: msg.id, ok: true, payload: result }));
break;
}
case "chat.history": {
const { sessionKey, limit } = msg;
if (!sessionKey) {
ws.send(JSON.stringify({ type: "error", id: msg.id, error: "sessionKey required" }));
return;
}
client.sessionKeys.add(sessionKey);
const result = await chatHistory(sessionKey, limit);
ws.send(JSON.stringify({ type: "res", id: msg.id, ok: true, payload: result }));
break;
}
case "chat.abort": {
const { sessionKey } = msg;
if (!sessionKey) {
ws.send(JSON.stringify({ type: "error", id: msg.id, error: "sessionKey required" }));
return;
}
const result = await chatAbort(sessionKey);
ws.send(JSON.stringify({ type: "res", id: msg.id, ok: true, payload: result }));
break;
}
case "sessions.list": {
const result = await sessionsList(msg.limit);
ws.send(JSON.stringify({ type: "res", id: msg.id, ok: true, payload: result }));
break;
}
default:
ws.send(JSON.stringify({ type: "error", id: msg.id, error: `Unknown message type: ${msg.type}` }));
}
}
// Forward gateway events to relevant WebSocket clients
gateway.onEvent((msg: any) => {
if (msg.type !== "event") return;
const payload = msg.payload || {};
const sessionKey = payload.sessionKey;
for (const [, client] of activeClients) {
// Forward to clients subscribed to this session key, or broadcast if no key
if (!sessionKey || client.sessionKeys.has(sessionKey)) {
try {
client.ws.send(JSON.stringify(msg));
} catch {
// Client disconnected, will be cleaned up
}
}
}
});

View File

@@ -0,0 +1,127 @@
import { Elysia, t } from "elysia";
import { db } from "../db";
import { taskComments, tasks } from "../db/schema";
import { eq, desc, asc } from "drizzle-orm";
import { auth } from "../lib/auth";
import { parseTaskIdentifier } from "../lib/utils";
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 { userId: "hammer", userName: "Hammer" };
}
try {
const session = await auth.api.getSession({ headers: request.headers });
if (session?.user) {
return { userId: session.user.id, userName: session.user.name || session.user.email };
}
} catch {}
throw new Error("Unauthorized");
}
async function resolveTaskId(idOrNumber: string): Promise<string | null> {
const parsed = parseTaskIdentifier(idOrNumber);
let result;
if (parsed.type === "number") {
result = await db.select({ id: tasks.id }).from(tasks).where(eq(tasks.taskNumber, parsed.value));
} else {
result = await db.select({ id: tasks.id }).from(tasks).where(eq(tasks.id, parsed.value));
}
return result[0]?.id || null;
}
export const commentRoutes = new Elysia({ prefix: "/api/tasks" })
.onError(({ error, set }) => {
const msg = (error as any)?.message || String(error);
if (msg === "Unauthorized") {
set.status = 401;
return { error: "Unauthorized" };
}
if (msg === "Task not found") {
set.status = 404;
return { error: "Task not found" };
}
set.status = 500;
return { error: "Internal server error" };
})
// GET comments for a task
.get(
"/:id/comments",
async ({ params, request, headers }) => {
await requireSessionOrBearer(request, headers);
const taskId = await resolveTaskId(params.id);
if (!taskId) throw new Error("Task not found");
const comments = await db
.select()
.from(taskComments)
.where(eq(taskComments.taskId, taskId))
.orderBy(asc(taskComments.createdAt));
return comments;
},
{ params: t.Object({ id: t.String() }) }
)
// POST add comment to a task
.post(
"/:id/comments",
async ({ params, body, request, headers }) => {
const user = await requireSessionOrBearer(request, headers);
const taskId = await resolveTaskId(params.id);
if (!taskId) throw new Error("Task not found");
const comment = await db
.insert(taskComments)
.values({
taskId,
authorId: user.userId,
authorName: body.authorName || user.userName,
content: body.content,
})
.returning();
return comment[0];
},
{
params: t.Object({ id: t.String() }),
body: t.Object({
content: t.String(),
authorName: t.Optional(t.String()),
}),
}
)
// DELETE a comment
.delete(
"/:id/comments/:commentId",
async ({ params, request, headers }) => {
const user = await requireSessionOrBearer(request, headers);
const taskId = await resolveTaskId(params.id);
if (!taskId) throw new Error("Task not found");
// Only allow deleting own comments (or bearer token = admin)
const comment = await db
.select()
.from(taskComments)
.where(eq(taskComments.id, params.commentId));
if (!comment[0]) throw new Error("Task not found");
if (comment[0].taskId !== taskId) throw new Error("Task not found");
// Bearer token can delete any, otherwise must be author
const authHeader = headers["authorization"];
if (authHeader !== `Bearer ${BEARER_TOKEN}` && comment[0].authorId !== user.userId) {
throw new Error("Unauthorized");
}
await db.delete(taskComments).where(eq(taskComments.id, params.commentId));
return { success: true };
},
{
params: t.Object({ id: t.String(), commentId: t.String() }),
}
);

View 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() }) }
);

View File

@@ -0,0 +1,197 @@
import { Elysia } 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;
try {
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),
};
} catch (e: any) {
console.error("Error fetching summaries:", e?.message || e);
throw e;
}
})
// 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];
});

View File

@@ -3,6 +3,7 @@ import { db } from "../db";
import { tasks, type ProgressNote, type Subtask, type Recurrence, type RecurrenceFrequency } from "../db/schema"; import { tasks, type ProgressNote, type Subtask, type Recurrence, type RecurrenceFrequency } from "../db/schema";
import { eq, asc, desc, sql, inArray, or } from "drizzle-orm"; import { eq, asc, desc, sql, inArray, or } from "drizzle-orm";
import { auth } from "../lib/auth"; import { auth } from "../lib/auth";
import { computeNextDueDate, resetSubtasks, parseTaskIdentifier } from "../lib/utils";
const BEARER_TOKEN = process.env.API_BEARER_TOKEN || "hammer-dev-token"; const BEARER_TOKEN = process.env.API_BEARER_TOKEN || "hammer-dev-token";
const CLAWDBOT_HOOK_URL = process.env.CLAWDBOT_HOOK_URL || "https://hammer.donovankelly.xyz/hooks/agent"; const CLAWDBOT_HOOK_URL = process.env.CLAWDBOT_HOOK_URL || "https://hammer.donovankelly.xyz/hooks/agent";
@@ -41,26 +42,6 @@ async function notifyTaskActivated(task: { id: string; title: string; descriptio
} }
} }
// Compute the next due date for a recurring task
function computeNextDueDate(frequency: RecurrenceFrequency, fromDate?: Date | null): Date {
const base = fromDate && fromDate > new Date() ? new Date(fromDate) : new Date();
switch (frequency) {
case "daily":
base.setDate(base.getDate() + 1);
break;
case "weekly":
base.setDate(base.getDate() + 7);
break;
case "biweekly":
base.setDate(base.getDate() + 14);
break;
case "monthly":
base.setMonth(base.getMonth() + 1);
break;
}
return base;
}
// Create the next instance of a recurring task // Create the next instance of a recurring task
async function spawnNextRecurrence(completedTask: typeof tasks.$inferSelect) { async function spawnNextRecurrence(completedTask: typeof tasks.$inferSelect) {
const recurrence = completedTask.recurrence as Recurrence | null; const recurrence = completedTask.recurrence as Recurrence | null;
@@ -96,11 +77,7 @@ async function spawnNextRecurrence(completedTask: typeof tasks.$inferSelect) {
estimatedHours: completedTask.estimatedHours, estimatedHours: completedTask.estimatedHours,
tags: completedTask.tags, tags: completedTask.tags,
recurrence: recurrence, recurrence: recurrence,
subtasks: (completedTask.subtasks as Subtask[] || []).map(s => ({ subtasks: resetSubtasks(completedTask.subtasks as Subtask[] || []),
...s,
completed: false,
completedAt: undefined,
})),
progressNotes: [], progressNotes: [],
}) })
.returning(); .returning();
@@ -161,17 +138,12 @@ async function requireAdmin(request: Request, headers: Record<string, string | u
// Resolve a task by UUID or sequential number (e.g. "5" or "HQ-5") // Resolve a task by UUID or sequential number (e.g. "5" or "HQ-5")
async function resolveTask(idOrNumber: string) { async function resolveTask(idOrNumber: string) {
// Strip "HQ-" prefix if present const parsed = parseTaskIdentifier(idOrNumber);
const cleaned = idOrNumber.replace(/^HQ-/i, "");
const asNumber = parseInt(cleaned, 10);
let result; let result;
if (!isNaN(asNumber) && String(asNumber) === cleaned) { if (parsed.type === "number") {
// Lookup by task_number result = await db.select().from(tasks).where(eq(tasks.taskNumber, parsed.value));
result = await db.select().from(tasks).where(eq(tasks.taskNumber, asNumber));
} else { } else {
// Lookup by UUID result = await db.select().from(tasks).where(eq(tasks.id, parsed.value));
result = await db.select().from(tasks).where(eq(tasks.id, cleaned));
} }
return result[0] || null; return result[0] || null;
} }

280
backend/src/routes/todos.ts Normal file
View File

@@ -0,0 +1,280 @@
import { Elysia, t } from "elysia";
import { db } from "../db";
import { todos } from "../db/schema";
import { eq, and, asc, desc, sql } from "drizzle-orm";
import type { 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 a default user ID for bearer token access
return { userId: "bearer" };
}
try {
const session = await auth.api.getSession({ headers: request.headers });
if (session?.user) return { userId: session.user.id };
} catch {}
throw new Error("Unauthorized");
}
export const todoRoutes = new Elysia({ prefix: "/api/todos" })
.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: "Not found" };
}
console.error("Todo route error:", msg);
set.status = 500;
return { error: "Internal server error", debug: msg };
})
// Debug endpoint - test DB connectivity for todos table
.get("/debug", async () => {
try {
const result = await db.execute(sql`SELECT EXISTS (SELECT FROM information_schema.tables WHERE table_name = 'todos')`);
const enumResult = await db.execute(sql`SELECT EXISTS (SELECT FROM pg_type WHERE typname = 'todo_priority')`);
return {
todosTableExists: result,
todoPriorityEnumExists: enumResult,
dbConnected: true
};
} catch (e: any) {
return { error: e.message, dbConnected: false };
}
})
// GET all todos for current user
.get("/", async ({ request, headers, query }) => {
const { userId } = await requireSessionOrBearer(request, headers);
const conditions = [eq(todos.userId, userId)];
// Filter by completion
if (query.completed === "true") {
conditions.push(eq(todos.isCompleted, true));
} else if (query.completed === "false") {
conditions.push(eq(todos.isCompleted, false));
}
// Filter by category
if (query.category) {
conditions.push(eq(todos.category, query.category));
}
const userTodos = await db
.select()
.from(todos)
.where(and(...conditions))
.orderBy(
asc(todos.isCompleted),
desc(sql`CASE ${todos.priority} WHEN 'high' THEN 0 WHEN 'medium' THEN 1 WHEN 'low' THEN 2 ELSE 3 END`),
asc(todos.sortOrder),
desc(todos.createdAt)
);
return userTodos;
}, {
query: t.Object({
completed: t.Optional(t.String()),
category: t.Optional(t.String()),
}),
})
// GET categories (distinct)
.get("/categories", async ({ request, headers }) => {
const { userId } = await requireSessionOrBearer(request, headers);
const result = await db
.selectDistinct({ category: todos.category })
.from(todos)
.where(and(eq(todos.userId, userId), sql`${todos.category} IS NOT NULL AND ${todos.category} != ''`))
.orderBy(asc(todos.category));
return result.map((r) => r.category).filter(Boolean) as string[];
})
// POST create todo
.post("/", async ({ body, request, headers }) => {
const { userId } = await requireSessionOrBearer(request, headers);
// Get max sort order
const maxOrder = await db
.select({ max: sql<number>`COALESCE(MAX(${todos.sortOrder}), 0)` })
.from(todos)
.where(eq(todos.userId, userId));
const [todo] = await db
.insert(todos)
.values({
userId,
title: body.title,
description: body.description || null,
priority: body.priority || "none",
category: body.category || null,
dueDate: body.dueDate ? new Date(body.dueDate) : null,
sortOrder: (maxOrder[0]?.max ?? 0) + 1,
})
.returning();
return todo;
}, {
body: t.Object({
title: t.String({ minLength: 1 }),
description: t.Optional(t.String()),
priority: t.Optional(t.Union([
t.Literal("high"),
t.Literal("medium"),
t.Literal("low"),
t.Literal("none"),
])),
category: t.Optional(t.String()),
dueDate: t.Optional(t.Union([t.String(), t.Null()])),
}),
})
// PATCH update todo
.patch("/:id", async ({ params, body, request, headers }) => {
const { userId } = await requireSessionOrBearer(request, headers);
const existing = await db
.select()
.from(todos)
.where(and(eq(todos.id, params.id), eq(todos.userId, userId)));
if (!existing.length) throw new Error("Not found");
const updates: Record<string, any> = { updatedAt: new Date() };
if (body.title !== undefined) updates.title = body.title;
if (body.description !== undefined) updates.description = body.description;
if (body.priority !== undefined) updates.priority = body.priority;
if (body.category !== undefined) updates.category = body.category || null;
if (body.dueDate !== undefined) updates.dueDate = body.dueDate ? new Date(body.dueDate) : null;
if (body.sortOrder !== undefined) updates.sortOrder = body.sortOrder;
if (body.isCompleted !== undefined) {
updates.isCompleted = body.isCompleted;
updates.completedAt = body.isCompleted ? new Date() : null;
}
const [updated] = await db
.update(todos)
.set(updates)
.where(eq(todos.id, params.id))
.returning();
return updated;
}, {
params: t.Object({ id: t.String() }),
body: t.Object({
title: t.Optional(t.String()),
description: t.Optional(t.String()),
priority: t.Optional(t.Union([
t.Literal("high"),
t.Literal("medium"),
t.Literal("low"),
t.Literal("none"),
])),
category: t.Optional(t.Union([t.String(), t.Null()])),
dueDate: t.Optional(t.Union([t.String(), t.Null()])),
isCompleted: t.Optional(t.Boolean()),
sortOrder: t.Optional(t.Number()),
}),
})
// PATCH toggle complete
.patch("/:id/toggle", async ({ params, request, headers }) => {
const { userId } = await requireSessionOrBearer(request, headers);
const existing = await db
.select()
.from(todos)
.where(and(eq(todos.id, params.id), eq(todos.userId, userId)));
if (!existing.length) throw new Error("Not found");
const nowCompleted = !existing[0].isCompleted;
const [updated] = await db
.update(todos)
.set({
isCompleted: nowCompleted,
completedAt: nowCompleted ? new Date() : null,
updatedAt: new Date(),
})
.where(eq(todos.id, params.id))
.returning();
return updated;
}, {
params: t.Object({ id: t.String() }),
})
// DELETE todo
.delete("/:id", async ({ params, request, headers }) => {
const { userId } = await requireSessionOrBearer(request, headers);
const existing = await db
.select()
.from(todos)
.where(and(eq(todos.id, params.id), eq(todos.userId, userId)));
if (!existing.length) throw new Error("Not found");
await db.delete(todos).where(eq(todos.id, params.id));
return { success: true };
}, {
params: t.Object({ id: t.String() }),
})
// POST bulk import (for migration)
.post("/import", async ({ body, request, headers }) => {
const { userId } = await requireSessionOrBearer(request, headers);
const imported = [];
for (const item of body.todos) {
const [todo] = await db
.insert(todos)
.values({
userId,
title: item.title,
description: item.description || null,
isCompleted: item.isCompleted || false,
priority: item.priority || "none",
category: item.category || null,
dueDate: item.dueDate ? new Date(item.dueDate) : null,
completedAt: item.completedAt ? new Date(item.completedAt) : null,
sortOrder: item.sortOrder || 0,
createdAt: item.createdAt ? new Date(item.createdAt) : new Date(),
})
.returning();
imported.push(todo);
}
return { imported: imported.length, todos: imported };
}, {
body: t.Object({
todos: t.Array(t.Object({
title: t.String(),
description: t.Optional(t.Union([t.String(), t.Null()])),
isCompleted: t.Optional(t.Boolean()),
priority: t.Optional(t.Union([
t.Literal("high"),
t.Literal("medium"),
t.Literal("low"),
t.Literal("none"),
])),
category: t.Optional(t.Union([t.String(), t.Null()])),
dueDate: t.Optional(t.Union([t.String(), t.Null()])),
completedAt: t.Optional(t.Union([t.String(), t.Null()])),
sortOrder: t.Optional(t.Number()),
createdAt: t.Optional(t.String()),
})),
}),
});

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

View File

@@ -0,0 +1,476 @@
import { db } from "./db";
import { securityAudits, type SecurityFinding } from "./db/schema";
const now = new Date().toISOString();
function finding(
status: SecurityFinding["status"],
title: string,
description: string,
recommendation: string
): SecurityFinding {
return {
id: crypto.randomUUID(),
status,
title,
description,
recommendation,
};
}
const auditData: {
projectName: string;
category: string;
score: number;
findings: SecurityFinding[];
}[] = [
// ═══════════════════════════════════════════
// HAMMER DASHBOARD
// ═══════════════════════════════════════════
{
projectName: "Hammer Dashboard",
category: "Authentication",
score: 75,
findings: [
finding("strong", "BetterAuth with email/password", "Uses BetterAuth library with email+password authentication, secure session management with cookie-based sessions.", ""),
finding("strong", "CSRF protection enabled", "BetterAuth CSRF check is explicitly enabled (disableCSRFCheck: false).", ""),
finding("strong", "Cross-subdomain cookies", "Secure cookie configuration with domain scoping to .donovankelly.xyz.", ""),
finding("needs_improvement", "No MFA support", "Multi-factor authentication is not implemented. Single-factor auth only.", "Add TOTP or WebAuthn MFA support via BetterAuth plugins."),
finding("needs_improvement", "No password policy enforcement", "No minimum password length, complexity, or breach-check enforcement visible in auth config.", "Configure BetterAuth password policy with minimum length (12+), complexity requirements."),
finding("needs_improvement", "Open signup", "emailAndPassword.enabled is true without disableSignUp — anyone can register.", "Set disableSignUp: true and use invite-only registration."),
],
},
{
projectName: "Hammer Dashboard",
category: "Authorization",
score: 70,
findings: [
finding("strong", "Role-based access control", "Users have roles (admin/user). Admin-only routes check role before processing.", ""),
finding("strong", "Bearer token + session dual auth", "API supports both session cookies and bearer token auth for programmatic access.", ""),
finding("needs_improvement", "Bearer token is env-based static token", "API_BEARER_TOKEN is a single static token shared across all API consumers. No per-client tokens.", "Implement per-client API keys or use OAuth2 client credentials."),
],
},
{
projectName: "Hammer Dashboard",
category: "Data Protection",
score: 80,
findings: [
finding("strong", "TLS encryption in transit", "All traffic served over HTTPS via Let's Encrypt certificates (auto-renewed). Valid cert for dash.donovankelly.xyz.", ""),
finding("strong", "Database in Docker network", "PostgreSQL is not exposed to the public internet — only accessible within the Docker compose network.", ""),
finding("needs_improvement", "No database encryption at rest", "PostgreSQL data volume uses default filesystem — no disk-level or column-level encryption.", "Consider enabling LUKS for the Docker volume or use PostgreSQL pgcrypto for sensitive columns."),
finding("needs_improvement", "No backup strategy visible", "No automated database backup configuration found in the compose file.", "Add pg_dump cron backup to S3 or another offsite location."),
],
},
{
projectName: "Hammer Dashboard",
category: "Infrastructure",
score: 70,
findings: [
finding("strong", "Container isolation", "Backend and database run as separate Docker containers with defined networking.", ""),
finding("strong", "Dokploy managed deployment", "Deployed via Dokploy with compose — automated builds and deploys on git push.", ""),
finding("needs_improvement", "Database uses simple credentials", "POSTGRES_USER/PASSWORD are set via env vars in docker-compose but appear to be simple values.", "Use strong randomly generated passwords stored in a secrets manager."),
finding("needs_improvement", "No health check monitoring", "Health endpoint exists (/health) but no external monitoring or alerting configured.", "Set up uptime monitoring (e.g., UptimeRobot, Betterstack) with alerts."),
],
},
{
projectName: "Hammer Dashboard",
category: "Application Security",
score: 65,
findings: [
finding("strong", "Elysia type validation on routes", "Most routes use Elysia's t.Object() schema validation for request bodies — provides automatic input validation.", ""),
finding("strong", "SQL injection protection via Drizzle ORM", "All database queries use Drizzle ORM's parameterized query builder — no raw SQL string concatenation.", ""),
finding("strong", "Generic error responses", "Error handler returns 'Internal server error' for unhandled errors without stack traces.", ""),
finding("needs_improvement", "No rate limiting", "No rate limiting middleware found on any routes. API is vulnerable to brute-force and abuse.", "Add rate limiting middleware (e.g., per-IP request limits on auth endpoints)."),
finding("needs_improvement", "CORS allows localhost", "CORS origin includes http://localhost:5173 in production, which is unnecessary.", "Remove localhost from CORS origins in production builds."),
finding("needs_improvement", "Some routes lack body validation", "Activity and chat routes have 0 type validations — accepting unvalidated input.", "Add t.Object() body/param validation to all routes."),
],
},
{
projectName: "Hammer Dashboard",
category: "Dependency Security",
score: 60,
findings: [
finding("needs_improvement", "Limited dependency set (good)", "Backend has only 5 production dependencies — small attack surface.", ""),
finding("needs_improvement", "No automated vulnerability scanning", "No bun audit or Snyk integration found. Dependencies not regularly checked for CVEs.", "Add dependency scanning to CI pipeline or run periodic audits."),
finding("needs_improvement", "Some dependencies slightly outdated", "Elysia 1.2.25 and drizzle-orm 0.44.2 — newer versions available with security fixes.", "Update to latest stable versions: elysia 1.4.x, drizzle-orm 0.45.x."),
],
},
{
projectName: "Hammer Dashboard",
category: "Logging & Monitoring",
score: 40,
findings: [
finding("needs_improvement", "Console-only logging", "Errors logged via console.error — no structured logging, no log aggregation.", "Use a structured logger (pino/winston) and ship logs to a central service."),
finding("critical", "No audit trail", "No logging of who accessed what, auth events, or data changes.", "Implement audit logging for auth events, CRUD operations, and admin actions."),
finding("critical", "No alerting", "No alerting on errors, failed auth attempts, or unusual activity.", "Set up alerting via email/Slack for critical errors and security events."),
],
},
{
projectName: "Hammer Dashboard",
category: "Compliance",
score: 45,
findings: [
finding("needs_improvement", "No data retention policy", "No defined policy for how long user data, tasks, or sessions are retained.", "Define and implement data retention policies — auto-expire old sessions, archive completed tasks."),
finding("needs_improvement", "No privacy policy", "No privacy policy or terms of service for users.", "Create a basic privacy policy documenting data collection and usage."),
finding("needs_improvement", "Session data lacks expiry cleanup", "Old sessions may accumulate in the database without cleanup.", "Add a periodic job to clean up expired sessions."),
],
},
// ═══════════════════════════════════════════
// NETWORK APP
// ═══════════════════════════════════════════
{
projectName: "Network App",
category: "Authentication",
score: 85,
findings: [
finding("strong", "BetterAuth with bearer plugin", "Uses BetterAuth with email/password and bearer token plugin for mobile app support.", ""),
finding("strong", "Invite-only registration", "Open signup is disabled (disableSignUp: true). Registration requires invite link.", ""),
finding("strong", "Session configuration", "7-day session expiry with daily refresh. Cookie caching enabled for 5 min. Cross-subdomain cookies with secure+sameSite:none.", ""),
finding("strong", "Signup endpoint blocked", "POST /api/auth/sign-up/email explicitly returns 403 — defense in depth on top of disableSignUp.", ""),
finding("needs_improvement", "No MFA", "No multi-factor authentication implemented.", "Add TOTP MFA via BetterAuth plugin."),
],
},
{
projectName: "Network App",
category: "Authorization",
score: 80,
findings: [
finding("strong", "Auth middleware plugin", "Dedicated authMiddleware Elysia plugin that derives user from session — consistently applied across routes.", ""),
finding("strong", "Admin separation", "Admin routes check user.role === 'admin' with dedicated admin routes.", ""),
finding("strong", "Scoped auth derivation", "Auth middleware uses 'as: scoped' derivation for proper Elysia context isolation.", ""),
],
},
{
projectName: "Network App",
category: "Application Security",
score: 80,
findings: [
finding("strong", "Rate limiting implemented", "Custom rate limiting middleware with per-IP buckets. Different limits for auth, general API, and sensitive routes.", ""),
finding("strong", "Extensive input validation", "Most routes (34+ files) use Elysia t.Object() type validation. High validation coverage.", ""),
finding("strong", "Drizzle ORM for SQL safety", "All queries via Drizzle ORM — parameterized, no raw SQL injection risk.", ""),
finding("needs_improvement", "Stack traces in error responses", "Error handler logs full stack traces and sends them in responses (line 100: stack) — even in production.", "Only include stack traces in development. Check NODE_ENV before including."),
finding("needs_improvement", "CORS allows any origin in fallback", "Falls back to localhost:3000 if ALLOWED_ORIGINS env not set — risky if env is misconfigured.", "Set a strict default origin list rather than localhost."),
],
},
{
projectName: "Network App",
category: "Data Protection",
score: 75,
findings: [
finding("strong", "TLS via Let's Encrypt", "Valid Let's Encrypt certificate for app.thenetwork.donovankelly.xyz. Auto-renewed.", ""),
finding("strong", "PII handling awareness", "Network app handles contacts and personal data — uses dedicated client/interaction models.", ""),
finding("needs_improvement", "No encryption at rest", "Contact data (names, emails, phone numbers) stored as plain text in PostgreSQL.", "Consider column-level encryption for PII fields."),
finding("needs_improvement", "Email service (Resend) integration", "Uses Resend for sending emails — API key stored in env vars.", "Ensure Resend API key is rotated periodically."),
],
},
{
projectName: "Network App",
category: "Dependency Security",
score: 55,
findings: [
finding("needs_improvement", "LangChain AI dependencies", "Includes @langchain/anthropic, @langchain/core, @langchain/openai — large dependency trees with potential supply chain risk.", "Audit LangChain dependencies regularly. Consider pinning exact versions."),
finding("needs_improvement", "No automated scanning", "No CI/CD vulnerability scanning configured.", "Add bun audit or Snyk scanning to the pipeline."),
],
},
{
projectName: "Network App",
category: "Logging & Monitoring",
score: 50,
findings: [
finding("strong", "Audit log routes", "Has dedicated audit-logs route — suggests audit logging infrastructure exists.", ""),
finding("needs_improvement", "Console-based logging", "Error logging via console.error only. No structured log format.", "Implement structured logging with request IDs and ship to log aggregation."),
finding("needs_improvement", "No external monitoring", "No uptime monitoring or alerting configured.", "Set up uptime monitoring and error alerting."),
],
},
{
projectName: "Network App",
category: "Infrastructure",
score: 75,
findings: [
finding("strong", "Docker container isolation", "Runs in isolated Docker containers on Dokploy.", ""),
finding("strong", "Production Dockerfile", "Multi-stage Dockerfile with production dependencies only (--production flag). NODE_ENV=production set.", ""),
finding("strong", "Entrypoint script", "Uses entrypoint.sh for startup — allows db:push before start.", ""),
finding("needs_improvement", "No container health checks", "Dockerfile doesn't define HEALTHCHECK instruction.", "Add Docker HEALTHCHECK to enable container auto-restart on failure."),
],
},
{
projectName: "Network App",
category: "Compliance",
score: 40,
findings: [
finding("needs_improvement", "Handles personal contacts", "Stores names, emails, phone numbers, notes about individuals — GDPR-relevant data.", "Implement data export and deletion capabilities for GDPR compliance."),
finding("needs_improvement", "No data retention policy", "No automatic cleanup of old data.", "Define retention periods for contacts and interactions."),
finding("critical", "No consent management", "No mechanism to track consent for storing contact information.", "Add consent tracking for contact data collection."),
],
},
// ═══════════════════════════════════════════
// TODO APP
// ═══════════════════════════════════════════
{
projectName: "Todo App",
category: "Authentication",
score: 80,
findings: [
finding("strong", "BetterAuth with invite system", "Uses BetterAuth for auth. Has a dedicated invite system with expiring tokens.", ""),
finding("strong", "Bearer token support", "Supports bearer tokens via @elysiajs/bearer for API access.", ""),
finding("strong", "Invite token validation", "Invite tokens are validated for expiry and status before acceptance.", ""),
finding("needs_improvement", "No MFA", "No multi-factor authentication available.", "Add TOTP MFA support."),
],
},
{
projectName: "Todo App",
category: "Authorization",
score: 75,
findings: [
finding("strong", "Hammer API key auth", "Separate authentication for the Hammer bot API using dedicated API key.", ""),
finding("strong", "Admin routes", "Admin routes with role checking for user management.", ""),
finding("needs_improvement", "Shared auth patterns", "Auth middleware duplicated across route files rather than centralized.", "Centralize auth middleware into a shared plugin like Network App does."),
],
},
{
projectName: "Todo App",
category: "Application Security",
score: 65,
findings: [
finding("strong", "Input validation", "Routes use Elysia type validation (t.Object) — high coverage across routes.", ""),
finding("strong", "Drizzle ORM", "Parameterized queries via Drizzle ORM prevent SQL injection.", ""),
finding("needs_improvement", "Stack traces conditional but present", "Error handler includes stack in non-production mode (NODE_ENV check). But stack is still captured.", "Ensure NODE_ENV=production is always set in deployed containers."),
finding("needs_improvement", "No rate limiting", "No rate limiting middleware found. Endpoints vulnerable to abuse.", "Implement rate limiting similar to Network App's approach."),
finding("needs_improvement", "CORS allows localhost fallback", "Falls back to localhost:5173 and todo.donovankelly.xyz if ALLOWED_ORIGINS not set.", "Remove localhost from production CORS."),
],
},
{
projectName: "Todo App",
category: "Data Protection",
score: 75,
findings: [
finding("strong", "TLS certificates valid", "Valid Let's Encrypt certs for api.todo.donovankelly.xyz and app.todo.donovankelly.xyz.", ""),
finding("strong", "Minimal PII", "Todo app stores minimal personal data — mainly task content.", ""),
finding("needs_improvement", "No backup automation", "No backup strategy visible in compose configuration.", "Add automated database backups."),
],
},
{
projectName: "Todo App",
category: "Dependency Security",
score: 55,
findings: [
finding("needs_improvement", "pg-boss dependency", "Uses pg-boss for job queuing — adds complexity and dependency surface.", "Ensure pg-boss is kept updated."),
finding("needs_improvement", "Zod v4 (beta)", "Using zod 4.3.6 which is a relatively new major version.", "Monitor for security advisories on the new Zod version."),
finding("needs_improvement", "No vulnerability scanning", "No automated dependency scanning in CI.", "Add scanning to CI pipeline."),
],
},
{
projectName: "Todo App",
category: "Infrastructure",
score: 70,
findings: [
finding("strong", "Docker compose with Dokploy", "Proper compose setup with db:push in CMD for schema management.", ""),
finding("strong", "Separate API and frontend", "API and frontend deployed as separate services.", ""),
finding("needs_improvement", "No health check monitoring", "No external monitoring configured.", "Add uptime monitoring."),
],
},
{
projectName: "Todo App",
category: "Logging & Monitoring",
score: 35,
findings: [
finding("needs_improvement", "Console logging only", "Errors logged to stdout/stderr via console. No structured logging.", "Implement structured logging."),
finding("critical", "No audit logging", "No tracking of user actions, auth events, or data changes.", "Add audit logging for all CRUD operations."),
finding("critical", "No alerting system", "No error alerting or uptime monitoring.", "Configure alerts for errors and downtime."),
],
},
{
projectName: "Todo App",
category: "Compliance",
score: 50,
findings: [
finding("needs_improvement", "No data retention policy", "No cleanup of completed tasks or expired sessions.", "Define data retention periods."),
finding("needs_improvement", "No privacy documentation", "No privacy policy for users.", "Create privacy policy."),
],
},
// ═══════════════════════════════════════════
// NKODE
// ═══════════════════════════════════════════
{
projectName: "nKode",
category: "Authentication",
score: 90,
findings: [
finding("strong", "OPAQUE protocol (state-of-the-art)", "Uses OPAQUE (opaque-ke v4) for password authentication — server never sees plaintext passwords. This is cryptographically stronger than traditional hashing.", ""),
finding("strong", "OIDC implementation", "Full OpenID Connect flow with discovery endpoint, JWKs, token endpoint, and userinfo.", ""),
finding("strong", "Argon2 password hashing", "OPAQUE configured with Argon2 (opaque-ke features=['argon2']) — the recommended modern password hashing algorithm.", ""),
finding("needs_improvement", "No MFA layer", "OPAQUE provides strong password auth but no second factor.", "Consider adding FIDO2/WebAuthn as a second factor."),
],
},
{
projectName: "nKode",
category: "Authorization",
score: 75,
findings: [
finding("strong", "Token-based authorization", "Uses OIDC tokens (JWT) for API authorization after OPAQUE login.", ""),
finding("strong", "Protected route extractors", "Has dedicated extractors.rs for auth extraction — consistent route protection.", ""),
finding("needs_improvement", "Role system unclear", "No visible role-based access control in the API routes.", "Implement RBAC if different user permission levels are needed."),
],
},
{
projectName: "nKode",
category: "Application Security",
score: 75,
findings: [
finding("strong", "Rust memory safety", "Backend written in Rust — eliminates entire classes of vulnerabilities (buffer overflows, use-after-free, data races).", ""),
finding("strong", "Type-safe with serde", "All request/response types defined with serde — strong input validation at deserialization.", ""),
finding("strong", "Axum framework", "Uses Axum (Tokio-based) — well-maintained with good security practices.", ""),
finding("needs_improvement", "CORS includes localhost", "CORS origins include localhost:3000 and localhost:5173 in production code.", "Move development origins to environment-only configuration."),
finding("needs_improvement", "No rate limiting visible", "No rate limiting middleware found in the Axum router setup.", "Add tower-governor or similar rate limiting middleware."),
],
},
{
projectName: "nKode",
category: "Data Protection",
score: 80,
findings: [
finding("strong", "TLS on all endpoints", "Valid Let's Encrypt certs for app.nkode.donovankelly.xyz and api.nkode.donovankelly.xyz.", ""),
finding("strong", "OPAQUE prevents password exposure", "Server stores OPAQUE registration records, not password hashes — even a database breach doesn't expose passwords.", ""),
finding("needs_improvement", "Login data storage", "Stores user login data (credentials manager data) — may contain sensitive information.", "Ensure login data is encrypted at the application level."),
],
},
{
projectName: "nKode",
category: "Dependency Security",
score: 75,
findings: [
finding("strong", "Rust ecosystem (cargo audit)", "Rust has cargo-audit for vulnerability checking. Smaller dependency trees than Node.js.", ""),
finding("strong", "Pinned versions", "Cargo.toml uses specific version ranges — reproducible builds.", ""),
finding("needs_improvement", "No CI audit pipeline", "No automated cargo-audit in CI.", "Add cargo-audit to CI pipeline."),
],
},
{
projectName: "nKode",
category: "Infrastructure",
score: 70,
findings: [
finding("strong", "Docker containerized", "Deployed as Docker container on Dokploy.", ""),
finding("strong", "Minimal base image", "Rust binary — small attack surface compared to Node.js containers.", ""),
finding("needs_improvement", "Service architecture spread", "Multiple microservices (OIDC, core, services) — increases operational complexity.", "Document service architecture and inter-service auth."),
],
},
{
projectName: "nKode",
category: "Logging & Monitoring",
score: 55,
findings: [
finding("strong", "tracing/tracing-subscriber", "Uses Rust tracing ecosystem — structured, span-based logging.", ""),
finding("needs_improvement", "Stdout only", "Logs go to stdout only. No log aggregation configured.", "Ship tracing output to a log aggregation service."),
finding("needs_improvement", "No alerting", "No alerting or monitoring configured.", "Set up monitoring and alerting."),
],
},
{
projectName: "nKode",
category: "Compliance",
score: 50,
findings: [
finding("needs_improvement", "Password manager data", "Stores user password/login data — high sensitivity. No documented data handling policy.", "Document data classification and handling procedures for stored credentials."),
finding("needs_improvement", "No data export/deletion", "No user data export or deletion capability visible.", "Implement GDPR-style data portability and right-to-erasure."),
],
},
// ═══════════════════════════════════════════
// INFRASTRUCTURE
// ═══════════════════════════════════════════
{
projectName: "Infrastructure",
category: "Authentication",
score: 65,
findings: [
finding("strong", "SSH key authentication available", "VPS supports SSH key authentication.", ""),
finding("strong", "Gitea self-hosted", "Self-hosted Gitea with authenticated access — no reliance on third-party code hosting.", ""),
finding("needs_improvement", "Git credentials in URL", "Git remote URLs contain credentials encoded in the URL string.", "Use SSH keys or git credential helper instead of URL-embedded credentials."),
finding("needs_improvement", "Dokploy API token", "Single API token for Dokploy management. Token compromise gives full deploy control.", "Rotate Dokploy API token periodically. Consider IP-restricted access."),
],
},
{
projectName: "Infrastructure",
category: "Data Protection",
score: 70,
findings: [
finding("strong", "TLS everywhere", "All 7 domains verified with valid Let's Encrypt certificates. Auto-renewal via Dokploy/Traefik.", ""),
finding("strong", "Separate VPS isolation", "Clawdbot VPS (72.60.68.214) and Dokploy VPS (191.101.0.153) are separate machines — blast radius limited.", ""),
finding("needs_improvement", "No centralized backup", "No unified backup strategy across all services.", "Implement automated backups for all databases to offsite storage."),
finding("needs_improvement", "DNS at Hostinger", "DNS managed through Hostinger control panel. No DNSSEC enabled.", "Consider enabling DNSSEC for donovankelly.xyz."),
],
},
{
projectName: "Infrastructure",
category: "Infrastructure",
score: 60,
findings: [
finding("strong", "Dokploy orchestration", "Dokploy manages container deployments with automated builds — reduces manual config drift.", ""),
finding("strong", "Let's Encrypt auto-renewal", "TLS certificates automatically renewed — no manual intervention needed.", ""),
finding("needs_improvement", "No firewall documentation", "Firewall rules for both VPS machines not documented or audited.", "Document and audit iptables/ufw rules on both VPS instances."),
finding("needs_improvement", "SSH configuration unknown", "SSH hardening status (password auth disabled, key-only, non-default port) not verified.", "Audit SSH config: disable password auth, use key-only, change default port."),
finding("needs_improvement", "No container vulnerability scanning", "Docker images not scanned for vulnerabilities before deployment.", "Add Trivy or similar container scanning to the deployment pipeline."),
finding("critical", "Exposed ports unknown", "No audit of which ports are exposed on both VPS machines.", "Run port scan audit on both VPS IPs and close unnecessary ports."),
],
},
{
projectName: "Infrastructure",
category: "Logging & Monitoring",
score: 30,
findings: [
finding("critical", "No centralized logging", "No log aggregation across services. Each container logs independently to stdout.", "Deploy a log aggregation stack (e.g., Loki + Grafana, or Betterstack)."),
finding("critical", "No uptime monitoring", "No external uptime monitoring for any of the 7+ domains.", "Set up UptimeRobot or Betterstack for all production domains."),
finding("critical", "No intrusion detection", "No IDS/IPS on either VPS. No fail2ban or similar tools verified.", "Install and configure fail2ban on both VPS instances."),
finding("needs_improvement", "No system update policy", "No documented policy for OS and package updates.", "Set up unattended-upgrades for security patches on both VPS machines."),
],
},
{
projectName: "Infrastructure",
category: "Compliance",
score: 40,
findings: [
finding("needs_improvement", "No incident response plan", "No documented procedure for security incidents.", "Create an incident response runbook."),
finding("needs_improvement", "No access review", "No periodic review of who has access to VPS, Dokploy, Gitea.", "Conduct quarterly access reviews."),
finding("needs_improvement", "No secrets rotation policy", "API tokens, database passwords, and auth secrets have no rotation schedule.", "Implement a secrets rotation policy (quarterly minimum)."),
],
},
];
async function seedSecurityAudits() {
console.log("🛡️ Seeding security audit data...");
// Clear existing data
await db.delete(securityAudits);
console.log(" Cleared existing audit data");
// Insert all audit data
for (const audit of auditData) {
await db.insert(securityAudits).values({
projectName: audit.projectName,
category: audit.category,
score: audit.score,
findings: audit.findings,
lastAudited: new Date(),
});
}
console.log(` ✅ Inserted ${auditData.length} audit entries across ${new Set(auditData.map(a => a.projectName)).size} projects`);
// Print summary
const projects = [...new Set(auditData.map(a => a.projectName))];
for (const project of projects) {
const projectAudits = auditData.filter(a => a.projectName === project);
const avgScore = Math.round(projectAudits.reduce((sum, a) => sum + a.score, 0) / projectAudits.length);
const totalFindings = projectAudits.reduce((sum, a) => sum + a.findings.length, 0);
console.log(` ${project}: avg score ${avgScore}/100, ${totalFindings} findings`);
}
process.exit(0);
}
seedSecurityAudits().catch((err) => {
console.error("Failed to seed security data:", err);
process.exit(1);
});

View File

@@ -9,7 +9,9 @@
"better-auth": "^1.4.17", "better-auth": "^1.4.17",
"react": "^19.2.0", "react": "^19.2.0",
"react-dom": "^19.2.0", "react-dom": "^19.2.0",
"react-markdown": "^10.1.0",
"react-router-dom": "^7.13.0", "react-router-dom": "^7.13.0",
"remark-gfm": "^4.0.1",
"tailwindcss": "^4.1.18", "tailwindcss": "^4.1.18",
}, },
"devDependencies": { "devDependencies": {
@@ -259,16 +261,28 @@
"@types/babel__traverse": ["@types/babel__traverse@7.28.0", "", { "dependencies": { "@babel/types": "^7.28.2" } }, "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q=="], "@types/babel__traverse": ["@types/babel__traverse@7.28.0", "", { "dependencies": { "@babel/types": "^7.28.2" } }, "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q=="],
"@types/debug": ["@types/debug@4.1.12", "", { "dependencies": { "@types/ms": "*" } }, "sha512-vIChWdVG3LG1SMxEvI/AK+FWJthlrqlTu7fbrlywTkkaONwk/UAGaULXRlf8vkzFBLVm0zkMdCquhL5aOjhXPQ=="],
"@types/estree": ["@types/estree@1.0.8", "", {}, "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w=="], "@types/estree": ["@types/estree@1.0.8", "", {}, "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w=="],
"@types/estree-jsx": ["@types/estree-jsx@1.0.5", "", { "dependencies": { "@types/estree": "*" } }, "sha512-52CcUVNFyfb1A2ALocQw/Dd1BQFNmSdkuC3BkZ6iqhdMfQz7JWOFRuJFloOzjk+6WijU56m9oKXFAXc7o3Towg=="],
"@types/hast": ["@types/hast@3.0.4", "", { "dependencies": { "@types/unist": "*" } }, "sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ=="],
"@types/json-schema": ["@types/json-schema@7.0.15", "", {}, "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA=="], "@types/json-schema": ["@types/json-schema@7.0.15", "", {}, "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA=="],
"@types/mdast": ["@types/mdast@4.0.4", "", { "dependencies": { "@types/unist": "*" } }, "sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA=="],
"@types/ms": ["@types/ms@2.1.0", "", {}, "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA=="],
"@types/node": ["@types/node@24.10.9", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-ne4A0IpG3+2ETuREInjPNhUGis1SFjv1d5asp8MzEAGtOZeTeHVDOYqOgqfhvseqg/iXty2hjBf1zAOb7RNiNw=="], "@types/node": ["@types/node@24.10.9", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-ne4A0IpG3+2ETuREInjPNhUGis1SFjv1d5asp8MzEAGtOZeTeHVDOYqOgqfhvseqg/iXty2hjBf1zAOb7RNiNw=="],
"@types/react": ["@types/react@19.2.10", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-WPigyYuGhgZ/cTPRXB2EwUw+XvsRA3GqHlsP4qteqrnnjDrApbS7MxcGr/hke5iUoeB7E/gQtrs9I37zAJ0Vjw=="], "@types/react": ["@types/react@19.2.10", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-WPigyYuGhgZ/cTPRXB2EwUw+XvsRA3GqHlsP4qteqrnnjDrApbS7MxcGr/hke5iUoeB7E/gQtrs9I37zAJ0Vjw=="],
"@types/react-dom": ["@types/react-dom@19.2.3", "", { "peerDependencies": { "@types/react": "^19.2.0" } }, "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ=="], "@types/react-dom": ["@types/react-dom@19.2.3", "", { "peerDependencies": { "@types/react": "^19.2.0" } }, "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ=="],
"@types/unist": ["@types/unist@3.0.3", "", {}, "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q=="],
"@typescript-eslint/eslint-plugin": ["@typescript-eslint/eslint-plugin@8.54.0", "", { "dependencies": { "@eslint-community/regexpp": "^4.12.2", "@typescript-eslint/scope-manager": "8.54.0", "@typescript-eslint/type-utils": "8.54.0", "@typescript-eslint/utils": "8.54.0", "@typescript-eslint/visitor-keys": "8.54.0", "ignore": "^7.0.5", "natural-compare": "^1.4.0", "ts-api-utils": "^2.4.0" }, "peerDependencies": { "@typescript-eslint/parser": "^8.54.0", "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "sha512-hAAP5io/7csFStuOmR782YmTthKBJ9ND3WVL60hcOjvtGFb+HJxH4O5huAcmcZ9v9G8P+JETiZ/G1B8MALnWZQ=="], "@typescript-eslint/eslint-plugin": ["@typescript-eslint/eslint-plugin@8.54.0", "", { "dependencies": { "@eslint-community/regexpp": "^4.12.2", "@typescript-eslint/scope-manager": "8.54.0", "@typescript-eslint/type-utils": "8.54.0", "@typescript-eslint/utils": "8.54.0", "@typescript-eslint/visitor-keys": "8.54.0", "ignore": "^7.0.5", "natural-compare": "^1.4.0", "ts-api-utils": "^2.4.0" }, "peerDependencies": { "@typescript-eslint/parser": "^8.54.0", "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "sha512-hAAP5io/7csFStuOmR782YmTthKBJ9ND3WVL60hcOjvtGFb+HJxH4O5huAcmcZ9v9G8P+JETiZ/G1B8MALnWZQ=="],
"@typescript-eslint/parser": ["@typescript-eslint/parser@8.54.0", "", { "dependencies": { "@typescript-eslint/scope-manager": "8.54.0", "@typescript-eslint/types": "8.54.0", "@typescript-eslint/typescript-estree": "8.54.0", "@typescript-eslint/visitor-keys": "8.54.0", "debug": "^4.4.3" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "sha512-BtE0k6cjwjLZoZixN0t5AKP0kSzlGu7FctRXYuPAm//aaiZhmfq1JwdYpYr1brzEspYyFeF+8XF5j2VK6oalrA=="], "@typescript-eslint/parser": ["@typescript-eslint/parser@8.54.0", "", { "dependencies": { "@typescript-eslint/scope-manager": "8.54.0", "@typescript-eslint/types": "8.54.0", "@typescript-eslint/typescript-estree": "8.54.0", "@typescript-eslint/visitor-keys": "8.54.0", "debug": "^4.4.3" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "sha512-BtE0k6cjwjLZoZixN0t5AKP0kSzlGu7FctRXYuPAm//aaiZhmfq1JwdYpYr1brzEspYyFeF+8XF5j2VK6oalrA=="],
@@ -289,6 +303,8 @@
"@typescript-eslint/visitor-keys": ["@typescript-eslint/visitor-keys@8.54.0", "", { "dependencies": { "@typescript-eslint/types": "8.54.0", "eslint-visitor-keys": "^4.2.1" } }, "sha512-VFlhGSl4opC0bprJiItPQ1RfUhGDIBokcPwaFH4yiBCaNPeld/9VeXbiPO1cLyorQi1G1vL+ecBk1x8o1axORA=="], "@typescript-eslint/visitor-keys": ["@typescript-eslint/visitor-keys@8.54.0", "", { "dependencies": { "@typescript-eslint/types": "8.54.0", "eslint-visitor-keys": "^4.2.1" } }, "sha512-VFlhGSl4opC0bprJiItPQ1RfUhGDIBokcPwaFH4yiBCaNPeld/9VeXbiPO1cLyorQi1G1vL+ecBk1x8o1axORA=="],
"@ungap/structured-clone": ["@ungap/structured-clone@1.3.0", "", {}, "sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g=="],
"@vitejs/plugin-react": ["@vitejs/plugin-react@5.1.2", "", { "dependencies": { "@babel/core": "^7.28.5", "@babel/plugin-transform-react-jsx-self": "^7.27.1", "@babel/plugin-transform-react-jsx-source": "^7.27.1", "@rolldown/pluginutils": "1.0.0-beta.53", "@types/babel__core": "^7.20.5", "react-refresh": "^0.18.0" }, "peerDependencies": { "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" } }, "sha512-EcA07pHJouywpzsoTUqNh5NwGayl2PPVEJKUSinGGSxFGYn+shYbqMGBg6FXDqgXum9Ou/ecb+411ssw8HImJQ=="], "@vitejs/plugin-react": ["@vitejs/plugin-react@5.1.2", "", { "dependencies": { "@babel/core": "^7.28.5", "@babel/plugin-transform-react-jsx-self": "^7.27.1", "@babel/plugin-transform-react-jsx-source": "^7.27.1", "@rolldown/pluginutils": "1.0.0-beta.53", "@types/babel__core": "^7.20.5", "react-refresh": "^0.18.0" }, "peerDependencies": { "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" } }, "sha512-EcA07pHJouywpzsoTUqNh5NwGayl2PPVEJKUSinGGSxFGYn+shYbqMGBg6FXDqgXum9Ou/ecb+411ssw8HImJQ=="],
"acorn": ["acorn@8.15.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg=="], "acorn": ["acorn@8.15.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg=="],
@@ -301,6 +317,8 @@
"argparse": ["argparse@2.0.1", "", {}, "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="], "argparse": ["argparse@2.0.1", "", {}, "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="],
"bail": ["bail@2.0.2", "", {}, "sha512-0xO6mYd7JB2YesxDKplafRpsiOzPt9V02ddPCLbY1xYGPOX24NTyN50qnUxgCPcSoYMhKpAuBTjQoRZCAkUDRw=="],
"balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="], "balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="],
"baseline-browser-mapping": ["baseline-browser-mapping@2.9.19", "", { "bin": { "baseline-browser-mapping": "dist/cli.js" } }, "sha512-ipDqC8FrAl/76p2SSWKSI+H9tFwm7vYqXQrItCuiVPt26Km0jS+NzSsBWAaBusvSbQcfJG+JitdMm+wZAgTYqg=="], "baseline-browser-mapping": ["baseline-browser-mapping@2.9.19", "", { "bin": { "baseline-browser-mapping": "dist/cli.js" } }, "sha512-ipDqC8FrAl/76p2SSWKSI+H9tFwm7vYqXQrItCuiVPt26Km0jS+NzSsBWAaBusvSbQcfJG+JitdMm+wZAgTYqg=="],
@@ -317,12 +335,24 @@
"caniuse-lite": ["caniuse-lite@1.0.30001766", "", {}, "sha512-4C0lfJ0/YPjJQHagaE9x2Elb69CIqEPZeG0anQt9SIvIoOH4a4uaRl73IavyO+0qZh6MDLH//DrXThEYKHkmYA=="], "caniuse-lite": ["caniuse-lite@1.0.30001766", "", {}, "sha512-4C0lfJ0/YPjJQHagaE9x2Elb69CIqEPZeG0anQt9SIvIoOH4a4uaRl73IavyO+0qZh6MDLH//DrXThEYKHkmYA=="],
"ccount": ["ccount@2.0.1", "", {}, "sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg=="],
"chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="], "chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="],
"character-entities": ["character-entities@2.0.2", "", {}, "sha512-shx7oQ0Awen/BRIdkjkvz54PnEEI/EjwXDSIZp86/KKdbafHh1Df/RYGBhn4hbe2+uKC9FnT5UCEdyPz3ai9hQ=="],
"character-entities-html4": ["character-entities-html4@2.1.0", "", {}, "sha512-1v7fgQRj6hnSwFpq1Eu0ynr/CDEw0rXo2B61qXrLNdHZmPKgb7fqS1a2JwF0rISo9q77jDI8VMEHoApn8qDoZA=="],
"character-entities-legacy": ["character-entities-legacy@3.0.0", "", {}, "sha512-RpPp0asT/6ufRm//AJVwpViZbGM/MkjQFxJccQRHmISF/22NBtsHqAWmL+/pmkPWoIUJdWyeVleTl1wydHATVQ=="],
"character-reference-invalid": ["character-reference-invalid@2.0.1", "", {}, "sha512-iBZ4F4wRbyORVsu0jPV7gXkOsGYjGHPmAyv+HiHG8gi5PtC9KI2j1+v8/tlibRvjoWX027ypmG/n0HtO5t7unw=="],
"color-convert": ["color-convert@2.0.1", "", { "dependencies": { "color-name": "~1.1.4" } }, "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ=="], "color-convert": ["color-convert@2.0.1", "", { "dependencies": { "color-name": "~1.1.4" } }, "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ=="],
"color-name": ["color-name@1.1.4", "", {}, "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="], "color-name": ["color-name@1.1.4", "", {}, "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="],
"comma-separated-tokens": ["comma-separated-tokens@2.0.3", "", {}, "sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg=="],
"concat-map": ["concat-map@0.0.1", "", {}, "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg=="], "concat-map": ["concat-map@0.0.1", "", {}, "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg=="],
"convert-source-map": ["convert-source-map@2.0.0", "", {}, "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg=="], "convert-source-map": ["convert-source-map@2.0.0", "", {}, "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg=="],
@@ -335,12 +365,18 @@
"debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="], "debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="],
"decode-named-character-reference": ["decode-named-character-reference@1.3.0", "", { "dependencies": { "character-entities": "^2.0.0" } }, "sha512-GtpQYB283KrPp6nRw50q3U9/VfOutZOe103qlN7BPP6Ad27xYnOIWv4lPzo8HCAL+mMZofJ9KEy30fq6MfaK6Q=="],
"deep-is": ["deep-is@0.1.4", "", {}, "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ=="], "deep-is": ["deep-is@0.1.4", "", {}, "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ=="],
"defu": ["defu@6.1.4", "", {}, "sha512-mEQCMmwJu317oSz8CwdIOdwf3xMif1ttiM8LTufzc3g6kR+9Pe236twL8j3IYT1F7GfRgGcW6MWxzZjLIkuHIg=="], "defu": ["defu@6.1.4", "", {}, "sha512-mEQCMmwJu317oSz8CwdIOdwf3xMif1ttiM8LTufzc3g6kR+9Pe236twL8j3IYT1F7GfRgGcW6MWxzZjLIkuHIg=="],
"dequal": ["dequal@2.0.3", "", {}, "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA=="],
"detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="], "detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="],
"devlop": ["devlop@1.1.0", "", { "dependencies": { "dequal": "^2.0.0" } }, "sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA=="],
"electron-to-chromium": ["electron-to-chromium@1.5.279", "", {}, "sha512-0bblUU5UNdOt5G7XqGiJtpZMONma6WAfq9vsFmtn9x1+joAObr6x1chfqyxFSDCAFwFhCQDrqeAr6MYdpwJ9Hg=="], "electron-to-chromium": ["electron-to-chromium@1.5.279", "", {}, "sha512-0bblUU5UNdOt5G7XqGiJtpZMONma6WAfq9vsFmtn9x1+joAObr6x1chfqyxFSDCAFwFhCQDrqeAr6MYdpwJ9Hg=="],
"enhanced-resolve": ["enhanced-resolve@5.18.4", "", { "dependencies": { "graceful-fs": "^4.2.4", "tapable": "^2.2.0" } }, "sha512-LgQMM4WXU3QI+SYgEc2liRgznaD5ojbmY3sb8LxyguVkIg5FxdpTkvk72te2R38/TGKxH634oLxXRGY6d7AP+Q=="], "enhanced-resolve": ["enhanced-resolve@5.18.4", "", { "dependencies": { "graceful-fs": "^4.2.4", "tapable": "^2.2.0" } }, "sha512-LgQMM4WXU3QI+SYgEc2liRgznaD5ojbmY3sb8LxyguVkIg5FxdpTkvk72te2R38/TGKxH634oLxXRGY6d7AP+Q=="],
@@ -369,8 +405,12 @@
"estraverse": ["estraverse@5.3.0", "", {}, "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA=="], "estraverse": ["estraverse@5.3.0", "", {}, "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA=="],
"estree-util-is-identifier-name": ["estree-util-is-identifier-name@3.0.0", "", {}, "sha512-hFtqIDZTIUZ9BXLb8y4pYGyk6+wekIivNVTcmvk8NoOh+VeRn5y6cEHzbURrWbfp1fIqdVipilzj+lfaadNZmg=="],
"esutils": ["esutils@2.0.3", "", {}, "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g=="], "esutils": ["esutils@2.0.3", "", {}, "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g=="],
"extend": ["extend@3.0.2", "", {}, "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g=="],
"fast-deep-equal": ["fast-deep-equal@3.1.3", "", {}, "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="], "fast-deep-equal": ["fast-deep-equal@3.1.3", "", {}, "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="],
"fast-json-stable-stringify": ["fast-json-stable-stringify@2.1.0", "", {}, "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw=="], "fast-json-stable-stringify": ["fast-json-stable-stringify@2.1.0", "", {}, "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw=="],
@@ -399,20 +439,38 @@
"has-flag": ["has-flag@4.0.0", "", {}, "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ=="], "has-flag": ["has-flag@4.0.0", "", {}, "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ=="],
"hast-util-to-jsx-runtime": ["hast-util-to-jsx-runtime@2.3.6", "", { "dependencies": { "@types/estree": "^1.0.0", "@types/hast": "^3.0.0", "@types/unist": "^3.0.0", "comma-separated-tokens": "^2.0.0", "devlop": "^1.0.0", "estree-util-is-identifier-name": "^3.0.0", "hast-util-whitespace": "^3.0.0", "mdast-util-mdx-expression": "^2.0.0", "mdast-util-mdx-jsx": "^3.0.0", "mdast-util-mdxjs-esm": "^2.0.0", "property-information": "^7.0.0", "space-separated-tokens": "^2.0.0", "style-to-js": "^1.0.0", "unist-util-position": "^5.0.0", "vfile-message": "^4.0.0" } }, "sha512-zl6s8LwNyo1P9uw+XJGvZtdFF1GdAkOg8ujOw+4Pyb76874fLps4ueHXDhXWdk6YHQ6OgUtinliG7RsYvCbbBg=="],
"hast-util-whitespace": ["hast-util-whitespace@3.0.0", "", { "dependencies": { "@types/hast": "^3.0.0" } }, "sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw=="],
"hermes-estree": ["hermes-estree@0.25.1", "", {}, "sha512-0wUoCcLp+5Ev5pDW2OriHC2MJCbwLwuRx+gAqMTOkGKJJiBCLjtrvy4PWUGn6MIVefecRpzoOZ/UV6iGdOr+Cw=="], "hermes-estree": ["hermes-estree@0.25.1", "", {}, "sha512-0wUoCcLp+5Ev5pDW2OriHC2MJCbwLwuRx+gAqMTOkGKJJiBCLjtrvy4PWUGn6MIVefecRpzoOZ/UV6iGdOr+Cw=="],
"hermes-parser": ["hermes-parser@0.25.1", "", { "dependencies": { "hermes-estree": "0.25.1" } }, "sha512-6pEjquH3rqaI6cYAXYPcz9MS4rY6R4ngRgrgfDshRptUZIc3lw0MCIJIGDj9++mfySOuPTHB4nrSW99BCvOPIA=="], "hermes-parser": ["hermes-parser@0.25.1", "", { "dependencies": { "hermes-estree": "0.25.1" } }, "sha512-6pEjquH3rqaI6cYAXYPcz9MS4rY6R4ngRgrgfDshRptUZIc3lw0MCIJIGDj9++mfySOuPTHB4nrSW99BCvOPIA=="],
"html-url-attributes": ["html-url-attributes@3.0.1", "", {}, "sha512-ol6UPyBWqsrO6EJySPz2O7ZSr856WDrEzM5zMqp+FJJLGMW35cLYmmZnl0vztAZxRUoNZJFTCohfjuIJ8I4QBQ=="],
"ignore": ["ignore@5.3.2", "", {}, "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g=="], "ignore": ["ignore@5.3.2", "", {}, "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g=="],
"import-fresh": ["import-fresh@3.3.1", "", { "dependencies": { "parent-module": "^1.0.0", "resolve-from": "^4.0.0" } }, "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ=="], "import-fresh": ["import-fresh@3.3.1", "", { "dependencies": { "parent-module": "^1.0.0", "resolve-from": "^4.0.0" } }, "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ=="],
"imurmurhash": ["imurmurhash@0.1.4", "", {}, "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA=="], "imurmurhash": ["imurmurhash@0.1.4", "", {}, "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA=="],
"inline-style-parser": ["inline-style-parser@0.2.7", "", {}, "sha512-Nb2ctOyNR8DqQoR0OwRG95uNWIC0C1lCgf5Naz5H6Ji72KZ8OcFZLz2P5sNgwlyoJ8Yif11oMuYs5pBQa86csA=="],
"is-alphabetical": ["is-alphabetical@2.0.1", "", {}, "sha512-FWyyY60MeTNyeSRpkM2Iry0G9hpr7/9kD40mD/cGQEuilcZYS4okz8SN2Q6rLCJ8gbCt6fN+rC+6tMGS99LaxQ=="],
"is-alphanumerical": ["is-alphanumerical@2.0.1", "", { "dependencies": { "is-alphabetical": "^2.0.0", "is-decimal": "^2.0.0" } }, "sha512-hmbYhX/9MUMF5uh7tOXyK/n0ZvWpad5caBA17GsC6vyuCqaWliRG5K1qS9inmUhEMaOBIW7/whAnSwveW/LtZw=="],
"is-decimal": ["is-decimal@2.0.1", "", {}, "sha512-AAB9hiomQs5DXWcRB1rqsxGUstbRroFOPPVAomNk/3XHR5JyEZChOyTWe2oayKnsSsr/kcGqF+z6yuH6HHpN0A=="],
"is-extglob": ["is-extglob@2.1.1", "", {}, "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ=="], "is-extglob": ["is-extglob@2.1.1", "", {}, "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ=="],
"is-glob": ["is-glob@4.0.3", "", { "dependencies": { "is-extglob": "^2.1.1" } }, "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg=="], "is-glob": ["is-glob@4.0.3", "", { "dependencies": { "is-extglob": "^2.1.1" } }, "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg=="],
"is-hexadecimal": ["is-hexadecimal@2.0.1", "", {}, "sha512-DgZQp241c8oO6cA1SbTEWiXeoxV42vlcJxgH+B3hi1AiqqKruZR3ZGF8In3fj4+/y/7rHvlOZLZtgJ/4ttYGZg=="],
"is-plain-obj": ["is-plain-obj@4.1.0", "", {}, "sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg=="],
"isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="], "isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="],
"jiti": ["jiti@2.6.1", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ=="], "jiti": ["jiti@2.6.1", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ=="],
@@ -467,10 +525,100 @@
"lodash.merge": ["lodash.merge@4.6.2", "", {}, "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ=="], "lodash.merge": ["lodash.merge@4.6.2", "", {}, "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ=="],
"longest-streak": ["longest-streak@3.1.0", "", {}, "sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g=="],
"lru-cache": ["lru-cache@5.1.1", "", { "dependencies": { "yallist": "^3.0.2" } }, "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w=="], "lru-cache": ["lru-cache@5.1.1", "", { "dependencies": { "yallist": "^3.0.2" } }, "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w=="],
"magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="], "magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="],
"markdown-table": ["markdown-table@3.0.4", "", {}, "sha512-wiYz4+JrLyb/DqW2hkFJxP7Vd7JuTDm77fvbM8VfEQdmSMqcImWeeRbHwZjBjIFki/VaMK2BhFi7oUUZeM5bqw=="],
"mdast-util-find-and-replace": ["mdast-util-find-and-replace@3.0.2", "", { "dependencies": { "@types/mdast": "^4.0.0", "escape-string-regexp": "^5.0.0", "unist-util-is": "^6.0.0", "unist-util-visit-parents": "^6.0.0" } }, "sha512-Tmd1Vg/m3Xz43afeNxDIhWRtFZgM2VLyaf4vSTYwudTyeuTneoL3qtWMA5jeLyz/O1vDJmmV4QuScFCA2tBPwg=="],
"mdast-util-from-markdown": ["mdast-util-from-markdown@2.0.2", "", { "dependencies": { "@types/mdast": "^4.0.0", "@types/unist": "^3.0.0", "decode-named-character-reference": "^1.0.0", "devlop": "^1.0.0", "mdast-util-to-string": "^4.0.0", "micromark": "^4.0.0", "micromark-util-decode-numeric-character-reference": "^2.0.0", "micromark-util-decode-string": "^2.0.0", "micromark-util-normalize-identifier": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0", "unist-util-stringify-position": "^4.0.0" } }, "sha512-uZhTV/8NBuw0WHkPTrCqDOl0zVe1BIng5ZtHoDk49ME1qqcjYmmLmOf0gELgcRMxN4w2iuIeVso5/6QymSrgmA=="],
"mdast-util-gfm": ["mdast-util-gfm@3.1.0", "", { "dependencies": { "mdast-util-from-markdown": "^2.0.0", "mdast-util-gfm-autolink-literal": "^2.0.0", "mdast-util-gfm-footnote": "^2.0.0", "mdast-util-gfm-strikethrough": "^2.0.0", "mdast-util-gfm-table": "^2.0.0", "mdast-util-gfm-task-list-item": "^2.0.0", "mdast-util-to-markdown": "^2.0.0" } }, "sha512-0ulfdQOM3ysHhCJ1p06l0b0VKlhU0wuQs3thxZQagjcjPrlFRqY215uZGHHJan9GEAXd9MbfPjFJz+qMkVR6zQ=="],
"mdast-util-gfm-autolink-literal": ["mdast-util-gfm-autolink-literal@2.0.1", "", { "dependencies": { "@types/mdast": "^4.0.0", "ccount": "^2.0.0", "devlop": "^1.0.0", "mdast-util-find-and-replace": "^3.0.0", "micromark-util-character": "^2.0.0" } }, "sha512-5HVP2MKaP6L+G6YaxPNjuL0BPrq9orG3TsrZ9YXbA3vDw/ACI4MEsnoDpn6ZNm7GnZgtAcONJyPhOP8tNJQavQ=="],
"mdast-util-gfm-footnote": ["mdast-util-gfm-footnote@2.1.0", "", { "dependencies": { "@types/mdast": "^4.0.0", "devlop": "^1.1.0", "mdast-util-from-markdown": "^2.0.0", "mdast-util-to-markdown": "^2.0.0", "micromark-util-normalize-identifier": "^2.0.0" } }, "sha512-sqpDWlsHn7Ac9GNZQMeUzPQSMzR6Wv0WKRNvQRg0KqHh02fpTz69Qc1QSseNX29bhz1ROIyNyxExfawVKTm1GQ=="],
"mdast-util-gfm-strikethrough": ["mdast-util-gfm-strikethrough@2.0.0", "", { "dependencies": { "@types/mdast": "^4.0.0", "mdast-util-from-markdown": "^2.0.0", "mdast-util-to-markdown": "^2.0.0" } }, "sha512-mKKb915TF+OC5ptj5bJ7WFRPdYtuHv0yTRxK2tJvi+BDqbkiG7h7u/9SI89nRAYcmap2xHQL9D+QG/6wSrTtXg=="],
"mdast-util-gfm-table": ["mdast-util-gfm-table@2.0.0", "", { "dependencies": { "@types/mdast": "^4.0.0", "devlop": "^1.0.0", "markdown-table": "^3.0.0", "mdast-util-from-markdown": "^2.0.0", "mdast-util-to-markdown": "^2.0.0" } }, "sha512-78UEvebzz/rJIxLvE7ZtDd/vIQ0RHv+3Mh5DR96p7cS7HsBhYIICDBCu8csTNWNO6tBWfqXPWekRuj2FNOGOZg=="],
"mdast-util-gfm-task-list-item": ["mdast-util-gfm-task-list-item@2.0.0", "", { "dependencies": { "@types/mdast": "^4.0.0", "devlop": "^1.0.0", "mdast-util-from-markdown": "^2.0.0", "mdast-util-to-markdown": "^2.0.0" } }, "sha512-IrtvNvjxC1o06taBAVJznEnkiHxLFTzgonUdy8hzFVeDun0uTjxxrRGVaNFqkU1wJR3RBPEfsxmU6jDWPofrTQ=="],
"mdast-util-mdx-expression": ["mdast-util-mdx-expression@2.0.1", "", { "dependencies": { "@types/estree-jsx": "^1.0.0", "@types/hast": "^3.0.0", "@types/mdast": "^4.0.0", "devlop": "^1.0.0", "mdast-util-from-markdown": "^2.0.0", "mdast-util-to-markdown": "^2.0.0" } }, "sha512-J6f+9hUp+ldTZqKRSg7Vw5V6MqjATc+3E4gf3CFNcuZNWD8XdyI6zQ8GqH7f8169MM6P7hMBRDVGnn7oHB9kXQ=="],
"mdast-util-mdx-jsx": ["mdast-util-mdx-jsx@3.2.0", "", { "dependencies": { "@types/estree-jsx": "^1.0.0", "@types/hast": "^3.0.0", "@types/mdast": "^4.0.0", "@types/unist": "^3.0.0", "ccount": "^2.0.0", "devlop": "^1.1.0", "mdast-util-from-markdown": "^2.0.0", "mdast-util-to-markdown": "^2.0.0", "parse-entities": "^4.0.0", "stringify-entities": "^4.0.0", "unist-util-stringify-position": "^4.0.0", "vfile-message": "^4.0.0" } }, "sha512-lj/z8v0r6ZtsN/cGNNtemmmfoLAFZnjMbNyLzBafjzikOM+glrjNHPlf6lQDOTccj9n5b0PPihEBbhneMyGs1Q=="],
"mdast-util-mdxjs-esm": ["mdast-util-mdxjs-esm@2.0.1", "", { "dependencies": { "@types/estree-jsx": "^1.0.0", "@types/hast": "^3.0.0", "@types/mdast": "^4.0.0", "devlop": "^1.0.0", "mdast-util-from-markdown": "^2.0.0", "mdast-util-to-markdown": "^2.0.0" } }, "sha512-EcmOpxsZ96CvlP03NghtH1EsLtr0n9Tm4lPUJUBccV9RwUOneqSycg19n5HGzCf+10LozMRSObtVr3ee1WoHtg=="],
"mdast-util-phrasing": ["mdast-util-phrasing@4.1.0", "", { "dependencies": { "@types/mdast": "^4.0.0", "unist-util-is": "^6.0.0" } }, "sha512-TqICwyvJJpBwvGAMZjj4J2n0X8QWp21b9l0o7eXyVJ25YNWYbJDVIyD1bZXE6WtV6RmKJVYmQAKWa0zWOABz2w=="],
"mdast-util-to-hast": ["mdast-util-to-hast@13.2.1", "", { "dependencies": { "@types/hast": "^3.0.0", "@types/mdast": "^4.0.0", "@ungap/structured-clone": "^1.0.0", "devlop": "^1.0.0", "micromark-util-sanitize-uri": "^2.0.0", "trim-lines": "^3.0.0", "unist-util-position": "^5.0.0", "unist-util-visit": "^5.0.0", "vfile": "^6.0.0" } }, "sha512-cctsq2wp5vTsLIcaymblUriiTcZd0CwWtCbLvrOzYCDZoWyMNV8sZ7krj09FSnsiJi3WVsHLM4k6Dq/yaPyCXA=="],
"mdast-util-to-markdown": ["mdast-util-to-markdown@2.1.2", "", { "dependencies": { "@types/mdast": "^4.0.0", "@types/unist": "^3.0.0", "longest-streak": "^3.0.0", "mdast-util-phrasing": "^4.0.0", "mdast-util-to-string": "^4.0.0", "micromark-util-classify-character": "^2.0.0", "micromark-util-decode-string": "^2.0.0", "unist-util-visit": "^5.0.0", "zwitch": "^2.0.0" } }, "sha512-xj68wMTvGXVOKonmog6LwyJKrYXZPvlwabaryTjLh9LuvovB/KAH+kvi8Gjj+7rJjsFi23nkUxRQv1KqSroMqA=="],
"mdast-util-to-string": ["mdast-util-to-string@4.0.0", "", { "dependencies": { "@types/mdast": "^4.0.0" } }, "sha512-0H44vDimn51F0YwvxSJSm0eCDOJTRlmN0R1yBh4HLj9wiV1Dn0QoXGbvFAWj2hSItVTlCmBF1hqKlIyUBVFLPg=="],
"micromark": ["micromark@4.0.2", "", { "dependencies": { "@types/debug": "^4.0.0", "debug": "^4.0.0", "decode-named-character-reference": "^1.0.0", "devlop": "^1.0.0", "micromark-core-commonmark": "^2.0.0", "micromark-factory-space": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-chunked": "^2.0.0", "micromark-util-combine-extensions": "^2.0.0", "micromark-util-decode-numeric-character-reference": "^2.0.0", "micromark-util-encode": "^2.0.0", "micromark-util-normalize-identifier": "^2.0.0", "micromark-util-resolve-all": "^2.0.0", "micromark-util-sanitize-uri": "^2.0.0", "micromark-util-subtokenize": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-zpe98Q6kvavpCr1NPVSCMebCKfD7CA2NqZ+rykeNhONIJBpc1tFKt9hucLGwha3jNTNI8lHpctWJWoimVF4PfA=="],
"micromark-core-commonmark": ["micromark-core-commonmark@2.0.3", "", { "dependencies": { "decode-named-character-reference": "^1.0.0", "devlop": "^1.0.0", "micromark-factory-destination": "^2.0.0", "micromark-factory-label": "^2.0.0", "micromark-factory-space": "^2.0.0", "micromark-factory-title": "^2.0.0", "micromark-factory-whitespace": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-chunked": "^2.0.0", "micromark-util-classify-character": "^2.0.0", "micromark-util-html-tag-name": "^2.0.0", "micromark-util-normalize-identifier": "^2.0.0", "micromark-util-resolve-all": "^2.0.0", "micromark-util-subtokenize": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-RDBrHEMSxVFLg6xvnXmb1Ayr2WzLAWjeSATAoxwKYJV94TeNavgoIdA0a9ytzDSVzBy2YKFK+emCPOEibLeCrg=="],
"micromark-extension-gfm": ["micromark-extension-gfm@3.0.0", "", { "dependencies": { "micromark-extension-gfm-autolink-literal": "^2.0.0", "micromark-extension-gfm-footnote": "^2.0.0", "micromark-extension-gfm-strikethrough": "^2.0.0", "micromark-extension-gfm-table": "^2.0.0", "micromark-extension-gfm-tagfilter": "^2.0.0", "micromark-extension-gfm-task-list-item": "^2.0.0", "micromark-util-combine-extensions": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-vsKArQsicm7t0z2GugkCKtZehqUm31oeGBV/KVSorWSy8ZlNAv7ytjFhvaryUiCUJYqs+NoE6AFhpQvBTM6Q4w=="],
"micromark-extension-gfm-autolink-literal": ["micromark-extension-gfm-autolink-literal@2.1.0", "", { "dependencies": { "micromark-util-character": "^2.0.0", "micromark-util-sanitize-uri": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-oOg7knzhicgQ3t4QCjCWgTmfNhvQbDDnJeVu9v81r7NltNCVmhPy1fJRX27pISafdjL+SVc4d3l48Gb6pbRypw=="],
"micromark-extension-gfm-footnote": ["micromark-extension-gfm-footnote@2.1.0", "", { "dependencies": { "devlop": "^1.0.0", "micromark-core-commonmark": "^2.0.0", "micromark-factory-space": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-normalize-identifier": "^2.0.0", "micromark-util-sanitize-uri": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-/yPhxI1ntnDNsiHtzLKYnE3vf9JZ6cAisqVDauhp4CEHxlb4uoOTxOCJ+9s51bIB8U1N1FJ1RXOKTIlD5B/gqw=="],
"micromark-extension-gfm-strikethrough": ["micromark-extension-gfm-strikethrough@2.1.0", "", { "dependencies": { "devlop": "^1.0.0", "micromark-util-chunked": "^2.0.0", "micromark-util-classify-character": "^2.0.0", "micromark-util-resolve-all": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-ADVjpOOkjz1hhkZLlBiYA9cR2Anf8F4HqZUO6e5eDcPQd0Txw5fxLzzxnEkSkfnD0wziSGiv7sYhk/ktvbf1uw=="],
"micromark-extension-gfm-table": ["micromark-extension-gfm-table@2.1.1", "", { "dependencies": { "devlop": "^1.0.0", "micromark-factory-space": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-t2OU/dXXioARrC6yWfJ4hqB7rct14e8f7m0cbI5hUmDyyIlwv5vEtooptH8INkbLzOatzKuVbQmAYcbWoyz6Dg=="],
"micromark-extension-gfm-tagfilter": ["micromark-extension-gfm-tagfilter@2.0.0", "", { "dependencies": { "micromark-util-types": "^2.0.0" } }, "sha512-xHlTOmuCSotIA8TW1mDIM6X2O1SiX5P9IuDtqGonFhEK0qgRI4yeC6vMxEV2dgyr2TiD+2PQ10o+cOhdVAcwfg=="],
"micromark-extension-gfm-task-list-item": ["micromark-extension-gfm-task-list-item@2.1.0", "", { "dependencies": { "devlop": "^1.0.0", "micromark-factory-space": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-qIBZhqxqI6fjLDYFTBIa4eivDMnP+OZqsNwmQ3xNLE4Cxwc+zfQEfbs6tzAo2Hjq+bh6q5F+Z8/cksrLFYWQQw=="],
"micromark-factory-destination": ["micromark-factory-destination@2.0.1", "", { "dependencies": { "micromark-util-character": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-Xe6rDdJlkmbFRExpTOmRj9N3MaWmbAgdpSrBQvCFqhezUn4AHqJHbaEnfbVYYiexVSs//tqOdY/DxhjdCiJnIA=="],
"micromark-factory-label": ["micromark-factory-label@2.0.1", "", { "dependencies": { "devlop": "^1.0.0", "micromark-util-character": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-VFMekyQExqIW7xIChcXn4ok29YE3rnuyveW3wZQWWqF4Nv9Wk5rgJ99KzPvHjkmPXF93FXIbBp6YdW3t71/7Vg=="],
"micromark-factory-space": ["micromark-factory-space@2.0.1", "", { "dependencies": { "micromark-util-character": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-zRkxjtBxxLd2Sc0d+fbnEunsTj46SWXgXciZmHq0kDYGnck/ZSGj9/wULTV95uoeYiK5hRXP2mJ98Uo4cq/LQg=="],
"micromark-factory-title": ["micromark-factory-title@2.0.1", "", { "dependencies": { "micromark-factory-space": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-5bZ+3CjhAd9eChYTHsjy6TGxpOFSKgKKJPJxr293jTbfry2KDoWkhBb6TcPVB4NmzaPhMs1Frm9AZH7OD4Cjzw=="],
"micromark-factory-whitespace": ["micromark-factory-whitespace@2.0.1", "", { "dependencies": { "micromark-factory-space": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-Ob0nuZ3PKt/n0hORHyvoD9uZhr+Za8sFoP+OnMcnWK5lngSzALgQYKMr9RJVOWLqQYuyn6ulqGWSXdwf6F80lQ=="],
"micromark-util-character": ["micromark-util-character@2.1.1", "", { "dependencies": { "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-wv8tdUTJ3thSFFFJKtpYKOYiGP2+v96Hvk4Tu8KpCAsTMs6yi+nVmGh1syvSCsaxz45J6Jbw+9DD6g97+NV67Q=="],
"micromark-util-chunked": ["micromark-util-chunked@2.0.1", "", { "dependencies": { "micromark-util-symbol": "^2.0.0" } }, "sha512-QUNFEOPELfmvv+4xiNg2sRYeS/P84pTW0TCgP5zc9FpXetHY0ab7SxKyAQCNCc1eK0459uoLI1y5oO5Vc1dbhA=="],
"micromark-util-classify-character": ["micromark-util-classify-character@2.0.1", "", { "dependencies": { "micromark-util-character": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-K0kHzM6afW/MbeWYWLjoHQv1sgg2Q9EccHEDzSkxiP/EaagNzCm7T/WMKZ3rjMbvIpvBiZgwR3dKMygtA4mG1Q=="],
"micromark-util-combine-extensions": ["micromark-util-combine-extensions@2.0.1", "", { "dependencies": { "micromark-util-chunked": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-OnAnH8Ujmy59JcyZw8JSbK9cGpdVY44NKgSM7E9Eh7DiLS2E9RNQf0dONaGDzEG9yjEl5hcqeIsj4hfRkLH/Bg=="],
"micromark-util-decode-numeric-character-reference": ["micromark-util-decode-numeric-character-reference@2.0.2", "", { "dependencies": { "micromark-util-symbol": "^2.0.0" } }, "sha512-ccUbYk6CwVdkmCQMyr64dXz42EfHGkPQlBj5p7YVGzq8I7CtjXZJrubAYezf7Rp+bjPseiROqe7G6foFd+lEuw=="],
"micromark-util-decode-string": ["micromark-util-decode-string@2.0.1", "", { "dependencies": { "decode-named-character-reference": "^1.0.0", "micromark-util-character": "^2.0.0", "micromark-util-decode-numeric-character-reference": "^2.0.0", "micromark-util-symbol": "^2.0.0" } }, "sha512-nDV/77Fj6eH1ynwscYTOsbK7rR//Uj0bZXBwJZRfaLEJ1iGBR6kIfNmlNqaqJf649EP0F3NWNdeJi03elllNUQ=="],
"micromark-util-encode": ["micromark-util-encode@2.0.1", "", {}, "sha512-c3cVx2y4KqUnwopcO9b/SCdo2O67LwJJ/UyqGfbigahfegL9myoEFoDYZgkT7f36T0bLrM9hZTAaAyH+PCAXjw=="],
"micromark-util-html-tag-name": ["micromark-util-html-tag-name@2.0.1", "", {}, "sha512-2cNEiYDhCWKI+Gs9T0Tiysk136SnR13hhO8yW6BGNyhOC4qYFnwF1nKfD3HFAIXA5c45RrIG1ub11GiXeYd1xA=="],
"micromark-util-normalize-identifier": ["micromark-util-normalize-identifier@2.0.1", "", { "dependencies": { "micromark-util-symbol": "^2.0.0" } }, "sha512-sxPqmo70LyARJs0w2UclACPUUEqltCkJ6PhKdMIDuJ3gSf/Q+/GIe3WKl0Ijb/GyH9lOpUkRAO2wp0GVkLvS9Q=="],
"micromark-util-resolve-all": ["micromark-util-resolve-all@2.0.1", "", { "dependencies": { "micromark-util-types": "^2.0.0" } }, "sha512-VdQyxFWFT2/FGJgwQnJYbe1jjQoNTS4RjglmSjTUlpUMa95Htx9NHeYW4rGDJzbjvCsl9eLjMQwGeElsqmzcHg=="],
"micromark-util-sanitize-uri": ["micromark-util-sanitize-uri@2.0.1", "", { "dependencies": { "micromark-util-character": "^2.0.0", "micromark-util-encode": "^2.0.0", "micromark-util-symbol": "^2.0.0" } }, "sha512-9N9IomZ/YuGGZZmQec1MbgxtlgougxTodVwDzzEouPKo3qFWvymFHWcnDi2vzV1ff6kas9ucW+o3yzJK9YB1AQ=="],
"micromark-util-subtokenize": ["micromark-util-subtokenize@2.1.0", "", { "dependencies": { "devlop": "^1.0.0", "micromark-util-chunked": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-XQLu552iSctvnEcgXw6+Sx75GflAPNED1qx7eBJ+wydBb2KCbRZe+NwvIEEMM83uml1+2WSXpBAcp9IUCgCYWA=="],
"micromark-util-symbol": ["micromark-util-symbol@2.0.1", "", {}, "sha512-vs5t8Apaud9N28kgCrRUdEed4UJ+wWNvicHLPxCa9ENlYuAY31M0ETy5y1vA33YoNPDFTghEbnh6efaE8h4x0Q=="],
"micromark-util-types": ["micromark-util-types@2.0.2", "", {}, "sha512-Yw0ECSpJoViF1qTU4DC6NwtC4aWGt1EkzaQB8KPPyCRR8z9TWeV0HbEFGTO+ZY1wB22zmxnJqhPyTpOVCpeHTA=="],
"minimatch": ["minimatch@3.1.2", "", { "dependencies": { "brace-expansion": "^1.1.7" } }, "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw=="], "minimatch": ["minimatch@3.1.2", "", { "dependencies": { "brace-expansion": "^1.1.7" } }, "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw=="],
"ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="], "ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],
@@ -491,6 +639,8 @@
"parent-module": ["parent-module@1.0.1", "", { "dependencies": { "callsites": "^3.0.0" } }, "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g=="], "parent-module": ["parent-module@1.0.1", "", { "dependencies": { "callsites": "^3.0.0" } }, "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g=="],
"parse-entities": ["parse-entities@4.0.2", "", { "dependencies": { "@types/unist": "^2.0.0", "character-entities-legacy": "^3.0.0", "character-reference-invalid": "^2.0.0", "decode-named-character-reference": "^1.0.0", "is-alphanumerical": "^2.0.0", "is-decimal": "^2.0.0", "is-hexadecimal": "^2.0.0" } }, "sha512-GG2AQYWoLgL877gQIKeRPGO1xF9+eG1ujIb5soS5gPvLQ1y2o8FL90w2QWNdf9I361Mpp7726c+lj3U0qK1uGw=="],
"path-exists": ["path-exists@4.0.0", "", {}, "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w=="], "path-exists": ["path-exists@4.0.0", "", {}, "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w=="],
"path-key": ["path-key@3.1.1", "", {}, "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q=="], "path-key": ["path-key@3.1.1", "", {}, "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q=="],
@@ -503,18 +653,30 @@
"prelude-ls": ["prelude-ls@1.2.1", "", {}, "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g=="], "prelude-ls": ["prelude-ls@1.2.1", "", {}, "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g=="],
"property-information": ["property-information@7.1.0", "", {}, "sha512-TwEZ+X+yCJmYfL7TPUOcvBZ4QfoT5YenQiJuX//0th53DE6w0xxLEtfK3iyryQFddXuvkIk51EEgrJQ0WJkOmQ=="],
"punycode": ["punycode@2.3.1", "", {}, "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg=="], "punycode": ["punycode@2.3.1", "", {}, "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg=="],
"react": ["react@19.2.4", "", {}, "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ=="], "react": ["react@19.2.4", "", {}, "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ=="],
"react-dom": ["react-dom@19.2.4", "", { "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.4" } }, "sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ=="], "react-dom": ["react-dom@19.2.4", "", { "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.4" } }, "sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ=="],
"react-markdown": ["react-markdown@10.1.0", "", { "dependencies": { "@types/hast": "^3.0.0", "@types/mdast": "^4.0.0", "devlop": "^1.0.0", "hast-util-to-jsx-runtime": "^2.0.0", "html-url-attributes": "^3.0.0", "mdast-util-to-hast": "^13.0.0", "remark-parse": "^11.0.0", "remark-rehype": "^11.0.0", "unified": "^11.0.0", "unist-util-visit": "^5.0.0", "vfile": "^6.0.0" }, "peerDependencies": { "@types/react": ">=18", "react": ">=18" } }, "sha512-qKxVopLT/TyA6BX3Ue5NwabOsAzm0Q7kAPwq6L+wWDwisYs7R8vZ0nRXqq6rkueboxpkjvLGU9fWifiX/ZZFxQ=="],
"react-refresh": ["react-refresh@0.18.0", "", {}, "sha512-QgT5//D3jfjJb6Gsjxv0Slpj23ip+HtOpnNgnb2S5zU3CB26G/IDPGoy4RJB42wzFE46DRsstbW6tKHoKbhAxw=="], "react-refresh": ["react-refresh@0.18.0", "", {}, "sha512-QgT5//D3jfjJb6Gsjxv0Slpj23ip+HtOpnNgnb2S5zU3CB26G/IDPGoy4RJB42wzFE46DRsstbW6tKHoKbhAxw=="],
"react-router": ["react-router@7.13.0", "", { "dependencies": { "cookie": "^1.0.1", "set-cookie-parser": "^2.6.0" }, "peerDependencies": { "react": ">=18", "react-dom": ">=18" }, "optionalPeers": ["react-dom"] }, "sha512-PZgus8ETambRT17BUm/LL8lX3Of+oiLaPuVTRH3l1eLvSPpKO3AvhAEb5N7ihAFZQrYDqkvvWfFh9p0z9VsjLw=="], "react-router": ["react-router@7.13.0", "", { "dependencies": { "cookie": "^1.0.1", "set-cookie-parser": "^2.6.0" }, "peerDependencies": { "react": ">=18", "react-dom": ">=18" }, "optionalPeers": ["react-dom"] }, "sha512-PZgus8ETambRT17BUm/LL8lX3Of+oiLaPuVTRH3l1eLvSPpKO3AvhAEb5N7ihAFZQrYDqkvvWfFh9p0z9VsjLw=="],
"react-router-dom": ["react-router-dom@7.13.0", "", { "dependencies": { "react-router": "7.13.0" }, "peerDependencies": { "react": ">=18", "react-dom": ">=18" } }, "sha512-5CO/l5Yahi2SKC6rGZ+HDEjpjkGaG/ncEP7eWFTvFxbHP8yeeI0PxTDjimtpXYlR3b3i9/WIL4VJttPrESIf2g=="], "react-router-dom": ["react-router-dom@7.13.0", "", { "dependencies": { "react-router": "7.13.0" }, "peerDependencies": { "react": ">=18", "react-dom": ">=18" } }, "sha512-5CO/l5Yahi2SKC6rGZ+HDEjpjkGaG/ncEP7eWFTvFxbHP8yeeI0PxTDjimtpXYlR3b3i9/WIL4VJttPrESIf2g=="],
"remark-gfm": ["remark-gfm@4.0.1", "", { "dependencies": { "@types/mdast": "^4.0.0", "mdast-util-gfm": "^3.0.0", "micromark-extension-gfm": "^3.0.0", "remark-parse": "^11.0.0", "remark-stringify": "^11.0.0", "unified": "^11.0.0" } }, "sha512-1quofZ2RQ9EWdeN34S79+KExV1764+wCUGop5CPL1WGdD0ocPpu91lzPGbwWMECpEpd42kJGQwzRfyov9j4yNg=="],
"remark-parse": ["remark-parse@11.0.0", "", { "dependencies": { "@types/mdast": "^4.0.0", "mdast-util-from-markdown": "^2.0.0", "micromark-util-types": "^2.0.0", "unified": "^11.0.0" } }, "sha512-FCxlKLNGknS5ba/1lmpYijMUzX2esxW5xQqjWxw2eHFfS2MSdaHVINFmhjo+qN1WhZhNimq0dZATN9pH0IDrpA=="],
"remark-rehype": ["remark-rehype@11.1.2", "", { "dependencies": { "@types/hast": "^3.0.0", "@types/mdast": "^4.0.0", "mdast-util-to-hast": "^13.0.0", "unified": "^11.0.0", "vfile": "^6.0.0" } }, "sha512-Dh7l57ianaEoIpzbp0PC9UKAdCSVklD8E5Rpw7ETfbTl3FqcOOgq5q2LVDhgGCkaBv7p24JXikPdvhhmHvKMsw=="],
"remark-stringify": ["remark-stringify@11.0.0", "", { "dependencies": { "@types/mdast": "^4.0.0", "mdast-util-to-markdown": "^2.0.0", "unified": "^11.0.0" } }, "sha512-1OSmLd3awB/t8qdoEOMazZkNsfVTeY4fTsgzcQFdXNq8ToTN4ZGwrMnlda4K6smTFKD+GRV6O48i6Z4iKgPPpw=="],
"resolve-from": ["resolve-from@4.0.0", "", {}, "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g=="], "resolve-from": ["resolve-from@4.0.0", "", {}, "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g=="],
"rollup": ["rollup@4.57.0", "", { "dependencies": { "@types/estree": "1.0.8" }, "optionalDependencies": { "@rollup/rollup-android-arm-eabi": "4.57.0", "@rollup/rollup-android-arm64": "4.57.0", "@rollup/rollup-darwin-arm64": "4.57.0", "@rollup/rollup-darwin-x64": "4.57.0", "@rollup/rollup-freebsd-arm64": "4.57.0", "@rollup/rollup-freebsd-x64": "4.57.0", "@rollup/rollup-linux-arm-gnueabihf": "4.57.0", "@rollup/rollup-linux-arm-musleabihf": "4.57.0", "@rollup/rollup-linux-arm64-gnu": "4.57.0", "@rollup/rollup-linux-arm64-musl": "4.57.0", "@rollup/rollup-linux-loong64-gnu": "4.57.0", "@rollup/rollup-linux-loong64-musl": "4.57.0", "@rollup/rollup-linux-ppc64-gnu": "4.57.0", "@rollup/rollup-linux-ppc64-musl": "4.57.0", "@rollup/rollup-linux-riscv64-gnu": "4.57.0", "@rollup/rollup-linux-riscv64-musl": "4.57.0", "@rollup/rollup-linux-s390x-gnu": "4.57.0", "@rollup/rollup-linux-x64-gnu": "4.57.0", "@rollup/rollup-linux-x64-musl": "4.57.0", "@rollup/rollup-openbsd-x64": "4.57.0", "@rollup/rollup-openharmony-arm64": "4.57.0", "@rollup/rollup-win32-arm64-msvc": "4.57.0", "@rollup/rollup-win32-ia32-msvc": "4.57.0", "@rollup/rollup-win32-x64-gnu": "4.57.0", "@rollup/rollup-win32-x64-msvc": "4.57.0", "fsevents": "~2.3.2" }, "bin": { "rollup": "dist/bin/rollup" } }, "sha512-e5lPJi/aui4TO1LpAXIRLySmwXSE8k3b9zoGfd42p67wzxog4WHjiZF3M2uheQih4DGyc25QEV4yRBbpueNiUA=="], "rollup": ["rollup@4.57.0", "", { "dependencies": { "@types/estree": "1.0.8" }, "optionalDependencies": { "@rollup/rollup-android-arm-eabi": "4.57.0", "@rollup/rollup-android-arm64": "4.57.0", "@rollup/rollup-darwin-arm64": "4.57.0", "@rollup/rollup-darwin-x64": "4.57.0", "@rollup/rollup-freebsd-arm64": "4.57.0", "@rollup/rollup-freebsd-x64": "4.57.0", "@rollup/rollup-linux-arm-gnueabihf": "4.57.0", "@rollup/rollup-linux-arm-musleabihf": "4.57.0", "@rollup/rollup-linux-arm64-gnu": "4.57.0", "@rollup/rollup-linux-arm64-musl": "4.57.0", "@rollup/rollup-linux-loong64-gnu": "4.57.0", "@rollup/rollup-linux-loong64-musl": "4.57.0", "@rollup/rollup-linux-ppc64-gnu": "4.57.0", "@rollup/rollup-linux-ppc64-musl": "4.57.0", "@rollup/rollup-linux-riscv64-gnu": "4.57.0", "@rollup/rollup-linux-riscv64-musl": "4.57.0", "@rollup/rollup-linux-s390x-gnu": "4.57.0", "@rollup/rollup-linux-x64-gnu": "4.57.0", "@rollup/rollup-linux-x64-musl": "4.57.0", "@rollup/rollup-openbsd-x64": "4.57.0", "@rollup/rollup-openharmony-arm64": "4.57.0", "@rollup/rollup-win32-arm64-msvc": "4.57.0", "@rollup/rollup-win32-ia32-msvc": "4.57.0", "@rollup/rollup-win32-x64-gnu": "4.57.0", "@rollup/rollup-win32-x64-msvc": "4.57.0", "fsevents": "~2.3.2" }, "bin": { "rollup": "dist/bin/rollup" } }, "sha512-e5lPJi/aui4TO1LpAXIRLySmwXSE8k3b9zoGfd42p67wzxog4WHjiZF3M2uheQih4DGyc25QEV4yRBbpueNiUA=="],
@@ -533,8 +695,16 @@
"source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="], "source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="],
"space-separated-tokens": ["space-separated-tokens@2.0.2", "", {}, "sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q=="],
"stringify-entities": ["stringify-entities@4.0.4", "", { "dependencies": { "character-entities-html4": "^2.0.0", "character-entities-legacy": "^3.0.0" } }, "sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg=="],
"strip-json-comments": ["strip-json-comments@3.1.1", "", {}, "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig=="], "strip-json-comments": ["strip-json-comments@3.1.1", "", {}, "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig=="],
"style-to-js": ["style-to-js@1.1.21", "", { "dependencies": { "style-to-object": "1.0.14" } }, "sha512-RjQetxJrrUJLQPHbLku6U/ocGtzyjbJMP9lCNK7Ag0CNh690nSH8woqWH9u16nMjYBAok+i7JO1NP2pOy8IsPQ=="],
"style-to-object": ["style-to-object@1.0.14", "", { "dependencies": { "inline-style-parser": "0.2.7" } }, "sha512-LIN7rULI0jBscWQYaSswptyderlarFkjQ+t79nzty8tcIAceVomEVlLzH5VP4Cmsv6MtKhs7qaAiwlcp+Mgaxw=="],
"supports-color": ["supports-color@7.2.0", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw=="], "supports-color": ["supports-color@7.2.0", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw=="],
"tailwindcss": ["tailwindcss@4.1.18", "", {}, "sha512-4+Z+0yiYyEtUVCScyfHCxOYP06L5Ne+JiHhY2IjR2KWMIWhJOYZKLSGZaP5HkZ8+bY0cxfzwDE5uOmzFXyIwxw=="], "tailwindcss": ["tailwindcss@4.1.18", "", {}, "sha512-4+Z+0yiYyEtUVCScyfHCxOYP06L5Ne+JiHhY2IjR2KWMIWhJOYZKLSGZaP5HkZ8+bY0cxfzwDE5uOmzFXyIwxw=="],
@@ -543,6 +713,10 @@
"tinyglobby": ["tinyglobby@0.2.15", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.3" } }, "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ=="], "tinyglobby": ["tinyglobby@0.2.15", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.3" } }, "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ=="],
"trim-lines": ["trim-lines@3.0.1", "", {}, "sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg=="],
"trough": ["trough@2.2.0", "", {}, "sha512-tmMpK00BjZiUyVyvrBK7knerNgmgvcV/KLVyuma/SC+TQN167GrMRciANTz09+k3zW8L8t60jWO1GpfkZdjTaw=="],
"ts-api-utils": ["ts-api-utils@2.4.0", "", { "peerDependencies": { "typescript": ">=4.8.4" } }, "sha512-3TaVTaAv2gTiMB35i3FiGJaRfwb3Pyn/j3m/bfAvGe8FB7CF6u+LMYqYlDh7reQf7UNvoTvdfAqHGmPGOSsPmA=="], "ts-api-utils": ["ts-api-utils@2.4.0", "", { "peerDependencies": { "typescript": ">=4.8.4" } }, "sha512-3TaVTaAv2gTiMB35i3FiGJaRfwb3Pyn/j3m/bfAvGe8FB7CF6u+LMYqYlDh7reQf7UNvoTvdfAqHGmPGOSsPmA=="],
"type-check": ["type-check@0.4.0", "", { "dependencies": { "prelude-ls": "^1.2.1" } }, "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew=="], "type-check": ["type-check@0.4.0", "", { "dependencies": { "prelude-ls": "^1.2.1" } }, "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew=="],
@@ -553,10 +727,26 @@
"undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="], "undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="],
"unified": ["unified@11.0.5", "", { "dependencies": { "@types/unist": "^3.0.0", "bail": "^2.0.0", "devlop": "^1.0.0", "extend": "^3.0.0", "is-plain-obj": "^4.0.0", "trough": "^2.0.0", "vfile": "^6.0.0" } }, "sha512-xKvGhPWw3k84Qjh8bI3ZeJjqnyadK+GEFtazSfZv/rKeTkTjOJho6mFqh2SM96iIcZokxiOpg78GazTSg8+KHA=="],
"unist-util-is": ["unist-util-is@6.0.1", "", { "dependencies": { "@types/unist": "^3.0.0" } }, "sha512-LsiILbtBETkDz8I9p1dQ0uyRUWuaQzd/cuEeS1hoRSyW5E5XGmTzlwY1OrNzzakGowI9Dr/I8HVaw4hTtnxy8g=="],
"unist-util-position": ["unist-util-position@5.0.0", "", { "dependencies": { "@types/unist": "^3.0.0" } }, "sha512-fucsC7HjXvkB5R3kTCO7kUjRdrS0BJt3M/FPxmHMBOm8JQi2BsHAHFsy27E0EolP8rp0NzXsJ+jNPyDWvOJZPA=="],
"unist-util-stringify-position": ["unist-util-stringify-position@4.0.0", "", { "dependencies": { "@types/unist": "^3.0.0" } }, "sha512-0ASV06AAoKCDkS2+xw5RXJywruurpbC4JZSm7nr7MOt1ojAzvyyaO+UxZf18j8FCF6kmzCZKcAgN/yu2gm2XgQ=="],
"unist-util-visit": ["unist-util-visit@5.1.0", "", { "dependencies": { "@types/unist": "^3.0.0", "unist-util-is": "^6.0.0", "unist-util-visit-parents": "^6.0.0" } }, "sha512-m+vIdyeCOpdr/QeQCu2EzxX/ohgS8KbnPDgFni4dQsfSCtpz8UqDyY5GjRru8PDKuYn7Fq19j1CQ+nJSsGKOzg=="],
"unist-util-visit-parents": ["unist-util-visit-parents@6.0.2", "", { "dependencies": { "@types/unist": "^3.0.0", "unist-util-is": "^6.0.0" } }, "sha512-goh1s1TBrqSqukSc8wrjwWhL0hiJxgA8m4kFxGlQ+8FYQ3C/m11FcTs4YYem7V664AhHVvgoQLk890Ssdsr2IQ=="],
"update-browserslist-db": ["update-browserslist-db@1.2.3", "", { "dependencies": { "escalade": "^3.2.0", "picocolors": "^1.1.1" }, "peerDependencies": { "browserslist": ">= 4.21.0" }, "bin": { "update-browserslist-db": "cli.js" } }, "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w=="], "update-browserslist-db": ["update-browserslist-db@1.2.3", "", { "dependencies": { "escalade": "^3.2.0", "picocolors": "^1.1.1" }, "peerDependencies": { "browserslist": ">= 4.21.0" }, "bin": { "update-browserslist-db": "cli.js" } }, "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w=="],
"uri-js": ["uri-js@4.4.1", "", { "dependencies": { "punycode": "^2.1.0" } }, "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg=="], "uri-js": ["uri-js@4.4.1", "", { "dependencies": { "punycode": "^2.1.0" } }, "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg=="],
"vfile": ["vfile@6.0.3", "", { "dependencies": { "@types/unist": "^3.0.0", "vfile-message": "^4.0.0" } }, "sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q=="],
"vfile-message": ["vfile-message@4.0.3", "", { "dependencies": { "@types/unist": "^3.0.0", "unist-util-stringify-position": "^4.0.0" } }, "sha512-QTHzsGd1EhbZs4AsQ20JX1rC3cOlt/IWJruk893DfLRr57lcnOeMaWG4K0JrRta4mIJZKth2Au3mM3u03/JWKw=="],
"vite": ["vite@7.3.1", "", { "dependencies": { "esbuild": "^0.27.0", "fdir": "^6.5.0", "picomatch": "^4.0.3", "postcss": "^8.5.6", "rollup": "^4.43.0", "tinyglobby": "^0.2.15" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "jiti": ">=1.21.0", "less": "^4.0.0", "lightningcss": "^1.21.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA=="], "vite": ["vite@7.3.1", "", { "dependencies": { "esbuild": "^0.27.0", "fdir": "^6.5.0", "picomatch": "^4.0.3", "postcss": "^8.5.6", "rollup": "^4.43.0", "tinyglobby": "^0.2.15" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "jiti": ">=1.21.0", "less": "^4.0.0", "lightningcss": "^1.21.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA=="],
"which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="], "which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="],
@@ -571,6 +761,8 @@
"zod-validation-error": ["zod-validation-error@4.0.2", "", { "peerDependencies": { "zod": "^3.25.0 || ^4.0.0" } }, "sha512-Q6/nZLe6jxuU80qb/4uJ4t5v2VEZ44lzQjPDhYJNztRQ4wyWc6VF3D3Kb/fAuPetZQnhS3hnajCf9CsWesghLQ=="], "zod-validation-error": ["zod-validation-error@4.0.2", "", { "peerDependencies": { "zod": "^3.25.0 || ^4.0.0" } }, "sha512-Q6/nZLe6jxuU80qb/4uJ4t5v2VEZ44lzQjPDhYJNztRQ4wyWc6VF3D3Kb/fAuPetZQnhS3hnajCf9CsWesghLQ=="],
"zwitch": ["zwitch@2.0.4", "", {}, "sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A=="],
"@eslint-community/eslint-utils/eslint-visitor-keys": ["eslint-visitor-keys@3.4.3", "", {}, "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag=="], "@eslint-community/eslint-utils/eslint-visitor-keys": ["eslint-visitor-keys@3.4.3", "", {}, "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag=="],
"@eslint/eslintrc/globals": ["globals@14.0.0", "", {}, "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ=="], "@eslint/eslintrc/globals": ["globals@14.0.0", "", {}, "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ=="],
@@ -593,6 +785,10 @@
"@typescript-eslint/typescript-estree/semver": ["semver@7.7.3", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q=="], "@typescript-eslint/typescript-estree/semver": ["semver@7.7.3", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q=="],
"mdast-util-find-and-replace/escape-string-regexp": ["escape-string-regexp@5.0.0", "", {}, "sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw=="],
"parse-entities/@types/unist": ["@types/unist@2.0.11", "", {}, "sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA=="],
"@typescript-eslint/typescript-estree/minimatch/brace-expansion": ["brace-expansion@2.0.2", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ=="], "@typescript-eslint/typescript-estree/minimatch/brace-expansion": ["brace-expansion@2.0.2", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ=="],
} }
} }

View File

@@ -9,11 +9,13 @@ import { useSession } from "./lib/auth-client";
// Lazy-loaded pages for code splitting // Lazy-loaded pages for code splitting
const DashboardPage = lazy(() => import("./pages/DashboardPage").then(m => ({ default: m.DashboardPage }))); const DashboardPage = lazy(() => import("./pages/DashboardPage").then(m => ({ default: m.DashboardPage })));
const QueuePage = lazy(() => import("./pages/QueuePage").then(m => ({ default: m.QueuePage }))); const QueuePage = lazy(() => import("./pages/QueuePage").then(m => ({ default: m.QueuePage })));
const ChatPage = lazy(() => import("./pages/ChatPage").then(m => ({ default: m.ChatPage })));
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 })));
const TodosPage = lazy(() => import("./pages/TodosPage").then(m => ({ default: m.TodosPage })));
function PageLoader() { function PageLoader() {
return ( return (
@@ -36,8 +38,10 @@ function AuthenticatedApp() {
<Route path="/queue" element={<Suspense fallback={<PageLoader />}><QueuePage /></Suspense>} /> <Route path="/queue" element={<Suspense fallback={<PageLoader />}><QueuePage /></Suspense>} />
<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="/chat" element={<Suspense fallback={<PageLoader />}><ChatPage /></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="/todos" element={<Suspense fallback={<PageLoader />}><TodosPage /></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>

View File

@@ -10,9 +10,11 @@ import { signOut } from "../lib/auth-client";
const navItems = [ const navItems = [
{ to: "/", label: "Dashboard", icon: "🔨", badgeKey: null }, { to: "/", label: "Dashboard", icon: "🔨", badgeKey: null },
{ to: "/queue", label: "Queue", icon: "📋", badgeKey: "queue" }, { to: "/queue", label: "Queue", icon: "📋", badgeKey: "queue" },
{ to: "/todos", label: "Todos", icon: "✅", badgeKey: null },
{ 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: "/chat", label: "Chat", 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() {

View File

@@ -0,0 +1,207 @@
import { useState, useEffect, useCallback, useRef } from "react";
import ReactMarkdown from "react-markdown";
import remarkGfm from "remark-gfm";
import { fetchComments, addComment, deleteComment, type TaskComment } from "../lib/api";
import { useCurrentUser } from "../hooks/useCurrentUser";
function timeAgo(dateStr: string): string {
const seconds = Math.floor((Date.now() - new Date(dateStr).getTime()) / 1000);
if (seconds < 60) return "just now";
const minutes = Math.floor(seconds / 60);
if (minutes < 60) return `${minutes}m ago`;
const hours = Math.floor(minutes / 60);
if (hours < 24) return `${hours}h ago`;
const days = Math.floor(hours / 24);
return `${days}d ago`;
}
function formatDate(dateStr: string): string {
return new Date(dateStr).toLocaleString(undefined, {
month: "short", day: "numeric",
hour: "2-digit", minute: "2-digit",
});
}
// Avatar color based on name
function avatarColor(name: string): string {
const colors = [
"bg-blue-500", "bg-green-500", "bg-purple-500", "bg-pink-500",
"bg-indigo-500", "bg-teal-500", "bg-cyan-500", "bg-rose-500",
];
let hash = 0;
for (let i = 0; i < name.length; i++) hash = name.charCodeAt(i) + ((hash << 5) - hash);
return colors[Math.abs(hash) % colors.length];
}
function avatarInitial(name: string): string {
return name.charAt(0).toUpperCase();
}
const proseClasses = "prose prose-sm prose-gray dark:prose-invert max-w-none [&_p]:mb-1 [&_p:last-child]:mb-0 [&_ul]:mb-1 [&_ol]:mb-1 [&_li]:mb-0 [&_code]:bg-gray-200 dark:[&_code]:bg-gray-700 [&_code]:px-1 [&_code]:py-0.5 [&_code]:rounded [&_code]:text-xs [&_a]:text-amber-600 dark:[&_a]:text-amber-400 [&_a]:underline";
export function TaskComments({ taskId }: { taskId: string }) {
const [comments, setComments] = useState<TaskComment[]>([]);
const [loading, setLoading] = useState(true);
const [commentText, setCommentText] = useState("");
const [submitting, setSubmitting] = useState(false);
const [deletingId, setDeletingId] = useState<string | null>(null);
const textareaRef = useRef<HTMLTextAreaElement>(null);
const { user } = useCurrentUser();
const loadComments = useCallback(async () => {
try {
const data = await fetchComments(taskId);
setComments(data);
} catch (e) {
console.error("Failed to load comments:", e);
} finally {
setLoading(false);
}
}, [taskId]);
useEffect(() => {
loadComments();
// Poll for new comments every 30s
const interval = setInterval(loadComments, 30000);
return () => clearInterval(interval);
}, [loadComments]);
const handleSubmit = async () => {
const text = commentText.trim();
if (!text) return;
setSubmitting(true);
try {
await addComment(taskId, text);
setCommentText("");
await loadComments();
// Reset textarea height
if (textareaRef.current) {
textareaRef.current.style.height = "42px";
}
} catch (e) {
console.error("Failed to add comment:", e);
} finally {
setSubmitting(false);
}
};
const handleDelete = async (commentId: string) => {
setDeletingId(commentId);
try {
await deleteComment(taskId, commentId);
setComments((prev) => prev.filter((c) => c.id !== commentId));
} catch (e) {
console.error("Failed to delete comment:", e);
} finally {
setDeletingId(null);
}
};
const autoResize = () => {
const el = textareaRef.current;
if (!el) return;
el.style.height = "42px";
el.style.height = Math.min(el.scrollHeight, 160) + "px";
};
return (
<div className="bg-white dark:bg-gray-900 rounded-xl border border-gray-200 dark:border-gray-800 shadow-sm p-5">
<h2 className="text-sm font-semibold text-gray-400 dark:text-gray-500 uppercase tracking-wider mb-3">
💬 Discussion {comments.length > 0 && (
<span className="text-gray-300 dark:text-gray-600 ml-1">({comments.length})</span>
)}
</h2>
{/* Comment input */}
<div className="mb-4">
<div className="flex gap-3">
{user && (
<div className={`w-8 h-8 rounded-full ${avatarColor(user.name)} flex items-center justify-center text-white text-sm font-bold shrink-0 mt-1`}>
{avatarInitial(user.name)}
</div>
)}
<div className="flex-1">
<textarea
ref={textareaRef}
value={commentText}
onChange={(e) => { setCommentText(e.target.value); autoResize(); }}
placeholder="Leave a comment..."
className="w-full text-sm border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 rounded-lg px-3 py-2 focus:outline-none focus:ring-2 focus:ring-amber-200 dark:focus:ring-amber-800 focus:border-amber-300 dark:focus:border-amber-700 resize-none placeholder:text-gray-400 dark:placeholder:text-gray-500"
style={{ minHeight: "42px" }}
onKeyDown={(e) => {
if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) {
e.preventDefault();
handleSubmit();
}
}}
/>
<div className="flex items-center justify-between mt-1.5">
<span className="text-[10px] text-gray-400 dark:text-gray-500">Markdown supported · +Enter to submit</span>
<button
onClick={handleSubmit}
disabled={!commentText.trim() || submitting}
className="text-xs px-3 py-1.5 bg-amber-500 text-white rounded-lg font-medium hover:bg-amber-600 transition disabled:opacity-50 disabled:cursor-not-allowed"
>
{submitting ? "Posting..." : "Comment"}
</button>
</div>
</div>
</div>
</div>
{/* Comments list */}
{loading ? (
<div className="text-center text-gray-400 dark:text-gray-500 py-6 text-sm">Loading comments...</div>
) : comments.length === 0 ? (
<div className="text-sm text-gray-400 dark:text-gray-500 italic py-6 text-center border-2 border-dashed border-gray-100 dark:border-gray-800 rounded-lg">
No comments yet be the first to chime in
</div>
) : (
<div className="space-y-4">
{comments.map((comment) => {
const isOwn = user && comment.authorId === user.id;
const isHammer = comment.authorId === "hammer";
return (
<div key={comment.id} className="flex gap-3 group">
<div className={`w-8 h-8 rounded-full flex items-center justify-center text-sm font-bold shrink-0 mt-0.5 ${
isHammer
? "bg-amber-500 text-white"
: `${avatarColor(comment.authorName)} text-white`
}`}>
{isHammer ? "🔨" : avatarInitial(comment.authorName)}
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-0.5">
<span className={`text-sm font-semibold ${
isHammer ? "text-amber-700 dark:text-amber-400" : "text-gray-800 dark:text-gray-200"
}`}>
{comment.authorName}
</span>
<span className="text-[10px] text-gray-400 dark:text-gray-500" title={formatDate(comment.createdAt)}>
{timeAgo(comment.createdAt)}
</span>
{isOwn && (
<button
onClick={() => handleDelete(comment.id)}
disabled={deletingId === comment.id}
className="opacity-0 group-hover:opacity-100 text-gray-300 dark:text-gray-600 hover:text-red-400 dark:hover:text-red-400 transition text-xs"
title="Delete comment"
>
{deletingId === comment.id ? "..." : "×"}
</button>
)}
</div>
<div className={`text-sm text-gray-700 dark:text-gray-300 leading-relaxed ${proseClasses}`}>
<ReactMarkdown remarkPlugins={[remarkGfm]}>
{comment.content}
</ReactMarkdown>
</div>
</div>
</div>
);
})}
</div>
)}
</div>
);
}

View File

@@ -1,6 +1,6 @@
import { useState, useEffect, useRef } from "react"; import { useState, useEffect, useRef } from "react";
import type { Task, TaskStatus, TaskPriority, TaskSource, Project, Recurrence } from "../lib/types"; import type { Task, TaskStatus, TaskPriority, TaskSource, Project, Recurrence } from "../lib/types";
import { updateTask, fetchProjects, addProgressNote, addSubtask, toggleSubtask, deleteSubtask, deleteTask } from "../lib/api"; import { updateTask, fetchProjects, addProgressNote, addSubtask, toggleSubtask, deleteSubtask, deleteTask, fetchComments, addComment, type TaskComment } from "../lib/api";
import { useToast } from "./Toast"; import { useToast } from "./Toast";
const priorityColors: Record<TaskPriority, string> = { const priorityColors: Record<TaskPriority, string> = {
@@ -241,6 +241,90 @@ interface TaskDetailPanelProps {
token: string; token: string;
} }
function CompactComments({ taskId }: { taskId: string }) {
const [comments, setComments] = useState<TaskComment[]>([]);
const [text, setText] = useState("");
const [submitting, setSubmitting] = useState(false);
const [expanded, setExpanded] = useState(false);
useEffect(() => {
fetchComments(taskId).then(setComments).catch(() => {});
}, [taskId]);
const handleSubmit = async () => {
if (!text.trim()) return;
setSubmitting(true);
try {
const comment = await addComment(taskId, text.trim());
setComments((prev) => [...prev, comment]);
setText("");
} catch (e) {
console.error("Failed to add comment:", e);
} finally {
setSubmitting(false);
}
};
const recentComments = expanded ? comments : comments.slice(-3);
return (
<div className="border-t border-gray-100 dark:border-gray-800 pt-4 mt-4">
<div className="flex items-center justify-between mb-2">
<h3 className="text-xs font-semibold text-gray-400 dark:text-gray-500 uppercase tracking-wider">
💬 Discussion {comments.length > 0 && <span className="text-gray-300 dark:text-gray-600">({comments.length})</span>}
</h3>
{comments.length > 3 && !expanded && (
<button onClick={() => setExpanded(true)} className="text-[10px] text-amber-600 dark:text-amber-400 hover:text-amber-700 dark:hover:text-amber-300 font-medium">
Show all ({comments.length})
</button>
)}
</div>
{/* Comments */}
{recentComments.length > 0 && (
<div className="space-y-2 mb-3">
{recentComments.map((c) => (
<div key={c.id} className="text-xs">
<span className={`font-semibold ${c.authorId === "hammer" ? "text-amber-700 dark:text-amber-400" : "text-gray-700 dark:text-gray-300"}`}>
{c.authorName}
</span>
<span className="text-gray-400 dark:text-gray-500 ml-1.5">
{timeAgo(c.createdAt)}
</span>
<p className="text-gray-600 dark:text-gray-400 mt-0.5 leading-relaxed">{c.content}</p>
</div>
))}
</div>
)}
{/* Input */}
<div className="flex gap-2">
<input
type="text"
value={text}
onChange={(e) => setText(e.target.value)}
placeholder="Add a comment..."
className="flex-1 text-xs border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 rounded-lg px-2.5 py-1.5 focus:outline-none focus:ring-1 focus:ring-amber-200 dark:focus:ring-amber-800 placeholder:text-gray-400 dark:placeholder:text-gray-500"
onKeyDown={(e) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
handleSubmit();
}
}}
disabled={submitting}
/>
<button
onClick={handleSubmit}
disabled={!text.trim() || submitting}
className="text-xs px-2.5 py-1.5 bg-amber-500 text-white rounded-lg font-medium hover:bg-amber-600 transition disabled:opacity-50 disabled:cursor-not-allowed shrink-0"
>
{submitting ? "..." : "Post"}
</button>
</div>
</div>
);
}
export function TaskDetailPanel({ task, onClose, onStatusChange, onTaskUpdated, hasToken, token }: TaskDetailPanelProps) { export function TaskDetailPanel({ task, onClose, onStatusChange, onTaskUpdated, hasToken, token }: TaskDetailPanelProps) {
const actions = statusActions[task.status] || []; const actions = statusActions[task.status] || [];
const isActive = task.status === "active"; const isActive = task.status === "active";
@@ -986,6 +1070,9 @@ export function TaskDetailPanel({ task, onClose, onStatusChange, onTaskUpdated,
</div> </div>
)} )}
</div> </div>
{/* Quick Comments */}
<CompactComments taskId={task.id} />
</div> </div>
{/* Save / Cancel Bar */} {/* Save / Cancel Bar */}

View File

@@ -1,4 +1,4 @@
import type { Task, Project, ProjectWithTasks, VelocityStats, Recurrence } from "./types"; import type { Task, Project, ProjectWithTasks, VelocityStats, Recurrence, Todo, TodoPriority } from "./types";
const BASE = "/api/tasks"; const BASE = "/api/tasks";
@@ -167,6 +167,42 @@ export async function addProgressNote(taskId: string, note: string): Promise<Tas
return res.json(); return res.json();
} }
// ─── Comments API ───
export interface TaskComment {
id: string;
taskId: string;
authorId: string | null;
authorName: string;
content: string;
createdAt: string;
}
export async function fetchComments(taskId: string): Promise<TaskComment[]> {
const res = await fetch(`${BASE}/${taskId}/comments`, { credentials: "include" });
if (!res.ok) throw new Error("Failed to fetch comments");
return res.json();
}
export async function addComment(taskId: string, content: string): Promise<TaskComment> {
const res = await fetch(`${BASE}/${taskId}/comments`, {
method: "POST",
credentials: "include",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ content }),
});
if (!res.ok) throw new Error("Failed to add comment");
return res.json();
}
export async function deleteComment(taskId: string, commentId: string): Promise<void> {
const res = await fetch(`${BASE}/${taskId}/comments/${commentId}`, {
method: "DELETE",
credentials: "include",
});
if (!res.ok) throw new Error("Failed to delete comment");
}
// Admin API // Admin API
export async function fetchUsers(): Promise<any[]> { export async function fetchUsers(): Promise<any[]> {
const res = await fetch("/api/admin/users", { credentials: "include" }); const res = await fetch("/api/admin/users", { credentials: "include" });
@@ -192,3 +228,75 @@ export async function deleteUser(userId: string): Promise<void> {
}); });
if (!res.ok) throw new Error("Failed to delete user"); if (!res.ok) throw new Error("Failed to delete user");
} }
// ─── Todos API ───
const TODOS_BASE = "/api/todos";
export async function fetchTodos(params?: { completed?: string; category?: string }): Promise<Todo[]> {
const url = new URL(TODOS_BASE, window.location.origin);
if (params?.completed) url.searchParams.set("completed", params.completed);
if (params?.category) url.searchParams.set("category", params.category);
const res = await fetch(url.toString(), { credentials: "include" });
if (!res.ok) throw new Error(res.status === 401 ? "Unauthorized" : "Failed to fetch todos");
return res.json();
}
export async function fetchTodoCategories(): Promise<string[]> {
const res = await fetch(`${TODOS_BASE}/categories`, { credentials: "include" });
if (!res.ok) throw new Error("Failed to fetch categories");
return res.json();
}
export async function createTodo(todo: {
title: string;
description?: string;
priority?: TodoPriority;
category?: string;
dueDate?: string | null;
}): Promise<Todo> {
const res = await fetch(TODOS_BASE, {
method: "POST",
credentials: "include",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(todo),
});
if (!res.ok) throw new Error("Failed to create todo");
return res.json();
}
export async function updateTodo(id: string, updates: Partial<{
title: string;
description: string;
priority: TodoPriority;
category: string | null;
dueDate: string | null;
isCompleted: boolean;
sortOrder: number;
}>): Promise<Todo> {
const res = await fetch(`${TODOS_BASE}/${id}`, {
method: "PATCH",
credentials: "include",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(updates),
});
if (!res.ok) throw new Error("Failed to update todo");
return res.json();
}
export async function toggleTodo(id: string): Promise<Todo> {
const res = await fetch(`${TODOS_BASE}/${id}/toggle`, {
method: "PATCH",
credentials: "include",
});
if (!res.ok) throw new Error("Failed to toggle todo");
return res.json();
}
export async function deleteTodo(id: string): Promise<void> {
const res = await fetch(`${TODOS_BASE}/${id}`, {
method: "DELETE",
credentials: "include",
});
if (!res.ok) throw new Error("Failed to delete todo");
}

View File

@@ -1,213 +0,0 @@
/**
* Chat WebSocket client for Hammer Dashboard
*
* Connects to the dashboard backend's WebSocket relay (which proxies to the Clawdbot gateway).
* Authentication is handled via BetterAuth session cookie.
*/
type MessageHandler = (msg: any) => void;
type StateHandler = (state: "connecting" | "connected" | "disconnected") => void;
let reqCounter = 0;
function nextId() {
return `r${++reqCounter}`;
}
export class ChatClient {
private ws: WebSocket | null = null;
private state: "connecting" | "connected" | "disconnected" = "disconnected";
private pendingRequests = new Map<string, { resolve: (v: any) => void; reject: (e: any) => void }>();
private eventHandlers = new Map<string, Set<MessageHandler>>();
private stateHandlers = new Set<StateHandler>();
private reconnectTimer: ReturnType<typeof setTimeout> | null = null;
private shouldReconnect = true;
connect() {
this.shouldReconnect = true;
this._connect();
}
private _connect() {
if (this.ws) {
try { this.ws.close(); } catch {}
}
// Build WebSocket URL from current page origin
const wsProtocol = window.location.protocol === "https:" ? "wss:" : "ws:";
// Backend is at the same origin via nginx proxy on Dokploy
const wsUrl = `${wsProtocol}//${window.location.host}/api/chat/ws`;
this.setState("connecting");
this.ws = new WebSocket(wsUrl);
this.ws.onopen = () => {
// Send auth message with session cookie
this._send({
type: "auth",
cookie: document.cookie,
});
};
this.ws.onmessage = (event) => {
try {
const msg = JSON.parse(event.data);
this._handleMessage(msg);
} catch (e) {
console.error("Failed to parse message:", e);
}
};
this.ws.onclose = () => {
this.setState("disconnected");
if (this.shouldReconnect) {
this.reconnectTimer = setTimeout(() => this._connect(), 3000);
}
};
this.ws.onerror = () => {
// onclose handles reconnect
};
}
disconnect() {
this.shouldReconnect = false;
if (this.reconnectTimer) clearTimeout(this.reconnectTimer);
if (this.ws) {
try { this.ws.close(); } catch {}
}
this.ws = null;
this.setState("disconnected");
}
isConnected() {
return this.state === "connected";
}
getState() {
return this.state;
}
onStateChange(handler: StateHandler): () => void {
this.stateHandlers.add(handler);
return () => { this.stateHandlers.delete(handler); };
}
on(event: string, handler: MessageHandler): () => void {
if (!this.eventHandlers.has(event)) {
this.eventHandlers.set(event, new Set());
}
this.eventHandlers.get(event)!.add(handler);
return () => { this.eventHandlers.get(event)?.delete(handler); };
}
async request(method: string, params?: any): Promise<any> {
return new Promise((resolve, reject) => {
if (!this.ws || this.state !== "connected") {
reject(new Error("Not connected"));
return;
}
const id = nextId();
this.pendingRequests.set(id, { resolve, reject });
this._send({ type: method, id, ...params });
setTimeout(() => {
if (this.pendingRequests.has(id)) {
this.pendingRequests.delete(id);
reject(new Error("Request timeout"));
}
}, 120000);
});
}
// Chat methods
async chatHistory(sessionKey: string, limit = 50) {
return this.request("chat.history", { sessionKey, limit });
}
async chatSend(sessionKey: string, message: string) {
return this.request("chat.send", {
sessionKey,
message,
});
}
async chatAbort(sessionKey: string) {
return this.request("chat.abort", { sessionKey });
}
async sessionsList(limit = 50) {
return this.request("sessions.list", { limit });
}
private setState(state: "connecting" | "connected" | "disconnected") {
this.state = state;
this.stateHandlers.forEach((h) => h(state));
}
private _send(msg: any) {
if (this.ws?.readyState === WebSocket.OPEN) {
this.ws.send(JSON.stringify(msg));
}
}
private _handleMessage(msg: any) {
// Auth response
if (msg.type === "auth_ok") {
this.setState("connected");
return;
}
if (msg.type === "error" && this.state !== "connected") {
console.error("Auth failed:", msg.error);
this.shouldReconnect = false;
this.ws?.close();
return;
}
// Request response
if (msg.type === "res") {
const pending = this.pendingRequests.get(msg.id);
if (pending) {
this.pendingRequests.delete(msg.id);
if (msg.ok) {
pending.resolve(msg.payload);
} else {
pending.reject(new Error(msg.error || "Request failed"));
}
}
return;
}
// Error for a specific request
if (msg.type === "error" && msg.id) {
const pending = this.pendingRequests.get(msg.id);
if (pending) {
this.pendingRequests.delete(msg.id);
pending.reject(new Error(msg.error || "Request failed"));
}
return;
}
// Gateway events (forwarded from backend)
if (msg.type === "event") {
const handlers = this.eventHandlers.get(msg.event);
if (handlers) {
handlers.forEach((h) => h(msg.payload));
}
const wildcardHandlers = this.eventHandlers.get("*");
if (wildcardHandlers) {
wildcardHandlers.forEach((h) => h({ event: msg.event, ...msg.payload }));
}
}
}
}
// Singleton
let _client: ChatClient | null = null;
export function getChatClient(): ChatClient {
if (!_client) {
_client = new ChatClient();
}
return _client;
}

View File

@@ -51,6 +51,27 @@ export interface Recurrence {
autoActivate?: boolean; autoActivate?: boolean;
} }
// ─── Personal Todos ───
export type TodoPriority = "high" | "medium" | "low" | "none";
export interface Todo {
id: string;
userId: string;
title: string;
description: string | null;
isCompleted: boolean;
priority: TodoPriority;
category: string | null;
dueDate: string | null;
completedAt: string | null;
sortOrder: number;
createdAt: string;
updatedAt: string;
}
// ─── Tasks ───
export interface Task { export interface Task {
id: string; id: string;
taskNumber: number; taskNumber: number;

View File

@@ -1,7 +1,5 @@
import { useMemo, useState } from "react"; import { useState, useEffect, useCallback } from "react";
import { Link } from "react-router-dom"; import { Link } from "react-router-dom";
import { useTasks } from "../hooks/useTasks";
import type { Task, ProgressNote } from "../lib/types";
function timeAgo(dateStr: string): string { function timeAgo(dateStr: string): string {
const seconds = Math.floor((Date.now() - new Date(dateStr).getTime()) / 1000); const seconds = Math.floor((Date.now() - new Date(dateStr).getTime()) / 1000);
@@ -26,55 +24,89 @@ function formatDate(dateStr: string): string {
} }
interface ActivityItem { interface ActivityItem {
task: Task; type: "progress" | "comment";
note: ProgressNote; timestamp: string;
taskId: string;
taskNumber: number | null;
taskTitle: string;
taskStatus: string;
note?: string;
commentId?: string;
authorName?: string;
authorId?: string | null;
content?: string;
}
interface ActivityGroup {
date: string;
items: ActivityItem[];
}
function avatarColor(name: string): string {
const colors = [
"bg-blue-500", "bg-green-500", "bg-purple-500", "bg-pink-500",
"bg-indigo-500", "bg-teal-500", "bg-cyan-500", "bg-rose-500",
];
let hash = 0;
for (let i = 0; i < name.length; i++) hash = name.charCodeAt(i) + ((hash << 5) - hash);
return colors[Math.abs(hash) % colors.length];
} }
export function ActivityPage() { export function ActivityPage() {
const { tasks, loading } = useTasks(15000); const [items, setItems] = useState<ActivityItem[]>([]);
const [loading, setLoading] = useState(true);
const [_total, setTotal] = useState(0);
const [filter, setFilter] = useState<string>("all"); const [filter, setFilter] = useState<string>("all");
const [typeFilter, setTypeFilter] = useState<string>("all");
const allActivity = useMemo(() => { const fetchActivity = useCallback(async () => {
const items: ActivityItem[] = []; try {
for (const task of tasks) { const res = await fetch("/api/activity?limit=200", { credentials: "include" });
if (task.progressNotes) { if (!res.ok) throw new Error("Failed to fetch activity");
for (const note of task.progressNotes) { const data = await res.json();
items.push({ task, note }); setItems(data.items || []);
} setTotal(data.total || 0);
} } catch (e) {
console.error("Failed to fetch activity:", e);
} finally {
setLoading(false);
} }
items.sort( }, []);
(a, b) =>
new Date(b.note.timestamp).getTime() - new Date(a.note.timestamp).getTime()
);
return items;
}, [tasks]);
const groupedActivity = useMemo(() => { useEffect(() => {
const filtered = fetchActivity();
filter === "all" const interval = setInterval(fetchActivity, 30000);
? allActivity return () => clearInterval(interval);
: allActivity.filter((a) => a.task.status === filter); }, [fetchActivity]);
const groups: { date: string; items: ActivityItem[] }[] = []; // Apply filters
let currentDate = ""; const filtered = items.filter((item) => {
for (const item of filtered) { if (filter !== "all" && item.taskStatus !== filter) return false;
const d = new Date(item.note.timestamp).toLocaleDateString("en-US", { if (typeFilter !== "all" && item.type !== typeFilter) return false;
weekday: "long", return true;
month: "long", });
day: "numeric",
year: "numeric", // Group by date
}); const grouped: ActivityGroup[] = [];
if (d !== currentDate) { let currentDate = "";
currentDate = d; for (const item of filtered) {
groups.push({ date: d, items: [] }); const d = new Date(item.timestamp).toLocaleDateString("en-US", {
} weekday: "long",
groups[groups.length - 1].items.push(item); month: "long",
day: "numeric",
year: "numeric",
});
if (d !== currentDate) {
currentDate = d;
grouped.push({ date: d, items: [] });
} }
return groups; grouped[grouped.length - 1].items.push(item);
}, [allActivity, filter]); }
if (loading && tasks.length === 0) { const commentCount = items.filter(i => i.type === "comment").length;
const progressCount = items.filter(i => i.type === "progress").length;
if (loading) {
return ( return (
<div className="min-h-screen flex items-center justify-center text-gray-400 dark:text-gray-500"> <div className="min-h-screen flex items-center justify-center text-gray-400 dark:text-gray-500">
Loading activity... Loading activity...
@@ -85,33 +117,54 @@ export function ActivityPage() {
return ( return (
<div className="min-h-screen"> <div className="min-h-screen">
<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"> <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-3xl mx-auto px-4 sm:px-6 py-4 flex items-center justify-between"> <div className="max-w-3xl mx-auto px-4 sm:px-6 py-4">
<div> <div className="flex items-center justify-between mb-2">
<h1 className="text-xl font-bold text-gray-900 dark:text-gray-100">📝 Activity Log</h1> <div>
<p className="text-sm text-gray-400 dark:text-gray-500"> <h1 className="text-xl font-bold text-gray-900 dark:text-gray-100">📝 Activity Log</h1>
{allActivity.length} updates across {tasks.length} tasks <p className="text-sm text-gray-400 dark:text-gray-500">
</p> {progressCount} updates · {commentCount} comments
</p>
</div>
</div>
<div className="flex gap-2 flex-wrap">
<select
value={filter}
onChange={(e) => setFilter(e.target.value)}
className="text-xs border border-gray-200 dark:border-gray-700 rounded-lg px-2.5 py-1.5 focus:outline-none focus:ring-2 focus:ring-amber-200 dark:focus:ring-amber-800 bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100"
>
<option value="all">All Statuses</option>
<option value="active">Active</option>
<option value="completed">Completed</option>
<option value="queued">Queued</option>
<option value="blocked">Blocked</option>
</select>
<select
value={typeFilter}
onChange={(e) => setTypeFilter(e.target.value)}
className="text-xs border border-gray-200 dark:border-gray-700 rounded-lg px-2.5 py-1.5 focus:outline-none focus:ring-2 focus:ring-amber-200 dark:focus:ring-amber-800 bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100"
>
<option value="all">All Types</option>
<option value="progress">🔨 Progress Notes</option>
<option value="comment">💬 Comments</option>
</select>
{(filter !== "all" || typeFilter !== "all") && (
<button
onClick={() => { setFilter("all"); setTypeFilter("all"); }}
className="text-xs text-gray-400 dark:text-gray-500 hover:text-gray-600 dark:hover:text-gray-300"
>
Clear filters
</button>
)}
</div> </div>
<select
value={filter}
onChange={(e) => setFilter(e.target.value)}
className="text-sm border border-gray-200 dark:border-gray-700 rounded-lg px-3 py-1.5 focus:outline-none focus:ring-2 focus:ring-amber-200 dark:focus:ring-amber-800 bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100"
>
<option value="all">All Tasks</option>
<option value="active">Active</option>
<option value="completed">Completed</option>
<option value="queued">Queued</option>
<option value="blocked">Blocked</option>
</select>
</div> </div>
</header> </header>
<div className="max-w-3xl mx-auto px-4 sm:px-6 py-6"> <div className="max-w-3xl mx-auto px-4 sm:px-6 py-6">
{groupedActivity.length === 0 ? ( {grouped.length === 0 ? (
<div className="text-center text-gray-400 dark:text-gray-500 py-12">No activity found</div> <div className="text-center text-gray-400 dark:text-gray-500 py-12">No activity found</div>
) : ( ) : (
<div className="space-y-8"> <div className="space-y-8">
{groupedActivity.map((group) => ( {grouped.map((group) => (
<div key={group.date}> <div key={group.date}>
<div className="sticky top-28 md:top-14 z-10 bg-gray-50/95 dark:bg-gray-950/95 backdrop-blur-sm py-2 mb-3"> <div className="sticky top-28 md:top-14 z-10 bg-gray-50/95 dark:bg-gray-950/95 backdrop-blur-sm py-2 mb-3">
<h2 className="text-sm font-semibold text-gray-500 dark:text-gray-400">{group.date}</h2> <h2 className="text-sm font-semibold text-gray-500 dark:text-gray-400">{group.date}</h2>
@@ -119,11 +172,21 @@ export function ActivityPage() {
<div className="space-y-1"> <div className="space-y-1">
{group.items.map((item, i) => ( {group.items.map((item, i) => (
<div <div
key={`${item.task.id}-${i}`} key={`${item.taskId}-${item.type}-${i}`}
className="flex gap-3 py-3 px-3 rounded-lg hover:bg-white dark:hover:bg-gray-900 transition group" className="flex gap-3 py-3 px-3 rounded-lg hover:bg-white dark:hover:bg-gray-900 transition group"
> >
<div className="flex flex-col items-center pt-1.5"> <div className="flex flex-col items-center pt-1.5">
<div className="w-2.5 h-2.5 rounded-full bg-amber-400 shrink-0" /> {item.type === "comment" ? (
<div className={`w-6 h-6 rounded-full flex items-center justify-center text-[10px] font-bold text-white shrink-0 ${
item.authorId === "hammer"
? "bg-amber-500"
: avatarColor(item.authorName || "?")
}`}>
{item.authorId === "hammer" ? "🔨" : (item.authorName || "?").charAt(0).toUpperCase()}
</div>
) : (
<div className="w-2.5 h-2.5 rounded-full bg-amber-400 shrink-0 mt-1.5" />
)}
{i < group.items.length - 1 && ( {i < group.items.length - 1 && (
<div className="w-px flex-1 bg-gray-200 dark:bg-gray-700 mt-1" /> <div className="w-px flex-1 bg-gray-200 dark:bg-gray-700 mt-1" />
)} )}
@@ -132,23 +195,35 @@ export function ActivityPage() {
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1 flex-wrap"> <div className="flex items-center gap-2 mb-1 flex-wrap">
<Link <Link
to={`/task/HQ-${item.task.taskNumber}`} to={`/task/HQ-${item.taskNumber}`}
className="text-xs font-bold text-amber-700 dark:text-amber-400 bg-amber-50 dark:bg-amber-900/30 px-1.5 py-0.5 rounded font-mono hover:bg-amber-100 dark:hover:bg-amber-900/50 transition" className="text-xs font-bold text-amber-700 dark:text-amber-400 bg-amber-50 dark:bg-amber-900/30 px-1.5 py-0.5 rounded font-mono hover:bg-amber-100 dark:hover:bg-amber-900/50 transition"
> >
HQ-{item.task.taskNumber} HQ-{item.taskNumber}
</Link> </Link>
<span className={`text-[10px] px-1.5 py-0.5 rounded-full font-medium ${
item.type === "comment"
? "bg-blue-100 dark:bg-blue-900/30 text-blue-600 dark:text-blue-400"
: "bg-amber-100 dark:bg-amber-900/30 text-amber-600 dark:text-amber-400"
}`}>
{item.type === "comment" ? "💬 comment" : "🔨 progress"}
</span>
{item.type === "comment" && item.authorName && (
<span className="text-[10px] text-gray-500 dark:text-gray-400 font-medium">
by {item.authorName}
</span>
)}
<span className="text-[10px] text-gray-400 dark:text-gray-500"> <span className="text-[10px] text-gray-400 dark:text-gray-500">
{formatDate(item.note.timestamp)} {formatDate(item.timestamp)}
</span> </span>
<span className="text-[10px] text-gray-300 dark:text-gray-600"> <span className="text-[10px] text-gray-300 dark:text-gray-600">
({timeAgo(item.note.timestamp)}) ({timeAgo(item.timestamp)})
</span> </span>
</div> </div>
<p className="text-sm text-gray-700 dark:text-gray-300 leading-relaxed"> <p className="text-sm text-gray-700 dark:text-gray-300 leading-relaxed">
{item.note.note} {item.type === "comment" ? item.content : item.note}
</p> </p>
<p className="text-xs text-gray-400 dark:text-gray-500 mt-1 truncate"> <p className="text-xs text-gray-400 dark:text-gray-500 mt-1 truncate">
{item.task.title} {item.taskTitle}
</p> </p>
</div> </div>
</div> </div>

View File

@@ -1,751 +0,0 @@
import { useState, useEffect, useRef, useCallback } from "react";
import ReactMarkdown from "react-markdown";
import remarkGfm from "remark-gfm";
import { getChatClient, type ChatClient } from "../lib/gateway";
interface ChatMessage {
role: "user" | "assistant" | "system";
content: string;
timestamp?: number;
}
interface ChatThread {
sessionKey: string;
name: string;
lastMessage?: string;
updatedAt: number;
}
interface GatewaySession {
sessionKey: string;
kind?: string;
channel?: string;
lastActivity?: string;
messageCount?: number;
}
function ThreadList({
threads,
activeThread,
onSelect,
onCreate,
onRename,
onDelete,
onClose,
onBrowseSessions,
gatewaySessions,
loadingGatewaySessions,
showGatewaySessions,
onToggleGatewaySessions,
}: {
threads: ChatThread[];
activeThread: string | null;
onSelect: (key: string) => void;
onCreate: () => void;
onRename?: (key: string, name: string) => void;
onDelete?: (key: string) => void;
onClose?: () => void;
onBrowseSessions?: () => void;
gatewaySessions?: GatewaySession[];
loadingGatewaySessions?: boolean;
showGatewaySessions?: boolean;
onToggleGatewaySessions?: () => void;
}) {
const [editingKey, setEditingKey] = useState<string | null>(null);
const [editName, setEditName] = useState("");
const startRename = (key: string, currentName: string) => {
setEditingKey(key);
setEditName(currentName);
};
const commitRename = () => {
if (editingKey && editName.trim() && onRename) {
onRename(editingKey, editName.trim());
}
setEditingKey(null);
};
// Check if a session key exists in local threads already
const localSessionKeys = new Set(threads.map(t => t.sessionKey));
return (
<div className="w-full sm:w-64 bg-white dark:bg-gray-900 border-r border-gray-200 dark:border-gray-800 flex flex-col h-full">
<div className="p-3 border-b border-gray-100 dark:border-gray-800 flex items-center justify-between">
<h3 className="text-sm font-semibold text-gray-600 dark:text-gray-400">Threads</h3>
<div className="flex items-center gap-2">
<button
onClick={onCreate}
className="text-xs bg-amber-500 text-white px-2.5 py-1 rounded-lg hover:bg-amber-600 transition font-medium"
>
+ New
</button>
{onClose && (
<button
onClick={onClose}
className="sm:hidden text-gray-400 hover:text-gray-600 p-1"
aria-label="Close threads"
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
)}
</div>
</div>
<div className="flex-1 overflow-y-auto">
{/* Local threads */}
{threads.length === 0 && !showGatewaySessions ? (
<div className="p-4 text-sm text-gray-400 dark:text-gray-500 text-center">
No threads yet
</div>
) : (
threads.map((thread) => (
<div
key={thread.sessionKey}
className={`group relative w-full text-left px-3 py-3 border-b border-gray-50 dark:border-gray-800 transition cursor-pointer ${
activeThread === thread.sessionKey
? "bg-amber-50 dark:bg-amber-900/20 border-l-2 border-l-amber-500"
: "hover:bg-gray-50 dark:hover:bg-gray-800"
}`}
onClick={() => onSelect(thread.sessionKey)}
>
{editingKey === thread.sessionKey ? (
<input
autoFocus
className="text-sm font-medium text-gray-800 dark:text-gray-200 w-full bg-white dark:bg-gray-800 border border-amber-300 dark:border-amber-700 rounded px-1 py-0.5 outline-none"
value={editName}
onChange={(e) => setEditName(e.target.value)}
onBlur={commitRename}
onKeyDown={(e) => {
if (e.key === "Enter") commitRename();
if (e.key === "Escape") setEditingKey(null);
}}
onClick={(e) => e.stopPropagation()}
/>
) : (
<div
className="text-sm font-medium text-gray-800 dark:text-gray-200 truncate pr-6"
onDoubleClick={(e) => {
e.stopPropagation();
startRename(thread.sessionKey, thread.name);
}}
>
{thread.name}
</div>
)}
{thread.lastMessage && (
<div className="text-xs text-gray-400 truncate mt-0.5">
{thread.lastMessage}
</div>
)}
{onDelete && editingKey !== thread.sessionKey && (
<button
onClick={(e) => {
e.stopPropagation();
onDelete(thread.sessionKey);
}}
className="absolute right-2 top-1/2 -translate-y-1/2 opacity-0 group-hover:opacity-100 text-gray-300 hover:text-red-400 transition p-1"
aria-label="Delete thread"
>
<svg className="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
)}
</div>
))
)}
{/* Gateway sessions browser */}
<div className="border-t border-gray-200 dark:border-gray-800">
<button
onClick={() => {
onToggleGatewaySessions?.();
if (!showGatewaySessions) onBrowseSessions?.();
}}
className="w-full px-3 py-2.5 text-xs font-semibold text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-800 transition flex items-center justify-between"
>
<span>🔌 Gateway Sessions</span>
<svg
className={`w-3.5 h-3.5 transition-transform ${showGatewaySessions ? "rotate-180" : ""}`}
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
</svg>
</button>
{showGatewaySessions && (
<div className="bg-gray-50 dark:bg-gray-800/50">
{loadingGatewaySessions ? (
<div className="px-3 py-3 text-xs text-gray-400 dark:text-gray-500 text-center">Loading sessions...</div>
) : !gatewaySessions || gatewaySessions.length === 0 ? (
<div className="px-3 py-3 text-xs text-gray-400 dark:text-gray-500 text-center">No sessions found</div>
) : (
gatewaySessions.filter(s => !localSessionKeys.has(s.sessionKey)).map((session) => (
<div
key={session.sessionKey}
className="px-3 py-2.5 border-b border-gray-100 dark:border-gray-700 hover:bg-gray-100 dark:hover:bg-gray-700 cursor-pointer transition"
onClick={() => onSelect(session.sessionKey)}
>
<div className="flex items-center gap-1.5">
<span className="text-xs">
{session.channel === "telegram" ? "📱" : session.kind === "cron" ? "⏰" : "💬"}
</span>
<span className="text-xs font-medium text-gray-700 dark:text-gray-300 truncate">
{session.sessionKey}
</span>
</div>
<div className="flex items-center gap-2 mt-0.5">
{session.channel && (
<span className="text-[10px] text-gray-400">{session.channel}</span>
)}
{session.kind && (
<span className="text-[10px] text-gray-400">{session.kind}</span>
)}
{session.messageCount != null && (
<span className="text-[10px] text-gray-400">{session.messageCount} msgs</span>
)}
</div>
</div>
))
)}
</div>
)}
</div>
</div>
</div>
);
}
function formatTimestamp(ts?: number): string {
if (!ts) return "";
const d = new Date(ts);
return d.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" });
}
function MessageBubble({ msg }: { msg: ChatMessage }) {
const [showTime, setShowTime] = useState(false);
const isUser = msg.role === "user";
const isSystem = msg.role === "system";
if (isSystem) {
return (
<div className="text-center my-2">
<span className="text-xs text-gray-400 dark:text-gray-500 bg-gray-100 dark:bg-gray-800 px-3 py-1 rounded-full">
{msg.content}
</span>
</div>
);
}
return (
<div
className={`flex ${isUser ? "justify-end" : "justify-start"} mb-3 group`}
onClick={() => setShowTime(!showTime)}
>
{!isUser && (
<div className="w-8 h-8 bg-amber-500 rounded-full flex items-center justify-center text-sm mr-2 shrink-0 mt-1">
🔨
</div>
)}
<div className="flex flex-col">
<div
className={`max-w-[75vw] sm:max-w-[60vw] rounded-2xl px-4 py-2.5 ${
isUser
? "bg-blue-500 text-white rounded-br-md"
: "bg-gray-100 dark:bg-gray-800 text-gray-800 dark:text-gray-200 rounded-bl-md"
}`}
>
{isUser ? (
<p className="text-sm whitespace-pre-wrap leading-relaxed">{msg.content}</p>
) : (
<div className="text-sm leading-relaxed prose prose-sm prose-gray max-w-none [&_pre]:bg-gray-800 [&_pre]:text-gray-100 [&_pre]:rounded-lg [&_pre]:p-3 [&_pre]:overflow-x-auto [&_pre]:text-xs [&_code]:bg-gray-200 [&_code]:px-1 [&_code]:py-0.5 [&_code]:rounded [&_code]:text-xs [&_pre_code]:bg-transparent [&_pre_code]:p-0 [&_p]:mb-2 [&_p:last-child]:mb-0 [&_ul]:mb-2 [&_ol]:mb-2 [&_li]:mb-0.5 [&_h1]:text-base [&_h2]:text-sm [&_h3]:text-sm [&_a]:text-amber-600 [&_a]:underline [&_blockquote]:border-l-2 [&_blockquote]:border-gray-300 [&_blockquote]:pl-3 [&_blockquote]:text-gray-500">
<ReactMarkdown remarkPlugins={[remarkGfm]}>
{msg.content}
</ReactMarkdown>
</div>
)}
</div>
{showTime && msg.timestamp && (
<span className={`text-[10px] text-gray-400 mt-0.5 ${isUser ? "text-right" : "text-left"}`}>
{formatTimestamp(msg.timestamp)}
</span>
)}
</div>
</div>
);
}
function ThinkingIndicator() {
return (
<div className="flex justify-start mb-3">
<div className="w-8 h-8 bg-amber-500 rounded-full flex items-center justify-center text-sm mr-2 shrink-0 mt-1">
🔨
</div>
<div className="bg-gray-100 dark:bg-gray-800 rounded-2xl rounded-bl-md px-4 py-3">
<div className="flex gap-1.5">
<span className="w-2 h-2 bg-gray-400 rounded-full animate-bounce" style={{ animationDelay: "0ms" }} />
<span className="w-2 h-2 bg-gray-400 rounded-full animate-bounce" style={{ animationDelay: "150ms" }} />
<span className="w-2 h-2 bg-gray-400 rounded-full animate-bounce" style={{ animationDelay: "300ms" }} />
</div>
</div>
</div>
);
}
function ChatArea({
messages,
loading,
streaming,
thinking,
streamText,
onSend,
onAbort,
connectionState,
}: {
messages: ChatMessage[];
loading: boolean;
streaming: boolean;
thinking: boolean;
streamText: string;
onSend: (msg: string) => void;
onAbort: () => void;
connectionState: "connecting" | "connected" | "disconnected";
}) {
const [input, setInput] = useState("");
const messagesEndRef = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLTextAreaElement>(null);
useEffect(() => {
messagesEndRef.current?.scrollIntoView({ behavior: "smooth" });
}, [messages, streamText, thinking]);
// Auto-resize textarea
const autoResize = useCallback(() => {
const el = inputRef.current;
if (!el) return;
el.style.height = "42px";
el.style.height = Math.min(el.scrollHeight, 128) + "px";
}, []);
const handleSend = () => {
const text = input.trim();
if (!text || connectionState !== "connected") return;
onSend(text);
setInput("");
inputRef.current?.focus();
};
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
handleSend();
}
};
const connected = connectionState === "connected";
return (
<div className="flex-1 flex flex-col h-full">
{/* Messages */}
<div className="flex-1 overflow-y-auto px-6 py-4">
{loading ? (
<div className="text-center text-gray-400 dark:text-gray-500 py-12">Loading messages...</div>
) : messages.length === 0 ? (
<div className="text-center text-gray-400 dark:text-gray-500 py-12">
<span className="text-4xl block mb-3">🔨</span>
<p className="text-sm">Send a message to start chatting with Hammer</p>
</div>
) : (
<>
{messages.map((msg, i) => (
<MessageBubble key={i} msg={msg} />
))}
{streaming && streamText && (
<MessageBubble msg={{ role: "assistant", content: streamText }} />
)}
{thinking && !streaming && <ThinkingIndicator />}
</>
)}
<div ref={messagesEndRef} />
</div>
{/* Input */}
<div className="border-t border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-900 px-4 py-3">
{connectionState === "disconnected" && (
<div className="text-xs text-red-500 mb-2 flex items-center gap-1">
<span className="w-2 h-2 bg-red-500 rounded-full" />
Disconnected reconnecting...
</div>
)}
{connectionState === "connecting" && (
<div className="text-xs text-amber-500 mb-2 flex items-center gap-1">
<span className="w-2 h-2 bg-amber-500 rounded-full animate-pulse" />
Connecting...
</div>
)}
<div className="flex gap-2 items-end">
<textarea
ref={inputRef}
value={input}
onChange={(e) => { setInput(e.target.value); autoResize(); }}
onKeyDown={handleKeyDown}
placeholder={connected ? "Type a message..." : "Connecting..."}
disabled={!connected}
rows={1}
className="flex-1 resize-none rounded-xl border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 px-4 py-2.5 text-sm focus:outline-none focus:ring-2 focus:ring-amber-200 dark:focus:ring-amber-800 focus:border-amber-300 dark:focus:border-amber-700 disabled:opacity-50 max-h-32 placeholder:text-gray-400 dark:placeholder:text-gray-500"
style={{ minHeight: "42px" }}
/>
{streaming ? (
<button
onClick={onAbort}
className="px-4 py-2.5 bg-red-500 text-white rounded-xl text-sm font-medium hover:bg-red-600 transition shrink-0"
>
Stop
</button>
) : (
<button
onClick={handleSend}
disabled={!input.trim() || !connected}
className="px-4 py-2.5 bg-amber-500 text-white rounded-xl text-sm font-medium hover:bg-amber-600 transition disabled:opacity-50 disabled:cursor-not-allowed shrink-0"
>
Send
</button>
)}
</div>
</div>
</div>
);
}
export function ChatPage() {
const [client] = useState<ChatClient>(() => getChatClient());
const [connectionState, setConnectionState] = useState<"connecting" | "connected" | "disconnected">("disconnected");
const [threads, setThreads] = useState<ChatThread[]>(() => {
try {
return JSON.parse(localStorage.getItem("hammer-chat-threads") || "[]");
} catch {
return [];
}
});
const [activeThread, setActiveThread] = useState<string | null>(null);
const [messages, setMessages] = useState<ChatMessage[]>([]);
const [loading, setLoading] = useState(false);
const [streaming, setStreaming] = useState(false);
const [thinking, setThinking] = useState(false);
const [streamText, setStreamText] = useState("");
const [showThreads, setShowThreads] = useState(false);
const [gatewaySessions, setGatewaySessions] = useState<GatewaySession[]>([]);
const [loadingGatewaySessions, setLoadingGatewaySessions] = useState(false);
const [showGatewaySessions, setShowGatewaySessions] = useState(false);
// Persist threads to localStorage
useEffect(() => {
localStorage.setItem("hammer-chat-threads", JSON.stringify(threads));
}, [threads]);
// Connect client
useEffect(() => {
client.connect();
const unsub = client.onStateChange(setConnectionState);
return () => {
unsub();
};
}, [client]);
// Listen for chat events (streaming responses)
useEffect(() => {
const unsub = client.on("chat", (payload: any) => {
if (payload.sessionKey !== activeThread) return;
if (payload.state === "delta" && payload.message?.content) {
setThinking(false);
setStreaming(true);
const textParts = payload.message.content
.filter((c: any) => c.type === "text")
.map((c: any) => c.text)
.join("");
if (textParts) {
setStreamText((prev) => prev + textParts);
}
} else if (payload.state === "final") {
setThinking(false);
if (payload.message?.content) {
const text = payload.message.content
.filter((c: any) => c.type === "text")
.map((c: any) => c.text)
.join("");
if (text) {
setMessages((prev) => [...prev, { role: "assistant", content: text, timestamp: Date.now() }]);
setThreads((prev) =>
prev.map((t) =>
t.sessionKey === activeThread
? { ...t, lastMessage: text.slice(0, 100), updatedAt: Date.now() }
: t
)
);
}
}
setStreaming(false);
setStreamText("");
} else if (payload.state === "aborted" || payload.state === "error") {
setThinking(false);
setStreaming(false);
if (streamText) {
setMessages((prev) => [...prev, { role: "assistant", content: streamText }]);
}
setStreamText("");
}
});
return unsub;
}, [client, activeThread, streamText]);
// Load messages when thread changes
const loadMessages = useCallback(
async (sessionKey: string) => {
setLoading(true);
setMessages([]);
try {
const result = await client.chatHistory(sessionKey);
if (result?.messages) {
const msgs: ChatMessage[] = result.messages
.filter((m: any) => m.role === "user" || m.role === "assistant")
.map((m: any) => ({
role: m.role as "user" | "assistant",
content:
typeof m.content === "string"
? m.content
: Array.isArray(m.content)
? m.content
.filter((c: any) => c.type === "text")
.map((c: any) => c.text)
.join("")
: "",
}))
.filter((m: ChatMessage) => m.content);
setMessages(msgs);
}
} catch (e) {
console.error("Failed to load chat history:", e);
} finally {
setLoading(false);
}
},
[client]
);
useEffect(() => {
if (activeThread && connectionState === "connected") {
loadMessages(activeThread);
}
}, [activeThread, connectionState, loadMessages]);
const handleBrowseSessions = useCallback(async () => {
if (!client.isConnected()) return;
setLoadingGatewaySessions(true);
try {
const result = await client.sessionsList(50);
if (result?.sessions) {
setGatewaySessions(result.sessions.map((s: any) => ({
sessionKey: s.sessionKey || s.key,
kind: s.kind,
channel: s.channel,
lastActivity: s.lastActivity,
messageCount: s.messageCount,
})));
}
} catch (e) {
console.error("Failed to load sessions:", e);
} finally {
setLoadingGatewaySessions(false);
}
}, [client]);
const handleCreateThread = () => {
const id = `dash:chat:${Date.now()}`;
const thread: ChatThread = {
sessionKey: id,
name: `Chat ${threads.length + 1}`,
updatedAt: Date.now(),
};
setThreads((prev) => [thread, ...prev]);
setActiveThread(id);
setMessages([]);
};
const handleSelectThread = (sessionKey: string) => {
// If it's a gateway session not in local threads, add it
if (!threads.find(t => t.sessionKey === sessionKey)) {
const gwSession = gatewaySessions.find(s => s.sessionKey === sessionKey);
const thread: ChatThread = {
sessionKey,
name: gwSession?.channel
? `${gwSession.channel} (${sessionKey.slice(0, 12)}...)`
: sessionKey.length > 20
? `${sessionKey.slice(0, 20)}...`
: sessionKey,
updatedAt: Date.now(),
};
setThreads((prev) => [thread, ...prev]);
}
setActiveThread(sessionKey);
};
const handleRenameThread = (key: string, name: string) => {
setThreads((prev) => prev.map((t) => (t.sessionKey === key ? { ...t, name } : t)));
};
const handleDeleteThread = (key: string) => {
setThreads((prev) => prev.filter((t) => t.sessionKey !== key));
if (activeThread === key) {
const remaining = threads.filter((t) => t.sessionKey !== key);
setActiveThread(remaining.length > 0 ? remaining[0].sessionKey : null);
setMessages([]);
}
};
const handleSend = async (text: string) => {
if (!activeThread) return;
setMessages((prev) => [...prev, { role: "user", content: text, timestamp: Date.now() }]);
setThinking(true);
setThreads((prev) =>
prev.map((t) =>
t.sessionKey === activeThread
? { ...t, lastMessage: text.slice(0, 100), updatedAt: Date.now() }
: t
)
);
try {
await client.chatSend(activeThread, text);
} catch (e) {
console.error("Failed to send:", e);
setThinking(false);
setMessages((prev) => [
...prev,
{ role: "system", content: "Failed to send message. Please try again." },
]);
}
};
const handleAbort = async () => {
if (!activeThread) return;
try {
await client.chatAbort(activeThread);
} catch (e) {
console.error("Failed to abort:", e);
}
};
// Auto-create first thread if none exist
useEffect(() => {
if (threads.length === 0) {
handleCreateThread();
} else if (!activeThread) {
setActiveThread(threads[0].sessionKey);
}
}, []);
return (
<div className="h-[calc(100vh-3.5rem)] md:h-screen flex flex-col">
{/* Page Header */}
<header className="bg-white dark:bg-gray-900 border-b border-gray-200 dark:border-gray-800 sticky top-0 z-30">
<div className="px-4 sm:px-6 py-3 flex items-center justify-between">
<div className="flex items-center gap-3">
<button
onClick={() => setShowThreads(!showThreads)}
className="sm:hidden p-1 rounded hover:bg-gray-100 dark:hover:bg-gray-800 transition"
aria-label="Toggle threads"
>
<svg className="w-5 h-5 text-gray-600 dark:text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 6h16M4 12h8m-8 6h16" />
</svg>
</button>
<h1 className="text-lg font-bold text-gray-900 dark:text-gray-100">Chat</h1>
</div>
<div className="flex items-center gap-2">
<span
className={`w-2 h-2 rounded-full ${
connectionState === "connected"
? "bg-green-500"
: connectionState === "connecting"
? "bg-amber-500 animate-pulse"
: "bg-red-500"
}`}
/>
<span className="text-xs text-gray-400">
{connectionState === "connected" ? "Connected" : connectionState === "connecting" ? "Connecting..." : "Disconnected"}
</span>
</div>
</div>
</header>
{/* Chat body */}
<div className="flex-1 flex overflow-hidden relative">
{/* Thread list */}
<div className={`
absolute inset-0 z-20 sm:relative sm:inset-auto sm:z-auto
${showThreads ? "block" : "hidden"} sm:block
`}>
{showThreads && (
<div
className="absolute inset-0 bg-black/30 sm:hidden"
onClick={() => setShowThreads(false)}
/>
)}
<div className="relative z-10 h-full">
<ThreadList
threads={threads}
activeThread={activeThread}
onSelect={(key) => {
handleSelectThread(key);
setShowThreads(false);
}}
onCreate={() => {
handleCreateThread();
setShowThreads(false);
}}
onRename={handleRenameThread}
onDelete={handleDeleteThread}
onClose={() => setShowThreads(false)}
onBrowseSessions={handleBrowseSessions}
gatewaySessions={gatewaySessions}
loadingGatewaySessions={loadingGatewaySessions}
showGatewaySessions={showGatewaySessions}
onToggleGatewaySessions={() => setShowGatewaySessions(!showGatewaySessions)}
/>
</div>
</div>
{activeThread ? (
<ChatArea
messages={messages}
loading={loading}
streaming={streaming}
thinking={thinking}
streamText={streamText}
onSend={handleSend}
onAbort={handleAbort}
connectionState={connectionState}
/>
) : (
<div className="flex-1 flex items-center justify-center text-gray-400 dark:text-gray-500 p-4 text-center">
<div>
<span className="text-3xl block mb-2">💬</span>
<p>Select or create a thread</p>
<button
onClick={() => setShowThreads(true)}
className="sm:hidden mt-3 text-sm text-amber-500 font-medium"
>
View Threads
</button>
</div>
</div>
)}
</div>
</div>
);
}

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

View File

@@ -5,6 +5,7 @@ import remarkGfm from "remark-gfm";
import type { Task, TaskStatus, Project } from "../lib/types"; import type { Task, TaskStatus, Project } from "../lib/types";
import { updateTask, fetchProjects, addProgressNote, addSubtask, toggleSubtask, deleteSubtask, deleteTask } from "../lib/api"; import { updateTask, fetchProjects, addProgressNote, addSubtask, toggleSubtask, deleteSubtask, deleteTask } from "../lib/api";
import { useToast } from "../components/Toast"; import { useToast } from "../components/Toast";
import { TaskComments } from "../components/TaskComments";
const priorityColors: Record<string, string> = { const priorityColors: Record<string, string> = {
critical: "bg-red-500 text-white", critical: "bg-red-500 text-white",
@@ -598,6 +599,8 @@ export function TaskPage() {
</div> </div>
)} )}
</div> </div>
{/* Comments / Discussion */}
<TaskComments taskId={task.id} />
</div> </div>
{/* Sidebar */} {/* Sidebar */}

View File

@@ -0,0 +1,511 @@
import { useState, useEffect, useCallback, useRef } from "react";
import type { Todo, TodoPriority } from "../lib/types";
import {
fetchTodos,
fetchTodoCategories,
createTodo,
updateTodo,
toggleTodo,
deleteTodo,
} from "../lib/api";
const PRIORITY_COLORS: Record<TodoPriority, string> = {
high: "text-red-500",
medium: "text-amber-500",
low: "text-blue-400",
none: "text-gray-400 dark:text-gray-600",
};
const PRIORITY_BG: Record<TodoPriority, string> = {
high: "bg-red-500/10 border-red-500/30 text-red-400",
medium: "bg-amber-500/10 border-amber-500/30 text-amber-400",
low: "bg-blue-500/10 border-blue-500/30 text-blue-400",
none: "bg-gray-500/10 border-gray-500/30 text-gray-400",
};
const PRIORITY_LABELS: Record<TodoPriority, string> = {
high: "High",
medium: "Medium",
low: "Low",
none: "None",
};
function formatDueDate(dateStr: string | null): string {
if (!dateStr) return "";
const date = new Date(dateStr);
const today = new Date();
today.setHours(0, 0, 0, 0);
const tomorrow = new Date(today);
tomorrow.setDate(tomorrow.getDate() + 1);
const dateOnly = new Date(date);
dateOnly.setHours(0, 0, 0, 0);
if (dateOnly.getTime() === today.getTime()) return "Today";
if (dateOnly.getTime() === tomorrow.getTime()) return "Tomorrow";
const diff = dateOnly.getTime() - today.getTime();
const days = Math.round(diff / (1000 * 60 * 60 * 24));
if (days < 0) return `${Math.abs(days)}d overdue`;
if (days < 7) return date.toLocaleDateString("en-US", { weekday: "short" });
return date.toLocaleDateString("en-US", { month: "short", day: "numeric" });
}
function isDueOverdue(dateStr: string | null): boolean {
if (!dateStr) return false;
const date = new Date(dateStr);
const today = new Date();
today.setHours(0, 0, 0, 0);
return date < today;
}
function isDueToday(dateStr: string | null): boolean {
if (!dateStr) return false;
const date = new Date(dateStr);
const today = new Date();
return date.toDateString() === today.toDateString();
}
// ─── Todo Item Component ───
function TodoItem({
todo,
onToggle,
onUpdate,
onDelete,
}: {
todo: Todo;
onToggle: (id: string) => void;
onUpdate: (id: string, updates: { title?: string }) => void;
onDelete: (id: string) => void;
}) {
const [editing, setEditing] = useState(false);
const [editTitle, setEditTitle] = useState(todo.title);
const [showActions, setShowActions] = useState(false);
const inputRef = useRef<HTMLInputElement>(null);
useEffect(() => {
if (editing) inputRef.current?.focus();
}, [editing]);
const handleSave = () => {
if (editTitle.trim() && editTitle !== todo.title) {
onUpdate(todo.id, { title: editTitle.trim() });
}
setEditing(false);
};
const overdue = isDueOverdue(todo.dueDate) && !todo.isCompleted;
const dueToday = isDueToday(todo.dueDate) && !todo.isCompleted;
return (
<div
className={`group flex items-start gap-3 px-3 py-2.5 rounded-lg transition hover:bg-gray-100 dark:hover:bg-gray-800/50 ${
todo.isCompleted ? "opacity-50" : ""
}`}
onMouseEnter={() => setShowActions(true)}
onMouseLeave={() => setShowActions(false)}
>
{/* Checkbox */}
<button
onClick={() => onToggle(todo.id)}
className={`mt-0.5 w-5 h-5 rounded-full border-2 flex-shrink-0 flex items-center justify-center transition-all ${
todo.isCompleted
? "bg-green-500 border-green-500 text-white"
: `border-gray-300 dark:border-gray-600 hover:border-green-400 ${PRIORITY_COLORS[todo.priority]}`
}`}
style={
!todo.isCompleted && todo.priority !== "none"
? {
borderColor:
todo.priority === "high"
? "#ef4444"
: todo.priority === "medium"
? "#f59e0b"
: "#60a5fa",
}
: undefined
}
>
{todo.isCompleted && (
<svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={3} d="M5 13l4 4L19 7" />
</svg>
)}
</button>
{/* Content */}
<div className="flex-1 min-w-0">
{editing ? (
<input
ref={inputRef}
value={editTitle}
onChange={(e) => setEditTitle(e.target.value)}
onBlur={handleSave}
onKeyDown={(e) => {
if (e.key === "Enter") handleSave();
if (e.key === "Escape") {
setEditTitle(todo.title);
setEditing(false);
}
}}
className="w-full bg-transparent border-b border-amber-400 dark:border-amber-500 text-gray-900 dark:text-gray-100 text-sm focus:outline-none py-0.5"
/>
) : (
<p
onClick={() => !todo.isCompleted && setEditing(true)}
className={`text-sm cursor-pointer ${
todo.isCompleted
? "line-through text-gray-400 dark:text-gray-500"
: "text-gray-900 dark:text-gray-100"
}`}
>
{todo.title}
</p>
)}
{/* Meta row */}
<div className="flex items-center gap-2 mt-0.5 flex-wrap">
{todo.category && (
<span className="text-[11px] px-1.5 py-0.5 rounded bg-gray-200 dark:bg-gray-700 text-gray-500 dark:text-gray-400">
{todo.category}
</span>
)}
{todo.dueDate && (
<span
className={`text-[11px] ${
overdue
? "text-red-500 font-medium"
: dueToday
? "text-amber-500 font-medium"
: "text-gray-400 dark:text-gray-500"
}`}
>
{formatDueDate(todo.dueDate)}
</span>
)}
{todo.priority !== "none" && (
<span
className={`text-[10px] px-1.5 py-0.5 rounded border ${PRIORITY_BG[todo.priority]}`}
>
{PRIORITY_LABELS[todo.priority]}
</span>
)}
</div>
</div>
{/* Actions */}
<div
className={`flex items-center gap-1 transition-opacity ${
showActions ? "opacity-100" : "opacity-0"
}`}
>
<button
onClick={() => onDelete(todo.id)}
className="p-1 rounded text-gray-400 hover:text-red-500 hover:bg-red-50 dark:hover:bg-red-900/20 transition"
title="Delete"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"
/>
</svg>
</button>
</div>
</div>
);
}
// ─── Add Todo Form ───
function AddTodoForm({
onAdd,
categories,
}: {
onAdd: (todo: { title: string; priority?: TodoPriority; category?: string; dueDate?: string }) => void;
categories: string[];
}) {
const [title, setTitle] = useState("");
const [showExpanded, setShowExpanded] = useState(false);
const [priority, setPriority] = useState<TodoPriority>("none");
const [category, setCategory] = useState("");
const [newCategory, setNewCategory] = useState("");
const [dueDate, setDueDate] = useState("");
const inputRef = useRef<HTMLInputElement>(null);
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (!title.trim()) return;
const cat = newCategory.trim() || category || undefined;
onAdd({
title: title.trim(),
priority: priority !== "none" ? priority : undefined,
category: cat,
dueDate: dueDate || undefined,
});
setTitle("");
setPriority("none");
setCategory("");
setNewCategory("");
setDueDate("");
setShowExpanded(false);
inputRef.current?.focus();
};
return (
<form onSubmit={handleSubmit} className="mb-4">
<div className="flex items-center gap-2">
<div className="flex-1 relative">
<input
ref={inputRef}
value={title}
onChange={(e) => setTitle(e.target.value)}
placeholder="Add a todo..."
className="w-full px-3 py-2.5 rounded-lg bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 text-gray-900 dark:text-gray-100 text-sm placeholder-gray-400 dark:placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-amber-400/50 focus:border-amber-400"
onFocus={() => setShowExpanded(true)}
/>
</div>
<button
type="submit"
disabled={!title.trim()}
className="px-4 py-2.5 rounded-lg bg-amber-500 text-white text-sm font-medium hover:bg-amber-600 disabled:opacity-50 disabled:cursor-not-allowed transition"
>
Add
</button>
</div>
{showExpanded && (
<div className="mt-2 flex items-center gap-2 flex-wrap animate-slide-up">
{/* Priority */}
<select
value={priority}
onChange={(e) => setPriority(e.target.value as TodoPriority)}
className="text-xs px-2 py-1.5 rounded-lg bg-gray-100 dark:bg-gray-800 border border-gray-200 dark:border-gray-700 text-gray-700 dark:text-gray-300 focus:outline-none focus:ring-1 focus:ring-amber-400"
>
<option value="none">No priority</option>
<option value="high">🔴 High</option>
<option value="medium">🟡 Medium</option>
<option value="low">🔵 Low</option>
</select>
{/* Category */}
<select
value={category}
onChange={(e) => {
setCategory(e.target.value);
if (e.target.value) setNewCategory("");
}}
className="text-xs px-2 py-1.5 rounded-lg bg-gray-100 dark:bg-gray-800 border border-gray-200 dark:border-gray-700 text-gray-700 dark:text-gray-300 focus:outline-none focus:ring-1 focus:ring-amber-400"
>
<option value="">No category</option>
{categories.map((c) => (
<option key={c} value={c}>{c}</option>
))}
</select>
<input
value={newCategory}
onChange={(e) => {
setNewCategory(e.target.value);
if (e.target.value) setCategory("");
}}
placeholder="New category..."
className="text-xs px-2 py-1.5 rounded-lg bg-gray-100 dark:bg-gray-800 border border-gray-200 dark:border-gray-700 text-gray-700 dark:text-gray-300 placeholder-gray-400 focus:outline-none focus:ring-1 focus:ring-amber-400 w-32"
/>
{/* Due date */}
<input
type="date"
value={dueDate}
onChange={(e) => setDueDate(e.target.value)}
className="text-xs px-2 py-1.5 rounded-lg bg-gray-100 dark:bg-gray-800 border border-gray-200 dark:border-gray-700 text-gray-700 dark:text-gray-300 focus:outline-none focus:ring-1 focus:ring-amber-400"
/>
<button
type="button"
onClick={() => setShowExpanded(false)}
className="text-xs text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 px-1"
>
</button>
</div>
)}
</form>
);
}
// ─── Main Todos Page ───
type FilterTab = "all" | "active" | "completed";
export function TodosPage() {
const [todos, setTodos] = useState<Todo[]>([]);
const [categories, setCategories] = useState<string[]>([]);
const [loading, setLoading] = useState(true);
const [filter, setFilter] = useState<FilterTab>("active");
const [categoryFilter, setCategoryFilter] = useState<string>("");
const loadTodos = useCallback(async () => {
try {
const params: { completed?: string; category?: string } = {};
if (filter === "active") params.completed = "false";
if (filter === "completed") params.completed = "true";
if (categoryFilter) params.category = categoryFilter;
const [data, cats] = await Promise.all([
fetchTodos(params),
fetchTodoCategories(),
]);
setTodos(data);
setCategories(cats);
} catch (e) {
console.error("Failed to load todos:", e);
} finally {
setLoading(false);
}
}, [filter, categoryFilter]);
useEffect(() => {
loadTodos();
}, [loadTodos]);
const handleAdd = async (todo: { title: string; priority?: TodoPriority; category?: string; dueDate?: string }) => {
try {
await createTodo(todo);
loadTodos();
} catch (e) {
console.error("Failed to create todo:", e);
}
};
const handleToggle = async (id: string) => {
try {
// Optimistic update
setTodos((prev) =>
prev.map((t) =>
t.id === id ? { ...t, isCompleted: !t.isCompleted } : t
)
);
await toggleTodo(id);
// Reload after a short delay to let the animation play
setTimeout(loadTodos, 300);
} catch (e) {
console.error("Failed to toggle todo:", e);
loadTodos();
}
};
const handleUpdate = async (id: string, updates: { title?: string; description?: string; priority?: TodoPriority; category?: string | null; dueDate?: string | null; isCompleted?: boolean }) => {
try {
await updateTodo(id, updates);
loadTodos();
} catch (e) {
console.error("Failed to update todo:", e);
}
};
const handleDelete = async (id: string) => {
try {
setTodos((prev) => prev.filter((t) => t.id !== id));
await deleteTodo(id);
} catch (e) {
console.error("Failed to delete todo:", e);
loadTodos();
}
};
return (
<div className="max-w-2xl mx-auto px-4 py-6 md:py-10">
{/* Header */}
<div className="mb-6">
<h1 className="text-2xl font-bold text-gray-900 dark:text-gray-100 flex items-center gap-2">
<span></span> Todos
</h1>
<p className="text-sm text-gray-500 dark:text-gray-400 mt-1">
Personal checklist quick todos and reminders
</p>
</div>
{/* Add form */}
<AddTodoForm onAdd={handleAdd} categories={categories} />
{/* Filter tabs */}
<div className="flex items-center gap-1 mb-4 border-b border-gray-200 dark:border-gray-800">
{(["active", "all", "completed"] as FilterTab[]).map((tab) => (
<button
key={tab}
onClick={() => setFilter(tab)}
className={`px-3 py-2 text-sm font-medium border-b-2 transition ${
filter === tab
? "border-amber-500 text-amber-600 dark:text-amber-400"
: "border-transparent text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-300"
}`}
>
{tab === "active" ? "Active" : tab === "completed" ? "Completed" : "All"}
</button>
))}
{/* Category filter */}
{categories.length > 0 && (
<div className="ml-auto">
<select
value={categoryFilter}
onChange={(e) => setCategoryFilter(e.target.value)}
className="text-xs px-2 py-1.5 rounded-lg bg-gray-100 dark:bg-gray-800 border border-gray-200 dark:border-gray-700 text-gray-600 dark:text-gray-400 focus:outline-none"
>
<option value="">All categories</option>
{categories.map((c) => (
<option key={c} value={c}>{c}</option>
))}
</select>
</div>
)}
</div>
{/* Todo list */}
{loading ? (
<div className="py-12 text-center text-gray-400">
<div className="flex items-center justify-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>
) : todos.length === 0 ? (
<div className="py-12 text-center">
<p className="text-gray-400 dark:text-gray-500 text-lg">
{filter === "completed" ? "No completed todos yet" : "All clear! 🎉"}
</p>
<p className="text-gray-400 dark:text-gray-600 text-sm mt-1">
{filter === "active" ? "Add a todo above to get started" : ""}
</p>
</div>
) : (
<div className="space-y-0.5">
{todos.map((todo) => (
<TodoItem
key={todo.id}
todo={todo}
onToggle={handleToggle}
onUpdate={handleUpdate}
onDelete={handleDelete}
/>
))}
</div>
)}
{/* Footer stats */}
{!loading && todos.length > 0 && (
<div className="mt-6 pt-4 border-t border-gray-200 dark:border-gray-800 text-center">
<p className="text-xs text-gray-400 dark:text-gray-600">
{todos.length} {todos.length === 1 ? "todo" : "todos"} shown
</p>
</div>
)}
</div>
);
}