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/LICENSE +661 -21
- package/README.md +173 -66
- package/cli/index.ts +9 -7
- package/dist/assets/index-C1FVIsAd.js +55 -0
- package/dist/index.html +1 -1
- package/package.json +2 -1
- package/server/db/schema.ts +1 -0
- package/server/db/setup.ts +9 -0
- package/server/index.ts +5 -0
- package/server/local-dev/.gitkeep +0 -0
- package/server/middleware/rate-limit.ts +7 -0
- package/server/routes/onboard.ts +0 -1
- package/server/routes/plan.ts +66 -27
- package/server/services/ai.ts +8 -1
- package/dist/assets/index-D5iezweF.js +0 -55
- package/server/local-dev/test-ai.ts +0 -88
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-
|
|
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
package/server/db/schema.ts
CHANGED
|
@@ -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
|
|
package/server/db/setup.ts
CHANGED
|
@@ -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'
|
package/server/routes/onboard.ts
CHANGED
|
@@ -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)
|
package/server/routes/plan.ts
CHANGED
|
@@ -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,
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
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
|
|
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 =
|
|
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:
|
|
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
|
|
352
|
-
if (!
|
|
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
|
|
408
|
-
const
|
|
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
|
|
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
|
|
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
|
-
|
|
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.
|
|
550
|
-
const diffDays = Math.
|
|
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 {
|
package/server/services/ai.ts
CHANGED
|
@@ -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
|
}
|