this repo has no description
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

Format codebase with Prettier

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

alice b4444695 c5771656

+365 -380
+1 -1
eslint.config.mjs
··· 1 1 import eslint from '@eslint/js'; 2 - import tseslint from 'typescript-eslint'; 3 2 import prettierConfig from 'eslint-config-prettier'; 3 + import tseslint from 'typescript-eslint'; 4 4 5 5 export default tseslint.config( 6 6 eslint.configs.recommended,
+1 -1
postcss.config.js
··· 3 3 tailwindcss: {}, 4 4 autoprefixer: {}, 5 5 }, 6 - } 6 + };
+4 -3
src/cli/index.ts
··· 1 1 #!/usr/bin/env bun 2 2 import { parseArgs } from 'util'; 3 - import { processCommand } from './process'; 3 + 4 + import { getDatesWithoutBragSummary, getSessionsForDate, getStats, saveDailySummary } from '../core/db'; 4 5 import { getSessionStats } from '../core/session-detector'; 5 - import { getStats, getDatesWithoutBragSummary, getSessionsForDate, saveDailySummary } from '../core/db'; 6 6 import { generateDailyBragSummary } from '../core/summarizer'; 7 + import { processCommand } from './process'; 7 8 8 9 interface DailySummaryParsed { 9 10 projects: { name: string; summary: string }[]; ··· 138 139 139 140 try { 140 141 const summary = await generateDailyBragSummary(date, sessions); 141 - const projectNames = [...new Set(sessions.map(s => s.project_name))].filter((n): n is string => n !== null); 142 + const projectNames = [...new Set(sessions.map((s) => s.project_name))].filter((n): n is string => n !== null); 142 143 saveDailySummary(date, summary, projectNames, sessions.length); 143 144 144 145 // Parse and show preview
+31 -39
src/cli/process.ts
··· 1 - import { findUnprocessedSessions } from '../core/session-detector'; 2 - import { parseSessionFile } from '../core/session-reader'; 3 1 import { parseCodexSessionFile } from '../core/codex-reader'; 4 - import type { SessionFile } from '../types'; 5 - import { summarizeSession, generateDailyBragSummary } from '../core/summarizer'; 6 2 import { 7 - markFileProcessed, 8 - saveSessionSummary, 9 - saveDailySummary, 10 3 getDatesWithoutBragSummary, 11 - getSessionsForDate, 12 4 getNewProjectsForDate, 5 + getSessionsForDate, 6 + markFileProcessed, 7 + saveDailySummary, 8 + saveSessionSummary, 13 9 upsertProjectFromSession, 14 10 } from '../core/db'; 11 + import { findUnprocessedSessions } from '../core/session-detector'; 12 + import { parseSessionFile } from '../core/session-reader'; 13 + import { generateDailyBragSummary, summarizeSession } from '../core/summarizer'; 14 + import type { SessionFile } from '../types'; 15 15 16 16 interface ProcessOptions { 17 17 force: boolean; ··· 120 120 startDate.setDate(startDate.getDate() - bufferDays); 121 121 endDate.setDate(endDate.getDate() + bufferDays); 122 122 123 - sessions = sessions.filter(s => 124 - s.modifiedAt >= startDate && s.modifiedAt <= endDate 125 - ); 123 + sessions = sessions.filter((s) => s.modifiedAt >= startDate && s.modifiedAt <= endDate); 126 124 127 125 console.log(`Pre-filtered ${String(originalCount)} → ${String(sessions.length)} sessions by modification time\n`); 128 126 } ··· 153 151 } catch (error) { 154 152 return { session, error }; 155 153 } 156 - }) 154 + }), 157 155 ); 158 156 results.push(...batchResults); 159 157 } ··· 241 239 async function processSession( 242 240 sessionFile: SessionFile, 243 241 verbose: boolean, 244 - dateFilter: DateFilter 242 + dateFilter: DateFilter, 245 243 ): Promise<{ 246 244 date: string; 247 245 startTime: string; ··· 251 249 filtered: boolean; 252 250 }> { 253 251 // Parse the session file (dispatch based on source) 254 - const parsed = sessionFile.source === 'codex' 255 - ? await parseCodexSessionFile( 256 - sessionFile.path, 257 - sessionFile.projectPath, 258 - sessionFile.projectName 259 - ) 260 - : await parseSessionFile( 261 - sessionFile.path, 262 - sessionFile.projectPath, 263 - sessionFile.projectName 264 - ); 252 + const parsed = 253 + sessionFile.source === 'codex' 254 + ? await parseCodexSessionFile(sessionFile.path, sessionFile.projectPath, sessionFile.projectName) 255 + : await parseSessionFile(sessionFile.path, sessionFile.projectPath, sessionFile.projectName); 265 256 266 257 if (verbose) { 267 - console.log(` Parsed: ${String(parsed.messages.length)} messages, ${String(Object.keys(parsed.stats.toolCalls).length)} tool types`); 258 + console.log( 259 + ` Parsed: ${String(parsed.messages.length)} messages, ${String(Object.keys(parsed.stats.toolCalls).length)} tool types`, 260 + ); 268 261 } 269 262 270 263 // Check date filter BEFORE expensive LLM summarization ··· 293 286 294 287 // Only these tools indicate actual work happened 295 288 const codeChangeTools = ['Edit', 'Write', 'NotebookEdit', 'MultiEdit']; 296 - const hasCodeChanges = codeChangeTools.some(tool => (tools[tool] || 0) > 0); 289 + const hasCodeChanges = codeChangeTools.some((tool) => (tools[tool] || 0) > 0); 297 290 298 291 if (!hasCodeChanges) { 299 292 // Mark as processed but don't save to DB ··· 318 311 319 312 // Filter out sessions that the LLM determined had no real work 320 313 const noWorkPhrases = [ 321 - 'no work', 'no coding', 'was interrupted', 'no substantive', 322 - 'minimal progress', 'minimal activity', 'no significant', 'nothing was accomplished' 314 + 'no work', 315 + 'no coding', 316 + 'was interrupted', 317 + 'no substantive', 318 + 'minimal progress', 319 + 'minimal activity', 320 + 'no significant', 321 + 'nothing was accomplished', 323 322 ]; 324 323 const summaryLower = summary.shortSummary.toLowerCase(); 325 - if (noWorkPhrases.some(phrase => summaryLower.includes(phrase))) { 324 + if (noWorkPhrases.some((phrase) => summaryLower.includes(phrase))) { 326 325 markFileProcessed(sessionFile.path, sessionFile.fileHash); 327 326 return { 328 327 date: parsed.date, ··· 349 348 }; 350 349 } 351 350 352 - async function regenerateSummariesForDates( 353 - datesToRegenerate: Set<string>, 354 - verbose: boolean 355 - ): Promise<void> { 351 + async function regenerateSummariesForDates(datesToRegenerate: Set<string>, verbose: boolean): Promise<void> { 356 352 // Also include any dates that have never had a summary generated 357 353 const datesWithoutSummary = getDatesWithoutBragSummary(); 358 354 const allDates = new Set([...datesToRegenerate, ...datesWithoutSummary]); ··· 367 363 // Find which projects are new (first appearance on this date) 368 364 const newProjectPaths = getNewProjectsForDate(date); 369 365 const newProjectNames = new Set( 370 - sessions 371 - .filter((s) => newProjectPaths.includes(s.project_path)) 372 - .map((s) => s.project_name) 366 + sessions.filter((s) => newProjectPaths.includes(s.project_path)).map((s) => s.project_name), 373 367 ); 374 368 375 369 if (verbose) { ··· 392 386 } 393 387 } 394 388 395 - function groupByProject( 396 - sessions: SessionFile[] 397 - ): Record<string, SessionFile[]> { 389 + function groupByProject(sessions: SessionFile[]): Record<string, SessionFile[]> { 398 390 const grouped: Record<string, SessionFile[]> = {}; 399 391 400 392 for (const session of sessions) {
+8 -20
src/core/codex-detector.ts
··· 1 + import { existsSync, readFileSync, readdirSync, statSync } from 'fs'; 2 + import { homedir } from 'os'; 1 3 import { join } from 'path'; 2 - import { homedir } from 'os'; 3 - import { existsSync, readdirSync, statSync, readFileSync } from 'fs'; 4 + 5 + import type { SessionFile } from '../types'; 4 6 import { findGitRoot } from './session-detector'; 5 - import type { SessionFile } from '../types'; 6 7 7 8 /** 8 9 * New format (post-October 2025): session_meta type with nested payload ··· 55 56 * Extract metadata from a Codex session file 56 57 * Supports both new format (session_meta) and old format (pre-October 2025) 57 58 */ 58 - function extractCodexSessionMeta( 59 - filePath: string 60 - ): { cwd: string; sessionId: string; gitBranch: string } | null { 59 + function extractCodexSessionMeta(filePath: string): { cwd: string; sessionId: string; gitBranch: string } | null { 61 60 try { 62 61 const content = readFileSync(filePath, 'utf-8'); 63 62 const lines = content.split('\n'); ··· 69 68 // Type guard for new format 70 69 const isNewFormat = (e: unknown): e is NewFormatEntry => { 71 70 return ( 72 - typeof e === 'object' && 73 - e !== null && 74 - 'type' in e && 75 - typeof e.type === 'string' && 76 - e.type === 'session_meta' 71 + typeof e === 'object' && e !== null && 'type' in e && typeof e.type === 'string' && e.type === 'session_meta' 77 72 ); 78 73 }; 79 74 ··· 112 107 if (line.length === 0) continue; 113 108 try { 114 109 const msg = JSON.parse(line) as OldFormatMessage; 115 - if ( 116 - msg.type === 'message' && 117 - msg.content !== undefined && 118 - Array.isArray(msg.content) 119 - ) { 110 + if (msg.type === 'message' && msg.content !== undefined && Array.isArray(msg.content)) { 120 111 for (const block of msg.content) { 121 - if ( 122 - block.type === 'input_text' && 123 - block.text?.includes('Current working directory:') === true 124 - ) { 112 + if (block.type === 'input_text' && block.text?.includes('Current working directory:') === true) { 125 113 const cwdRegex = /Current working directory: ([^\n\\]+)/; 126 114 const match = cwdRegex.exec(block.text); 127 115 const extractedCwd = match?.[1];
+34 -19
src/core/codex-reader.ts
··· 1 1 import { createReadStream } from 'fs'; 2 2 import * as readline from 'readline'; 3 - import type { ParsedSession, ParsedMessage, ToolUse, SessionStats } from '../types'; 3 + 4 + import type { ParsedMessage, ParsedSession, SessionStats, ToolUse } from '../types'; 4 5 5 6 // Codex JSONL entry types 6 7 interface CodexEntry { ··· 64 65 /** 65 66 * Stream-parse a Codex JSONL session file 66 67 */ 67 - async function* parseCodexJSONLStream( 68 - filePath: string 69 - ): AsyncGenerator<CodexEntry> { 68 + async function* parseCodexJSONLStream(filePath: string): AsyncGenerator<CodexEntry> { 70 69 const rl = readline.createInterface({ 71 70 input: createReadStream(filePath), 72 71 crlfDelay: Infinity, ··· 129 128 let cmd = ''; 130 129 if (Array.isArray(args.command)) { 131 130 cmd = args.command.join(' '); 132 - } else if (typeof args.command === 'string' || typeof args.command === 'number' || typeof args.command === 'boolean') { 131 + } else if ( 132 + typeof args.command === 'string' || 133 + typeof args.command === 'number' || 134 + typeof args.command === 'boolean' 135 + ) { 133 136 cmd = String(args.command); 134 137 } 135 138 return truncate(cmd, MAX_LENGTH); ··· 164 167 const texts: string[] = []; 165 168 for (const item of content) { 166 169 // Handle both new format ('text') and old format ('input_text', 'output_text') 167 - if ((item.type === 'text' || item.type === 'input_text' || item.type === 'output_text') && item.text !== undefined) { 170 + if ( 171 + (item.type === 'text' || item.type === 'input_text' || item.type === 'output_text') && 172 + item.text !== undefined 173 + ) { 168 174 texts.push(item.text); 169 175 } 170 176 } ··· 177 183 export async function parseCodexSessionFile( 178 184 filePath: string, 179 185 projectPath: string, 180 - projectName: string 186 + projectName: string, 181 187 ): Promise<ParsedSession> { 182 188 const messages: ParsedMessage[] = []; 183 189 const toolCalls: Record<string, number> = {}; ··· 212 218 if (rawEntry.id !== undefined && rawEntry.type === undefined && rawEntry.git !== undefined) { 213 219 sessionId = rawEntry.id as string; 214 220 const git = rawEntry.git as Record<string, unknown>; 215 - gitBranch = (git.branch !== undefined ? git.branch as string : ''); 221 + gitBranch = git.branch !== undefined ? (git.branch as string) : ''; 216 222 continue; 217 223 } 218 224 ··· 318 324 type: 'assistant', 319 325 timestamp: (oldEntry.timestamp as string | undefined) ?? '', 320 326 text: '', 321 - toolUses: [{ 322 - name: 'Edit', 323 - input: `apply_patch: ${files.join(', ') !== '' ? files.join(', ') : 'file changes'}`, 324 - rawInput: oldEntry, 325 - }], 327 + toolUses: [ 328 + { 329 + name: 'Edit', 330 + input: `apply_patch: ${files.join(', ') !== '' ? files.join(', ') : 'file changes'}`, 331 + rawInput: oldEntry, 332 + }, 333 + ], 326 334 }); 327 335 } else { 328 336 // Regular shell command ··· 332 340 type: 'assistant', 333 341 timestamp: (oldEntry.timestamp as string | undefined) ?? '', 334 342 text: '', 335 - toolUses: [{ 336 - name: 'Bash', 337 - input: shellCmd.substring(0, 100), 338 - rawInput: oldEntry, 339 - }], 343 + toolUses: [ 344 + { 345 + name: 'Bash', 346 + input: shellCmd.substring(0, 100), 347 + rawInput: oldEntry, 348 + }, 349 + ], 340 350 }); 341 351 } 342 352 } ··· 349 359 // Handle old format: top-level message (pre-October 2025) 350 360 // Old format: {"type":"message","role":"user/assistant","content":[{"type":"input_text/output_text","text":"..."}]} 351 361 if (entry.type === 'message') { 352 - const msgEntry = entry as unknown as { type: string; role: string; content?: CodexContentItem[]; timestamp?: string }; 362 + const msgEntry = entry as unknown as { 363 + type: string; 364 + role: string; 365 + content?: CodexContentItem[]; 366 + timestamp?: string; 367 + }; 353 368 const text = extractTextFromContent(msgEntry.content); 354 369 355 370 // Skip environment_context messages (just contain cwd/approval policy info)
+75 -92
src/core/db.ts
··· 1 1 import { Database } from 'bun:sqlite'; 2 + import { existsSync, mkdirSync } from 'fs'; 2 3 import { join } from 'path'; 3 - import { mkdirSync, existsSync } from 'fs'; 4 + 4 5 import type { 5 - DBSessionSummary, 6 6 DBDailySummary, 7 7 DBProcessedFile, 8 - SessionSummary, 8 + DBSessionSummary, 9 + DayDetail, 10 + DayListItem, 9 11 ParsedSession, 10 - SessionStats, 11 - SessionSource, 12 - DayListItem, 13 - DayDetail, 14 12 ProjectDetail, 15 - SessionDetail, 16 13 ProjectListItem, 17 14 ProjectStatus, 15 + SessionDetail, 16 + SessionSource, 17 + SessionStats, 18 + SessionSummary, 18 19 } from '../types'; 19 20 20 21 const DATA_DIR = join(import.meta.dir, '../../data'); ··· 106 107 } 107 108 108 109 // Check if source column exists 109 - const columns = database 110 - .query<{ name: string }, []>(`PRAGMA table_info(session_summaries)`) 111 - .all(); 110 + const columns = database.query<{ name: string }, []>(`PRAGMA table_info(session_summaries)`).all(); 112 111 113 112 const hasSourceColumn = columns.some((col) => col.name === 'source'); 114 113 ··· 124 123 // Processed files tracking 125 124 export function isFileProcessed(filePath: string, fileHash: string): boolean { 126 125 const database = getDb(); 127 - const row = database.query<DBProcessedFile, [string]>( 128 - 'SELECT * FROM processed_files WHERE file_path = ?' 129 - ).get(filePath); 126 + const row = database 127 + .query<DBProcessedFile, [string]>('SELECT * FROM processed_files WHERE file_path = ?') 128 + .get(filePath); 130 129 131 130 return row !== null && row.file_hash === fileHash; 132 131 } ··· 136 135 database.run( 137 136 `INSERT OR REPLACE INTO processed_files (file_path, file_hash, processed_at) 138 137 VALUES (?, ?, ?)`, 139 - [filePath, fileHash, new Date().toISOString()] 138 + [filePath, fileHash, new Date().toISOString()], 140 139 ); 141 140 } 142 141 ··· 144 143 export function saveSessionSummary( 145 144 session: ParsedSession, 146 145 summary: SessionSummary, 147 - source: SessionSource = 'claude' 146 + source: SessionSource = 'claude', 148 147 ): void { 149 148 const database = getDb(); 150 149 database.run( ··· 167 166 JSON.stringify(session.stats), 168 167 source, 169 168 new Date().toISOString(), 170 - ] 169 + ], 171 170 ); 172 171 } 173 172 ··· 176 175 date: string, 177 176 bragSummary: string, 178 177 projectsWorked: string[], 179 - totalSessions: number 178 + totalSessions: number, 180 179 ): void { 181 180 const database = getDb(); 182 181 database.run( 183 182 `INSERT OR REPLACE INTO daily_summaries 184 183 (date, brag_summary, projects_worked, total_sessions, generated_at) 185 184 VALUES (?, ?, ?, ?, ?)`, 186 - [ 187 - date, 188 - bragSummary, 189 - JSON.stringify(projectsWorked), 190 - totalSessions, 191 - new Date().toISOString(), 192 - ] 185 + [date, bragSummary, JSON.stringify(projectsWorked), totalSessions, new Date().toISOString()], 193 186 ); 194 187 } 195 188 196 189 // Query functions for API 197 190 export function getDays(limit = 365): DayListItem[] { 198 191 const database = getDb(); 199 - const rows = database.query< 200 - { date: string; project_count: number; session_count: number }, 201 - [number] 202 - >(` 192 + const rows = database 193 + .query<{ date: string; project_count: number; session_count: number }, [number]>( 194 + ` 203 195 SELECT 204 196 date, 205 197 COUNT(DISTINCT project_path) as project_count, ··· 208 200 GROUP BY date 209 201 ORDER BY date DESC 210 202 LIMIT ? 211 - `).all(limit); 203 + `, 204 + ) 205 + .all(limit); 212 206 213 207 const dailySummaries = new Map<string, string>(); 214 - const summaryRows = database.query<{ date: string; brag_summary: string }, []>( 215 - 'SELECT date, brag_summary FROM daily_summaries' 216 - ).all(); 208 + const summaryRows = database 209 + .query<{ date: string; brag_summary: string }, []>('SELECT date, brag_summary FROM daily_summaries') 210 + .all(); 217 211 for (const row of summaryRows) { 218 212 dailySummaries.set(row.date, row.brag_summary); 219 213 } ··· 228 222 229 223 export function getDayDetail(date: string): DayDetail | null { 230 224 const database = getDb(); 231 - const sessions = database.query<DBSessionSummary, [string]>( 232 - 'SELECT * FROM session_summaries WHERE date = ? ORDER BY start_time' 233 - ).all(date); 225 + const sessions = database 226 + .query<DBSessionSummary, [string]>('SELECT * FROM session_summaries WHERE date = ? ORDER BY start_time') 227 + .all(date); 234 228 235 229 if (sessions.length === 0) return null; 236 230 237 - const dailySummary = database.query<DBDailySummary, [string]>( 238 - 'SELECT * FROM daily_summaries WHERE date = ?' 239 - ).get(date); 231 + const dailySummary = database 232 + .query<DBDailySummary, [string]>('SELECT * FROM daily_summaries WHERE date = ?') 233 + .get(date); 240 234 241 235 // Group by project 242 236 const projectMap = new Map<string, SessionDetail[]>(); ··· 262 256 projectMap.set(session.project_path, existing); 263 257 } 264 258 265 - const projects: ProjectDetail[] = Array.from(projectMap.entries()).map( 266 - ([path, sessions]) => ({ 267 - name: sessions[0]?.sessionId ? path.split('/').pop() ?? path : path, 268 - path, 269 - sessions, 270 - }) 271 - ); 259 + const projects: ProjectDetail[] = Array.from(projectMap.entries()).map(([path, sessions]) => ({ 260 + name: sessions[0]?.sessionId ? (path.split('/').pop() ?? path) : path, 261 + path, 262 + sessions, 263 + })); 272 264 273 265 // Get project name from first session 274 266 for (const project of projects) { ··· 295 287 totalProjects: number; 296 288 } { 297 289 const database = getDb(); 298 - const stats = database.query< 299 - { total_sessions: number; total_days: number; total_projects: number }, 300 - [] 301 - >(` 290 + const stats = database 291 + .query<{ total_sessions: number; total_days: number; total_projects: number }, []>( 292 + ` 302 293 SELECT 303 294 COUNT(*) as total_sessions, 304 295 COUNT(DISTINCT date) as total_days, 305 296 COUNT(DISTINCT project_path) as total_projects 306 297 FROM session_summaries 307 - `).get(); 298 + `, 299 + ) 300 + .get(); 308 301 309 302 return { 310 303 totalSessions: stats?.total_sessions ?? 0, ··· 315 308 316 309 export function getSessionsForDate(date: string): DBSessionSummary[] { 317 310 const database = getDb(); 318 - return database.query<DBSessionSummary, [string]>( 319 - 'SELECT * FROM session_summaries WHERE date = ? ORDER BY start_time' 320 - ).all(date); 311 + return database 312 + .query<DBSessionSummary, [string]>('SELECT * FROM session_summaries WHERE date = ? ORDER BY start_time') 313 + .all(date); 321 314 } 322 315 323 316 export function getDatesWithoutBragSummary(): string[] { 324 317 const database = getDb(); 325 - const rows = database.query<{ date: string }, []>(` 318 + const rows = database 319 + .query<{ date: string }, []>( 320 + ` 326 321 SELECT DISTINCT s.date 327 322 FROM session_summaries s 328 323 LEFT JOIN daily_summaries d ON s.date = d.date 329 324 WHERE d.date IS NULL 330 325 ORDER BY s.date DESC 331 - `).all(); 326 + `, 327 + ) 328 + .all(); 332 329 333 330 return rows.map((r) => r.date); 334 331 } ··· 338 335 */ 339 336 export function isNewProject(projectPath: string, beforeDate: string): boolean { 340 337 const database = getDb(); 341 - const row = database.query<{ count: number }, [string, string]>( 342 - 'SELECT COUNT(*) as count FROM session_summaries WHERE project_path = ? AND date < ?' 343 - ).get(projectPath, beforeDate); 338 + const row = database 339 + .query< 340 + { count: number }, 341 + [string, string] 342 + >('SELECT COUNT(*) as count FROM session_summaries WHERE project_path = ? AND date < ?') 343 + .get(projectPath, beforeDate); 344 344 345 345 return (row?.count ?? 0) === 0; 346 346 } ··· 352 352 const database = getDb(); 353 353 354 354 // Get all projects that appear on this date 355 - const projectsOnDate = database.query<{ project_path: string }, [string]>( 356 - 'SELECT DISTINCT project_path FROM session_summaries WHERE date = ?' 357 - ).all(date); 355 + const projectsOnDate = database 356 + .query<{ project_path: string }, [string]>('SELECT DISTINCT project_path FROM session_summaries WHERE date = ?') 357 + .all(date); 358 358 359 359 // Filter to those with no prior sessions 360 - return projectsOnDate 361 - .filter((p) => isNewProject(p.project_path, date)) 362 - .map((p) => p.project_path); 360 + return projectsOnDate.filter((p) => isNewProject(p.project_path, date)).map((p) => p.project_path); 363 361 } 364 362 365 363 // ============ Project Status Tracking ============ ··· 375 373 376 374 // Check if projects table is empty but sessions exist 377 375 const projectCount = 378 - database 379 - .query<{ count: number }, []>('SELECT COUNT(*) as count FROM projects') 380 - .get()?.count ?? 0; 376 + database.query<{ count: number }, []>('SELECT COUNT(*) as count FROM projects').get()?.count ?? 0; 381 377 382 378 const sessionCount = 383 - database 384 - .query<{ count: number }, []>( 385 - 'SELECT COUNT(*) as count FROM session_summaries' 386 - ) 387 - .get()?.count ?? 0; 379 + database.query<{ count: number }, []>('SELECT COUNT(*) as count FROM session_summaries').get()?.count ?? 0; 388 380 389 381 if (projectCount === 0 && sessionCount > 0) { 390 382 console.log('Backfilling projects table from session data...'); ··· 409 401 FROM session_summaries 410 402 GROUP BY project_path 411 403 `, 412 - [now, now] 404 + [now, now], 413 405 ); 414 406 415 - const filled = 416 - database 417 - .query<{ count: number }, []>('SELECT COUNT(*) as count FROM projects') 418 - .get()?.count ?? 0; 407 + const filled = database.query<{ count: number }, []>('SELECT COUNT(*) as count FROM projects').get()?.count ?? 0; 419 408 console.log(`Created ${String(filled)} project records.`); 420 409 } 421 410 } ··· 423 412 /** 424 413 * Upsert project when processing a session 425 414 */ 426 - export function upsertProjectFromSession( 427 - projectPath: string, 428 - projectName: string, 429 - sessionDate: string 430 - ): void { 415 + export function upsertProjectFromSession(projectPath: string, projectName: string, sessionDate: string): void { 431 416 const database = getDb(); 432 417 const now = new Date().toISOString(); 433 418 ··· 446 431 total_sessions = (SELECT COUNT(*) FROM session_summaries WHERE project_path = excluded.project_path), 447 432 updated_at = excluded.updated_at 448 433 `, 449 - [projectPath, projectName, sessionDate, sessionDate, now, now] 434 + [projectPath, projectName, sessionDate, sessionDate, now, now], 450 435 ); 451 436 } 452 437 ··· 508 493 /** 509 494 * Update a project's status 510 495 */ 511 - export function updateProjectStatus( 512 - projectPath: string, 513 - status: ProjectStatus 514 - ): boolean { 496 + export function updateProjectStatus(projectPath: string, status: ProjectStatus): boolean { 515 497 const database = getDb(); 516 - const result = database.run( 517 - `UPDATE projects SET status = ?, updated_at = ? WHERE project_path = ?`, 518 - [status, new Date().toISOString(), projectPath] 519 - ); 498 + const result = database.run(`UPDATE projects SET status = ?, updated_at = ? WHERE project_path = ?`, [ 499 + status, 500 + new Date().toISOString(), 501 + projectPath, 502 + ]); 520 503 return result.changes > 0; 521 504 }
+12 -30
src/core/session-detector.ts
··· 1 - import { join, dirname } from 'path'; 1 + import { createHash } from 'crypto'; 2 + import { existsSync, readFileSync, readdirSync, statSync } from 'fs'; 2 3 import { homedir } from 'os'; 3 - import { existsSync, readdirSync, statSync, readFileSync } from 'fs'; 4 - import { createHash } from 'crypto'; 4 + import { dirname, join } from 'path'; 5 + 6 + import type { SessionFile } from '../types'; 7 + import { findAllCodexSessionFiles } from './codex-detector'; 5 8 import { isFileProcessed } from './db'; 6 - import { findAllCodexSessionFiles } from './codex-detector'; 7 - import type { SessionFile } from '../types'; 8 9 9 10 /** 10 11 * Find the git root for a given path. ··· 27 28 return null; 28 29 } 29 30 30 - 31 31 /** 32 32 * Get possible Claude config directories 33 33 */ 34 34 export function getClaudePaths(): string[] { 35 35 const envPaths = process.env.CLAUDE_CONFIG_DIR?.split(',') ?? []; 36 - const defaults = [ 37 - join(homedir(), '.config', 'claude'), 38 - join(homedir(), '.claude'), 39 - ]; 36 + const defaults = [join(homedir(), '.config', 'claude'), join(homedir(), '.claude')]; 40 37 41 - return [...envPaths, ...defaults].filter((p) => 42 - existsSync(join(p, 'projects')) 43 - ); 38 + return [...envPaths, ...defaults].filter((p) => existsSync(join(p, 'projects'))); 44 39 } 45 40 46 41 /** ··· 94 89 if (!line.trim()) continue; 95 90 try { 96 91 const entry: unknown = JSON.parse(line); 97 - if ( 98 - typeof entry === 'object' && 99 - entry !== null && 100 - 'cwd' in entry && 101 - typeof entry.cwd === 'string' 102 - ) { 92 + if (typeof entry === 'object' && entry !== null && 'cwd' in entry && typeof entry.cwd === 'string') { 103 93 return entry.cwd; 104 94 } 105 95 } catch { ··· 184 174 return name.replace(/^\d{4}-\d{2}-\d{2}-/, ''); 185 175 } 186 176 187 - export function decodeProjectFolder( 188 - folderName: string, 189 - sessionFilePath?: string 190 - ): { path: string; name: string } { 177 + export function decodeProjectFolder(folderName: string, sessionFilePath?: string): { path: string; name: string } { 191 178 // Remove leading dash 192 179 const withoutLeading = folderName.slice(1); 193 180 ··· 321 308 /** 322 309 * Find unprocessed or modified session files 323 310 */ 324 - export async function findUnprocessedSessions( 325 - force = false 326 - ): Promise<SessionFile[]> { 311 + export async function findUnprocessedSessions(force = false): Promise<SessionFile[]> { 327 312 const allSessions = findAllSessions(); 328 313 329 314 if (force) { ··· 360 345 /** 361 346 * Filter sessions by date 362 347 */ 363 - export function filterSessionsByDate( 364 - sessions: SessionFile[], 365 - _targetDate: string 366 - ): SessionFile[] { 348 + export function filterSessionsByDate(sessions: SessionFile[], _targetDate: string): SessionFile[] { 367 349 // We need to peek into files to check dates, but that's expensive 368 350 // For now, return all and let the processor filter 369 351 return sessions;
+14 -25
src/core/session-reader.ts
··· 1 1 import { createReadStream } from 'fs'; 2 2 import * as readline from 'readline'; 3 - import type { 4 - RawSessionEntry, 5 - ParsedSession, 6 - ParsedMessage, 7 - ToolUse, 8 - SessionStats, 9 - MessageContent, 10 - } from '../types'; 3 + 4 + import type { MessageContent, ParsedMessage, ParsedSession, RawSessionEntry, SessionStats, ToolUse } from '../types'; 11 5 12 6 /** 13 7 * Get the "effective date" for a timestamp using a 3am boundary. ··· 22 16 /** 23 17 * Stream-parse a JSONL session file 24 18 */ 25 - export async function* parseJSONLStream( 26 - filePath: string 27 - ): AsyncGenerator<RawSessionEntry> { 19 + export async function* parseJSONLStream(filePath: string): AsyncGenerator<RawSessionEntry> { 28 20 const rl = readline.createInterface({ 29 21 input: createReadStream(filePath), 30 22 crlfDelay: Infinity, ··· 46 38 export async function parseSessionFile( 47 39 filePath: string, 48 40 projectPath: string, 49 - projectName: string 41 + projectName: string, 50 42 ): Promise<ParsedSession> { 51 43 const messages: ParsedMessage[] = []; 52 44 const toolCalls: Record<string, number> = {}; ··· 190 182 /** 191 183 * Summarize tool input for display (truncate long content) 192 184 */ 193 - function summarizeToolInput( 194 - toolName: string, 195 - input: Record<string, unknown> 196 - ): string { 185 + function summarizeToolInput(toolName: string, input: Record<string, unknown>): string { 197 186 const MAX_LENGTH = 200; 198 187 199 188 switch (toolName) { ··· 282 271 lower.includes('/controllers/') || 283 272 lower.includes('/models/') || 284 273 lower.includes('/utils/') || 285 - (lower.endsWith('.ts') && !lower.endsWith('.test.ts') && !lower.endsWith('.spec.ts') && !lower.endsWith('.d.ts') && !isFrontend(file)) 274 + (lower.endsWith('.ts') && 275 + !lower.endsWith('.test.ts') && 276 + !lower.endsWith('.spec.ts') && 277 + !lower.endsWith('.d.ts') && 278 + !isFrontend(file)) 286 279 ); 287 280 } 288 281 ··· 347 340 } 348 341 349 342 // Docs 350 - if ( 351 - lower.endsWith('.md') || 352 - lower.includes('/docs/') || 353 - lower.includes('/documentation/') 354 - ) { 343 + if (lower.endsWith('.md') || lower.includes('/docs/') || lower.includes('/documentation/')) { 355 344 scope.docs++; 356 345 continue; 357 346 } ··· 493 482 // Show action summary at the TOP 494 483 if (filesWritten.length > 0) { 495 484 parts.push(`FILES CREATED (${filesWritten.length.toString()}):`); 496 - filesWritten.slice(0, 15).forEach(f => parts.push(` - ${f}`)); 485 + filesWritten.slice(0, 15).forEach((f) => parts.push(` - ${f}`)); 497 486 if (filesWritten.length > 15) parts.push(` ... and ${(filesWritten.length - 15).toString()} more`); 498 487 parts.push(''); 499 488 } 500 489 501 490 if (filesEdited.length > 0) { 502 491 parts.push(`FILES EDITED (${filesEdited.length.toString()}):`); 503 - filesEdited.slice(0, 15).forEach(f => parts.push(` - ${f}`)); 492 + filesEdited.slice(0, 15).forEach((f) => parts.push(` - ${f}`)); 504 493 if (filesEdited.length > 15) parts.push(` ... and ${(filesEdited.length - 15).toString()} more`); 505 494 parts.push(''); 506 495 } 507 496 508 497 if (commandsRun.length > 0) { 509 498 parts.push(`COMMANDS RUN (${commandsRun.length.toString()}):`); 510 - commandsRun.slice(0, 5).forEach(c => parts.push(` $ ${c}`)); 499 + commandsRun.slice(0, 5).forEach((c) => parts.push(` $ ${c}`)); 511 500 parts.push(''); 512 501 } 513 502
+29 -29
src/core/summarizer.ts
··· 1 1 import { createAnthropic } from '@ai-sdk/anthropic'; 2 2 import { generateObject } from 'ai'; 3 3 import { z } from 'zod'; 4 - import type { ParsedSession, SessionSummary, DBSessionSummary } from '../types'; 4 + 5 + import type { DBSessionSummary, ParsedSession, SessionSummary } from '../types'; 5 6 import { createCondensedTranscript } from './session-reader'; 6 7 7 8 const anthropic = createAnthropic({ 8 9 apiKey: process.env.WORKLOG_API_KEY, 9 - ...(process.env.WORKLOG_BASE_URL !== undefined && process.env.WORKLOG_BASE_URL.length > 0 && { baseURL: process.env.WORKLOG_BASE_URL }), 10 + ...(process.env.WORKLOG_BASE_URL !== undefined && 11 + process.env.WORKLOG_BASE_URL.length > 0 && { baseURL: process.env.WORKLOG_BASE_URL }), 10 12 headers: { 11 13 'Accept-Encoding': 'identity', 12 14 }, ··· 47 49 if (rawText === undefined || typeof rawText !== 'string') return null; 48 50 49 51 // Unescape the content 50 - const unescaped = rawText 51 - .replace(/\\n/g, '\n') 52 - .replace(/\\"/g, '"') 53 - .replace(/\\\\/g, '\\'); 52 + const unescaped = rawText.replace(/\\n/g, '\n').replace(/\\"/g, '"').replace(/\\\\/g, '\\'); 54 53 55 54 // Pattern: the model returned JSON with shortSummary containing the rest 56 55 // Extract: shortSummary ends at ",\n"accomplishments" or similar ··· 122 121 const sessionSummarySchema = z.object({ 123 122 shortSummary: z 124 123 .string() 125 - .describe('1-2 sentence summary focusing on capabilities/value, not code artifacts. What can users do now? What problem was solved?'), 124 + .describe( 125 + '1-2 sentence summary focusing on capabilities/value, not code artifacts. What can users do now? What problem was solved?', 126 + ), 126 127 accomplishments: z 127 128 .array(z.string()) 128 - .describe('List of outcomes framed as capabilities or value. Never list modules/types/components as accomplishments.'), 129 - filesChanged: z 130 - .array(z.string()) 131 - .describe('List of files that were modified or created'), 132 - toolsUsed: z 133 - .array(z.string()) 134 - .describe('List of tools used like Edit, Bash, Read, Write, Grep'), 129 + .describe( 130 + 'List of outcomes framed as capabilities or value. Never list modules/types/components as accomplishments.', 131 + ), 132 + filesChanged: z.array(z.string()).describe('List of files that were modified or created'), 133 + toolsUsed: z.array(z.string()).describe('List of tools used like Edit, Bash, Read, Write, Grep'), 135 134 }); 136 135 137 136 /** 138 137 * Generate a summary for a session using Claude with structured output 139 138 */ 140 - export async function summarizeSession( 141 - session: ParsedSession 142 - ): Promise<SessionSummary> { 139 + export async function summarizeSession(session: ParsedSession): Promise<SessionSummary> { 143 140 const transcript = createCondensedTranscript(session); 144 141 145 142 const systemPrompt = `Summarize this Claude Code session for a worklog. Be concise. ··· 175 172 shortSummary: object.shortSummary, 176 173 accomplishments: object.accomplishments, 177 174 filesChanged: object.filesChanged, 178 - toolsUsed: object.toolsUsed.length > 0 179 - ? object.toolsUsed 180 - : Object.keys(session.stats.toolCalls), 175 + toolsUsed: object.toolsUsed.length > 0 ? object.toolsUsed : Object.keys(session.stats.toolCalls), 181 176 }; 182 177 } catch (error: unknown) { 183 178 // Haiku sometimes returns double-encoded JSON (valid JSON wrapped in a string) ··· 188 183 shortSummary: recovered.shortSummary ?? 'Session completed', 189 184 accomplishments: recovered.accomplishments ?? [], 190 185 filesChanged: recovered.filesChanged ?? [], 191 - toolsUsed: (recovered.toolsUsed?.length ?? 0) > 0 192 - ? recovered.toolsUsed ?? [] 193 - : Object.keys(session.stats.toolCalls), 186 + toolsUsed: 187 + (recovered.toolsUsed?.length ?? 0) > 0 ? (recovered.toolsUsed ?? []) : Object.keys(session.stats.toolCalls), 194 188 }; 195 189 } 196 190 ··· 208 202 // Schema for daily summary - structured for easy rendering 209 203 const dailySummarySchema = z.object({ 210 204 projects: z 211 - .array(z.object({ 212 - name: z.string().describe('Project name - use exactly as given'), 213 - summary: z.string().describe('Brief capabilities with scope, like "multi-dose scheduling (backend, frontend), dark mode (frontend)"'), 214 - })) 205 + .array( 206 + z.object({ 207 + name: z.string().describe('Project name - use exactly as given'), 208 + summary: z 209 + .string() 210 + .describe( 211 + 'Brief capabilities with scope, like "multi-dose scheduling (backend, frontend), dark mode (frontend)"', 212 + ), 213 + }), 214 + ) 215 215 .describe('List of projects with brief outcome summaries including scope'), 216 216 }); 217 217 ··· 233 233 export async function generateDailyBragSummary( 234 234 date: string, 235 235 sessions: DBSessionSummary[], 236 - newProjectNames = new Set<string>() 236 + newProjectNames = new Set<string>(), 237 237 ): Promise<string> { 238 238 if (sessions.length === 0) { 239 239 return JSON.stringify({ projects: [] }); ··· 304 304 console.error('Brag summary error:', (error as Error).message); 305 305 306 306 // Generate a basic summary on failure 307 - const projects = Array.from(accomplishmentsByProject.keys()).map(name => ({ 307 + const projects = Array.from(accomplishmentsByProject.keys()).map((name) => ({ 308 308 name, 309 309 summary: 'Session details unavailable', 310 310 isNew: newProjectNames.has(name) ? true : undefined,
+21 -17
src/web/api.ts
··· 1 - import { getDays, getDayDetail, getStats, getProjects, updateProjectStatus } from '../core/db'; 2 1 import { processCommand } from '../cli/process'; 2 + import { getDayDetail, getDays, getProjects, getStats, updateProjectStatus } from '../core/db'; 3 3 import type { ProjectStatus } from '../types'; 4 4 5 5 type ApiHandler = (req: Request, url: URL) => Promise<Response>; ··· 18 18 'PATCH /api/projects/status': handleUpdateProjectStatus, 19 19 }; 20 20 21 - export async function handleApiRequest( 22 - req: Request, 23 - url: URL 24 - ): Promise<Response> { 21 + export async function handleApiRequest(req: Request, url: URL): Promise<Response> { 25 22 const method = req.method; 26 23 const path = url.pathname; 27 24 ··· 46 43 return jsonResponse({ error: 'Not found' }, 404); 47 44 } 48 45 49 - function matchPath( 50 - pattern: string, 51 - path: string 52 - ): Record<string, string> | null { 46 + function matchPath(pattern: string, path: string): Record<string, string> | null { 53 47 const patternParts = pattern.split('/'); 54 48 const pathParts = path.split('/'); 55 49 ··· 118 112 return Promise.resolve(jsonResponse({ error: 'Day not found' }, 404)); 119 113 } 120 114 121 - return Promise.resolve(jsonResponse({ 122 - date, 123 - bragSummary: detail.bragSummary ?? 'No summary available', 124 - projectCount: detail.projects.length, 125 - sessionCount: detail.stats.totalSessions, 126 - })); 115 + return Promise.resolve( 116 + jsonResponse({ 117 + date, 118 + bragSummary: detail.bragSummary ?? 'No summary available', 119 + projectCount: detail.projects.length, 120 + sessionCount: detail.stats.totalSessions, 121 + }), 122 + ); 127 123 } 128 124 129 125 function handleGetStats(_req: Request, _url: URL): Promise<Response> { ··· 153 149 success: false, 154 150 error: error instanceof Error ? error.message : 'Unknown error', 155 151 }, 156 - 500 152 + 500, 157 153 ); 158 154 } 159 155 } ··· 171 167 return jsonResponse({ error: 'Missing path or status' }, 400); 172 168 } 173 169 174 - const validStatuses: ProjectStatus[] = ['shipped', 'in_progress', 'ready_to_ship', 'abandoned', 'ignore', 'one_off', 'experiment']; 170 + const validStatuses: ProjectStatus[] = [ 171 + 'shipped', 172 + 'in_progress', 173 + 'ready_to_ship', 174 + 'abandoned', 175 + 'ignore', 176 + 'one_off', 177 + 'experiment', 178 + ]; 175 179 if (!validStatuses.includes(body.status)) { 176 180 return jsonResponse({ error: 'Invalid status' }, 400); 177 181 }
+3 -2
src/web/app/App.tsx
··· 1 1 import React from 'react'; 2 - import { BrowserRouter, Routes, Route } from 'react-router-dom'; 3 - import Layout from './components/Layout'; 2 + import { BrowserRouter, Route, Routes } from 'react-router-dom'; 3 + 4 4 import DayList from './components/DayList'; 5 5 import DayView from './components/DayView'; 6 + import Layout from './components/Layout'; 6 7 import ProjectList from './components/ProjectList'; 7 8 8 9 function App() {
+11 -15
src/web/app/components/BragSummary.tsx
··· 1 - import React, { useState, useMemo } from 'react'; 2 - import { Copy, Check, Sparkles } from 'lucide-react'; 1 + import { Check, Copy, Sparkles } from 'lucide-react'; 2 + import React, { useMemo, useState } from 'react'; 3 3 4 4 interface Props { 5 5 summary: string; ··· 12 12 function parseSummary(summary: string): DailySummary | null { 13 13 try { 14 14 const parsed = JSON.parse(summary) as unknown; 15 - if ( 16 - typeof parsed === 'object' && 17 - parsed !== null && 18 - 'projects' in parsed && 19 - Array.isArray(parsed.projects) 20 - ) { 15 + if (typeof parsed === 'object' && parsed !== null && 'projects' in parsed && Array.isArray(parsed.projects)) { 21 16 return parsed as DailySummary; 22 17 } 23 18 } catch { ··· 33 28 34 29 const handleCopy = () => { 35 30 // Copy as readable text 36 - const text = parsed !== null 37 - ? parsed.projects.map(p => `${p.name}: ${p.summary}`).join('\n') 38 - : summary; 31 + const text = parsed !== null ? parsed.projects.map((p) => `${p.name}: ${p.summary}`).join('\n') : summary; 39 32 void navigator.clipboard.writeText(text); 40 33 setCopied(true); 41 - setTimeout(() => { setCopied(false); }, 2000); 34 + setTimeout(() => { 35 + setCopied(false); 36 + }, 2000); 42 37 }; 43 38 44 39 if (summary.length === 0) return null; ··· 67 62 <li key={i} className="text-sm"> 68 63 <span className="font-semibold text-slate-800">{project.name}</span> 69 64 {project.isNew === true && ( 70 - <span className="ml-1.5 px-1.5 py-0.5 text-xs font-medium bg-green-100 text-green-700 rounded">NEW</span> 65 + <span className="ml-1.5 px-1.5 py-0.5 text-xs font-medium bg-green-100 text-green-700 rounded"> 66 + NEW 67 + </span> 71 68 )} 72 - <span className="text-slate-800">:</span>{' '} 73 - <span className="text-slate-600">{project.summary}</span> 69 + <span className="text-slate-800">:</span> <span className="text-slate-600">{project.summary}</span> 74 70 </li> 75 71 ))} 76 72 </ul>
+15 -9
src/web/app/components/DayList.tsx
··· 1 + import { Calendar, ChevronRight, Clock, Layers } from 'lucide-react'; 1 2 import React from 'react'; 2 3 import { Link } from 'react-router-dom'; 3 - import { Calendar, ChevronRight, Layers, Clock } from 'lucide-react'; 4 + 5 + import type { DayListItem } from '../../../types'; 4 6 import { useDays } from '../hooks/useWorklog'; 5 - import type { DayListItem } from '../../../types'; 6 7 7 8 // Get ISO week number 8 9 function getWeekNumber(date: Date): number { ··· 10 11 const dayNum = d.getUTCDay() || 7; 11 12 d.setUTCDate(d.getUTCDate() + 4 - dayNum); 12 13 const yearStart = new Date(Date.UTC(d.getUTCFullYear(), 0, 1)); 13 - return Math.ceil((((d.getTime() - yearStart.getTime()) / 86400000) + 1) / 7); 14 + return Math.ceil(((d.getTime() - yearStart.getTime()) / 86400000 + 1) / 7); 14 15 } 15 16 16 17 // Get week start date for display ··· 119 120 <Link to={`/day/${day.date}`} className="block group"> 120 121 <div className="bg-white rounded-lg p-3 border border-gray-200 shadow-sm hover:shadow-md hover:border-blue-200 transition-all flex items-center justify-between"> 121 122 <div className="flex items-center gap-3"> 122 - <div className={`p-2 rounded-md transition-colors ${ 123 - isToday 124 - ? 'bg-blue-600 text-white' 125 - : 'bg-blue-50 text-blue-600 group-hover:bg-blue-600 group-hover:text-white' 126 - }`}> 123 + <div 124 + className={`p-2 rounded-md transition-colors ${ 125 + isToday 126 + ? 'bg-blue-600 text-white' 127 + : 'bg-blue-50 text-blue-600 group-hover:bg-blue-600 group-hover:text-white' 128 + }`} 129 + > 127 130 <Calendar size={18} /> 128 131 </div> 129 132 <div> ··· 157 160 <div className="text-center py-20"> 158 161 <Calendar size={48} className="mx-auto text-slate-300 mb-4" /> 159 162 <h2 className="text-xl font-semibold text-slate-600 mb-2">No sessions yet</h2> 160 - <p className="text-slate-400">Run <code className="bg-slate-100 px-2 py-1 rounded">bun cli process</code> to process your Claude Code sessions.</p> 163 + <p className="text-slate-400"> 164 + Run <code className="bg-slate-100 px-2 py-1 rounded">bun cli process</code> to process your Claude Code 165 + sessions. 166 + </p> 161 167 </div> 162 168 ); 163 169 }
+10 -14
src/web/app/components/DayView.tsx
··· 1 + import { ArrowLeft } from 'lucide-react'; 1 2 import React, { useMemo } from 'react'; 2 - import { useParams, Link } from 'react-router-dom'; 3 - import { ArrowLeft } from 'lucide-react'; 3 + import { Link, useParams } from 'react-router-dom'; 4 + 4 5 import { useDayDetail } from '../hooks/useWorklog'; 5 6 import BragSummary from './BragSummary'; 6 7 import ProjectCard from './ProjectCard'; ··· 19 20 if (bragSummary === undefined) return new Set(); 20 21 try { 21 22 const parsed = JSON.parse(bragSummary) as unknown; 22 - if ( 23 - typeof parsed === 'object' && 24 - parsed !== null && 25 - 'projects' in parsed && 26 - Array.isArray(parsed.projects) 27 - ) { 23 + if (typeof parsed === 'object' && parsed !== null && 'projects' in parsed && Array.isArray(parsed.projects)) { 28 24 const typedParsed = parsed as ParsedBragSummary; 29 25 const names: string[] = []; 30 26 for (const p of typedParsed.projects) { ··· 44 40 const { date } = useParams<{ date: string }>(); 45 41 const { day, loading, error } = useDayDetail(date); 46 42 47 - const newProjects = useMemo( 48 - () => parseNewProjects(day?.bragSummary), 49 - [day?.bragSummary] 50 - ); 43 + const newProjects = useMemo(() => parseNewProjects(day?.bragSummary), [day?.bragSummary]); 51 44 52 45 if (loading) return <div className="text-center py-20 text-slate-400">Loading day details...</div>; 53 46 if (error !== null) return <div className="text-center py-20 text-red-500">Error: {error}</div>; ··· 57 50 weekday: 'long', 58 51 year: 'numeric', 59 52 month: 'long', 60 - day: 'numeric' 53 + day: 'numeric', 61 54 }); 62 55 63 56 return ( 64 57 <div> 65 58 <div className="mb-4"> 66 - <Link to="/" className="inline-flex items-center text-sm text-slate-500 hover:text-blue-600 mb-2 transition-colors"> 59 + <Link 60 + to="/" 61 + className="inline-flex items-center text-sm text-slate-500 hover:text-blue-600 mb-2 transition-colors" 62 + > 67 63 <ArrowLeft size={16} className="mr-1" /> 68 64 Back 69 65 </Link>
+4 -5
src/web/app/components/Layout.tsx
··· 1 + import { Activity, Calendar, Folder, RefreshCw } from 'lucide-react'; 1 2 import React from 'react'; 2 3 import { Link } from 'react-router-dom'; 3 - import { RefreshCw, Activity, Calendar, Folder } from 'lucide-react'; 4 - import { useStats, useRefresh } from '../hooks/useWorklog'; 4 + 5 + import { useRefresh, useStats } from '../hooks/useWorklog'; 5 6 6 7 export default function Layout({ children }: { children: React.ReactNode }) { 7 8 const { stats, refetch: refetchStats } = useStats(); ··· 65 66 </div> 66 67 </header> 67 68 68 - <main className="max-w-5xl mx-auto px-4 sm:px-6 lg:px-8 py-4"> 69 - {children} 70 - </main> 69 + <main className="max-w-5xl mx-auto px-4 sm:px-6 lg:px-8 py-4">{children}</main> 71 70 </div> 72 71 ); 73 72 }
+5 -2
src/web/app/components/ProjectCard.tsx
··· 1 + import { FileCode, Folder, Wrench } from 'lucide-react'; 1 2 import React, { useMemo } from 'react'; 2 - import { Folder, FileCode, Wrench } from 'lucide-react'; 3 3 4 4 interface SessionDetail { 5 5 sessionId: string; ··· 74 74 </span> 75 75 <div className="flex flex-wrap gap-1.5"> 76 76 {aggregated.files.slice(0, 8).map((f, i) => ( 77 - <span key={i} className="text-xs px-2 py-1 bg-slate-50 text-slate-600 rounded border border-slate-100 font-mono"> 77 + <span 78 + key={i} 79 + className="text-xs px-2 py-1 bg-slate-50 text-slate-600 rounded border border-slate-100 font-mono" 80 + > 78 81 {f.split('/').pop()} 79 82 </span> 80 83 ))}
+70 -44
src/web/app/components/ProjectList.tsx
··· 1 + import { Archive, Beaker, ChevronDown, Clock, Construction, EyeOff, Folder, Rocket, Ship, Zap } from 'lucide-react'; 1 2 import React, { useState } from 'react'; 2 - import { Folder, Ship, Construction, Archive, Beaker, Zap, Clock, ChevronDown, Rocket, EyeOff } from 'lucide-react'; 3 + 4 + import type { ProjectListItem, ProjectStatus } from '../../../types'; 3 5 import { useProjects, useUpdateProjectStatus } from '../hooks/useProjects'; 4 - import type { ProjectStatus, ProjectListItem } from '../../../types'; 5 6 6 - const STATUS_CONFIG: Record<ProjectStatus, { icon: React.ElementType; color: string; bgColor: string; label: string }> = { 7 - shipped: { icon: Ship, color: 'text-green-600', bgColor: 'bg-green-50', label: 'Shipped' }, 8 - in_progress: { icon: Construction, color: 'text-blue-600', bgColor: 'bg-blue-50', label: 'In Progress' }, 9 - ready_to_ship: { icon: Rocket, color: 'text-teal-600', bgColor: 'bg-teal-50', label: 'Ready to Ship' }, 10 - abandoned: { icon: Archive, color: 'text-gray-500', bgColor: 'bg-gray-100', label: 'Abandoned' }, 11 - ignore: { icon: EyeOff, color: 'text-slate-400', bgColor: 'bg-slate-100', label: 'Ignore' }, 12 - one_off: { icon: Zap, color: 'text-amber-600', bgColor: 'bg-amber-50', label: 'One-off' }, 13 - experiment: { icon: Beaker, color: 'text-purple-600', bgColor: 'bg-purple-50', label: 'Experiment' }, 14 - }; 7 + const STATUS_CONFIG: Record<ProjectStatus, { icon: React.ElementType; color: string; bgColor: string; label: string }> = 8 + { 9 + shipped: { icon: Ship, color: 'text-green-600', bgColor: 'bg-green-50', label: 'Shipped' }, 10 + in_progress: { icon: Construction, color: 'text-blue-600', bgColor: 'bg-blue-50', label: 'In Progress' }, 11 + ready_to_ship: { icon: Rocket, color: 'text-teal-600', bgColor: 'bg-teal-50', label: 'Ready to Ship' }, 12 + abandoned: { icon: Archive, color: 'text-gray-500', bgColor: 'bg-gray-100', label: 'Abandoned' }, 13 + ignore: { icon: EyeOff, color: 'text-slate-400', bgColor: 'bg-slate-100', label: 'Ignore' }, 14 + one_off: { icon: Zap, color: 'text-amber-600', bgColor: 'bg-amber-50', label: 'One-off' }, 15 + experiment: { icon: Beaker, color: 'text-purple-600', bgColor: 'bg-purple-50', label: 'Experiment' }, 16 + }; 15 17 16 - const ALL_STATUSES: ProjectStatus[] = ['shipped', 'in_progress', 'ready_to_ship', 'abandoned', 'ignore', 'one_off', 'experiment']; 18 + const ALL_STATUSES: ProjectStatus[] = [ 19 + 'shipped', 20 + 'in_progress', 21 + 'ready_to_ship', 22 + 'abandoned', 23 + 'ignore', 24 + 'one_off', 25 + 'experiment', 26 + ]; 17 27 18 28 function StatusBadge({ 19 29 status, ··· 52 62 return ( 53 63 <button 54 64 key={s} 55 - onClick={() => { onSelect(s); }} 65 + onClick={() => { 66 + onSelect(s); 67 + }} 56 68 className={`w-full px-3 py-1.5 text-left text-sm flex items-center gap-2 hover:bg-gray-50 ${ 57 69 s === status ? 'bg-gray-50' : '' 58 70 }`} ··· 106 118 107 119 <StatusBadge 108 120 status={project.status} 109 - onClick={() => { setShowDropdown(!showDropdown); }} 121 + onClick={() => { 122 + setShowDropdown(!showDropdown); 123 + }} 110 124 showDropdown={showDropdown} 111 125 onSelect={handleStatusSelect} 112 - onClose={() => { setShowDropdown(false); }} 126 + onClose={() => { 127 + setShowDropdown(false); 128 + }} 113 129 /> 114 130 </div> 115 131 ); ··· 122 138 const { projects: allProjects, loading, error, refetch } = useProjects(); 123 139 124 140 // Filter locally instead of via API to keep counts in sync 125 - const projects = filter === 'all' 126 - ? allProjects 127 - : allProjects.filter((p) => p.status === filter); 141 + const projects = filter === 'all' ? allProjects : allProjects.filter((p) => p.status === filter); 128 142 129 - const counts = ALL_STATUSES.reduce((acc, s) => { 130 - acc[s] = allProjects.filter((p) => p.status === s).length; 131 - return acc; 132 - }, {} as Record<ProjectStatus, number>); 143 + const counts = ALL_STATUSES.reduce( 144 + (acc, s) => { 145 + acc[s] = allProjects.filter((p) => p.status === s).length; 146 + return acc; 147 + }, 148 + {} as Record<ProjectStatus, number>, 149 + ); 133 150 134 - const staleCount = allProjects.filter( 135 - (p) => p.status === 'in_progress' && p.daysSinceLastSession > 30 136 - ).length; 151 + const staleCount = allProjects.filter((p) => p.status === 'in_progress' && p.daysSinceLastSession > 30).length; 137 152 138 153 if (loading && projects.length === 0) { 139 154 return <div className="text-center py-20 text-slate-400">Loading projects...</div>; ··· 156 171 label="All" 157 172 count={allProjects.length} 158 173 isActive={filter === 'all'} 159 - onClick={() => { setFilter('all'); }} 174 + onClick={() => { 175 + setFilter('all'); 176 + }} 160 177 /> 161 178 {ALL_STATUSES.map((s) => ( 162 179 <FilterTab ··· 164 181 label={STATUS_CONFIG[s].label} 165 182 count={counts[s]} 166 183 isActive={filter === s} 167 - onClick={() => { setFilter(s); }} 184 + onClick={() => { 185 + setFilter(s); 186 + }} 168 187 /> 169 188 ))} 170 189 </div> 171 190 172 191 {/* Stale projects callout */} 173 - {staleCount > 0 && filter !== 'shipped' && filter !== 'ready_to_ship' && filter !== 'abandoned' && filter !== 'ignore' && filter !== 'one_off' && filter !== 'experiment' && ( 174 - <div className="mb-4 p-3 bg-amber-50 border border-amber-200 rounded-lg text-sm text-amber-800"> 175 - <strong>{String(staleCount)} project{staleCount > 1 ? 's' : ''}</strong> marked "In Progress" but untouched for 30+ days. 176 - </div> 177 - )} 192 + {staleCount > 0 && 193 + filter !== 'shipped' && 194 + filter !== 'ready_to_ship' && 195 + filter !== 'abandoned' && 196 + filter !== 'ignore' && 197 + filter !== 'one_off' && 198 + filter !== 'experiment' && ( 199 + <div className="mb-4 p-3 bg-amber-50 border border-amber-200 rounded-lg text-sm text-amber-800"> 200 + <strong> 201 + {String(staleCount)} project{staleCount > 1 ? 's' : ''} 202 + </strong>{' '} 203 + marked "In Progress" but untouched for 30+ days. 204 + </div> 205 + )} 178 206 179 207 {projects.length === 0 ? ( 180 - <div className="text-center py-12 text-slate-400"> 181 - No projects with this status 182 - </div> 208 + <div className="text-center py-12 text-slate-400">No projects with this status</div> 183 209 ) : ( 184 210 <div className="space-y-2"> 185 211 {projects.map((project) => ( 186 - <ProjectRow key={project.path} project={project} onStatusChange={() => { void refetch(); }} /> 212 + <ProjectRow 213 + key={project.path} 214 + project={project} 215 + onStatusChange={() => { 216 + void refetch(); 217 + }} 218 + /> 187 219 ))} 188 220 </div> 189 221 )} ··· 206 238 <button 207 239 onClick={onClick} 208 240 className={`px-3 py-1.5 text-sm rounded-md transition-all ${ 209 - isActive 210 - ? 'bg-white shadow text-slate-800 font-medium' 211 - : 'text-slate-600 hover:bg-gray-200' 241 + isActive ? 'bg-white shadow text-slate-800 font-medium' : 'text-slate-600 hover:bg-gray-200' 212 242 }`} 213 243 > 214 244 {label} 215 - {count > 0 && ( 216 - <span className={`ml-1.5 ${isActive ? 'text-slate-500' : 'text-slate-400'}`}> 217 - {String(count)} 218 - </span> 219 - )} 245 + {count > 0 && <span className={`ml-1.5 ${isActive ? 'text-slate-500' : 'text-slate-400'}`}>{String(count)}</span>} 220 246 </button> 221 247 ); 222 248 }
+3 -2
src/web/app/hooks/useProjects.ts
··· 1 - import { useState, useEffect, useCallback } from 'react'; 2 - import type { ProjectStatus, ProjectListItem } from '../../../types'; 1 + import { useCallback, useEffect, useState } from 'react'; 2 + 3 + import type { ProjectListItem, ProjectStatus } from '../../../types'; 3 4 4 5 export function useProjects(status?: ProjectStatus) { 5 6 const [projects, setProjects] = useState<ProjectListItem[]>([]);
+7 -3
src/web/app/hooks/useWorklog.ts
··· 1 - import { useState, useEffect, useCallback } from 'react'; 1 + import { useCallback, useEffect, useState } from 'react'; 2 2 3 3 interface DayListItem { 4 4 date: string; ··· 58 58 } 59 59 }, []); 60 60 61 - useEffect(() => { void fetchDays(); }, [fetchDays]); 61 + useEffect(() => { 62 + void fetchDays(); 63 + }, [fetchDays]); 62 64 63 65 return { days, loading, error, refetch: fetchDays }; 64 66 } ··· 104 106 } 105 107 }, []); 106 108 107 - useEffect(() => { void fetchStats(); }, [fetchStats]); 109 + useEffect(() => { 110 + void fetchStats(); 111 + }, [fetchStats]); 108 112 return { stats, refetch: fetchStats }; 109 113 } 110 114
+2 -1
src/web/app/main.tsx
··· 1 1 import React from 'react'; 2 2 import ReactDOM from 'react-dom/client'; 3 + 3 4 import App from './App'; 4 5 import './globals.css'; 5 6 ··· 11 12 ReactDOM.createRoot(rootElement).render( 12 13 <React.StrictMode> 13 14 <App /> 14 - </React.StrictMode> 15 + </React.StrictMode>, 15 16 );
+3 -2
src/web/server.ts
··· 1 1 import { serve } from 'bun'; 2 + import { existsSync } from 'fs'; 2 3 import { join } from 'path'; 3 - import { existsSync } from 'fs'; 4 + 4 5 import { handleApiRequest } from './api'; 5 6 6 7 const PORT = parseInt(process.env.PORT ?? '3456'); ··· 80 81 </html>`, 81 82 { 82 83 headers: { 'Content-Type': 'text/html' }, 83 - } 84 + }, 84 85 ); 85 86 } 86 87
+2 -5
tailwind.config.js
··· 1 1 /** @type {import('tailwindcss').Config} */ 2 2 export default { 3 - content: [ 4 - "./index.html", 5 - "./src/**/*.{js,ts,jsx,tsx}", 6 - ], 3 + content: ['./index.html', './src/**/*.{js,ts,jsx,tsx}'], 7 4 theme: { 8 5 extend: {}, 9 6 }, 10 7 plugins: [], 11 - } 8 + };