algocoach 0.1.4 → 0.1.7

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-D5iezweF.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,7 @@
1
1
  {
2
2
  "name": "algocoach",
3
- "version": "0.1.4",
3
+ "version": "0.1.7",
4
+ "license": "AGPL-3.0-only",
4
5
  "type": "module",
5
6
  "bin": {
6
7
  "algocoach": "cli/index.ts"
@@ -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,7 +115,25 @@ 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(),
119
+ explanation: text("explanation"),
120
+ createdAt: integer("created_at", { mode: "timestamp_ms" }).notNull().$defaultFn(() => new Date()),
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),
118
137
  createdAt: integer("created_at", { mode: "timestamp_ms" }).notNull().$defaultFn(() => new Date()),
119
138
  })
120
139
 
@@ -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
  `)
@@ -137,8 +138,34 @@ export function createTables(db: Database) {
137
138
  "updated_at" integer NOT NULL
138
139
  )
139
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
+ `)
140
159
 
141
160
  // Add unique indexes for tables that might already exist without them
142
161
  db.run("CREATE UNIQUE INDEX IF NOT EXISTS idx_leetcode_account_user_id ON leetcode_account(user_id)")
143
162
  db.run("CREATE UNIQUE INDEX IF NOT EXISTS idx_roadmap_plan_user_id ON roadmap_plan(user_id)")
163
+
164
+ // Performance indexes for frequently queried columns
165
+ db.run("CREATE INDEX IF NOT EXISTS idx_daily_plan_user_date ON daily_plan(user_id, date)")
166
+ db.run("CREATE INDEX IF NOT EXISTS idx_daily_progress_user_problem ON daily_progress(user_id, problem_id)")
167
+ db.run("CREATE INDEX IF NOT EXISTS idx_roadmap_job_user ON roadmap_job(user_id)")
168
+
169
+ // Migrations for existing databases (safe to run multiple times)
170
+ try { db.run("ALTER TABLE daily_plan ADD COLUMN explanation text") } catch {}
144
171
  }
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,137 @@
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
- import { userPreferences, roadmapPlan, dailyPlan, dailyProgress, roadmapJob } from '../db/schema'
5
+ import { userPreferences, roadmapPlan, dailyPlan, dailyProgress, roadmapJob, problemReview } 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
+
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,
114
+ })
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
+ }))
126
+ }
127
+
128
+ const difficultySchema = z.enum(['EASY', 'MEDIUM', 'HARD', 'MIXED'])
129
+ const planStatusSchema = z.enum(['SOLVED', 'TRIED', 'SKIPPED', 'PENDING'])
130
+ const regenerateBodySchema = z.object({
131
+ slot: z.number().int().min(0).optional(),
132
+ easier: z.boolean().optional(),
133
+ })
134
+
13
135
  function tryParseError(msg: string): string {
14
136
  try {
15
137
  const top = JSON.parse(msg)
@@ -38,7 +160,8 @@ app.get('/roadmap', async (c) => {
38
160
  })
39
161
  if (!plan) return c.json({ success: false, error: 'No roadmap found' }, 404)
40
162
  const weeks = Array.isArray(plan.weeks) ? plan.weeks : []
41
- return c.json({ success: true, data: { ...plan, ready: weeks.length > 0 } })
163
+ const currentWeek = await getCurrentWeek(userId, weeks)
164
+ return c.json({ success: true, data: { ...plan, currentWeek, ready: weeks.length > 0 } })
42
165
  } catch (err: any) {
43
166
  return c.json({ success: false, error: err.message }, 500)
44
167
  }
@@ -55,20 +178,47 @@ app.patch('/roadmap/advance', async (c) => {
55
178
  const weeks = Array.isArray(plan.weeks) ? plan.weeks : []
56
179
  if (!weeks.length) return c.json({ success: false, error: 'Roadmap not ready' }, 400)
57
180
 
58
- if (plan.currentWeek >= weeks.length) {
181
+ const currentWeek = await getCurrentWeek(userId, weeks)
182
+ if (currentWeek >= weeks.length) {
59
183
  return c.json({ success: false, error: 'Roadmap already completed' }, 400)
60
184
  }
61
185
 
62
- const nextWeek = plan.currentWeek + 1
186
+ return c.json({ success: true, data: { ...plan, currentWeek, ready: true } })
187
+ } catch (err: any) {
188
+ return c.json({ success: false, error: err.message }, 500)
189
+ }
190
+ })
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
+
63
213
  await db.update(roadmapPlan).set({
64
- currentWeek: nextWeek,
214
+ weeks: weeks as any,
65
215
  updatedAt: new Date(),
66
216
  }).where(eq(roadmapPlan.userId, userId))
67
217
 
68
218
  const updated = await db.query.roadmapPlan.findFirst({
69
219
  where: eq(roadmapPlan.userId, userId),
70
220
  })
71
- return c.json({ success: true, data: { ...updated, ready: true } })
221
+ return c.json({ success: true, data: updated })
72
222
  } catch (err: any) {
73
223
  return c.json({ success: false, error: err.message }, 500)
74
224
  }
@@ -234,7 +384,7 @@ app.get('/roadmap/progress', async (c) => {
234
384
  targetCount: w.problemsCount,
235
385
  assignedCount: total,
236
386
  solvedCount: solved,
237
- percent: total > 0 ? Math.round((solved / total) * 100) : 0,
387
+ percent: w.problemsCount > 0 ? Math.round((solved / w.problemsCount) * 100) : 0,
238
388
  }
239
389
  })
240
390
 
@@ -258,7 +408,15 @@ app.get('/today', async (c) => {
258
408
  ),
259
409
  })
260
410
  if (!plan) return c.json({ success: false, exists: false }, 404)
261
- return c.json({ success: true, exists: true, data: plan })
411
+
412
+ const planRecord = await db.query.roadmapPlan.findFirst({
413
+ where: eq(roadmapPlan.userId, userId),
414
+ })
415
+ const weeks = Array.isArray(planRecord?.weeks) ? planRecord.weeks : []
416
+ const currentWeek = await getCurrentWeek(userId, weeks)
417
+ const topic = weeks[currentWeek - 1] ? (weeks[currentWeek - 1] as Record<string, unknown>)?.topic as string : plan.topic
418
+
419
+ return c.json({ success: true, exists: true, data: { ...plan, weekNumber: currentWeek, topic } })
262
420
  } catch (err: any) {
263
421
  return c.json({ success: false, error: err.message }, 500)
264
422
  }
