algocoach 0.1.3 → 0.1.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.html CHANGED
@@ -8,7 +8,7 @@
8
8
  <link rel="preconnect" href="https://fonts.googleapis.com" />
9
9
  <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
10
10
  <link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700;800&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet" />
11
- <script type="module" crossorigin src="/assets/index-D5iezweF.js"></script>
11
+ <script type="module" crossorigin src="/assets/index-C1FVIsAd.js"></script>
12
12
  <link rel="stylesheet" crossorigin href="/assets/index-C4ELaZwf.css">
13
13
  </head>
14
14
  <body>
package/package.json CHANGED
@@ -1,6 +1,7 @@
1
1
  {
2
2
  "name": "algocoach",
3
- "version": "0.1.3",
3
+ "version": "0.1.6",
4
+ "license": "AGPL-3.0-only",
4
5
  "type": "module",
5
6
  "bin": {
6
7
  "algocoach": "cli/index.ts"
@@ -115,6 +115,7 @@ export const dailyPlan = sqliteTable("daily_plan", {
115
115
  weekNumber: integer("week_number").notNull(),
116
116
  topic: text("topic").notNull(),
117
117
  problems: jsonText<{ title: string; titleSlug: string; difficulty: string; topicTags: string[]; leetcodeUrl: string; acRate: number; status?: string; completedAt?: string | null }[]>()("problems").notNull(),
118
+ explanation: text("explanation"),
118
119
  createdAt: integer("created_at", { mode: "timestamp_ms" }).notNull().$defaultFn(() => new Date()),
119
120
  })
120
121
 
@@ -122,6 +122,7 @@ export function createTables(db: Database) {
122
122
  "week_number" integer NOT NULL,
123
123
  "topic" text NOT NULL,
124
124
  "problems" text NOT NULL,
125
+ "explanation" text,
125
126
  "created_at" integer NOT NULL
126
127
  )
127
128
  `)
@@ -141,4 +142,12 @@ export function createTables(db: Database) {
141
142
  // Add unique indexes for tables that might already exist without them
142
143
  db.run("CREATE UNIQUE INDEX IF NOT EXISTS idx_leetcode_account_user_id ON leetcode_account(user_id)")
143
144
  db.run("CREATE UNIQUE INDEX IF NOT EXISTS idx_roadmap_plan_user_id ON roadmap_plan(user_id)")
145
+
146
+ // Performance indexes for frequently queried columns
147
+ db.run("CREATE INDEX IF NOT EXISTS idx_daily_plan_user_date ON daily_plan(user_id, date)")
148
+ db.run("CREATE INDEX IF NOT EXISTS idx_daily_progress_user_problem ON daily_progress(user_id, problem_id)")
149
+ db.run("CREATE INDEX IF NOT EXISTS idx_roadmap_job_user ON roadmap_job(user_id)")
150
+
151
+ // Migrations for existing databases (safe to run multiple times)
152
+ try { db.run("ALTER TABLE daily_plan ADD COLUMN explanation text") } catch {}
144
153
  }
package/server/index.ts CHANGED
@@ -12,6 +12,11 @@ import fs from 'fs'
12
12
 
13
13
  const app = new Hono()
14
14
 
15
+ app.onError((err, c) => {
16
+ console.error(err)
17
+ return c.json({ success: false, error: err.message || 'Internal server error' }, 500)
18
+ })
19
+
15
20
  const productionUrl = process.env.BETTER_AUTH_URL || ''
16
21
 
17
22
  app.use('/*', cors({
File without changes
@@ -7,6 +7,13 @@ interface Entry {
7
7
 
8
8
  const store = new Map<string, Entry>()
9
9
 
10
+ setInterval(() => {
11
+ const now = Date.now()
12
+ for (const [key, entry] of store) {
13
+ if (now > entry.resetAt) store.delete(key)
14
+ }
15
+ }, 60_000)
16
+
10
17
  export function rateLimit(maxRequests: number, windowMs: number) {
11
18
  return createMiddleware(async (c, next) => {
12
19
  const ip = c.req.header('x-forwarded-for') || c.req.header('x-real-ip') || 'unknown'
@@ -4,7 +4,6 @@ import { db } from '../db'
4
4
  import { userPreferences, roadmapPlan } from '../db/schema'
5
5
  import { authMiddleware } from '../middleware/auth'
6
6
  import { eq } from 'drizzle-orm'
7
- import { generateRoadmap } from '../services/ai'
8
7
 
9
8
  const app = new Hono<{ Variables: { userId: string } }>()
10
9
  app.use('/*', authMiddleware)
@@ -1,15 +1,34 @@
1
1
  import { Hono } from 'hono'
2
2
  import { streamSSE } from 'hono/streaming'
3
+ import { z } from 'zod'
3
4
  import { db } from '../db'
4
5
  import { userPreferences, roadmapPlan, dailyPlan, dailyProgress, roadmapJob } from '../db/schema'
5
6
  import { authMiddleware } from '../middleware/auth'
6
- import { eq, and, gte, desc, sql } from 'drizzle-orm'
7
+ import { eq, and, desc, sql } from 'drizzle-orm'
7
8
  import { selectDailyProblems, startJobProcessing } from '../services/ai'
8
9
 
9
10
  function sleep(ms: number) {
10
11
  return new Promise((r) => setTimeout(r, ms))
11
12
  }
12
13
 
14
+ async function getCurrentWeek(userId: string, weeks: any[]): Promise<number> {
15
+ if (!weeks.length) return 1
16
+ const solved = await db.query.dailyProgress.findMany({
17
+ where: and(eq(dailyProgress.userId, userId), eq(dailyProgress.status, 'SOLVED')),
18
+ })
19
+ const solvedDays = new Set(solved.map(r => r.date.toISOString().slice(0, 10))).size
20
+ let week = Math.floor(solvedDays / 7) + 1
21
+ if (week > weeks.length) week = weeks.length
22
+ return week
23
+ }
24
+
25
+ const difficultySchema = z.enum(['EASY', 'MEDIUM', 'HARD', 'MIXED'])
26
+ const planStatusSchema = z.enum(['SOLVED', 'TRIED', 'SKIPPED', 'PENDING'])
27
+ const regenerateBodySchema = z.object({
28
+ slot: z.number().int().min(0).optional(),
29
+ easier: z.boolean().optional(),
30
+ })
31
+
13
32
  function tryParseError(msg: string): string {
14
33
  try {
15
34
  const top = JSON.parse(msg)
@@ -38,7 +57,8 @@ app.get('/roadmap', async (c) => {
38
57
  })
39
58
  if (!plan) return c.json({ success: false, error: 'No roadmap found' }, 404)
40
59
  const weeks = Array.isArray(plan.weeks) ? plan.weeks : []
41
- return c.json({ success: true, data: { ...plan, ready: weeks.length > 0 } })
60
+ const currentWeek = await getCurrentWeek(userId, weeks)
61
+ return c.json({ success: true, data: { ...plan, currentWeek, ready: weeks.length > 0 } })
42
62
  } catch (err: any) {
43
63
  return c.json({ success: false, error: err.message }, 500)
44
64
  }
@@ -55,20 +75,12 @@ app.patch('/roadmap/advance', async (c) => {
55
75
  const weeks = Array.isArray(plan.weeks) ? plan.weeks : []
56
76
  if (!weeks.length) return c.json({ success: false, error: 'Roadmap not ready' }, 400)
57
77
 
58
- if (plan.currentWeek >= weeks.length) {
78
+ const currentWeek = await getCurrentWeek(userId, weeks)
79
+ if (currentWeek >= weeks.length) {
59
80
  return c.json({ success: false, error: 'Roadmap already completed' }, 400)
60
81
  }
61
82
 
62
- const nextWeek = plan.currentWeek + 1
63
- await db.update(roadmapPlan).set({
64
- currentWeek: nextWeek,
65
- updatedAt: new Date(),
66
- }).where(eq(roadmapPlan.userId, userId))
67
-
68
- const updated = await db.query.roadmapPlan.findFirst({
69
- where: eq(roadmapPlan.userId, userId),
70
- })
71
- return c.json({ success: true, data: { ...updated, ready: true } })
83
+ return c.json({ success: true, data: { ...plan, currentWeek, ready: true } })
72
84
  } catch (err: any) {
73
85
  return c.json({ success: false, error: err.message }, 500)
74
86
  }
@@ -234,7 +246,7 @@ app.get('/roadmap/progress', async (c) => {
234
246
  targetCount: w.problemsCount,
235
247
  assignedCount: total,
236
248
  solvedCount: solved,
237
- percent: total > 0 ? Math.round((solved / total) * 100) : 0,
249
+ percent: w.problemsCount > 0 ? Math.round((solved / w.problemsCount) * 100) : 0,
238
250
  }
239
251
  })
240
252
 
@@ -258,7 +270,15 @@ app.get('/today', async (c) => {
258
270
  ),
259
271
  })
260
272
  if (!plan) return c.json({ success: false, exists: false }, 404)
261
- return c.json({ success: true, exists: true, data: plan })
273
+
274
+ const planRecord = await db.query.roadmapPlan.findFirst({
275
+ where: eq(roadmapPlan.userId, userId),
276
+ })
277
+ const weeks = Array.isArray(planRecord?.weeks) ? planRecord.weeks : []
278
+ const currentWeek = await getCurrentWeek(userId, weeks)
279
+ const topic = weeks[currentWeek - 1] ? (weeks[currentWeek - 1] as Record<string, unknown>)?.topic as string : plan.topic
280
+
281
+ return c.json({ success: true, exists: true, data: { ...plan, weekNumber: currentWeek, topic } })
262
282
  } catch (err: any) {
263
283
  return c.json({ success: false, error: err.message }, 500)
264
284
  }
@@ -268,7 +288,8 @@ app.post('/today', async (c) => {
268
288
  try {
269
289
  const userId = c.get('userId')
270
290
  const body: any = await c.req.json().catch(() => ({}))
271
- const difficultyFilter = (body.difficulty || "MIXED") as "EASY" | "MEDIUM" | "HARD" | "MIXED"
291
+ const diffResult = difficultySchema.safeParse(body.difficulty)
292
+ const difficultyFilter = diffResult.success ? diffResult.data : 'MIXED'
272
293
 
273
294
  const todayMs = Date.now() - (Date.now() % 86400000)
274
295
  const tomorrowMs = todayMs + 86400000
@@ -289,7 +310,10 @@ app.post('/today', async (c) => {
289
310
 
290
311
  const roadmap = Array.isArray(planRecord.weeks) ? planRecord.weeks : []
291
312
  if (roadmap.length === 0) return c.json({ success: false, error: 'Roadmap is still being generated. Please try again shortly.' }, 400)
292
- const currentWeek = planRecord.currentWeek
313
+ const currentWeek = await getCurrentWeek(userId, roadmap)
314
+ if (currentWeek > roadmap.length) {
315
+ return c.json({ success: false, error: 'Roadmap is complete. Generate a new roadmap to continue.' }, 400)
316
+ }
293
317
 
294
318
  const allPlans = await db.query.dailyPlan.findMany({
295
319
  where: eq(dailyPlan.userId, userId),
@@ -333,11 +357,12 @@ app.post('/today', async (c) => {
333
357
  weekNumber: currentWeek,
334
358
  topic: (roadmap[currentWeek - 1] as Record<string, unknown>)?.topic as string || '',
335
359
  problems,
360
+ explanation: task.explanation,
336
361
  }
337
362
 
338
363
  await db.insert(dailyPlan).values(entry)
339
364
 
340
- return c.json({ success: true, data: { ...entry, explanation: task.explanation } }, 201)
365
+ return c.json({ success: true, data: entry }, 201)
341
366
  } catch (err: any) {
342
367
  return c.json({ success: false, error: `Failed to generate daily plan: ${err.message}` }, 500)
343
368
  }
@@ -348,10 +373,11 @@ app.patch('/today/:planId/problem/:slug', async (c) => {
348
373
  const userId = c.get('userId')
349
374
  const { planId, slug } = c.req.param()
350
375
  const body: any = await c.req.json()
351
- const status = body.status as string
352
- if (!["SOLVED", "TRIED", "SKIPPED", "PENDING"].includes(status)) {
376
+ const statusResult = planStatusSchema.safeParse(body.status)
377
+ if (!statusResult.success) {
353
378
  return c.json({ success: false, error: 'Invalid status. Use SOLVED, TRIED, SKIPPED, or PENDING.' }, 400)
354
379
  }
380
+ const status = statusResult.data
355
381
 
356
382
  const plan = await db.query.dailyPlan.findFirst({
357
383
  where: and(eq(dailyPlan.id, planId), eq(dailyPlan.userId, userId)),
@@ -404,8 +430,9 @@ app.post('/today/:planId/regenerate', async (c) => {
404
430
  const userId = c.get('userId')
405
431
  const { planId } = c.req.param()
406
432
  const body: any = await c.req.json().catch(() => ({}))
407
- const slot = body.slot as number | undefined
408
- const easier = body.easier === true
433
+ const regenResult = regenerateBodySchema.safeParse(body)
434
+ const slot = regenResult.success ? regenResult.data.slot : undefined
435
+ const easier = regenResult.success ? regenResult.data.easier === true : false
409
436
 
410
437
  const plan = await db.query.dailyPlan.findFirst({
411
438
  where: and(eq(dailyPlan.id, planId), eq(dailyPlan.userId, userId)),
@@ -424,6 +451,7 @@ app.post('/today/:planId/regenerate', async (c) => {
424
451
  where: eq(roadmapPlan.userId, userId),
425
452
  })
426
453
  const roadmap = Array.isArray(planRecord?.weeks) ? planRecord.weeks : []
454
+ const currentWeek = await getCurrentWeek(userId, roadmap)
427
455
 
428
456
  const allPlans = await db.query.dailyPlan.findMany({
429
457
  where: eq(dailyPlan.userId, userId),
@@ -452,7 +480,7 @@ app.post('/today/:planId/regenerate', async (c) => {
452
480
  }
453
481
  const result = await selectDailyProblems({
454
482
  roadmap,
455
- currentWeek: plan.weekNumber,
483
+ currentWeek,
456
484
  progress: progress.map((p) => ({ problemId: p.problemId, status: p.status })),
457
485
  dedupCount,
458
486
  solvedSlugs,
@@ -468,7 +496,7 @@ app.post('/today/:planId/regenerate', async (c) => {
468
496
  } else {
469
497
  const result = await selectDailyProblems({
470
498
  roadmap,
471
- currentWeek: plan.weekNumber,
499
+ currentWeek,
472
500
  progress: progress.map((p) => ({ problemId: p.problemId, status: p.status })),
473
501
  dedupCount,
474
502
  solvedSlugs,
@@ -503,7 +531,11 @@ app.get('/streak', async (c) => {
503
531
  orderBy: [desc(dailyPlan.date)],
504
532
  })
505
533
 
506
- if (!plans.length) {
534
+ const progressRecords = await db.query.dailyProgress.findMany({
535
+ where: and(eq(dailyProgress.userId, userId), eq(dailyProgress.status, 'SOLVED')),
536
+ })
537
+
538
+ if (!plans.length && !progressRecords.length) {
507
539
  return c.json({ success: true, data: { currentStreak: 0, longestStreak: 0, solvedToday: false } })
508
540
  }
509
541
 
@@ -519,6 +551,13 @@ app.get('/streak', async (c) => {
519
551
  if (hasSolved) solvedDates.add(dateStr)
520
552
  }
521
553
 
554
+ for (const r of progressRecords) {
555
+ const d = new Date(r.date)
556
+ const dateStr = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`
557
+ solvedDates.add(dateStr)
558
+ allDates.add(dateStr)
559
+ }
560
+
522
561
  const today = new Date()
523
562
  const todayStr = `${today.getFullYear()}-${String(today.getMonth() + 1).padStart(2, "0")}-${String(today.getDate()).padStart(2, "0")}`
524
563
  const solvedToday = solvedDates.has(todayStr)
@@ -546,8 +585,8 @@ app.get('/streak', async (c) => {
546
585
  } else {
547
586
  const prev = new Date(sortedDates[i - 1])
548
587
  const curr = new Date(sortedDates[i])
549
- const diffMs = curr.getTime() - prev.getTime()
550
- const diffDays = Math.round(diffMs / (1000 * 60 * 60 * 24))
588
+ const diffMs = Date.UTC(curr.getFullYear(), curr.getMonth(), curr.getDate()) - Date.UTC(prev.getFullYear(), prev.getMonth(), prev.getDate())
589
+ const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24))
551
590
  if (diffDays === 1) {
552
591
  tempStreak++
553
592
  } else {
@@ -192,6 +192,13 @@ export async function selectDailyProblems(params: {
192
192
  if (filtered.length) searchResults = filtered
193
193
  }
194
194
 
195
+ if (!searchResults.length) {
196
+ return {
197
+ problems: [],
198
+ explanation: `No unsolved ${difficultyFilter.toLowerCase()} problems found for ${week.topic}. Try a different difficulty filter.`,
199
+ }
200
+ }
201
+
195
202
  const easy = searchResults.filter((p) => p.difficulty === "Easy").sort((a, b) => b.acRate - a.acRate)
196
203
  const medium = searchResults.filter((p) => p.difficulty === "Medium").sort((a, b) => b.acRate - a.acRate)
197
204
  const hard = searchResults.filter((p) => p.difficulty === "Hard").sort((a, b) => b.acRate - a.acRate)
@@ -364,5 +371,5 @@ function parseTopicToSlugs(topic: string): string[] {
364
371
  if (slug) slugs.add(slug)
365
372
  }
366
373
  }
367
- return [...slugs]
374
+ return [...slugs].filter(Boolean)
368
375
  }