Conversation
把 Next.js 專案整包搬進 apps/web(package.json 改名 web),新增 packages/core 作為 web/mobile 未來共用的純資料與純函式套件(@easylearn/core),目前先放 IconName/Question/Progress 等共用型別(從 Icons.tsx 反向抽出)。 根目錄 package.json 改成 workspace root;next.config.ts 加 transpilePackages 讓 Next.js 能編譯 workspace 內的 TS 套件;重寫沒編輯過的 Vite 樣板 README.md。 Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
data/chapters.ts、data/typeMeta.ts、data/questions/*.json、utils/quiz.ts 從 apps/web 搬進 packages/core/src;新增 progressCalc.ts,把 useProgress.ts 裡的 todayStr/yesterdayStr/bumpXpLog/bumpCounter/bumpStreak/GRADUATE_BOX 抽成純函式。apps/web 消費端全部改成從 @easylearn/core 匯入。 伺服器端 progressLogic.ts 原本重複宣告一份 GRADUATE_BOX,改成匯入同一份; bumpStreakServer/addDaysToDateStr 刻意不搬(純 UTC 字串運算+外部傳入 today,跟前端用裝置本地時區的機制不同,硬併會破壞避免主機時區誤判 streak 的設計)。validate-questions.mjs 的 QUESTIONS_DIR 路徑同步更新。 Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
用 create-expo-app --template tabs 建立 Expo Router 專案,4 個 tab (home/notes/stats/profile)對應 web 版 Navbar.tsx,目前只有 Profile 有實際內容,其餘是待後續 phase 補完的佔位畫面。 metro.config.js 加上 watchFolders/nodeModulesPaths/disableHierarchicalLookup 讓 Metro 能解析 workspace 裡的 packages/core;一次裝齊 Phase 2-6 會用到的 native module(@clerk/expo、expo-secure-store、gesture-handler、svg、 slider、image-picker、expo-dev-client 等),避免之後每個 phase 各自 rebuild 一次 Dev Client。 app/_layout.tsx 用 ClerkProvider 包住整個 app,lib/tokenCache.ts 透過 expo-secure-store 持久化 session token;lib/api.ts 是帶 Bearer token 的 fetch 包裝,打 apps/web 既有的 API routes。app/(tabs)/profile.tsx 未登入 顯示 Google 登入按鈕(Clerk 這個 instance 目前只開 Google),已登入唯讀 顯示 GET /api/progress 回傳的 XP/streak/關卡數。 react-native-screens 手動固定在 4.26.0(比 SDK 57 建議的 4.25.2 新一個 patch)並在 package.json 的 expo.install.exclude 註記,解決 expo-router 內部依賴造成的 duplicate native module。 Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
真機驗證過完整流程(選章節→答題→結算)、隨機綜合練習、AsyncStorage 跨 app 重啟持久化。Icon/CodeBlock 先用 emoji/純文字頂替 web 版視覺,屬刻意的 MVP 簡化。Profile tab 訪客進度顯示留到 Phase 4 一併處理。
Phase 2「一次裝齊 native module」漏了 expo-auth-session,@clerk/expo 的 useSSO().startSSOFlow() 同時需要它和 expo-web-browser。真機測試登入時 才發現。這三個套件(連帶裝入的 expo-application/expo-crypto)都有原生 模組,裝完需要重建 Dev Client。
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
|
Warning Review limit reached
Next review available in: 12 minutes Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available. How can I continue?After more reviews become available, a review can be triggered using the To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews. How do review limits work?CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability. For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window. Please refer docs for additional details. Review details⚙️ Run configurationConfiguration used: defaults Review profile: CHILL Plan: Pro Plus Run ID: 📒 Files selected for processing (42)
📝 WalkthroughWalkthroughEasyLearn is reorganized as a Yarn workspaces monorepo with shared learning content and progress logic, a new Expo mobile app, and a Next.js web application backed by Clerk and Prisma. The PR also adds question datasets, responsive UI styling, API routes, and migration documentation. ChangesEasyLearn platform
Estimated code review effort: 5 (Critical) | ~120 minutes Possibly related PRs
🚥 Pre-merge checks | ✅ 3 | ❌ 2❌ Failed checks (1 warning, 1 inconclusive)
✅ Passed checks (3 passed)
✨ Finishing Touches🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
There was a problem hiding this comment.
Actionable comments posted: 16
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (3)
apps/web/src/app/api/progress/answer/route.ts (2)
19-23: 🩺 Stability & Availability | 🟠 Major | ⚡ Quick winMissing input validation on request body.
questionId,correct, andtodayare destructured fromrequest.json()without any validation. If any are missing ortodayis not a valid date string, the Prisma writes will fail with an opaque error or, worse, persist malformed data. Add a guard before proceeding.🛡️ Proposed validation guard
export const POST = async (request: Request) => { const { userId } = await auth() if (!userId) return NextResponse.json({ error: 'unauthorized' }, { status: 401 }) const { questionId, correct, chapterId, today } = (await request.json()) as AnswerBody + + if (!questionId || typeof correct !== 'boolean' || !today) { + return NextResponse.json({ error: 'invalid body' }, { status: 400 }) + } const existing = await prisma.wrongEntry.findUnique({ where: { userId_questionId: { userId, questionId } } })🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@apps/web/src/app/api/progress/answer/route.ts` around lines 19 - 23, Validate the parsed AnswerBody immediately after request.json() in POST before any Prisma operations: require questionId, correct, and today, ensure today is a valid date string, and return a clear 400 response when validation fails. Preserve the existing unauthorized response and normal processing for valid input.
48-64: 🔒 Security & Privacy | 🟠 Major | 🏗️ Heavy liftUnhandled transaction errors and client-controlled
todayfield.Two concerns:
- The
prisma.$transactioncall has no try/catch. If any write fails, the raw Prisma error propagates as an unhandled 500 with potentially sensitive details.- The
todayvalue is fully client-controlled and used directly indailyStatupserts. A malicious client could send arbitrary dates to pollute daily statistics across any date.Consider wrapping the transaction in a try/catch and deriving
todayserver-side (e.g., fromtodayStr()in@easylearn/core) rather than trusting the client.🛡️ Proposed fix: server-side date + error handling
- const { questionId, correct, chapterId, today } = (await request.json()) as AnswerBody + const { questionId, correct, chapterId } = (await request.json()) as AnswerBody + const today = todayStr() // derive server-side from `@easylearn/core` const existing = await prisma.wrongEntry.findUnique({ where: { userId_questionId: { userId, questionId } } }) @@ - await prisma.$transaction([...wrongEntryWrites, dailyStatWrite, ...chapterStatWrites]) + try { + await prisma.$transaction([...wrongEntryWrites, dailyStatWrite, ...chapterStatWrites]) + } catch (err) { + console.error('answer route transaction failed', err) + return NextResponse.json({ error: 'internal error' }, { status: 500 }) + }🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@apps/web/src/app/api/progress/answer/route.ts` around lines 48 - 64, Update the answer route’s transaction flow around dailyStatWrite and prisma.$transaction: derive today server-side using the existing todayStr() utility from `@easylearn/core` instead of accepting the client-provided date, and wrap the transaction in try/catch. Return the route’s established safe error response from the catch while logging or preserving only appropriate error details, without exposing raw Prisma errors.apps/web/src/hooks/useProgress.ts (1)
98-108: 🩺 Stability & Availability | 🟠 Major | ⚡ Quick win
migratedRefset before async fetch completes — transient failure permanently blocks server progress loading.
migratedRef.current = trueis set synchronously before the async IIFE runs. If the fetch fails (network error, server down, non-2xx response), the ref is already true, so the effect won't retry on subsequent renders. The user's server-side progress is silently never loaded, and they're stuck withdefaultProgressor stale guest data. Additionally, there's notry/catcharound the async IIFE, producing an unhandled promise rejection, and nores.okcheck beforeres.json().🔧 Proposed fix: move ref guard after success and add error handling
if (migratedRef.current) return - migratedRef.current = true ;(async () => { - const res = await fetch('/api/progress') - const data = (await res.json()) as { progress: Progress; isNew: boolean } - if (data.isNew) { - setProgress(await postJson('/api/progress/migrate-local', load())) - } else { - setProgress(data.progress) + try { + const res = await fetch('/api/progress') + if (!res.ok) return + const data = (await res.json()) as { progress: Progress; isNew: boolean } + migratedRef.current = true + if (data.isNew) { + setProgress(await postJson('/api/progress/migrate-local', load())) + } else { + setProgress(data.progress) + } + } catch (err) { + console.error('progress migration failed', err) } })()🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@apps/web/src/hooks/useProgress.ts` around lines 98 - 108, Update the migration effect around migratedRef and the async fetch so the ref is marked true only after server progress loading or migration completes successfully. Add try/catch handling for fetch, response parsing, and migration errors, and validate res.ok before consuming the response; leave the ref unset on failure so later renders can retry without unhandled promise rejections.
🧹 Nitpick comments (12)
apps/web/src/components/CodeBlock.tsx (1)
7-28: 🚀 Performance & Scalability | 🔵 Trivial | 💤 Low valueOptional: use
pushinstead of spread-in-reduce for O(n) performance.The
...acc.partsspread on each iteration makeshighlightO(n²) in the number of regex matches. For short question snippets this is negligible, but switching topushis a trivial change that keeps it O(n) if code blocks ever grow.♻️ Suggested refactor
const highlight = (code: string): ReactNode[] => { const matches = Array.from(code.matchAll(TOKEN_RE)) - const { parts, last } = matches.reduce<{ parts: ReactNode[]; last: number }>( - (acc, m, i) => { - const [text, comment, string, keyword, number] = m - const cls = comment ? 'tok-comment' : string ? 'tok-string' : keyword ? 'tok-keyword' : number ? 'tok-number' : '' - const gap = m.index > acc.last ? [code.slice(acc.last, m.index)] : [] - return { - parts: [ - ...acc.parts, - ...gap, - <span key={i} className={cls}> - {text} - </span>, - ], - last: m.index + text.length, - } - }, - { parts: [], last: 0 }, - ) - return last < code.length ? [...parts, code.slice(last)] : parts + const parts: ReactNode[] = [] + let last = 0 + for (const m of matches) { + const i = parts.length + const [text, comment, string, keyword, number] = m + const cls = comment ? 'tok-comment' : string ? 'tok-string' : keyword ? 'tok-keyword' : number ? 'tok-number' : '' + if (m.index > last) parts.push(code.slice(last, m.index)) + parts.push( + <span key={i} className={cls}> + {text} + </span>, + ) + last = m.index + text.length + } + if (last < code.length) parts.push(code.slice(last)) + return parts }🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@apps/web/src/components/CodeBlock.tsx` around lines 7 - 28, Update the reduce callback in highlight to append gaps and token spans by mutating acc.parts with push instead of recreating the array via ...acc.parts on every match. Preserve the existing token ordering, keys, and last-index tracking while keeping the final trailing-text handling unchanged.packages/core/src/progressCalc.ts (1)
6-12: 🩺 Stability & Availability | 🔵 Trivial | 💤 Low valueMinor midnight boundary inconsistency between
todayStrandyesterdayStr.Each function calls
new Date()independently. IfbumpStreakis invoked exactly at midnight,todayStr()andyesterdayStr()could return the same date, causing the streak to reset to 1 instead of incrementing. This is a low-probability edge case but worth noting for a shared core module.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@packages/core/src/progressCalc.ts` around lines 6 - 12, Update the date handling in todayStr and yesterdayStr so both calculations use the same captured Date instance when invoked together, preventing a midnight rollover from producing inconsistent dates. Preserve the existing local YYYY-MM-DD formatting and yesterday offset behavior.apps/mobile/components/QuestionCard.tsx (1)
77-80: 🩺 Stability & Availability | 🔵 Trivial | ⚡ Quick win
Linking.openURLreturns a Promise — add error handling.If the URL can't be opened (invalid format, no matching app), the rejection is unhandled.
🛡️ Proposed fix
<Pressable onPress={() => Linking.openURL(question.docs)}> + // Replace with: + <Pressable + onPress={async () => { + try { + await Linking.openURL(question.docs); + } catch { + // Optionally show an Alert to the user + } + }} + > <Text style={styles.docsLink}>延伸閱讀:官方文件</Text> </Pressable>🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@apps/mobile/components/QuestionCard.tsx` around lines 77 - 80, Update the docs link Pressable in QuestionCard so the Linking.openURL call handles rejected promises, including failures from invalid URLs or unavailable apps. Add appropriate local error handling without changing the existing link rendering or press behavior.apps/web/prisma/schema.prisma (1)
9-95: 🗄️ Data Integrity & Integration | 🔵 Trivial | ⚡ Quick winPrefer
DateTimeoverStringfor date fields.
streakLast,lastWrong, anddate(inXpLog/DailyStat) are stored asString. This limits date-range queries, timezone handling, and validation. If these represent calendar dates, considerDateTime(orDateTime@db.Date`` for date-only fields). If string storage is intentional for grouping keys (e.g.,"2026-07-12"), document the format constraint.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@apps/web/prisma/schema.prisma` around lines 9 - 95, Replace the date-like String fields streakLast, lastWrong, and the date fields in XpLog and DailyStat with DateTime types, using `@db.Date` where they represent date-only values. Update related defaults, writes, and queries to pass DateTime values consistently; if any field must remain a grouping key, document its required format explicitly.apps/mobile/lib/tokenCache.ts (1)
6-9: 🩺 Stability & Availability | 🔵 Trivial | 💤 Low valueConsider adding error handling to
saveToken.
getTokencatches storage errors and returnsnull, butsaveTokenletsSecureStore.setItemAsyncrejections propagate unhandled. Adding a catch (even a silent one) prevents potential unhandled promise rejections if the keychain/keystore is unavailable.🛡️ Suggested improvement
export const tokenCache: TokenCache = { getToken: (key) => SecureStore.getItemAsync(key).catch(() => null), - saveToken: (key, value) => SecureStore.setItemAsync(key, value), + saveToken: (key, value) => SecureStore.setItemAsync(key, value).catch(() => {}), }🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@apps/mobile/lib/tokenCache.ts` around lines 6 - 9, Update the saveToken method in tokenCache to handle rejected SecureStore.setItemAsync promises, matching getToken’s error-handling behavior and preventing unhandled storage errors. Keep the existing key and value arguments unchanged, and use the established silent-failure behavior unless an existing fallback is required.apps/web/src/hooks/useProgress.ts (1)
136-140: 🗄️ Data Integrity & Integration | 🔵 Trivial | ⚖️ Poor tradeoffServer response overwrites concurrent local state updates.
Each action calls
setProgresslocally, then fires a server sync that resolves with.then(setProgress). The server response uses the non-functional form, replacing the entire state. If the user performs another action before the previous server response arrives, the earlier response overwrites the newer local update. In a quiz app with seconds between answers this is unlikely, but it's a latent data-integrity risk worth noting.Consider merging server responses into local state via functional updates (e.g.,
setProgress(p => ({ ...p, ...serverData }))) rather than wholesale replacement.Also applies to: 153-156, 178-181, 193-197
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@apps/web/src/hooks/useProgress.ts` around lines 136 - 140, Update the server-response handlers in the progress actions, including the paths around the existing setProgress calls at lines 136–140, 153–156, 178–181, and 193–197, to use functional state updates that merge returned server data into the latest local progress state. Preserve newer local fields by spreading the current state first and applying the server response afterward, while leaving the existing local action updates unchanged.packages/core/src/index.ts (1)
1-5: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low valueConsider namespace exports to prevent naming conflicts
Using
export *from five modules risks silent symbol collisions if any two modules export the same name. If this barrel file grows, consider explicit re-exports or namespace-style exports (e.g.,export * as Types from './types') to keep the public API surface unambiguous.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@packages/core/src/index.ts` around lines 1 - 5, Update the barrel exports in the package index to avoid wildcard re-export collisions by using explicit or namespace-style exports for types, progressCalc, quiz, chapters, and typeMeta. Preserve access to each module’s public API while making module ownership unambiguous.apps/web/src/app/layout.tsx (2)
29-29: 🚀 Performance & Scalability | 🔵 Trivial | ⚡ Quick winConsider
next/font/googleinstead of manual<link>tags.Manual Google Fonts links add an extra render-blocking round trip to
fonts.googleapis.com/fonts.gstatic.com.next/font/googleself-hosts and inlines the font, avoiding that extra request and layout shift.♻️ Example using next/font
+import { JetBrains_Mono, Noto_Sans_TC } from 'next/font/google' + +const jetbrainsMono = JetBrains_Mono({ subsets: ['latin'], weight: ['400','500','700','800'], variable: '--font-mono' }) +const notoSansTC = Noto_Sans_TC({ subsets: ['latin'], weight: ['400','500','700','900'], variable: '--font-sans' })Then apply
${jetbrainsMono.variable} ${notoSansTC.variable}on<body>and drop the manual<link>tags.Also applies to: 29-29, 34-39
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@apps/web/src/app/layout.tsx` at line 29, Replace the manual Google Fonts link tags in RootLayout with next/font/google font imports for JetBrains Mono and Noto Sans TC, configure their variables, and apply both variables to the body className. Remove the corresponding external font links while preserving the existing layout structure.
29-29: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick winAvoid relying on the implicit
ReactUMD global for the props type.
children: React.ReactNodeworks without importingReactonly depending on this project'stsconfig.jsonjsxsetting; some configurations raise TS2686 ("'React' refers to a UMD global, but the current file is a module") for this exact pattern. ImportingReactNodedirectly sidesteps the ambiguity entirely.♻️ Proposed fix
import type { Metadata, Viewport } from 'next' import { ClerkProvider } from '`@clerk/nextjs`' +import type { ReactNode } from 'react' import '../index.css' ... -const RootLayout = ({ children }: { children: React.ReactNode }) => { +const RootLayout = ({ children }: { children: ReactNode }) => {Please confirm the web app's
tsconfig.jsonjsxmode (preservevsreact-jsx) to determine whether this is currently a live compile error.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@apps/web/src/app/layout.tsx` at line 29, Update the RootLayout props type to import and use ReactNode directly instead of referencing the implicit React UMD global. Verify the web app’s tsconfig JSX mode and preserve the existing layout behavior while eliminating the React namespace dependency.apps/web/src/components/Mascot.tsx (1)
25-26: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low valueConsider narrowing
moodto a union type.
mood?: stringis permissive and interpolated directly into a className (mood-${mood}). A union type (e.g.,'idle' | 'happy' | 'celebrate') would provide compile-time safety against typos and invalid class names. Only worth doing if the set of moods is known and small.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@apps/web/src/components/Mascot.tsx` around lines 25 - 26, Update the Mascot component’s mood prop type to a union of the known supported mood values, ensuring each value matches an available mood class used by the className interpolation. Keep the prop optional and avoid narrowing it until the complete supported mood set is identified.apps/web/src/components/GrowthHistory.tsx (1)
1-1: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low valueConsider extracting
STAGESandgetStageto a shared module.Both
GrowthHistoryandMascot(andProfile.tsxviagetStage/getNextStage) depend onSTAGESand stage logic currently defined inMascot.tsx. Extracting these to a dedicated module (e.g.,stages.ts) would decouple domain data/logic from the React component and clarify the dependency direction.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@apps/web/src/components/GrowthHistory.tsx` at line 1, Extract the shared STAGES data and getStage stage logic from Mascot.tsx into a dedicated stages module, then update GrowthHistory, Mascot, and Profile.tsx (including getNextStage usage) to import these symbols from the new module. Keep the existing stage behavior unchanged and remove the duplicated or re-exported domain logic from the component module.apps/mobile/hooks/useProgress.ts (1)
65-86: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick winConsider extracting the Leitner box transition logic into
packages/core.
bumpCounter/bumpStreak/bumpXpLog/GRADUATE_BOXwere already extracted to@easylearn/coreper the migration notes, but the wrongIds box increment/graduate/reset logic here is inlined and likely duplicated inapps/web/src/hooks/useProgress.ts. Pulling it into a sharedapplyAnswer(wrongIds, questionId, correct)helper would keep both platforms' Leitner-box semantics from drifting apart over time.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@apps/mobile/hooks/useProgress.ts` around lines 65 - 86, Extract the wrongIds Leitner transition logic from answerQuestion into a shared applyAnswer helper in `@easylearn/core`, covering correct-answer box advancement and graduation removal plus incorrect-answer reset and count updates. Update answerQuestion and the corresponding web useProgress implementation to reuse this helper, while preserving the existing dailyStats and chapterStats updates.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@apps/mobile/app/`(tabs)/_layout.tsx:
- Around line 9-19: Update TabLayout’s tabBarActiveTintColor access to safely
handle a null result from useColorScheme, using the existing light/dark theme
fallback so Colors is always indexed by a valid key. Preserve the current tint
selection for non-null color schemes.
In `@apps/mobile/components/QuestionCard.tsx`:
- Around line 27-43: Update the star Icon in QuestionCard to use the
destructured saved prop for visible state feedback, switching between the
existing filled and outline appearance (or an equivalent color distinction)
while preserving the onToggleSave behavior.
In `@apps/mobile/components/useColorScheme.ts`:
- Around line 3-6: Update useColorScheme to normalize a null result from
useColorSchemeCore to 'light', alongside the existing 'unspecified' fallback.
Preserve the existing scheme value for all supported non-null schemes so
downstream Colors[colorScheme] access remains valid.
In `@apps/web/src/app/api/account/route.ts`:
- Around line 11-14: Update the deletion flow around client.users.deleteUser and
prisma.user.deleteMany to handle partial failures: delete the Prisma records
first, then delete the Clerk user, and wrap both operations in try/catch with
appropriate error logging and propagation. Preserve the existing userId-based
deletion behavior while ensuring failures are observable and retryable.
In `@apps/web/src/app/api/progress/finish-level/route.ts`:
- Around line 17-21: Validate the parsed FinishLevelBody fields in POST before
processing the level: require a valid levelId, ensure correct and total are
integers with 0 ≤ correct ≤ total, enforce xpEarned as an integer from 0 through
1000, and validate today with the same date-format and calendar-date rules used
by finish-review. Reuse shared MAX_XP_PER_REQUEST, DATE_RE, and isValidDateStr
symbols if extracted, and return the established invalid-input response for
failures.
- Around line 23-45: Update the finish-level POST flow to create or upsert the
user before processing progress, replacing findUniqueOrThrow so first-time
signed-in requests do not fail; apply the same user-handling change in
finish-review. Remove the stale prevLevel read and make completedLevel.best
monotonic through an atomic database-side update that never lowers an existing
score, while preserving the current XP, streak, total, and create behavior.
In `@apps/web/src/app/api/progress/finish-review/route.ts`:
- Around line 32-45: Move the user lookup and streak calculation into the
prisma.$transaction callback so they execute within the same transaction as the
user update and xpLog.upsert. Update the transaction flow around the user update
to derive bumpStreakServer inputs from the transaction-local user record,
preventing stale streak state from being used by concurrent requests.
In `@apps/web/src/app/api/progress/save-toggle/route.ts`:
- Line 17: Validate questionId immediately after parsing the request body in the
save-toggle route and before any Prisma/database operations; reject missing or
empty values with the route’s existing client-error response pattern, while
preserving the current flow for valid question IDs.
In `@apps/web/src/components/AccountHeader.tsx`:
- Around line 85-102: The successful upload path in handleAvatarFile must
immediately persist DEFAULT_POS to unsafeMetadata when resetting local pos, so
abandoning repositioning cannot reuse the previous photo’s crop. Update the
existing user metadata update mechanism used by confirmReposition, while
preserving the current repositioning state and upload error handling.
- Line 241: Update the user ID span in the AccountHeader component to render the
intended Clerk user identifier instead of the literal “USER.ID”; if no
identifier is available or the field is unnecessary, remove the span.
- Around line 68-70: Update the useEffect that synchronizes pos from
user.unsafeMetadata so it does not overwrite an in-progress avatar reposition:
gate the sync with !isRepositioning or otherwise narrow the dependency to avoid
unrelated Clerk user updates, while preserving metadata hydration when
repositioning is inactive.
In `@apps/web/src/index.css`:
- Around line 866-868: Update the fill value in the .icon-filled CSS rule to use
the lowercase keyword expected by stylelint, while preserving the existing
current-color behavior.
- Line 29: Update the --font-mono font-family declaration in the root CSS
variables to use the casing expected by stylelint for the Menlo and Consolas
font names, while preserving the existing font-stack order and fallback
behavior.
In `@packages/core/src/data/questions/jsa-9-promises-advanced.json`:
- Line 162: Update the explanation for the Promise.any question to replace the
incorrect character 賭 with 賽 in the phrase describing who succeeds first;
preserve the remaining Chinese wording and explanation unchanged.
In `@packages/core/src/data/questions/react-9-preserving-state.json`:
- Around line 1-270: The quiz data in react-9-preserving-state.json assigns
answer "a" to all questions, and QuestionCard.tsx preserves JSON order, making
the correct choice predictable. Update the question-option rendering flow in
QuestionCard.tsx to shuffle each question’s options while retaining the correct
option identity and answer validation, distributing correct answers across
positions a–d rather than relying on the source order.
In `@README.md`:
- Around line 1-16: Update the README apps/mobile entry to remove the outdated
“規劃中,尚未建立” wording and describe it as the existing Expo React Native workspace,
matching the mobile app files and configuration included in the repository.
---
Outside diff comments:
In `@apps/web/src/app/api/progress/answer/route.ts`:
- Around line 19-23: Validate the parsed AnswerBody immediately after
request.json() in POST before any Prisma operations: require questionId,
correct, and today, ensure today is a valid date string, and return a clear 400
response when validation fails. Preserve the existing unauthorized response and
normal processing for valid input.
- Around line 48-64: Update the answer route’s transaction flow around
dailyStatWrite and prisma.$transaction: derive today server-side using the
existing todayStr() utility from `@easylearn/core` instead of accepting the
client-provided date, and wrap the transaction in try/catch. Return the route’s
established safe error response from the catch while logging or preserving only
appropriate error details, without exposing raw Prisma errors.
In `@apps/web/src/hooks/useProgress.ts`:
- Around line 98-108: Update the migration effect around migratedRef and the
async fetch so the ref is marked true only after server progress loading or
migration completes successfully. Add try/catch handling for fetch, response
parsing, and migration errors, and validate res.ok before consuming the
response; leave the ref unset on failure so later renders can retry without
unhandled promise rejections.
---
Nitpick comments:
In `@apps/mobile/components/QuestionCard.tsx`:
- Around line 77-80: Update the docs link Pressable in QuestionCard so the
Linking.openURL call handles rejected promises, including failures from invalid
URLs or unavailable apps. Add appropriate local error handling without changing
the existing link rendering or press behavior.
In `@apps/mobile/hooks/useProgress.ts`:
- Around line 65-86: Extract the wrongIds Leitner transition logic from
answerQuestion into a shared applyAnswer helper in `@easylearn/core`, covering
correct-answer box advancement and graduation removal plus incorrect-answer
reset and count updates. Update answerQuestion and the corresponding web
useProgress implementation to reuse this helper, while preserving the existing
dailyStats and chapterStats updates.
In `@apps/mobile/lib/tokenCache.ts`:
- Around line 6-9: Update the saveToken method in tokenCache to handle rejected
SecureStore.setItemAsync promises, matching getToken’s error-handling behavior
and preventing unhandled storage errors. Keep the existing key and value
arguments unchanged, and use the established silent-failure behavior unless an
existing fallback is required.
In `@apps/web/prisma/schema.prisma`:
- Around line 9-95: Replace the date-like String fields streakLast, lastWrong,
and the date fields in XpLog and DailyStat with DateTime types, using `@db.Date`
where they represent date-only values. Update related defaults, writes, and
queries to pass DateTime values consistently; if any field must remain a
grouping key, document its required format explicitly.
In `@apps/web/src/app/layout.tsx`:
- Line 29: Replace the manual Google Fonts link tags in RootLayout with
next/font/google font imports for JetBrains Mono and Noto Sans TC, configure
their variables, and apply both variables to the body className. Remove the
corresponding external font links while preserving the existing layout
structure.
- Line 29: Update the RootLayout props type to import and use ReactNode directly
instead of referencing the implicit React UMD global. Verify the web app’s
tsconfig JSX mode and preserve the existing layout behavior while eliminating
the React namespace dependency.
In `@apps/web/src/components/CodeBlock.tsx`:
- Around line 7-28: Update the reduce callback in highlight to append gaps and
token spans by mutating acc.parts with push instead of recreating the array via
...acc.parts on every match. Preserve the existing token ordering, keys, and
last-index tracking while keeping the final trailing-text handling unchanged.
In `@apps/web/src/components/GrowthHistory.tsx`:
- Line 1: Extract the shared STAGES data and getStage stage logic from
Mascot.tsx into a dedicated stages module, then update GrowthHistory, Mascot,
and Profile.tsx (including getNextStage usage) to import these symbols from the
new module. Keep the existing stage behavior unchanged and remove the duplicated
or re-exported domain logic from the component module.
In `@apps/web/src/components/Mascot.tsx`:
- Around line 25-26: Update the Mascot component’s mood prop type to a union of
the known supported mood values, ensuring each value matches an available mood
class used by the className interpolation. Keep the prop optional and avoid
narrowing it until the complete supported mood set is identified.
In `@apps/web/src/hooks/useProgress.ts`:
- Around line 136-140: Update the server-response handlers in the progress
actions, including the paths around the existing setProgress calls at lines
136–140, 153–156, 178–181, and 193–197, to use functional state updates that
merge returned server data into the latest local progress state. Preserve newer
local fields by spreading the current state first and applying the server
response afterward, while leaving the existing local action updates unchanged.
In `@packages/core/src/index.ts`:
- Around line 1-5: Update the barrel exports in the package index to avoid
wildcard re-export collisions by using explicit or namespace-style exports for
types, progressCalc, quiz, chapters, and typeMeta. Preserve access to each
module’s public API while making module ownership unambiguous.
In `@packages/core/src/progressCalc.ts`:
- Around line 6-12: Update the date handling in todayStr and yesterdayStr so
both calculations use the same captured Date instance when invoked together,
preventing a midnight rollover from producing inconsistent dates. Preserve the
existing local YYYY-MM-DD formatting and yesterday offset behavior.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: defaults
Review profile: CHILL
Plan: Pro Plus
Run ID: df317be5-a0aa-4970-a924-1b797aeddc03
⛔ Files ignored due to path filters (20)
apps/mobile/assets/fonts/SpaceMono-Regular.ttfis excluded by!**/*.ttfapps/mobile/assets/images/android-icon-background.pngis excluded by!**/*.pngapps/mobile/assets/images/android-icon-foreground.pngis excluded by!**/*.pngapps/mobile/assets/images/android-icon-monochrome.pngis excluded by!**/*.pngapps/mobile/assets/images/favicon.pngis excluded by!**/*.pngapps/mobile/assets/images/icon.pngis excluded by!**/*.pngapps/mobile/assets/images/splash-icon.pngis excluded by!**/*.pngapps/web/public/apple-touch-icon.pngis excluded by!**/*.pngapps/web/public/icon-1024.pngis excluded by!**/*.pngapps/web/public/icon-120.pngis excluded by!**/*.pngapps/web/public/icon-180.pngis excluded by!**/*.pngapps/web/public/icon-192.pngis excluded by!**/*.pngapps/web/public/icon-29.pngis excluded by!**/*.pngapps/web/public/icon-40.pngis excluded by!**/*.pngapps/web/public/icon-512.pngis excluded by!**/*.pngapps/web/public/icon-540.pngis excluded by!**/*.pngapps/web/public/icon-60.pngis excluded by!**/*.pngapps/web/public/icon-87.pngis excluded by!**/*.pngapps/web/src/assets/vite.svgis excluded by!**/*.svgyarn.lockis excluded by!**/yarn.lock,!**/*.lock
📒 Files selected for processing (117)
README.mdapps/mobile/.env.exampleapps/mobile/.gitignoreapps/mobile/.vscode/extensions.jsonapps/mobile/.vscode/settings.jsonapps/mobile/LICENSEapps/mobile/app.jsonapps/mobile/app/(tabs)/_layout.tsxapps/mobile/app/(tabs)/index.tsxapps/mobile/app/(tabs)/notes.tsxapps/mobile/app/(tabs)/profile.tsxapps/mobile/app/(tabs)/stats.tsxapps/mobile/app/+html.tsxapps/mobile/app/+not-found.tsxapps/mobile/app/_layout.tsxapps/mobile/components/CodeBlock.tsxapps/mobile/components/ComingSoon.tsxapps/mobile/components/Icon.tsxapps/mobile/components/QuestionCard.tsxapps/mobile/components/StyledText.tsxapps/mobile/components/Themed.tsxapps/mobile/components/useClientOnlyValue.tsapps/mobile/components/useClientOnlyValue.web.tsapps/mobile/components/useColorScheme.tsapps/mobile/components/useColorScheme.web.tsapps/mobile/constants/Colors.tsapps/mobile/hooks/useProgress.tsapps/mobile/lib/api.tsapps/mobile/lib/tokenCache.tsapps/mobile/metro.config.jsapps/mobile/package.jsonapps/mobile/screens/ChapterMap.tsxapps/mobile/screens/Home.tsxapps/mobile/screens/Quiz.tsxapps/mobile/tsconfig.jsonapps/web/.env.exampleapps/web/.oxlintrc.jsonapps/web/next.config.tsapps/web/package.jsonapps/web/prisma.config.tsapps/web/prisma/migrations/20260711114101_init/migration.sqlapps/web/prisma/migrations/migration_lock.tomlapps/web/prisma/schema.prismaapps/web/public/manifest.webmanifestapps/web/scripts/validate-questions.mjsapps/web/src/App.tsxapps/web/src/app/api/account/route.tsapps/web/src/app/api/progress/answer/route.tsapps/web/src/app/api/progress/finish-level/route.tsapps/web/src/app/api/progress/finish-review/route.tsapps/web/src/app/api/progress/migrate-local/route.tsapps/web/src/app/api/progress/route.tsapps/web/src/app/api/progress/save-toggle/route.tsapps/web/src/app/layout.tsxapps/web/src/app/page.tsxapps/web/src/components/AccountHeader.tsxapps/web/src/components/CodeBlock.tsxapps/web/src/components/GrowthHistory.tsxapps/web/src/components/Icons.tsxapps/web/src/components/Mascot.tsxapps/web/src/components/Navbar.tsxapps/web/src/components/QuestionCard.tsxapps/web/src/components/QuestionReview.tsxapps/web/src/hooks/useProgress.tsapps/web/src/index.cssapps/web/src/lib/prisma.tsapps/web/src/lib/progressLogic.tsapps/web/src/lib/progressStore.tsapps/web/src/proxy.tsapps/web/src/screens/ChapterMap.tsxapps/web/src/screens/Home.tsxapps/web/src/screens/Notes.tsxapps/web/src/screens/Profile.tsxapps/web/src/screens/QuestionBook.tsxapps/web/src/screens/Quiz.tsxapps/web/src/screens/Stats.tsxapps/web/tsconfig.jsondocs/rn-migration.mdnext.config.tspackage.jsonpackages/core/package.jsonpackages/core/src/data/chapters.tspackages/core/src/data/questions/jsa-1-this-scope.jsonpackages/core/src/data/questions/jsa-2-closures.jsonpackages/core/src/data/questions/jsa-3-async-basics.jsonpackages/core/src/data/questions/jsa-4-async-await.jsonpackages/core/src/data/questions/jsa-5-classes.jsonpackages/core/src/data/questions/jsa-6-collections-json.jsonpackages/core/src/data/questions/jsa-7-modules.jsonpackages/core/src/data/questions/jsa-8-iterators-generators.jsonpackages/core/src/data/questions/jsa-9-promises-advanced.jsonpackages/core/src/data/questions/jsb-1-variables.jsonpackages/core/src/data/questions/jsb-2-functions.jsonpackages/core/src/data/questions/jsb-3-arrays.jsonpackages/core/src/data/questions/jsb-4-objects.jsonpackages/core/src/data/questions/jsb-5-control-flow.jsonpackages/core/src/data/questions/jsb-6-loops.jsonpackages/core/src/data/questions/jsb-7-numbers-dates.jsonpackages/core/src/data/questions/jsb-8-strings.jsonpackages/core/src/data/questions/jsb-9-regex.jsonpackages/core/src/data/questions/react-1-jsx.jsonpackages/core/src/data/questions/react-10-reducer-context.jsonpackages/core/src/data/questions/react-11-refs.jsonpackages/core/src/data/questions/react-2-props.jsonpackages/core/src/data/questions/react-3-state-events.jsonpackages/core/src/data/questions/react-4-hooks.jsonpackages/core/src/data/questions/react-5-lists-conditional.jsonpackages/core/src/data/questions/react-6-updating-state.jsonpackages/core/src/data/questions/react-7-pure-components.jsonpackages/core/src/data/questions/react-8-sharing-state.jsonpackages/core/src/data/questions/react-9-preserving-state.jsonpackages/core/src/data/typeMeta.tspackages/core/src/index.tspackages/core/src/progressCalc.tspackages/core/src/quiz.tspackages/core/src/types.tspackages/core/tsconfig.json
💤 Files with no reviewable changes (1)
- next.config.ts
There was a problem hiding this comment.
Caution
Inline review comments failed to post. This is likely due to GitHub's internal server error or limits when posting large numbers of comments. If you are seeing this consistently it is likely a permissions issue. Please check "Moderation" -> "Code review limits" under your organization settings.
🛑 Comments failed to post (16)
apps/mobile/app/(tabs)/_layout.tsx (1)
9-19: 🩺 Stability & Availability | 🟠 Major
Downstream impact:
Colors[colorScheme]will crash ifuseColorSchemereturnsnull.
Colors[colorScheme].tinton line 15 will throw aTypeErrorifcolorSchemeisnull, which can happen when RN'suseColorSchemereturnsnull. See the review comment onuseColorScheme.tsfor the root-cause fix.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@apps/mobile/app/`(tabs)/_layout.tsx around lines 9 - 19, Update TabLayout’s tabBarActiveTintColor access to safely handle a null result from useColorScheme, using the existing light/dark theme fallback so Colors is always indexed by a valid key. Preserve the current tint selection for non-null color schemes.apps/mobile/components/QuestionCard.tsx (1)
27-43: 🎯 Functional Correctness | 🟠 Major | ⚡ Quick win
savedprop is destructured but never used — the star gives no visual feedback.The
savedprop is received but the star icon is always<Icon name="star" size={18} />. Users cannot tell whether a question is saved or not. Usesavedto toggle the icon appearance (e.g., filled vs outline, or a color change).🐛 Proposed fix
<Pressable onPress={() => onToggleSave(question.id)} hitSlop={8}> - <Icon name="star" size={18} /> + <Icon name={saved ? 'star-filled' : 'star'} size={18} /> </Pressable>📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.const answered = selected !== null; const correct = answered && selected === question.answer; const typeMeta = TYPE_META[question.type]; return ( <View style={styles.card}> <View style={styles.metaRow}> <View style={styles.typeBadge}> <Icon name={typeMeta.icon} size={13} /> <Text style={styles.typeBadgeText}>{typeMeta.label}</Text> </View> <Text style={styles.topicLabel} numberOfLines={1}> {question.topic} </Text> <Pressable onPress={() => onToggleSave(question.id)} hitSlop={8}> <Icon name={saved ? 'star-filled' : 'star'} size={18} /> </Pressable>🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@apps/mobile/components/QuestionCard.tsx` around lines 27 - 43, Update the star Icon in QuestionCard to use the destructured saved prop for visible state feedback, switching between the existing filled and outline appearance (or an equivalent color distinction) while preserving the onToggleSave behavior.apps/mobile/components/useColorScheme.ts (1)
3-6: 🩺 Stability & Availability | 🟠 Major | ⚡ Quick win
nullreturn fromuseColorSchemeCoreis not handled.React Native's
useColorScheme()can returnnull(e.g., during initial render or on platforms where the scheme is unavailable). The current code normalizes'unspecified'→'light'but passesnullthrough. Downstream,Colors[colorScheme].tintin_layout.tsxwould evaluate toColors[null].tint→undefined.tint→ TypeError crash.🛡️ Proposed fix: also normalize null to 'light'
export const useColorScheme = () => { const coreScheme = useColorSchemeCore(); - return coreScheme === 'unspecified' ? 'light' : coreScheme; + return coreScheme === 'unspecified' || coreScheme === null ? 'light' : coreScheme; };📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.export const useColorScheme = () => { const coreScheme = useColorSchemeCore(); return coreScheme === 'unspecified' || coreScheme === null ? 'light' : coreScheme; };🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@apps/mobile/components/useColorScheme.ts` around lines 3 - 6, Update useColorScheme to normalize a null result from useColorSchemeCore to 'light', alongside the existing 'unspecified' fallback. Preserve the existing scheme value for all supported non-null schemes so downstream Colors[colorScheme] access remains valid.apps/web/src/app/api/account/route.ts (1)
11-14: 🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win
No error handling for partial failure between Clerk and Prisma deletions.
If
client.users.deleteUser(userId)succeeds butprisma.user.deleteManythrows, the user is permanently deleted from Clerk while their database records remain orphaned. There is no try/catch, compensation logic, or logging to detect this inconsistency.🛡️ Suggested fix: wrap in try/catch with compensation
export const DELETE = async () => { const { userId } = await auth() if (!userId) return NextResponse.json({ error: 'unauthorized' }, { status: 401 }) const client = await clerkClient() - - await client.users.deleteUser(userId) - - await prisma.user.deleteMany({ where: { id: userId } }) + try { + await prisma.user.deleteMany({ where: { id: userId } }) + await client.users.deleteUser(userId) + } catch (err) { + console.error('Account deletion failed:', err) + return NextResponse.json({ error: 'deletion failed' }, { status: 500 }) + } return NextResponse.json({ ok: true }) }Reversing the order (Prisma first, Clerk second) reduces the window for inconsistency — if Prisma fails, the Clerk user is untouched and can retry. If Clerk fails after Prisma succeeds, the DB records are gone but the user can still re-auth and retry deletion.
📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.const client = await clerkClient() try { await prisma.user.deleteMany({ where: { id: userId } }) await client.users.deleteUser(userId) } catch (err) { console.error('Account deletion failed:', err) return NextResponse.json({ error: 'deletion failed' }, { status: 500 }) }🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@apps/web/src/app/api/account/route.ts` around lines 11 - 14, Update the deletion flow around client.users.deleteUser and prisma.user.deleteMany to handle partial failures: delete the Prisma records first, then delete the Clerk user, and wrap both operations in try/catch with appropriate error logging and propagation. Preserve the existing userId-based deletion behavior while ensuring failures are observable and retryable.apps/web/src/app/api/progress/finish-level/route.ts (2)
17-21: 🔒 Security & Privacy | 🟠 Major | ⚡ Quick win
Add input validation consistent with
finish-reviewroute.This route accepts
levelId,correct,total,xpEarned, andtodaywithout any validation, whilefinish-review/route.tsvalidatesxpEarned(integer, 0–1000) andtoday(date format). A malicious client can inflate XP with arbitraryxpEarned, send invalid dates that corruptxpLogentries, or setcorrect > total.🛡️ Proposed validation
export const POST = async (request: Request) => { const { userId } = await auth() if (!userId) return NextResponse.json({ error: 'unauthorized' }, { status: 401 }) const { levelId, correct, total, xpEarned, today } = (await request.json()) as FinishLevelBody + const MAX_XP_PER_REQUEST = 1000 + const DATE_RE = /^\d{4}-\d{2}-\d{2}$/ + const isValidDateStr = (value: string) => DATE_RE.test(value) && !Number.isNaN(new Date(value).getTime()) + + if (typeof levelId !== 'string' || !levelId) { + return NextResponse.json({ error: 'invalid levelId' }, { status: 400 }) + } + if (!Number.isInteger(correct) || correct < 0 || !Number.isInteger(total) || total <= 0 || correct > total) { + return NextResponse.json({ error: 'invalid correct/total' }, { status: 400 }) + } + if (!Number.isInteger(xpEarned) || xpEarned < 0 || xpEarned > MAX_XP_PER_REQUEST) { + return NextResponse.json({ error: 'invalid xpEarned' }, { status: 400 }) + } + if (typeof today !== 'string' || !isValidDateStr(today)) { + return NextResponse.json({ error: 'invalid today' }, { status: 400 }) + }Consider extracting
MAX_XP_PER_REQUEST,DATE_RE, andisValidDateStrto a shared module to avoid duplication withfinish-review/route.ts.📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.export const POST = async (request: Request) => { const { userId } = await auth() if (!userId) return NextResponse.json({ error: 'unauthorized' }, { status: 401 }) const { levelId, correct, total, xpEarned, today } = (await request.json()) as FinishLevelBody const MAX_XP_PER_REQUEST = 1000 const DATE_RE = /^\d{4}-\d{2}-\d{2}$/ const isValidDateStr = (value: string) => DATE_RE.test(value) && !Number.isNaN(new Date(value).getTime()) if (typeof levelId !== 'string' || !levelId) { return NextResponse.json({ error: 'invalid levelId' }, { status: 400 }) } if (!Number.isInteger(correct) || correct < 0 || !Number.isInteger(total) || total <= 0 || correct > total) { return NextResponse.json({ error: 'invalid correct/total' }, { status: 400 }) } if (!Number.isInteger(xpEarned) || xpEarned < 0 || xpEarned > MAX_XP_PER_REQUEST) { return NextResponse.json({ error: 'invalid xpEarned' }, { status: 400 }) } if (typeof today !== 'string' || !isValidDateStr(today)) { return NextResponse.json({ error: 'invalid today' }, { status: 400 }) }🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@apps/web/src/app/api/progress/finish-level/route.ts` around lines 17 - 21, Validate the parsed FinishLevelBody fields in POST before processing the level: require a valid levelId, ensure correct and total are integers with 0 ≤ correct ≤ total, enforce xpEarned as an integer from 0 through 1000, and validate today with the same date-format and calendar-date rules used by finish-review. Reuse shared MAX_XP_PER_REQUEST, DATE_RE, and isValidDateStr symbols if extracted, and return the established invalid-input response for failures.
23-45: 🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win
Avoid stale
bestwrites and missing-user 500s
completedLevel.bestis read before the write, so concurrent requests can still overwrite a higher score with a lower one.findUniqueOrThrowalso assumes the user row already exists; the first signed-in POST can fail before/api/progresscreates it.finish-review/route.tshas the same issue.Move user creation into the POST path and make the
bestupdate atomic instead of relying on a stale pre-read.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@apps/web/src/app/api/progress/finish-level/route.ts` around lines 23 - 45, Update the finish-level POST flow to create or upsert the user before processing progress, replacing findUniqueOrThrow so first-time signed-in requests do not fail; apply the same user-handling change in finish-review. Remove the stale prevLevel read and make completedLevel.best monotonic through an atomic database-side update that never lowers an existing score, while preserving the current XP, streak, total, and create behavior.apps/web/src/app/api/progress/finish-review/route.ts (1)
32-45: 🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win
🧩 Analysis chain
🏁 Script executed:
#!/bin/bash set -euo pipefail printf '--- finish-review route ---\n' sed -n '1,220p' apps/web/src/app/api/progress/finish-review/route.ts printf '\n--- finish-level route ---\n' sed -n '1,220p' apps/web/src/app/api/progress/finish-level/route.ts printf '\n--- search for user upsert / streak usage ---\n' rg -n "bumpStreakServer|streakCount|streakLast|findUniqueOrThrow\\(|user\\.upsert\\(|prisma\\.\\$transaction" apps/web/src/app/api -SRepository: Retsomm/EasyLearn
Length of output: 246
🏁 Script executed:
#!/bin/bash set -euo pipefail printf '%s\n' '--- finish-review route ---' sed -n '1,220p' apps/web/src/app/api/progress/finish-review/route.ts printf '\n%s\n' '--- finish-level route ---' sed -n '1,220p' apps/web/src/app/api/progress/finish-level/route.ts printf '\n%s\n' '--- search for user upsert / streak usage ---' rg -n "bumpStreakServer|streakCount|streakLast|findUniqueOrThrow\\(|user\\.upsert\\(|prisma\\.\\$transaction" apps/web/src/app/api -SRepository: Retsomm/EasyLearn
Length of output: 3771
🏁 Script executed:
#!/bin/bash set -euo pipefail sed -n '1,220p' apps/web/src/app/api/progress/finish-review/route.ts printf '\n---\n' sed -n '1,220p' apps/web/src/app/api/progress/finish-level/route.ts printf '\n---\n' rg -n "findUniqueOrThrow\\(|bumpStreakServer|streakCount|streakLast|prisma\\.\\$transaction|user\\.upsert\\(" apps/web/src/app/api -SRepository: Retsomm/EasyLearn
Length of output: 3677
🏁 Script executed:
#!/bin/bash set -euo pipefail rg -n "prisma\.user\.(create|upsert|findUniqueOrThrow|findUnique)|model User|createUser|sync.*user|clerk" apps/web/src lib prisma -SRepository: Retsomm/EasyLearn
Length of output: 2136
🏁 Script executed:
#!/bin/bash set -euo pipefail rg -n "prisma\.user\.(create|upsert|findUniqueOrThrow|findUnique)|model User|createUser|sync.*user|clerk" apps/web src lib prisma -SRepository: Retsomm/EasyLearn
Length of output: 2283
🏁 Script executed:
#!/bin/bash set -euo pipefail rg -n "prisma\.user\.(create|upsert|findUniqueOrThrow|findUnique)|model User|createUser|sync.*user|clerk" apps/web lib prisma -SRepository: Retsomm/EasyLearn
Length of output: 2239
🏁 Script executed:
#!/bin/bash set -euo pipefail printf '%s\n' '--- apps/web/src/lib/progressStore.ts ---' sed -n '1,220p' apps/web/src/lib/progressStore.ts printf '\n%s\n' '--- apps/web/src/app/api/progress/route.ts ---' sed -n '1,220p' apps/web/src/app/api/progress/route.tsRepository: Retsomm/EasyLearn
Length of output: 1942
🏁 Script executed:
#!/bin/bash set -euo pipefail printf '%s\n' '--- apps/web/src/hooks/useProgress.ts ---' sed -n '1,240p' apps/web/src/hooks/useProgress.ts printf '\n%s\n' '--- search progress API usage ---' rg -n "/api/progress/finish-review|/api/progress/finish-level|/api/progress\"|loadFullProgress\\(" apps/web/src -SRepository: Retsomm/EasyLearn
Length of output: 7617
Read streak state inside the transaction. In
apps/web/src/app/api/progress/finish-review/route.ts,useris loaded beforeprisma.$transaction, so concurrent requests can compute from stalestreakCount/streakLastand overwrite a newer streak.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@apps/web/src/app/api/progress/finish-review/route.ts` around lines 32 - 45, Move the user lookup and streak calculation into the prisma.$transaction callback so they execute within the same transaction as the user update and xpLog.upsert. Update the transaction flow around the user update to derive bumpStreakServer inputs from the transaction-local user record, preventing stale streak state from being used by concurrent requests.apps/web/src/app/api/progress/save-toggle/route.ts (1)
17-17: 🔒 Security & Privacy | 🟡 Minor | ⚡ Quick win
Validate
questionIdbefore database operations.
questionIdis used without validation. An empty or missing value could cause unexpected Prisma errors or create invalid records.🛡️ Proposed validation
const { questionId } = (await request.json()) as SaveToggleBody + + if (typeof questionId !== 'string' || !questionId) { + return NextResponse.json({ error: 'invalid questionId' }, { status: 400 }) + } + const existing = await prisma.savedQuestion.findUnique({ where: { userId_questionId: { userId, questionId } } })📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.const { questionId } = (await request.json()) as SaveToggleBody if (typeof questionId !== 'string' || !questionId) { return NextResponse.json({ error: 'invalid questionId' }, { status: 400 }) } const existing = await prisma.savedQuestion.findUnique({ where: { userId_questionId: { userId, questionId } } })🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@apps/web/src/app/api/progress/save-toggle/route.ts` at line 17, Validate questionId immediately after parsing the request body in the save-toggle route and before any Prisma/database operations; reject missing or empty values with the route’s existing client-error response pattern, while preserving the current flow for valid question IDs.apps/web/src/components/AccountHeader.tsx (3)
68-70: 🎯 Functional Correctness | 🟠 Major | ⚡ Quick win
🧩 Analysis chain
🏁 Script executed:
#!/bin/bash set -euo pipefail # Map the component and inspect relevant lines. git ls-files 'apps/web/src/components/AccountHeader.tsx' wc -l apps/web/src/components/AccountHeader.tsx cat -n apps/web/src/components/AccountHeader.tsx | sed -n '1,360p' # Search for the avatar position helpers and related state handling. rg -n "readAvatarPosition|DEFAULT_POS|isRepositioning|saveMetadata|handleAvatarFile|saveName|unsafeMetadata|avatarPosition" apps/web/src/components/AccountHeader.tsx apps/web/src -S # Look for Clerk SDK usage patterns that might affect referential stability assumptions. rg -n "useUser\\(|unsafeMetadata|user.update\\(" apps/web/src -SRepository: Retsomm/EasyLearn
Length of output: 17452
🏁 Script executed:
#!/bin/bash set -euo pipefail # Find the Clerk package version used by the web app. git ls-files 'apps/web/package.json' 'package.json' 'pnpm-lock.yaml' 'yarn.lock' 'package-lock.json' cat -n apps/web/package.json | sed -n '1,220p' rg -n '"`@clerk/nextjs`"|`@clerk/nextjs`' apps/web/package.json package.json pnpm-lock.yaml yarn.lock package-lock.json -S # Inspect any local Clerk-related type or usage hints. rg -n "unsafeMetadata|useUser\\(" apps/web -SRepository: Retsomm/EasyLearn
Length of output: 1694
🌐 Web query:
Clerk@clerk/nextjs7.5.17 useUser unsafeMetadata object reference update unrelated user update firstName snapshot💡 Result:
In Clerk SDK version 7.5.17 and later, updating user metadata via the
user.update()method is deprecated [1][2]. You should instead use the dedicateduser.updateMetadata()method [3][2]. Key points regarding your query: 1. Deprecation of Metadata in update: Theuser.update()method previously acceptedunsafeMetadata, but it performed a full replacement rather than a merge [4][2]. This behavior is now deprecated to prevent accidental data loss [1][2]. 2. Preferred Method: Useuser.updateMetadata({ unsafeMetadata: { ... } })[2][5]. This method performs a deep merge of the provided object with the existingunsafeMetadata, making it safer and more efficient for partial updates [6][2]. 3. Unrelated Updates: UpdatingunsafeMetadataviaupdateMetadata()is distinct from updating properties likefirstName[5]. If you need to update both, you should perform them as separate operations:user.update({ firstName: 'NewName' })for standard profile fields anduser.updateMetadata({ unsafeMetadata: { ... } })for metadata [7][2]. 4. Snapshot/State Consistency: TheuseUser()hook provides a reactiveuserobject [7]. After calling an update method, the Clerk SDK typically handles synchronization automatically; however, if you find that state does not reflect immediately in complex scenarios, you can calluser.reload()to force a refresh of the user object data [7]. By migrating toupdateMetadata(), you avoid the pitfalls of object reference replacement and benefit from the intended deep-merge functionality [6][2].Citations:
- 1: clerk/javascript#8587
- 2: https://clerk.com/docs/react/reference/objects/user
- 3: clerk/javascript#8537
- 4: https://github.com/clerk/clerk-docs/blob/126650ba/docs/users/metadata.mdx
- 5: https://clerk.com/docs/guides/development/upgrading/upgrade-guides/2026-05-12
- 6: clerk/javascript@18e0a1a
- 7: https://clerk.com/docs/nextjs/reference/hooks/use-user
🌐 Web query:
site:clerk.com/docs/nextjs/reference/hooks/use-user user object reference update on unrelated field change unsafeMetadata💡 Result:
There is no documented property or feature named unsafeMetadata associated with the Clerk user object or its metadata management in the provided search results [1]. Clerk organizes metadata into categories such as publicMetadata and privateMetadata, which are managed through specific update methods or backend API calls [1]. When updating user data, such as metadata, it is best practice to use the established Clerk User object methods (like user.update) or, if performing updates via an external API endpoint, to call the user.reload method to ensure the local user object reference remains synchronized with the latest server-side state [1]. Stale object references can occur if the user object is not refreshed after backend changes [1].
Citations:
🌐 Web query:
Clerk useUser hook reactive user object reference unsafeMetadata same reference unrelated update firstName docs source💡 Result:
The Clerk useUser hook provides access to a reactive User object [1][2]. Because this hook is designed to keep your UI in sync with the current user's authentication state, the User object returned by useUser is reactive; it will update whenever the underlying user data changes (e.g., after calling user.update or user.reload) [1][3]. Regarding reference stability and unrelated updates: 1. Reactive Behavior: When Clerk detects a change to user data (such as an update to firstName or unsafeMetadata), the useUser hook triggers a re-render in components consuming it [1][3]. 2. Reference Changes: Because the User object is updated to reflect the new state, the reference to the user object may change when updates occur. If you are relying on strict reference equality (e.g., using the user object as a dependency in useEffect or useMemo), you should be aware that this reference can change when the user data is updated or reloaded [1]. 3. UnsafeMetadata Updates: While unsafeMetadata is readable and writable from the frontend, updating it (or other fields like firstName) will cause the useUser hook to provide a new User object instance [4][5]. To perform updates without manually managing state, the User object provides built-in methods like update (for full replacement) and updateMetadata (for deep-merging) [4][6]. 4. Best Practices: If you need to perform an action triggered by a specific user property change, consider using the specific field (e.g., user.firstName) as a dependency in your hooks rather than the entire user object [1]. In summary, the User object is not guaranteed to maintain a stable reference across updates; it is a reactive proxy managed by Clerk to ensure your frontend always has access to the most current session data [1][7].
Citations:
- 1: https://clerk.com/docs/react/reference/hooks/use-user
- 2: https://clerk.com/docs/nextjs/reference/hooks/use-user
- 3: https://clerk.com/blog/read-user-data-guide
- 4: https://clerk.com/docs/react/reference/objects/user
- 5: https://github.com/clerk/clerk-docs/blob/126650ba/docs/users/metadata.mdx
- 6: https://clerk.com/docs/react-router/reference/objects/user
- 7: clerk/javascript#4661
Prevent avatar edits from being reset by user refreshes.
apps/web/src/components/AccountHeader.tsx:68-70rehydratesposfromuser.unsafeMetadataon every Clerk user update, anduseUser()is reactive here—an unrelateduser.update({ firstName })can still swap in a new user object and retrigger this effect mid-drag, clobbering the in-progress reposition. Gate the sync behind!isRepositioningor narrow the dependency.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@apps/web/src/components/AccountHeader.tsx` around lines 68 - 70, Update the useEffect that synchronizes pos from user.unsafeMetadata so it does not overwrite an in-progress avatar reposition: gate the sync with !isRepositioning or otherwise narrow the dependency to avoid unrelated Clerk user updates, while preserving metadata hydration when repositioning is inactive.
85-102: 🗄️ Data Integrity & Integration | 🟡 Minor | ⚡ Quick win
Stale
avatarPositionmetadata if upload is abandoned mid-reposition.After a successful upload, local
posresets toDEFAULT_POSbut nothing is persisted tounsafeMetadatauntilconfirmRepositionruns. If the user uploads a new photo and leaves without confirming, the next load reapplies the previous photo's saved crop coordinates to the new photo viareadAvatarPosition.🐛 Proposed fix
await user.setProfileImage({ file }) + await saveMetadata(DEFAULT_POS) setPos(DEFAULT_POS) setPosBeforeEdit(DEFAULT_POS) setIsRepositioning(true)📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.const handleAvatarFile = async (e: ChangeEvent<HTMLInputElement>) => { const file = e.target.files?.[0] e.target.value = '' if (!file) return setPosBeforeEdit(pos) setUploading(true) try { await user.setProfileImage({ file }) await saveMetadata(DEFAULT_POS) setPos(DEFAULT_POS) setPosBeforeEdit(DEFAULT_POS) setIsRepositioning(true) } catch (err) { console.error('avatar upload failed', err) alert('上傳照片失敗,請稍後再試') } finally { setUploading(false) } }🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@apps/web/src/components/AccountHeader.tsx` around lines 85 - 102, The successful upload path in handleAvatarFile must immediately persist DEFAULT_POS to unsafeMetadata when resetting local pos, so abandoning repositioning cannot reuse the previous photo’s crop. Update the existing user metadata update mechanism used by confirmReposition, while preserving the current repositioning state and upload error handling.
241-241: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win
🧩 Analysis chain
🏁 Script executed:
ast-grep outline apps/web/src/components/AccountHeader.tsx --view expandedRepository: Retsomm/EasyLearn
Length of output: 854
🏁 Script executed:
cat -n apps/web/src/components/AccountHeader.tsx | sed -n '220,255p'Repository: Retsomm/EasyLearn
Length of output: 1739
🏁 Script executed:
rg -n "USER\.ID|account-userid|username|user\.id|useUser" apps/web/src/components/AccountHeader.tsx apps/web/src -g '!**/node_modules/**'Repository: Retsomm/EasyLearn
Length of output: 1301
🏁 Script executed:
rg -n "\.username\b|user\.id\b|account-userid|firstName|lastName" apps/web/src -g '!**/node_modules/**'Repository: Retsomm/EasyLearn
Length of output: 1103
Replace the placeholder user id text
USER.IDis rendered literally here; bind this to the intended Clerk identifier or remove the field if it’s not needed.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@apps/web/src/components/AccountHeader.tsx` at line 241, Update the user ID span in the AccountHeader component to render the intended Clerk user identifier instead of the literal “USER.ID”; if no identifier is available or the field is unnecessary, remove the span.apps/web/src/index.css (2)
29-29: 📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win
Fix stylelint
value-keyword-caseviolations.Stylelint flags
Menlo/Consolascasing in the font stack.🔧 Proposed fix
- --font-mono: 'JetBrains Mono', 'SF Mono', Menlo, Consolas, monospace; + --font-mono: 'JetBrains Mono', 'SF Mono', menlo, consolas, monospace;📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.--font-mono: 'JetBrains Mono', 'SF Mono', menlo, consolas, monospace;🧰 Tools
🪛 Stylelint (17.14.0)
[error] 29-29: Expected "Menlo" to be "menlo" (value-keyword-case)
(value-keyword-case)
[error] 29-29: Expected "Consolas" to be "consolas" (value-keyword-case)
(value-keyword-case)
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@apps/web/src/index.css` at line 29, Update the --font-mono font-family declaration in the root CSS variables to use the casing expected by stylelint for the Menlo and Consolas font names, while preserving the existing font-stack order and fallback behavior.Source: Linters/SAST tools
866-868: 📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win
Fix stylelint
value-keyword-caseviolation.Stylelint flags
currentColor, expects lowercase.🔧 Proposed fix
.icon-filled { - fill: currentColor; + fill: currentcolor; }📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements..icon-filled { fill: currentcolor; }🧰 Tools
🪛 Stylelint (17.14.0)
[error] 867-867: Expected "currentColor" to be "currentcolor" (value-keyword-case)
(value-keyword-case)
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@apps/web/src/index.css` around lines 866 - 868, Update the fill value in the .icon-filled CSS rule to use the lowercase keyword expected by stylelint, while preserving the existing current-color behavior.Source: Linters/SAST tools
packages/core/src/data/questions/jsa-9-promises-advanced.json (1)
162-162: 📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win
Possible typo in q7 explanation: 賭 → 賽.
The explanation reads "any 賭的是「誰先成功」". The character 賭 (zhì, "to lose") doesn't fit the context of "any competes for whoever succeeds first". It should likely be 賽 (sài, "to compete/race"), matching the pattern used elsewhere in this file (e.g., q2's "race 是賽跑").
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@packages/core/src/data/questions/jsa-9-promises-advanced.json` at line 162, Update the explanation for the Promise.any question to replace the incorrect character 賭 with 賽 in the phrase describing who succeeds first; preserve the remaining Chinese wording and explanation unchanged.packages/core/src/data/questions/react-9-preserving-state.json (1)
1-270: 🎯 Functional Correctness | 🟠 Major | ⚡ Quick win
All 12 questions have answer "a" — the quiz is trivially gameable.
Every correct answer is option
a, andQuestionCard.tsxrenders options in JSON order without shuffling. Users will immediately notice the pattern and always pick the first option, defeating the quiz's purpose.Shuffle the option order per question so the correct answer is distributed across positions a–d.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@packages/core/src/data/questions/react-9-preserving-state.json` around lines 1 - 270, The quiz data in react-9-preserving-state.json assigns answer "a" to all questions, and QuestionCard.tsx preserves JSON order, making the correct choice predictable. Update the question-option rendering flow in QuestionCard.tsx to shuffle each question’s options while retaining the correct option identity and answer validation, distributing correct answers across positions a–d rather than relying on the source order.README.md (1)
1-16: 📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win
README says mobile app is "尚未建立" but the PR includes mobile app files.
Line 6 states
apps/mobile — Expo React Native App(規劃中,尚未建立), yet this PR adds a fullapps/mobileworkspace with components, screens, and configuration. Update the description to reflect the actual state.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@README.md` around lines 1 - 16, Update the README apps/mobile entry to remove the outdated “規劃中,尚未建立” wording and describe it as the existing Expo React Native workspace, matching the mobile app files and configuration included in the repository.
- 修掉題庫「正解永遠是 a」的系統性問題:QuestionCard 改成渲染時洗牌選項 - API routes:first-time 使用者 upsert、completedLevel.best 原子更新只升不降、補輸入驗證 - AccountHeader:修正頭像裁切位置未即時存檔、USER.ID 佔位字串、重新命名時中斷拖曳的 race - packages/core 新增共用 applyAnswer(),web/mobile 兩邊錯題本更新邏輯合併;修 bumpStreak 跨午夜競態 - apps/web、apps/mobile 內部 import 全面改用 @/ alias,不再用 ../ 相對路徑 Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Summary by CodeRabbit