algocoach 0.1.6 → 0.1.8

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,8 +8,8 @@
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-C1FVIsAd.js"></script>
12
- <link rel="stylesheet" crossorigin href="/assets/index-C4ELaZwf.css">
11
+ <script type="module" crossorigin src="/assets/index-PSyWQaoU.js"></script>
12
+ <link rel="stylesheet" crossorigin href="/assets/index-Cdh7Qj-d.css">
13
13
  </head>
14
14
  <body>
15
15
  <div id="root"></div>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "algocoach",
3
- "version": "0.1.6",
3
+ "version": "0.1.8",
4
4
  "license": "AGPL-3.0-only",
5
5
  "type": "module",
6
6
  "bin": {
@@ -2,6 +2,7 @@ import {
2
2
  sqliteTable,
3
3
  text as sqliteText,
4
4
  integer,
5
+ real,
5
6
  } from "drizzle-orm/sqlite-core"
6
7
  import { jsonText, textArray } from "./custom-types"
7
8
 
@@ -114,11 +115,28 @@ export const dailyPlan = sqliteTable("daily_plan", {
114
115
  date: integer("date", { mode: "timestamp_ms" }).notNull(),
115
116
  weekNumber: integer("week_number").notNull(),
116
117
  topic: text("topic").notNull(),
117
- problems: jsonText<{ title: string; titleSlug: string; difficulty: string; topicTags: string[]; leetcodeUrl: string; acRate: number; status?: string; completedAt?: string | null }[]>()("problems").notNull(),
118
+ problems: jsonText<{ title: string; titleSlug: string; difficulty: string; topicTags: string[]; leetcodeUrl: string; acRate: number; status?: string; completedAt?: string | null; isReview?: boolean }[]>()("problems").notNull(),
118
119
  explanation: text("explanation"),
119
120
  createdAt: integer("created_at", { mode: "timestamp_ms" }).notNull().$defaultFn(() => new Date()),
120
121
  })
121
122
 
123
+ export const problemReview = sqliteTable("problem_review", {
124
+ id: text("id").primaryKey(),
125
+ userId: text("user_id").notNull().references(() => user.id),
126
+ problemId: text("problem_id").notNull(),
127
+ problemName: text("problem_name").notNull(),
128
+ difficulty: text("difficulty").notNull(),
129
+ topics: jsonText<string[]>()("topics").notNull(),
130
+ leetcodeUrl: text("leetcode_url").notNull(),
131
+ acRate: real("ac_rate").notNull(),
132
+ interval: integer("interval").notNull().default(1),
133
+ easinessFactor: real("easiness_factor").notNull().default(2.5),
134
+ nextReviewAt: integer("next_review_at", { mode: "timestamp_ms" }).notNull(),
135
+ lastReviewedAt: integer("last_reviewed_at", { mode: "timestamp_ms" }),
136
+ reviewCount: integer("review_count").notNull().default(0),
137
+ createdAt: integer("created_at", { mode: "timestamp_ms" }).notNull().$defaultFn(() => new Date()),
138
+ })
139
+
122
140
  export const roadmapJob = sqliteTable("roadmap_job", {
123
141
  id: text("id").primaryKey(),
124
142
  userId: text("user_id").notNull().references(() => user.id),
@@ -138,6 +138,24 @@ export function createTables(db: Database) {
138
138
  "updated_at" integer NOT NULL
139
139
  )
140
140
  `)
141
+ db.run(`
142
+ CREATE TABLE IF NOT EXISTS "problem_review" (
143
+ "id" text PRIMARY KEY NOT NULL,
144
+ "user_id" text NOT NULL REFERENCES "user"("id"),
145
+ "problem_id" text NOT NULL,
146
+ "problem_name" text NOT NULL,
147
+ "difficulty" text NOT NULL,
148
+ "topics" text NOT NULL,
149
+ "leetcode_url" text NOT NULL,
150
+ "ac_rate" real NOT NULL,
151
+ "interval" integer NOT NULL DEFAULT 1,
152
+ "easiness_factor" real NOT NULL DEFAULT 2.5,
153
+ "next_review_at" integer NOT NULL,
154
+ "last_reviewed_at" integer,
155
+ "review_count" integer NOT NULL DEFAULT 0,
156
+ "created_at" integer NOT NULL
157
+ )
158
+ `)
141
159
 
142
160
  // Add unique indexes for tables that might already exist without them
143
161
  db.run("CREATE UNIQUE INDEX IF NOT EXISTS idx_leetcode_account_user_id ON leetcode_account(user_id)")
@@ -2,7 +2,7 @@ import { Hono } from 'hono'
2
2
  import { streamSSE } from 'hono/streaming'
3
3
  import { z } from 'zod'
4
4
  import { db } from '../db'
5
- import { userPreferences, roadmapPlan, dailyPlan, dailyProgress, roadmapJob } from '../db/schema'
5
+ import { userPreferences, roadmapPlan, dailyPlan, dailyProgress, roadmapJob, problemReview } from '../db/schema'
6
6
  import { authMiddleware } from '../middleware/auth'
7
7
  import { eq, and, desc, sql } from 'drizzle-orm'
8
8
  import { selectDailyProblems, startJobProcessing } from '../services/ai'
@@ -13,13 +13,116 @@ function sleep(ms: number) {
13
13
 
14
14
  async function getCurrentWeek(userId: string, weeks: any[]): Promise<number> {
15
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')),
16
+
17
+ const allPlans = await db.query.dailyPlan.findMany({
18
+ where: eq(dailyPlan.userId, userId),
19
+ })
20
+
21
+ for (let i = 0; i < weeks.length; i++) {
22
+ const week = weeks[i] as Record<string, unknown>
23
+ const weekNum = week.week as number
24
+ const targetCount = (week.problemsCount as number) || 1
25
+ const weekPlans = allPlans.filter((p: any) => p.weekNumber === weekNum)
26
+ const solvedCount = weekPlans.reduce((count, plan) => {
27
+ const problems = Array.isArray(plan.problems) ? plan.problems : []
28
+ return count + problems.filter((p: any) => (p as Record<string, unknown>).status === 'SOLVED').length
29
+ }, 0)
30
+ const percent = Math.round((solvedCount / targetCount) * 100)
31
+ if (percent < 100) return weekNum
32
+ }
33
+
34
+ return weeks.length
35
+ }
36
+
37
+ async function scheduleReview(userId: string, problem: any, status: string) {
38
+ const existing = await db.query.problemReview.findFirst({
39
+ where: and(eq(problemReview.userId, userId), eq(problemReview.problemId, problem.titleSlug)),
40
+ })
41
+
42
+ const now = Date.now()
43
+ const dayMs = 86400000
44
+
45
+ if (status === 'SOLVED') {
46
+ const quality = existing ? 4 : 3
47
+ if (existing) {
48
+ let ef = existing.easinessFactor + (0.1 - (5 - quality) * (0.08 + (5 - quality) * 0.02))
49
+ if (ef < 1.3) ef = 1.3
50
+ const newInterval = existing.reviewCount === 0 ? 1 : existing.reviewCount === 1 ? 6 : Math.round(existing.interval * ef)
51
+ await db.update(problemReview).set({
52
+ interval: newInterval,
53
+ easinessFactor: ef,
54
+ nextReviewAt: new Date(now + newInterval * dayMs),
55
+ lastReviewedAt: new Date(now),
56
+ reviewCount: existing.reviewCount + 1,
57
+ }).where(eq(problemReview.id, existing.id))
58
+ } else {
59
+ await db.insert(problemReview).values({
60
+ id: crypto.randomUUID(),
61
+ userId,
62
+ problemId: problem.titleSlug,
63
+ problemName: problem.title,
64
+ difficulty: problem.difficulty,
65
+ topics: problem.topicTags || [],
66
+ leetcodeUrl: problem.leetcodeUrl,
67
+ acRate: problem.acRate,
68
+ interval: 1,
69
+ easinessFactor: 2.5,
70
+ nextReviewAt: new Date(now + dayMs),
71
+ lastReviewedAt: new Date(now),
72
+ reviewCount: 1,
73
+ createdAt: new Date(),
74
+ })
75
+ }
76
+ } else if (['TRIED', 'SKIPPED'].includes(status)) {
77
+ if (existing) {
78
+ let ef = existing.easinessFactor - 0.2
79
+ if (ef < 1.3) ef = 1.3
80
+ await db.update(problemReview).set({
81
+ interval: 1,
82
+ easinessFactor: ef,
83
+ nextReviewAt: new Date(now + dayMs),
84
+ lastReviewedAt: new Date(now),
85
+ reviewCount: existing.reviewCount + 1,
86
+ }).where(eq(problemReview.id, existing.id))
87
+ } else {
88
+ await db.insert(problemReview).values({
89
+ id: crypto.randomUUID(),
90
+ userId,
91
+ problemId: problem.titleSlug,
92
+ problemName: problem.title,
93
+ difficulty: problem.difficulty,
94
+ topics: problem.topicTags || [],
95
+ leetcodeUrl: problem.leetcodeUrl,
96
+ acRate: problem.acRate,
97
+ interval: 1,
98
+ easinessFactor: 1.3,
99
+ nextReviewAt: new Date(now + Math.round(dayMs / 2)),
100
+ lastReviewedAt: new Date(now),
101
+ reviewCount: 1,
102
+ createdAt: new Date(),
103
+ })
104
+ }
105
+ }
106
+ }
107
+
108
+ async function getDueReviews(userId: string): Promise<any[]> {
109
+ const now = new Date()
110
+ const due = await db.query.problemReview.findMany({
111
+ where: and(eq(problemReview.userId, userId), sql`${problemReview.nextReviewAt} <= ${now}`),
112
+ orderBy: [sql`${problemReview.nextReviewAt} ASC`],
113
+ limit: 2,
18
114
  })
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
115
+ return due.map((r) => ({
116
+ title: r.problemName,
117
+ titleSlug: r.problemId,
118
+ difficulty: r.difficulty,
119
+ topicTags: r.topics,
120
+ leetcodeUrl: r.leetcodeUrl,
121
+ acRate: r.acRate,
122
+ status: 'PENDING' as const,
123
+ completedAt: null,
124
+ isReview: true,
125
+ }))
23
126
  }
24
127
 
25
128
  const difficultySchema = z.enum(['EASY', 'MEDIUM', 'HARD', 'MIXED'])
@@ -86,6 +189,41 @@ app.patch('/roadmap/advance', async (c) => {
86
189
  }
87
190
  })
88
191
 
192
+ app.patch('/roadmap/week/:weekNumber', async (c) => {
193
+ try {
194
+ const userId = c.get('userId')
195
+ const weekNumber = parseInt(c.req.param('weekNumber'), 10)
196
+ const body: any = await c.req.json()
197
+ const { problemsCount } = body
198
+ if (typeof problemsCount !== 'number' || problemsCount < 1 || problemsCount > 30) {
199
+ return c.json({ success: false, error: 'problemsCount must be a number between 1 and 30' }, 400)
200
+ }
201
+
202
+ const plan = await db.query.roadmapPlan.findFirst({
203
+ where: eq(roadmapPlan.userId, userId),
204
+ })
205
+ if (!plan) return c.json({ success: false, error: 'No roadmap found' }, 404)
206
+
207
+ const weeks = Array.isArray(plan.weeks) ? [...plan.weeks] : []
208
+ const idx = weeks.findIndex((w: any) => (w as Record<string, unknown>).week === weekNumber)
209
+ if (idx === -1) return c.json({ success: false, error: `Week ${weekNumber} not found` }, 404)
210
+
211
+ weeks[idx] = { ...(weeks[idx] as any), problemsCount }
212
+
213
+ await db.update(roadmapPlan).set({
214
+ weeks: weeks as any,
215
+ updatedAt: new Date(),
216
+ }).where(eq(roadmapPlan.userId, userId))
217
+
218
+ const updated = await db.query.roadmapPlan.findFirst({
219
+ where: eq(roadmapPlan.userId, userId),
220
+ })
221
+ return c.json({ success: true, data: updated })
222
+ } catch (err: any) {
223
+ return c.json({ success: false, error: err.message }, 500)
224
+ }
225
+ })
226
+
89
227
  app.post('/roadmap/generate', async (c) => {
90
228
  const userId = c.get('userId')
91
229
 
@@ -350,13 +488,16 @@ app.post('/today', async (c) => {
350
488
  completedAt: null,
351
489
  }))
352
490
 
491
+ const dueReviews = await getDueReviews(userId)
492
+ const allProblems = [...problems, ...dueReviews]
493
+
353
494
  const entry = {
354
495
  id: crypto.randomUUID(),
355
496
  userId,
356
497
  date: new Date(),
357
498
  weekNumber: currentWeek,
358
499
  topic: (roadmap[currentWeek - 1] as Record<string, unknown>)?.topic as string || '',
359
- problems,
500
+ problems: allProblems,
360
501
  explanation: task.explanation,
361
502
  }
362
503
 
@@ -419,6 +560,8 @@ app.patch('/today/:planId/problem/:slug', async (c) => {
419
560
  })
420
561
  }
421
562
 
563
+ scheduleReview(userId, problem, status).catch(() => {})
564
+
422
565
  return c.json({ success: true, data: { ...problem, status, completedAt: problem.completedAt } })
423
566
  } catch (err: any) {
424
567
  return c.json({ success: false, error: err.message }, 500)