this repo has no description
0
fork

Configure Feed

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

Add web server and REST API

- Bun.serve() for static files and API
- API endpoints: /api/days, /api/days/:date, /api/stats, /api/refresh
- SPA fallback for React router
- Refresh endpoint triggers background processing

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

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

alice e303a09c 55c00eae

+248
+144
src/web/api.ts
··· 1 + import { getDays, getDayDetail, getStats } from '../core/db'; 2 + import { processCommand } from '../cli/process'; 3 + 4 + type ApiHandler = (req: Request, url: URL) => Promise<Response>; 5 + 6 + const routes: Record<string, ApiHandler> = { 7 + 'GET /api/days': handleGetDays, 8 + 'GET /api/days/:date': handleGetDayDetail, 9 + 'GET /api/days/:date/brag': handleGetDayBrag, 10 + 'GET /api/stats': handleGetStats, 11 + 'POST /api/refresh': handleRefresh, 12 + }; 13 + 14 + export async function handleApiRequest( 15 + req: Request, 16 + url: URL 17 + ): Promise<Response> { 18 + const method = req.method; 19 + const path = url.pathname; 20 + 21 + // Match routes 22 + for (const [route, handler] of Object.entries(routes)) { 23 + const [routeMethod, routePath] = route.split(' '); 24 + if (method !== routeMethod) continue; 25 + 26 + const params = matchPath(routePath, path); 27 + if (params !== null) { 28 + try { 29 + // Attach params to URL for handler access 30 + (url as any).params = params; 31 + return await handler(req, url); 32 + } catch (error) { 33 + console.error('API error:', error); 34 + return jsonResponse({ error: 'Internal server error' }, 500); 35 + } 36 + } 37 + } 38 + 39 + return jsonResponse({ error: 'Not found' }, 404); 40 + } 41 + 42 + function matchPath( 43 + pattern: string, 44 + path: string 45 + ): Record<string, string> | null { 46 + const patternParts = pattern.split('/'); 47 + const pathParts = path.split('/'); 48 + 49 + if (patternParts.length !== pathParts.length) return null; 50 + 51 + const params: Record<string, string> = {}; 52 + 53 + for (let i = 0; i < patternParts.length; i++) { 54 + const patternPart = patternParts[i]; 55 + const pathPart = pathParts[i]; 56 + 57 + if (patternPart.startsWith(':')) { 58 + params[patternPart.slice(1)] = pathPart; 59 + } else if (patternPart !== pathPart) { 60 + return null; 61 + } 62 + } 63 + 64 + return params; 65 + } 66 + 67 + function jsonResponse(data: unknown, status = 200): Response { 68 + return new Response(JSON.stringify(data), { 69 + status, 70 + headers: { 71 + 'Content-Type': 'application/json', 72 + 'Access-Control-Allow-Origin': '*', 73 + }, 74 + }); 75 + } 76 + 77 + // Handlers 78 + 79 + async function handleGetDays(req: Request, url: URL): Promise<Response> { 80 + const limit = parseInt(url.searchParams.get('limit') || '30'); 81 + const days = getDays(limit); 82 + return jsonResponse(days); 83 + } 84 + 85 + async function handleGetDayDetail(req: Request, url: URL): Promise<Response> { 86 + const params = (url as any).params as Record<string, string>; 87 + const date = params.date; 88 + 89 + const detail = getDayDetail(date); 90 + if (!detail) { 91 + return jsonResponse({ error: 'Day not found' }, 404); 92 + } 93 + 94 + return jsonResponse(detail); 95 + } 96 + 97 + async function handleGetDayBrag(req: Request, url: URL): Promise<Response> { 98 + const params = (url as any).params as Record<string, string>; 99 + const date = params.date; 100 + 101 + const detail = getDayDetail(date); 102 + if (!detail) { 103 + return jsonResponse({ error: 'Day not found' }, 404); 104 + } 105 + 106 + return jsonResponse({ 107 + date, 108 + bragSummary: detail.bragSummary || 'No summary available', 109 + projectCount: detail.projects.length, 110 + sessionCount: detail.stats.totalSessions, 111 + }); 112 + } 113 + 114 + async function handleGetStats(req: Request, url: URL): Promise<Response> { 115 + const stats = getStats(); 116 + return jsonResponse(stats); 117 + } 118 + 119 + async function handleRefresh(req: Request, url: URL): Promise<Response> { 120 + // Run processing in background 121 + const startTime = Date.now(); 122 + 123 + try { 124 + const result = await processCommand({ 125 + force: false, 126 + verbose: false, 127 + }); 128 + 129 + return jsonResponse({ 130 + success: true, 131 + sessionsProcessed: result.sessionsProcessed, 132 + errors: result.errors, 133 + durationMs: Date.now() - startTime, 134 + }); 135 + } catch (error) { 136 + return jsonResponse( 137 + { 138 + success: false, 139 + error: error instanceof Error ? error.message : 'Unknown error', 140 + }, 141 + 500 142 + ); 143 + } 144 + }
+104
src/web/server.ts
··· 1 + import { serve } from 'bun'; 2 + import { join } from 'path'; 3 + import { existsSync } from 'fs'; 4 + import { handleApiRequest } from './api'; 5 + 6 + const PORT = parseInt(process.env.PORT || '3456'); 7 + const STATIC_DIR = join(import.meta.dir, '../../dist'); 8 + 9 + export async function startServer() { 10 + console.log(`\n🚀 Starting worklog server on http://localhost:${PORT}\n`); 11 + 12 + serve({ 13 + port: PORT, 14 + async fetch(req) { 15 + const url = new URL(req.url); 16 + 17 + // API routes 18 + if (url.pathname.startsWith('/api/')) { 19 + return handleApiRequest(req, url); 20 + } 21 + 22 + // Static files 23 + return serveStatic(url.pathname); 24 + }, 25 + error(error) { 26 + console.error('Server error:', error); 27 + return new Response('Internal Server Error', { status: 500 }); 28 + }, 29 + }); 30 + 31 + console.log('Server running. Press Ctrl+C to stop.\n'); 32 + } 33 + 34 + async function serveStatic(pathname: string): Promise<Response> { 35 + // Map pathname to file 36 + let filePath = pathname === '/' ? '/index.html' : pathname; 37 + filePath = join(STATIC_DIR, filePath); 38 + 39 + // Check if file exists 40 + if (existsSync(filePath)) { 41 + const file = Bun.file(filePath); 42 + return new Response(file, { 43 + headers: { 44 + 'Content-Type': getContentType(filePath), 45 + }, 46 + }); 47 + } 48 + 49 + // SPA fallback - serve index.html for all routes 50 + const indexPath = join(STATIC_DIR, 'index.html'); 51 + if (existsSync(indexPath)) { 52 + const file = Bun.file(indexPath); 53 + return new Response(file, { 54 + headers: { 55 + 'Content-Type': 'text/html', 56 + }, 57 + }); 58 + } 59 + 60 + // No static files yet - serve a placeholder 61 + return new Response( 62 + `<!DOCTYPE html> 63 + <html> 64 + <head> 65 + <title>Worklog</title> 66 + <style> 67 + body { font-family: system-ui; max-width: 600px; margin: 100px auto; padding: 20px; } 68 + code { background: #f0f0f0; padding: 2px 6px; border-radius: 3px; } 69 + </style> 70 + </head> 71 + <body> 72 + <h1>Worklog</h1> 73 + <p>Frontend not built yet. Run:</p> 74 + <pre><code>bun run build</code></pre> 75 + <p>Or for development:</p> 76 + <pre><code>bun run dev</code></pre> 77 + <hr> 78 + <p>API is available at <a href="/api/stats">/api/stats</a></p> 79 + </body> 80 + </html>`, 81 + { 82 + headers: { 'Content-Type': 'text/html' }, 83 + } 84 + ); 85 + } 86 + 87 + function getContentType(filePath: string): string { 88 + const ext = filePath.split('.').pop()?.toLowerCase(); 89 + const types: Record<string, string> = { 90 + html: 'text/html', 91 + css: 'text/css', 92 + js: 'application/javascript', 93 + json: 'application/json', 94 + png: 'image/png', 95 + jpg: 'image/jpeg', 96 + jpeg: 'image/jpeg', 97 + gif: 'image/gif', 98 + svg: 'image/svg+xml', 99 + ico: 'image/x-icon', 100 + woff: 'font/woff', 101 + woff2: 'font/woff2', 102 + }; 103 + return types[ext || ''] || 'application/octet-stream'; 104 + }