@@ -268,7 +426,8 @@ app.post('/today', async (c) => {
268
426
  try {
269
427
  const userId = c.get('userId')
270
428
  const body: any = await c.req.json().catch(() => ({}))
271
- const difficultyFilter = (body.difficulty || "MIXED") as "EASY" | "MEDIUM" | "HARD" | "MIXED"
429
+ const diffResult = difficultySchema.safeParse(body.difficulty)
430
+ const difficultyFilter = diffResult.success ? diffResult.data : 'MIXED'
272
431
 
273
432
  const todayMs = Date.now() - (Date.now() % 86400000)
274
433
  const tomorrowMs = todayMs + 86400000
@@ -289,7 +448,10 @@ app.post('/today', async (c) => {
289
448
 
290
449
  const roadmap = Array.isArray(planRecord.weeks) ? planRecord.weeks : []
291
450
  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
451
+ const currentWeek = await getCurrentWeek(userId, roadmap)
452
+ if (currentWeek > roadmap.length) {
453
+ return c.json({ success: false, error: 'Roadmap is complete. Generate a new roadmap to continue.' }, 400)
454
+ }
293
455
 
294
456
  const allPlans = await db.query.dailyPlan.findMany({
295
457
  where: eq(dailyPlan.userId, userId),
@@ -326,18 +488,22 @@ app.post('/today', async (c) => {
326
488
  completedAt: null,
327
489
  }))
328
490
 
491
+ const dueReviews = await getDueReviews(userId)
492
+ const allProblems = [...problems, ...dueReviews]
493
+
329
494
  const entry = {
330
495
  id: crypto.randomUUID(),
331
496
  userId,
332
497
  date: new Date(),
333
498
  weekNumber: currentWeek,
334
499
  topic: (roadmap[currentWeek - 1] as Record<string, unknown>)?.topic as string || '',
335
- problems,
500
+ problems: allProblems,
501
+ explanation: task.explanation,
336
502
  }
337
503
 
338
504
  await db.insert(dailyPlan).values(entry)
339
505
 
340
- return c.json({ success: true, data: { ...entry, explanation: task.explanation } }, 201)
506
+ return c.json({ success: true, data: entry }, 201)
341
507
  } catch (err: any) {
342
508
  return c.json({ success: false, error: `Failed to generate daily plan: ${err.message}` }, 500)
343
509
  }
@@ -348,10 +514,11 @@ app.patch('/today/:planId/problem/:slug', async (c) => {
348
514
  const userId = c.get('userId')
349
515
  const { planId, slug } = c.req.param()
350
516
  const body: any = await c.req.json()
351
- const status = body.status as string
352
- if (!["SOLVED", "TRIED", "SKIPPED", "PENDING"].includes(status)) {
517
+ const statusResult = planStatusSchema.safeParse(body.status)
518
+ if (!statusResult.success) {
353
519
  return c.json({ success: false, error: 'Invalid status. Use SOLVED, TRIED, SKIPPED, or PENDING.' }, 400)
354
520
  }
521
+ const status = statusResult.data
355
522
 
356
523
  const plan = await db.query.dailyPlan.findFirst({
357
524
  where: and(eq(dailyPlan.id, planId), eq(dailyPlan.userId, userId)),
@@ -393,6 +560,8 @@ app.patch('/today/:planId/problem/:slug', async (c) => {
393
560
  })
394
561
  }
395
562
 
563
+ scheduleReview(userId, problem, status).catch(() => {})
564
+
396
565
  return c.json({ success: true, data: { ...problem, status, completedAt: problem.completedAt } })
397
566
  } catch (err: any) {
398
567
  return c.json({ success: false, error: err.message }, 500)
@@ -404,8 +573,9 @@ app.post('/today/:planId/regenerate', async (c) => {
404
573
  const userId = c.get('userId')
405
574
  const { planId } = c.req.param()
406
575
  const body: any = await c.req.json().catch(() => ({}))
407
- const slot = body.slot as number | undefined
408
- const easier = body.easier === true
576
+ const regenResult = regenerateBodySchema.safeParse(body)
577
+ const slot = regenResult.success ? regenResult.data.slot : undefined
578
+ const easier = regenResult.success ? regenResult.data.easier === true : false
409
579
 
410
580
  const plan = await db.query.dailyPlan.findFirst({
411
581
  where: and(eq(dailyPlan.id, planId), eq(dailyPlan.userId, userId)),
@@ -424,6 +594,7 @@ app.post('/today/:planId/regenerate', async (c) => {
424
594
  where: eq(roadmapPlan.userId, userId),
425
595
  })
426
596
  const roadmap = Array.isArray(planRecord?.weeks) ? planRecord.weeks : []
597
+ const currentWeek = await getCurrentWeek(userId, roadmap)
427
598
 
428
599
  const allPlans = await db.query.dailyPlan.findMany({
429
600
  where: eq(dailyPlan.userId, userId),
@@ -452,7 +623,7 @@ app.post('/today/:planId/regenerate', async (c) => {
452
623
  }
453
624
  const result = await selectDailyProblems({
454
625
  roadmap,
455
- currentWeek: plan.weekNumber,
626
+ currentWeek,
456
627
  progress: progress.map((p) => ({ problemId: p.problemId, status: p.status })),
457
628
  dedupCount,
458
629
  solvedSlugs,
@@ -468,7 +639,7 @@ app.post('/today/:planId/regenerate', async (c) => {
468
639
  } else {
469
640
  const result = await selectDailyProblems({
470
641
  roadmap,
471
- currentWeek: plan.weekNumber,
642
+ currentWeek,
472
643
  progress: progress.map((p) => ({ problemId: p.problemId, status: p.status })),
473
644
  dedupCount,
474
645
  solvedSlugs,
@@ -503,7 +674,11 @@ app.get('/streak', async (c) => {
503
674
  orderBy: [desc(dailyPlan.date)],
504
675
  })
505
676
 
506
- if (!plans.length) {
677
+ const progressRecords = await db.query.dailyProgress.findMany({
678
+ where: and(eq(dailyProgress.userId, userId), eq(dailyProgress.status, 'SOLVED')),
679
+ })
680
+
681
+ if (!plans.length && !progressRecords.length) {
507
682
  return c.json({ success: true, data: { currentStreak: 0, longestStreak: 0, solvedToday: false } })
508
683
  }
509
684
 
@@ -519,6 +694,13 @@ app.get('/streak', async (c) => {
519
694
  if (hasSolved) solvedDates.add(dateStr)
520
695
  }
521
696
 
697
+ for (const r of progressRecords) {
698
+ const d = new Date(r.date)
699
+ const dateStr = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`
700
+ solvedDates.add(dateStr)
701
+ allDates.add(dateStr)
702
+ }
703
+
522
704
  const today = new Date()
523
705
  const todayStr = `${today.getFullYear()}-${String(today.getMonth() + 1).padStart(2, "0")}-${String(today.getDate()).padStart(2, "0")}`
524
706
  const solvedToday = solvedDates.has(todayStr)
@@ -546,8 +728,8 @@ app.get('/streak', async (c) => {
546
728
  } else {
547
729
  const prev = new Date(sortedDates[i - 1])
548
730
  const curr = new Date(sortedDates[i])
549
- const diffMs = curr.getTime() - prev.getTime()
550
- const diffDays = Math.round(diffMs / (1000 * 60 * 60 * 24))
731
+ const diffMs = Date.UTC(curr.getFullYear(), curr.getMonth(), curr.getDate()) - Date.UTC(prev.getFullYear(), prev.getMonth(), prev.getDate())
732
+ const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24))
551
733
  if (diffDays === 1) {
552
734
  tempStreak++
553
735
  } 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
  }
@@ -1 +0,0 @@
1
- *,:before,:after,::backdrop{--tw-border-spacing-x:0;--tw-border-spacing-y:0;--tw-translate-x:0;--tw-translate-y:0;--tw-rotate:0;--tw-skew-x:0;--tw-skew-y:0;--tw-scale-x:1;--tw-scale-y:1;--tw-pan-x: ;--tw-pan-y: ;--tw-pinch-zoom: ;--tw-scroll-snap-strictness:proximity;--tw-gradient-from-position: ;--tw-gradient-via-position: ;--tw-gradient-to-position: ;--tw-ordinal: ;--tw-slashed-zero: ;--tw-numeric-figure: ;--tw-numeric-spacing: ;--tw-numeric-fraction: ;--tw-ring-inset: ;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-color:#3b82f680;--tw-ring-offset-shadow:0 0 #0000;--tw-ring-shadow:0 0 #0000;--tw-shadow:0 0 #0000;--tw-shadow-colored:0 0 #0000;--tw-blur: ;--tw-brightness: ;--tw-contrast: ;--tw-grayscale: ;--tw-hue-rotate: ;--tw-invert: ;--tw-saturate: ;--tw-sepia: ;--tw-drop-shadow: ;--tw-backdrop-blur: ;--tw-backdrop-brightness: ;--tw-backdrop-contrast: ;--tw-backdrop-grayscale: ;--tw-backdrop-hue-rotate: ;--tw-backdrop-invert: ;--tw-backdrop-opacity: ;--tw-backdrop-saturate: ;--tw-backdrop-sepia: ;--tw-contain-size: ;--tw-contain-layout: ;--tw-contain-paint: ;--tw-contain-style: }*,:before,:after{box-sizing:border-box;border:0 solid #e5e7eb}:before,:after{--tw-content:""}html,:host{-webkit-text-size-adjust:100%;tab-size:4;font-feature-settings:normal;font-variation-settings:normal;-webkit-tap-highlight-color:transparent;font-family:Inter,system-ui,-apple-system,sans-serif;line-height:1.5}body{line-height:inherit;margin:0}hr{height:0;color:inherit;border-top-width:1px}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;-webkit-text-decoration:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,samp,pre{font-feature-settings:normal;font-variation-settings:normal;font-family:JetBrains Mono,Fira Code,monospace;font-size:1em}small{font-size:80%}sub,sup{vertical-align:baseline;font-size:75%;line-height:0;position:relative}sub{bottom:-.25em}sup{top:-.5em}table{text-indent:0;border-color:inherit;border-collapse:collapse}button,input,optgroup,select,textarea{font-feature-settings:inherit;font-variation-settings:inherit;font-family:inherit;font-size:100%;font-weight:inherit;line-height:inherit;letter-spacing:inherit;color:inherit;margin:0;padding:0}button,select{text-transform:none}button,input:where([type=button]),input:where([type=reset]),input:where([type=submit]){-webkit-appearance:button;background-color:#0000;background-image:none}:-moz-focusring{outline:auto}:-moz-ui-invalid{box-shadow:none}progress{vertical-align:baseline}::-webkit-inner-spin-button{height:auto}::-webkit-outer-spin-button{height:auto}[type=search]{-webkit-appearance:textfield;outline-offset:-2px}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-file-upload-button{-webkit-appearance:button;font:inherit}summary{display:list-item}blockquote,dl,dd,h1,h2,h3,h4,h5,h6,hr,figure,p,pre{margin:0}fieldset{margin:0;padding:0}legend{padding:0}ol,ul,menu{margin:0;padding:0;list-style:none}dialog{padding:0}textarea{resize:vertical}input::-moz-placeholder{opacity:1;color:#9ca3af}textarea::-moz-placeholder{opacity:1;color:#9ca3af}input::placeholder,textarea::placeholder{opacity:1;color:#9ca3af}button,[role=button]{cursor:pointer}:disabled{cursor:default}img,svg,video,canvas,audio,iframe,embed,object{vertical-align:middle;display:block}img,video{max-width:100%;height:auto}[hidden]:where(:not([hidden=until-found])){display:none}:root{--surface-950:255 255 255;--surface-900:250 250 250;--surface-800:243 244 246;--surface-700:229 231 235;--surface-600:209 213 219;--surface-500:156 163 175;--surface-400:107 114 128;--surface-300:75 85 99;--surface-200:31 41 55;--surface-100:17 24 39;--surface-50:3 7 12;--accent-50:239 246 255;--accent-100:219 234 254;--accent-200:191 219 254;--accent-300:147 197 253;--accent-400:96 165 250;--accent-500:59 130 246;--accent-600:37 99 235;--accent-700:29 78 216;--accent-800:30 64 175;--accent-900:30 58 138;--accent-950:23 37 84}.dark{--surface-50:212 212 212;--surface-100:200 200 200;--surface-200:180 180 180;--surface-300:156 156 156;--surface-400:120 120 120;--surface-500:100 100 100;--surface-600:80 80 80;--surface-700:60 60 60;--surface-800:45 45 45;--surface-900:30 30 30;--surface-950:20 20 20;--accent-50:240 240 240;--accent-100:224 224 224;--accent-200:200 200 200;--accent-300:176 176 176;--accent-400:150 150 150;--accent-500:120 120 120;--accent-600:100 100 100;--accent-700:80 80 80;--accent-800:60 60 60;--accent-900:45 45 45;--accent-950:35 35 35}html{scroll-behavior:smooth}body{--tw-bg-opacity:1;background-color:rgb(var(--surface-950) / var(--tw-bg-opacity,1));--tw-text-opacity:1;color:rgb(var(--surface-200) / var(--tw-text-opacity,1));-webkit-font-smoothing:antialiased;-moz-osx-font-smoothing:grayscale;font-family:Inter,system-ui,-apple-system,sans-serif}::selection{background-color:rgb(var(--accent-500) / .3);--tw-text-opacity:1;color:rgb(255 255 255/var(--tw-text-opacity,1))}::-webkit-scrollbar{width:.5rem}::-webkit-scrollbar-track{--tw-bg-opacity:1;background-color:rgb(var(--surface-800) / var(--tw-bg-opacity,1))}::-webkit-scrollbar-thumb{--tw-bg-opacity:1;background-color:rgb(var(--surface-600) / var(--tw-bg-opacity,1));border-radius:9999px}::-webkit-scrollbar-thumb:hover{--tw-bg-opacity:1;background-color:rgb(var(--surface-500) / var(--tw-bg-opacity,1))}.glass-card{border-width:1px;border-color:rgb(var(--surface-600) / .5);background-color:rgb(var(--surface-800) / .6);--tw-backdrop-blur:blur(24px);-webkit-backdrop-filter:var(--tw-backdrop-blur) var(--tw-backdrop-brightness) var(--tw-backdrop-contrast) var(--tw-backdrop-grayscale) var(--tw-backdrop-hue-rotate) var(--tw-backdrop-invert) var(--tw-backdrop-opacity) var(--tw-backdrop-saturate) var(--tw-backdrop-sepia);backdrop-filter:var(--tw-backdrop-blur) var(--tw-backdrop-brightness) var(--tw-backdrop-contrast) var(--tw-backdrop-grayscale) var(--tw-backdrop-hue-rotate) var(--tw-backdrop-invert) var(--tw-backdrop-opacity) var(--tw-backdrop-saturate) var(--tw-backdrop-sepia);border-radius:1rem}.glass-card-hover{border-width:1px;border-color:rgb(var(--surface-600) / .5);background-color:rgb(var(--surface-800) / .6);--tw-backdrop-blur:blur(24px);-webkit-backdrop-filter:var(--tw-backdrop-blur) var(--tw-backdrop-brightness) var(--tw-backdrop-contrast) var(--tw-backdrop-grayscale) var(--tw-backdrop-hue-rotate) var(--tw-backdrop-invert) var(--tw-backdrop-opacity) var(--tw-backdrop-saturate) var(--tw-backdrop-sepia);backdrop-filter:var(--tw-backdrop-blur) var(--tw-backdrop-brightness) var(--tw-backdrop-contrast) var(--tw-backdrop-grayscale) var(--tw-backdrop-hue-rotate) var(--tw-backdrop-invert) var(--tw-backdrop-opacity) var(--tw-backdrop-saturate) var(--tw-backdrop-sepia);border-radius:1rem;transition-property:all;transition-duration:.3s;transition-timing-function:cubic-bezier(.4,0,.2,1)}.glass-card-hover:hover{border-color:rgb(var(--accent-500) / .3);--tw-shadow:var(--tw-shadow-colored);--tw-shadow-colored:0 10px 15px -3px var(--tw-shadow-color), 0 4px 6px -4px var(--tw-shadow-color);box-shadow:var(--tw-ring-offset-shadow,0 0 #0000), var(--tw-ring-shadow,0 0 #0000), var(--tw-shadow);--tw-shadow-color:rgb(var(--accent-500) / .05)}.gradient-text{background-image:linear-gradient(to right, var(--tw-gradient-stops));--tw-gradient-from:#fff var(--tw-gradient-from-position);--tw-gradient-to:rgb(var(--accent-400) / 1) var(--tw-gradient-to-position);--tw-gradient-stops:var(--tw-gradient-from), rgb(var(--accent-200) / 1) var(--tw-gradient-via-position), var(--tw-gradient-to);color:#0000;-webkit-background-clip:text;background-clip:text}.pointer-events-none{pointer-events:none}.fixed{position:fixed}.absolute{position:absolute}.relative{position:relative}.sticky{position:sticky}.inset-0{inset:0}.inset-4{inset:1rem}.-left-32{left:-8rem}.-right-32{right:-8rem}.bottom-1\/4{bottom:25%}.left-0{left:0}.left-3{left:.75rem}.right-0{right:0}.top-0{top:0}.top-1\/2{top:50%}.top-1\/4{top:25%}.z-40{z-index:40}.z-50{z-index:50}.mx-auto{margin-left:auto;margin-right:auto}.mb-1{margin-bottom:.25rem}.mb-12{margin-bottom:3rem}.mb-16{margin-bottom:4rem}.mb-2{margin-bottom:.5rem}.mb-3{margin-bottom:.75rem}.mb-4{margin-bottom:1rem}.mb-5{margin-bottom:1.25rem}.mb-6{margin-bottom:1.5rem}.mb-8{margin-bottom:2rem}.ml-1{margin-left:.25rem}.ml-1\.5{margin-left:.375rem}.ml-4{margin-left:1rem}.mt-0\.5{margin-top:.125rem}.mt-1{margin-top:.25rem}.mt-1\.5{margin-top:.375rem}.mt-12{margin-top:3rem}.mt-2{margin-top:.5rem}.mt-3{margin-top:.75rem}.mt-4{margin-top:1rem}.mt-6{margin-top:1.5rem}.mt-8{margin-top:2rem}.block{display:block}.flex{display:flex}.inline-flex{display:inline-flex}.table{display:table}.grid{display:grid}.hidden{display:none}.h-0\.5{height:.125rem}.h-1\.5{height:.375rem}.h-10{height:2.5rem}.h-12{height:3rem}.h-14{height:3.5rem}.h-16{height:4rem}.h-2{height:.5rem}.h-2\.5{height:.625rem}.h-24{height:6rem}.h-3{height:.75rem}.h-3\.5{height:.875rem}.h-4{height:1rem}.h-48{height:12rem}.h-5{height:1.25rem}.h-6{height:1.5rem}.h-64{height:16rem}.h-8{height:2rem}.h-9{height:2.25rem}.h-96{height:24rem}.h-full{height:100%}.max-h-32{max-height:8rem}.max-h-60{max-height:15rem}.max-h-80{max-height:20rem}.min-h-screen{min-height:100vh}.w-1\.5{width:.375rem}.w-10{width:2.5rem}.w-12{width:3rem}.w-14{width:3.5rem}.w-16{width:4rem}.w-2{width:.5rem}.w-2\.5{width:.625rem}.w-20{width:5rem}.w-24{width:6rem}.w-3{width:.75rem}.w-3\.5{width:.875rem}.w-32{width:8rem}.w-36{width:9rem}.w-4{width:1rem}.w-48{width:12rem}.w-5{width:1.25rem}.w-56{width:14rem}.w-6{width:1.5rem}.w-8{width:2rem}.w-9{width:2.25rem}.w-96{width:24rem}.w-full{width:100%}.min-w-0{min-width:0}.max-w-2xl{max-width:42rem}.max-w-3xl{max-width:48rem}.max-w-7xl{max-width:80rem}.max-w-md{max-width:28rem}.max-w-xl{max-width:36rem}.flex-1{flex:1}.shrink-0{flex-shrink:0}.-translate-y-1\/2{--tw-translate-y:-50%;transform:translate(var(--tw-translate-x), var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.-rotate-90{--tw-rotate:-90deg;transform:translate(var(--tw-translate-x), var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.transform{transform:translate(var(--tw-translate-x), var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}@keyframes pulse{50%{opacity:.5}}.animate-pulse{animation:2s cubic-bezier(.4,0,.6,1) infinite pulse}@keyframes spin{to{transform:rotate(360deg)}}.animate-spin{animation:1s linear infinite spin}.cursor-not-allowed{cursor:not-allowed}.cursor-pointer{cursor:pointer}.resize-none{resize:none}.grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.grid-cols-3{grid-template-columns:repeat(3,minmax(0,1fr))}.flex-col{flex-direction:column}.flex-wrap{flex-wrap:wrap}.items-start{align-items:flex-start}.items-center{align-items:center}.justify-center{justify-content:center}.justify-between{justify-content:space-between}.justify-around{justify-content:space-around}.gap-0\.5{gap:.125rem}.gap-1{gap:.25rem}.gap-1\.5{gap:.375rem}.gap-16{gap:4rem}.gap-2{gap:.5rem}.gap-2\.5{gap:.625rem}.gap-3{gap:.75rem}.gap-4{gap:1rem}.gap-6{gap:1.5rem}.gap-8{gap:2rem}.-space-x-2>:not([hidden])~:not([hidden]){--tw-space-x-reverse:0;margin-right:calc(-.5rem * var(--tw-space-x-reverse));margin-left:calc(-.5rem * calc(1 - var(--tw-space-x-reverse)))}.space-y-2>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-top:calc(.5rem * calc(1 - var(--tw-space-y-reverse)));margin-bottom:calc(.5rem * var(--tw-space-y-reverse))}.space-y-3>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-top:calc(.75rem * calc(1 - var(--tw-space-y-reverse)));margin-bottom:calc(.75rem * var(--tw-space-y-reverse))}.space-y-4>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-top:calc(1rem * calc(1 - var(--tw-space-y-reverse)));margin-bottom:calc(1rem * var(--tw-space-y-reverse))}.space-y-6>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-top:calc(1.5rem * calc(1 - var(--tw-space-y-reverse)));margin-bottom:calc(1.5rem * var(--tw-space-y-reverse))}.space-y-8>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-top:calc(2rem * calc(1 - var(--tw-space-y-reverse)));margin-bottom:calc(2rem * var(--tw-space-y-reverse))}.overflow-hidden{overflow:hidden}.overflow-x-auto{overflow-x:auto}.overflow-y-auto{overflow-y:auto}.truncate{text-overflow:ellipsis;white-space:nowrap;overflow:hidden}.whitespace-pre-wrap{white-space:pre-wrap}.rounded{border-radius:.25rem}.rounded-2xl{border-radius:1rem}.rounded-3xl{border-radius:1.5rem}.rounded-full{border-radius:9999px}.rounded-lg{border-radius:.5rem}.rounded-xl{border-radius:.75rem}.border{border-width:1px}.border-2{border-width:2px}.border-b{border-bottom-width:1px}.border-t{border-top-width:1px}.border-accent-500{--tw-border-opacity:1;border-color:rgb(var(--accent-500) / var(--tw-border-opacity,1))}.border-accent-500\/20{border-color:rgb(var(--accent-500) / .2)}.border-accent-500\/40{border-color:rgb(var(--accent-500) / .4)}.border-accent-500\/50{border-color:rgb(var(--accent-500) / .5)}.border-amber-500\/20{border-color:#f59e0b33}.border-emerald-500\/20{border-color:#10b98133}.border-red-500\/20{border-color:#ef444433}.border-surface-500\/30{border-color:rgb(var(--surface-500) / .3)}.border-surface-600{--tw-border-opacity:1;border-color:rgb(var(--surface-600) / var(--tw-border-opacity,1))}.border-surface-600\/30{border-color:rgb(var(--surface-600) / .3)}.border-surface-700{--tw-border-opacity:1;border-color:rgb(var(--surface-700) / var(--tw-border-opacity,1))}.border-surface-700\/30{border-color:rgb(var(--surface-700) / .3)}.border-surface-700\/50{border-color:rgb(var(--surface-700) / .5)}.border-surface-800{--tw-border-opacity:1;border-color:rgb(var(--surface-800) / var(--tw-border-opacity,1))}.border-surface-800\/50{border-color:rgb(var(--surface-800) / .5)}.border-surface-950{--tw-border-opacity:1;border-color:rgb(var(--surface-950) / var(--tw-border-opacity,1))}.border-transparent{border-color:#0000}.bg-accent-400{--tw-bg-opacity:1;background-color:rgb(var(--accent-400) / var(--tw-bg-opacity,1))}.bg-accent-500{--tw-bg-opacity:1;background-color:rgb(var(--accent-500) / var(--tw-bg-opacity,1))}.bg-accent-500\/10{background-color:rgb(var(--accent-500) / .1)}.bg-accent-500\/20{background-color:rgb(var(--accent-500) / .2)}.bg-accent-500\/30{background-color:rgb(var(--accent-500) / .3)}.bg-accent-500\/5{background-color:rgb(var(--accent-500) / .05)}.bg-accent-600{--tw-bg-opacity:1;background-color:rgb(var(--accent-600) / var(--tw-bg-opacity,1))}.bg-amber-500{--tw-bg-opacity:1;background-color:rgb(245 158 11/var(--tw-bg-opacity,1))}.bg-amber-500\/10{background-color:#f59e0b1a}.bg-black\/60{background-color:#0009}.bg-emerald-500{--tw-bg-opacity:1;background-color:rgb(16 185 129/var(--tw-bg-opacity,1))}.bg-emerald-500\/10{background-color:#10b9811a}.bg-emerald-500\/5{background-color:#10b9810d}.bg-emerald-600{--tw-bg-opacity:1;background-color:rgb(5 150 105/var(--tw-bg-opacity,1))}.bg-green-500\/80{background-color:#22c55ecc}.bg-indigo-500\/10{background-color:#6366f11a}.bg-red-500{--tw-bg-opacity:1;background-color:rgb(239 68 68/var(--tw-bg-opacity,1))}.bg-red-500\/10{background-color:#ef44441a}.bg-red-500\/80{background-color:#ef4444cc}.bg-surface-500\/30{background-color:rgb(var(--surface-500) / .3)}.bg-surface-600\/30{background-color:rgb(var(--surface-600) / .3)}.bg-surface-700{--tw-bg-opacity:1;background-color:rgb(var(--surface-700) / var(--tw-bg-opacity,1))}.bg-surface-700\/30{background-color:rgb(var(--surface-700) / .3)}.bg-surface-700\/50{background-color:rgb(var(--surface-700) / .5)}.bg-surface-800{--tw-bg-opacity:1;background-color:rgb(var(--surface-800) / var(--tw-bg-opacity,1))}.bg-surface-800\/10{background-color:rgb(var(--surface-800) / .1)}.bg-surface-800\/20{background-color:rgb(var(--surface-800) / .2)}.bg-surface-800\/30{background-color:rgb(var(--surface-800) / .3)}.bg-surface-800\/50{background-color:rgb(var(--surface-800) / .5)}.bg-surface-900{--tw-bg-opacity:1;background-color:rgb(var(--surface-900) / var(--tw-bg-opacity,1))}.bg-surface-900\/30{background-color:rgb(var(--surface-900) / .3)}.bg-surface-900\/50{background-color:rgb(var(--surface-900) / .5)}.bg-surface-900\/80{background-color:rgb(var(--surface-900) / .8)}.bg-surface-950{--tw-bg-opacity:1;background-color:rgb(var(--surface-950) / var(--tw-bg-opacity,1))}.bg-surface-950\/80{background-color:rgb(var(--surface-950) / .8)}.bg-yellow-500\/80{background-color:#eab308cc}.bg-gradient-to-b{background-image:linear-gradient(to bottom, var(--tw-gradient-stops))}.bg-gradient-to-br{background-image:linear-gradient(to bottom right, var(--tw-gradient-stops))}.bg-gradient-to-r{background-image:linear-gradient(to right, var(--tw-gradient-stops))}.from-accent-400{--tw-gradient-from:rgb(var(--accent-400) / 1) var(--tw-gradient-from-position);--tw-gradient-to:rgb(var(--accent-400) / 0) var(--tw-gradient-to-position);--tw-gradient-stops:var(--tw-gradient-from), var(--tw-gradient-to)}.from-accent-500{--tw-gradient-from:rgb(var(--accent-500) / 1) var(--tw-gradient-from-position);--tw-gradient-to:rgb(var(--accent-500) / 0) var(--tw-gradient-to-position);--tw-gradient-stops:var(--tw-gradient-from), var(--tw-gradient-to)}.from-accent-500\/10{--tw-gradient-from:rgb(var(--accent-500) / .1) var(--tw-gradient-from-position);--tw-gradient-to:rgb(var(--accent-500) / 0) var(--tw-gradient-to-position);--tw-gradient-stops:var(--tw-gradient-from), var(--tw-gradient-to)}.from-accent-500\/20{--tw-gradient-from:rgb(var(--accent-500) / .2) var(--tw-gradient-from-position);--tw-gradient-to:rgb(var(--accent-500) / 0) var(--tw-gradient-to-position);--tw-gradient-stops:var(--tw-gradient-from), var(--tw-gradient-to)}.from-accent-500\/5{--tw-gradient-from:rgb(var(--accent-500) / .05) var(--tw-gradient-from-position);--tw-gradient-to:rgb(var(--accent-500) / 0) var(--tw-gradient-to-position);--tw-gradient-stops:var(--tw-gradient-from), var(--tw-gradient-to)}.from-surface-300{--tw-gradient-from:rgb(var(--surface-300) / 1) var(--tw-gradient-from-position);--tw-gradient-to:rgb(var(--surface-300) / 0) var(--tw-gradient-to-position);--tw-gradient-stops:var(--tw-gradient-from), var(--tw-gradient-to)}.from-surface-400{--tw-gradient-from:rgb(var(--surface-400) / 1) var(--tw-gradient-from-position);--tw-gradient-to:rgb(var(--surface-400) / 0) var(--tw-gradient-to-position);--tw-gradient-stops:var(--tw-gradient-from), var(--tw-gradient-to)}.from-surface-500{--tw-gradient-from:rgb(var(--surface-500) / 1) var(--tw-gradient-from-position);--tw-gradient-to:rgb(var(--surface-500) / 0) var(--tw-gradient-to-position);--tw-gradient-stops:var(--tw-gradient-from), var(--tw-gradient-to)}.from-surface-900\/30{--tw-gradient-from:rgb(var(--surface-900) / .3) var(--tw-gradient-from-position);--tw-gradient-to:rgb(var(--surface-900) / 0) var(--tw-gradient-to-position);--tw-gradient-stops:var(--tw-gradient-from), var(--tw-gradient-to)}.from-surface-900\/50{--tw-gradient-from:rgb(var(--surface-900) / .5) var(--tw-gradient-from-position);--tw-gradient-to:rgb(var(--surface-900) / 0) var(--tw-gradient-to-position);--tw-gradient-stops:var(--tw-gradient-from), var(--tw-gradient-to)}.from-transparent{--tw-gradient-from:transparent var(--tw-gradient-from-position);--tw-gradient-to:#0000 var(--tw-gradient-to-position);--tw-gradient-stops:var(--tw-gradient-from), var(--tw-gradient-to)}.via-accent-500\/5{--tw-gradient-to:rgb(var(--accent-500) / 0) var(--tw-gradient-to-position);--tw-gradient-stops:var(--tw-gradient-from), rgb(var(--accent-500) / .05) var(--tw-gradient-via-position), var(--tw-gradient-to)}.via-transparent{--tw-gradient-to:#0000 var(--tw-gradient-to-position);--tw-gradient-stops:var(--tw-gradient-from), transparent var(--tw-gradient-via-position), var(--tw-gradient-to)}.to-accent-600{--tw-gradient-to:rgb(var(--accent-600) / 1) var(--tw-gradient-to-position)}.to-accent-600\/10{--tw-gradient-to:rgb(var(--accent-600) / .1) var(--tw-gradient-to-position)}.to-accent-600\/20{--tw-gradient-to:rgb(var(--accent-600) / .2) var(--tw-gradient-to-position)}.to-accent-700{--tw-gradient-to:rgb(var(--accent-700) / 1) var(--tw-gradient-to-position)}.to-surface-100{--tw-gradient-to:rgb(var(--surface-100) / 1) var(--tw-gradient-to-position)}.to-surface-200{--tw-gradient-to:rgb(var(--surface-200) / 1) var(--tw-gradient-to-position)}.to-surface-300{--tw-gradient-to:rgb(var(--surface-300) / 1) var(--tw-gradient-to-position)}.to-surface-600{--tw-gradient-to:rgb(var(--surface-600) / 1) var(--tw-gradient-to-position)}.to-surface-700{--tw-gradient-to:rgb(var(--surface-700) / 1) var(--tw-gradient-to-position)}.to-surface-900\/30{--tw-gradient-to:rgb(var(--surface-900) / .3) var(--tw-gradient-to-position)}.to-surface-900\/50{--tw-gradient-to:rgb(var(--surface-900) / .5) var(--tw-gradient-to-position)}.to-transparent{--tw-gradient-to:transparent var(--tw-gradient-to-position)}.bg-clip-text{-webkit-background-clip:text;background-clip:text}.stroke-surface-100{stroke:rgb(var(--surface-100) / 1)}.stroke-surface-200{stroke:rgb(var(--surface-200) / 1)}.stroke-surface-300{stroke:rgb(var(--surface-300) / 1)}.p-1{padding:.25rem}.p-12{padding:3rem}.p-2{padding:.5rem}.p-2\.5{padding:.625rem}.p-3{padding:.75rem}.p-3\.5{padding:.875rem}.p-4{padding:1rem}.p-5{padding:1.25rem}.p-6{padding:1.5rem}.p-8{padding:2rem}.px-1\.5{padding-left:.375rem;padding-right:.375rem}.px-2{padding-left:.5rem;padding-right:.5rem}.px-2\.5{padding-left:.625rem;padding-right:.625rem}.px-3{padding-left:.75rem;padding-right:.75rem}.px-4{padding-left:1rem;padding-right:1rem}.px-5{padding-left:1.25rem;padding-right:1.25rem}.px-8{padding-left:2rem;padding-right:2rem}.py-0\.5{padding-top:.125rem;padding-bottom:.125rem}.py-1{padding-top:.25rem;padding-bottom:.25rem}.py-1\.5{padding-top:.375rem;padding-bottom:.375rem}.py-12{padding-top:3rem;padding-bottom:3rem}.py-2{padding-top:.5rem;padding-bottom:.5rem}.py-2\.5{padding-top:.625rem;padding-bottom:.625rem}.py-24{padding-top:6rem;padding-bottom:6rem}.py-3{padding-top:.75rem;padding-bottom:.75rem}.py-3\.5{padding-top:.875rem;padding-bottom:.875rem}.py-4{padding-top:1rem;padding-bottom:1rem}.py-8{padding-top:2rem;padding-bottom:2rem}.pb-16{padding-bottom:4rem}.pl-10{padding-left:2.5rem}.pl-9{padding-left:2.25rem}.pr-3{padding-right:.75rem}.pr-4{padding-right:1rem}.pt-1{padding-top:.25rem}.pt-2{padding-top:.5rem}.pt-24{padding-top:6rem}.pt-3{padding-top:.75rem}.pt-4{padding-top:1rem}.pt-8{padding-top:2rem}.text-left{text-align:left}.text-center{text-align:center}.font-mono{font-family:JetBrains Mono,Fira Code,monospace}.text-2xl{font-size:1.5rem;line-height:2rem}.text-3xl{font-size:1.875rem;line-height:2.25rem}.text-4xl{font-size:2.25rem;line-height:2.5rem}.text-5xl{font-size:3rem;line-height:1}.text-6xl{font-size:3.75rem;line-height:1}.text-base{font-size:1rem;line-height:1.5rem}.text-lg{font-size:1.125rem;line-height:1.75rem}.text-sm{font-size:.875rem;line-height:1.25rem}.text-xl{font-size:1.25rem;line-height:1.75rem}.text-xs{font-size:.75rem;line-height:1rem}.font-bold{font-weight:700}.font-medium{font-weight:500}.font-normal{font-weight:400}.font-semibold{font-weight:600}.capitalize{text-transform:capitalize}.leading-relaxed{line-height:1.625}.leading-tight{line-height:1.25}.text-accent-300{--tw-text-opacity:1;color:rgb(var(--accent-300) / var(--tw-text-opacity,1))}.text-accent-400{--tw-text-opacity:1;color:rgb(var(--accent-400) / var(--tw-text-opacity,1))}.text-amber-400{--tw-text-opacity:1;color:rgb(251 191 36/var(--tw-text-opacity,1))}.text-emerald-400{--tw-text-opacity:1;color:rgb(52 211 153/var(--tw-text-opacity,1))}.text-indigo-400{--tw-text-opacity:1;color:rgb(129 140 248/var(--tw-text-opacity,1))}.text-red-400{--tw-text-opacity:1;color:rgb(248 113 113/var(--tw-text-opacity,1))}.text-surface-100{--tw-text-opacity:1;color:rgb(var(--surface-100) / var(--tw-text-opacity,1))}.text-surface-200{--tw-text-opacity:1;color:rgb(var(--surface-200) / var(--tw-text-opacity,1))}.text-surface-300{--tw-text-opacity:1;color:rgb(var(--surface-300) / var(--tw-text-opacity,1))}.text-surface-400{--tw-text-opacity:1;color:rgb(var(--surface-400) / var(--tw-text-opacity,1))}.text-surface-50{--tw-text-opacity:1;color:rgb(var(--surface-50) / var(--tw-text-opacity,1))}.text-surface-500{--tw-text-opacity:1;color:rgb(var(--surface-500) / var(--tw-text-opacity,1))}.text-surface-600{--tw-text-opacity:1;color:rgb(var(--surface-600) / var(--tw-text-opacity,1))}.text-transparent{color:#0000}.text-white{--tw-text-opacity:1;color:rgb(255 255 255/var(--tw-text-opacity,1))}.placeholder-surface-500::placeholder{--tw-placeholder-opacity:1;color:rgb(var(--surface-500) / var(--tw-placeholder-opacity,1))}.accent-accent-500{accent-color:rgb(var(--accent-500) / 1)}.opacity-0{opacity:0}.opacity-50{opacity:.5}.opacity-60{opacity:.6}.shadow-lg{--tw-shadow:0 10px 15px -3px #0000001a, 0 4px 6px -4px #0000001a;--tw-shadow-colored:0 10px 15px -3px var(--tw-shadow-color), 0 4px 6px -4px var(--tw-shadow-color);box-shadow:var(--tw-ring-offset-shadow,0 0 #0000), var(--tw-ring-shadow,0 0 #0000), var(--tw-shadow)}.shadow-xl{--tw-shadow:0 20px 25px -5px #0000001a, 0 8px 10px -6px #0000001a;--tw-shadow-colored:0 20px 25px -5px var(--tw-shadow-color), 0 8px 10px -6px var(--tw-shadow-color);box-shadow:var(--tw-ring-offset-shadow,0 0 #0000), var(--tw-ring-shadow,0 0 #0000), var(--tw-shadow)}.shadow-accent-500\/20{--tw-shadow-color:rgb(var(--accent-500) / .2);--tw-shadow:var(--tw-shadow-colored)}.outline{outline-style:solid}.ring{--tw-ring-offset-shadow:var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);--tw-ring-shadow:var(--tw-ring-inset) 0 0 0 calc(3px + var(--tw-ring-offset-width)) var(--tw-ring-color);box-shadow:var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow,0 0 #0000)}.blur-2xl{--tw-blur:blur(40px);filter:var(--tw-blur) var(--tw-brightness) var(--tw-contrast) var(--tw-grayscale) var(--tw-hue-rotate) var(--tw-invert) var(--tw-saturate) var(--tw-sepia) var(--tw-drop-shadow)}.blur-3xl{--tw-blur:blur(64px);filter:var(--tw-blur) var(--tw-brightness) var(--tw-contrast) var(--tw-grayscale) var(--tw-hue-rotate) var(--tw-invert) var(--tw-saturate) var(--tw-sepia) var(--tw-drop-shadow)}.blur-xl{--tw-blur:blur(24px);filter:var(--tw-blur) var(--tw-brightness) var(--tw-contrast) var(--tw-grayscale) var(--tw-hue-rotate) var(--tw-invert) var(--tw-saturate) var(--tw-sepia) var(--tw-drop-shadow)}.filter{filter:var(--tw-blur) var(--tw-brightness) var(--tw-contrast) var(--tw-grayscale) var(--tw-hue-rotate) var(--tw-invert) var(--tw-saturate) var(--tw-sepia) var(--tw-drop-shadow)}.backdrop-blur-sm{--tw-backdrop-blur:blur(4px);-webkit-backdrop-filter:var(--tw-backdrop-blur) var(--tw-backdrop-brightness) var(--tw-backdrop-contrast) var(--tw-backdrop-grayscale) var(--tw-backdrop-hue-rotate) var(--tw-backdrop-invert) var(--tw-backdrop-opacity) var(--tw-backdrop-saturate) var(--tw-backdrop-sepia);backdrop-filter:var(--tw-backdrop-blur) var(--tw-backdrop-brightness) var(--tw-backdrop-contrast) var(--tw-backdrop-grayscale) var(--tw-backdrop-hue-rotate) var(--tw-backdrop-invert) var(--tw-backdrop-opacity) var(--tw-backdrop-saturate) var(--tw-backdrop-sepia)}.backdrop-blur-xl{--tw-backdrop-blur:blur(24px);-webkit-backdrop-filter:var(--tw-backdrop-blur) var(--tw-backdrop-brightness) var(--tw-backdrop-contrast) var(--tw-backdrop-grayscale) var(--tw-backdrop-hue-rotate) var(--tw-backdrop-invert) var(--tw-backdrop-opacity) var(--tw-backdrop-saturate) var(--tw-backdrop-sepia);backdrop-filter:var(--tw-backdrop-blur) var(--tw-backdrop-brightness) var(--tw-backdrop-contrast) var(--tw-backdrop-grayscale) var(--tw-backdrop-hue-rotate) var(--tw-backdrop-invert) var(--tw-backdrop-opacity) var(--tw-backdrop-saturate) var(--tw-backdrop-sepia)}.transition{transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,-webkit-backdrop-filter,backdrop-filter;transition-duration:.15s;transition-timing-function:cubic-bezier(.4,0,.2,1)}.transition-all{transition-property:all;transition-duration:.15s;transition-timing-function:cubic-bezier(.4,0,.2,1)}.transition-colors{transition-property:color,background-color,border-color,text-decoration-color,fill,stroke;transition-duration:.15s;transition-timing-function:cubic-bezier(.4,0,.2,1)}.transition-opacity{transition-property:opacity;transition-duration:.15s;transition-timing-function:cubic-bezier(.4,0,.2,1)}.transition-transform{transition-property:transform;transition-duration:.15s;transition-timing-function:cubic-bezier(.4,0,.2,1)}.duration-200{transition-duration:.2s}.duration-500{transition-duration:.5s}.text-balance{text-wrap:balance}.hover\:scale-110:hover{--tw-scale-x:1.1;--tw-scale-y:1.1;transform:translate(var(--tw-translate-x), var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.hover\:border-accent-400:hover{--tw-border-opacity:1;border-color:rgb(var(--accent-400) / var(--tw-border-opacity,1))}.hover\:border-emerald-500:hover{--tw-border-opacity:1;border-color:rgb(16 185 129/var(--tw-border-opacity,1))}.hover\:border-surface-600:hover{--tw-border-opacity:1;border-color:rgb(var(--surface-600) / var(--tw-border-opacity,1))}.hover\:border-surface-600\/50:hover{border-color:rgb(var(--surface-600) / .5)}.hover\:border-surface-700\/60:hover{border-color:rgb(var(--surface-700) / .6)}.hover\:bg-accent-500:hover{--tw-bg-opacity:1;background-color:rgb(var(--accent-500) / var(--tw-bg-opacity,1))}.hover\:bg-accent-500\/10:hover{background-color:rgb(var(--accent-500) / .1)}.hover\:bg-amber-500\/20:hover{background-color:#f59e0b33}.hover\:bg-emerald-500\/20:hover{background-color:#10b98133}.hover\:bg-indigo-500\/20:hover{background-color:#6366f133}.hover\:bg-surface-700:hover{--tw-bg-opacity:1;background-color:rgb(var(--surface-700) / var(--tw-bg-opacity,1))}.hover\:bg-surface-800:hover{--tw-bg-opacity:1;background-color:rgb(var(--surface-800) / var(--tw-bg-opacity,1))}.hover\:bg-surface-800\/20:hover{background-color:rgb(var(--surface-800) / .2)}.hover\:bg-surface-800\/50:hover{background-color:rgb(var(--surface-800) / .5)}.hover\:text-accent-400:hover{--tw-text-opacity:1;color:rgb(var(--accent-400) / var(--tw-text-opacity,1))}.hover\:text-surface-200:hover{--tw-text-opacity:1;color:rgb(var(--surface-200) / var(--tw-text-opacity,1))}.hover\:text-surface-300:hover{--tw-text-opacity:1;color:rgb(var(--surface-300) / var(--tw-text-opacity,1))}.hover\:text-surface-50:hover{--tw-text-opacity:1;color:rgb(var(--surface-50) / var(--tw-text-opacity,1))}.hover\:text-white:hover{--tw-text-opacity:1;color:rgb(255 255 255/var(--tw-text-opacity,1))}.hover\:shadow-accent-500\/30:hover{--tw-shadow-color:rgb(var(--accent-500) / .3);--tw-shadow:var(--tw-shadow-colored)}.focus\:border-accent-500\/50:focus{border-color:rgb(var(--accent-500) / .5)}.focus\:outline-none:focus{outline-offset:2px;outline:2px solid #0000}.focus\:ring-1:focus{--tw-ring-offset-shadow:var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);--tw-ring-shadow:var(--tw-ring-inset) 0 0 0 calc(1px + var(--tw-ring-offset-width)) var(--tw-ring-color);box-shadow:var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow,0 0 #0000)}.focus\:ring-accent-500\/20:focus{--tw-ring-color:rgb(var(--accent-500) / .2)}.disabled\:opacity-50:disabled{opacity:.5}.group:hover .group-hover\:opacity-100{opacity:1}@media (width>=640px){.sm\:inset-auto{inset:auto}.sm\:left-1\/2{left:50%}.sm\:top-1\/2{top:50%}.sm\:block{display:block}.sm\:inline{display:inline}.sm\:w-48{width:12rem}.sm\:w-auto{width:auto}.sm\:w-full{width:100%}.sm\:max-w-lg{max-width:32rem}.sm\:flex-initial{flex:0 auto}.sm\:-translate-x-1\/2{--tw-translate-x:-50%;transform:translate(var(--tw-translate-x), var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.sm\:-translate-y-1\/2{--tw-translate-y:-50%;transform:translate(var(--tw-translate-x), var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.sm\:grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.sm\:flex-row{flex-direction:row}.sm\:items-center{align-items:center}.sm\:px-6{padding-left:1.5rem;padding-right:1.5rem}.sm\:text-3xl{font-size:1.875rem;line-height:2.25rem}.sm\:text-5xl{font-size:3rem;line-height:1}.sm\:text-6xl{font-size:3.75rem;line-height:1}.sm\:text-xl{font-size:1.25rem;line-height:1.75rem}}@media (width>=768px){.md\:col-span-4{grid-column:span 4/span 4}.md\:flex{display:flex}.md\:hidden{display:none}.md\:grid-cols-3{grid-template-columns:repeat(3,minmax(0,1fr))}.md\:grid-cols-4{grid-template-columns:repeat(4,minmax(0,1fr))}.md\:flex-row{flex-direction:row}}@media (width>=1024px){.lg\:col-span-2{grid-column:span 2/span 2}.lg\:grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.lg\:grid-cols-3{grid-template-columns:repeat(3,minmax(0,1fr))}.lg\:grid-cols-4{grid-template-columns:repeat(4,minmax(0,1fr))}.lg\:p-8{padding:2rem}.lg\:px-8{padding-left:2rem;padding-right:2rem}.lg\:text-7xl{font-size:4.5rem;line-height:1}}