Skip to content

Release v1.0.0#326

Merged
ViktorSvertoka merged 11 commits intomainfrom
develop
Feb 14, 2026
Merged

Release v1.0.0#326
ViktorSvertoka merged 11 commits intomainfrom
develop

Conversation

@ViktorSvertoka
Copy link
Member

@ViktorSvertoka ViktorSvertoka commented Feb 14, 2026

v1.0.0 - DevLovers Production Release ❤️

Summary by CodeRabbit

  • New Features

    • Added quiz results dashboard with score tracking, integrity metrics, and detailed attempt review functionality
    • Introduced interactive flip card learning component with auto-advancing Q&A
    • Added blog section with product catalog and descriptions
    • Implemented touch-based drag-and-drop for term reordering on mobile
    • Added dynamic status badges (Study/Review/Mastered) for quiz progress tracking
    • Enhanced pagination with configurable page size controls
  • Improvements

    • Redesigned home page with multiple hero sections and animated visualizations
    • Increased question pagination limit to 100 items per page
    • Added breadcrumb navigation to quiz review pages
    • Improved mobile responsiveness for text selection and UI interactions
    • Enhanced leaderboard table layout with optimized column sizing
    • Added Cyrillic language font support
  • Localization

    • Expanded translations across English, Polish, and Ukrainian with new content blocks
    • Added quiz result terminology updates (Study/Review/Mastered framework)

TiZorii and others added 11 commits February 12, 2026 03:05
… devices

  - Add touch drag support for AI helper modal and explained terms reorder
  - Position explain button below selected word on mobile
  - Show delete/restore buttons always visible on mobile (no hover)
  - Add user avatar to dashboard profile card (same as leaderboard)
  - Fix leaderboard page layout
  - Fix Tailwind v4 canonical class warnings
* refactor(home): rename hero sections and add complete i18n support

- Rename LegacyHeroSection → WelcomeHeroSection
- Rename HeroSection → FeaturesHeroSection
- Add welcomeDescription translation key to eliminate duplication
- Translate all hardcoded text (headings, badges, CTAs)
- Improve Ukrainian/Polish translations for better readability
- Remove unused legacy components and images

* feat(about): update LinkedIn follower count to reflect current stat (1.5k+)

* refactor(home): implement i18n for FlipCardQA & fix memory leaks

* fix(home): resolve rotateY conflict & scope keyboard events in FlipCardQA

* fix(home): resolve all issues

* chore(home): cleanup comments, remove dead code & fix trailing spaces
…mbs, status badges (#320)

* feat(quiz): add guest warning before start and bot protection

Guest warning: show login/signup/continue buttons for unauthenticated
users on quiz rules screen before starting.

Bot protection: multi-attempt verification via Redis - each question
can only be verified once per user per attempt. Keys use dynamic TTL
matching quiz time limit and are cleared on retake.

Additional fixes:
- Footer flash on quiz navigation (added loading.tsx, eliminated redirect)
- Renamed QaLoader to Loader for reuse across pages
- React compiler purity errors (crypto.getRandomValues in handlers)
- Start button disabled after retake (isStarting not reset)

* refactor(quiz): PR review feedback

- Extract shared resolveRequestIdentifier() helper to eliminate
  duplicated auth/IP resolution logic in route.ts and actions/quiz.ts
- Return null instead of 'unknown' when identifier unresolvable,
  skip verification tracking for unidentifiable users
- Cap Redis TTL with MAX_TTL (3600s) to prevent client-supplied
  timeLimitSeconds from persisting keys indefinitely
- Add locale prefix to returnTo paths in guest warning links
- Replace nested Button inside Link with styled Link to fix
  invalid HTML (interactive element nesting)

* fix(quiz): fall through to IP when auth cookie is expired/invalid

* feat(quiz): add quiz results dashboard and review page

- Add quiz history section to dashboard with last attempt per quiz
- Add review page showing incorrect questions with explanations
- Add collapsible cards with expand/collapse all toggle
- Add "Review Mistakes" button on quiz result screen
- Add category icons to quiz page and review page headers
- Add BookOpen icon to explanation block in QuizQuestion
- Update guest message to mention error review benefit
- Add i18n translations (en/uk/pl) for all new features

* fix(quiz): scroll to next button on answer reveal, scope review cache by userId

* fix(quiz): restore type imports and userId cache key after merge conflict

* fix: restore type imports, sync @swc/helpers, fix indentation after merge

* feat(quiz): add violations counter UI, fix disqualification threshold

- Add ViolationsCounter component with color escalation (green/yellow/red)
- Sticky top bar keeps counter visible on scroll (mobile/tablet)
- Add i18n counter keys for en/uk/pl with ICU plural forms
- Fix threshold bug: violations warning now triggers at 4+ (was 3+)
  to match actual integrity score calculation (100 - violations * 10 < 70)

* fix(quiz): fix points mismatch between leaderboard and dashboard

Dashboard showed raw pointsEarned from last quiz_attempt, while
leaderboard summed improvement deltas from point_transactions.
Additionally, orphaned transactions from re-seeded quizzes inflated
leaderboard totals (12 rows, 83 ghost points cleaned up in DB).

- Dashboard query now joins point_transactions to show actual awarded
  points per quiz instead of raw attempt score
- Leaderboard query filters out orphaned transactions where the
  source attempt no longer exists in quiz_attempts

* OBfix(quiz): fix points mismatch, consistent status badges, mobile UX

Dashboard showed raw pointsEarned from last attempt while leaderboard
summed improvement deltas from point_transactions. Orphaned transactions
from re-seeded quizzes inflated leaderboard totals (cleaned up in DB).

- Dashboard query joins point_transactions for actual awarded points
- Leaderboard query filters orphaned transactions (source_id not in quiz_attempts)
- Quiz cards use 3-level badges (Mastered/Review/Study) matching dashboard
- Mobile quiz results show dash for zero points, added chevron indicator

* fix(quiz): add breadcrumbs to review page, fix recommendation tautology
…product descriptions (#322)

* Header UX: reorder languages, swap controls, fix quiz highlight, style Blog button

* shop i18n product descriptions
* fix(qa): align Next.js tab states and speed up loader startup

* feat(home,qa): improve home snap flow and add configurable Q&A page size

* fix(i18n,qa,seed): address review issues for locale handling and pagination state
… locale switch on result page (#325)

* feat(quiz): add guest warning before start and bot protection

Guest warning: show login/signup/continue buttons for unauthenticated
users on quiz rules screen before starting.

Bot protection: multi-attempt verification via Redis - each question
can only be verified once per user per attempt. Keys use dynamic TTL
matching quiz time limit and are cleared on retake.

Additional fixes:
- Footer flash on quiz navigation (added loading.tsx, eliminated redirect)
- Renamed QaLoader to Loader for reuse across pages
- React compiler purity errors (crypto.getRandomValues in handlers)
- Start button disabled after retake (isStarting not reset)

* refactor(quiz): PR review feedback

- Extract shared resolveRequestIdentifier() helper to eliminate
  duplicated auth/IP resolution logic in route.ts and actions/quiz.ts
- Return null instead of 'unknown' when identifier unresolvable,
  skip verification tracking for unidentifiable users
- Cap Redis TTL with MAX_TTL (3600s) to prevent client-supplied
  timeLimitSeconds from persisting keys indefinitely
- Add locale prefix to returnTo paths in guest warning links
- Replace nested Button inside Link with styled Link to fix
  invalid HTML (interactive element nesting)

* fix(quiz): fall through to IP when auth cookie is expired/invalid

* feat(quiz): add quiz results dashboard and review page

- Add quiz history section to dashboard with last attempt per quiz
- Add review page showing incorrect questions with explanations
- Add collapsible cards with expand/collapse all toggle
- Add "Review Mistakes" button on quiz result screen
- Add category icons to quiz page and review page headers
- Add BookOpen icon to explanation block in QuizQuestion
- Update guest message to mention error review benefit
- Add i18n translations (en/uk/pl) for all new features

* fix(quiz): scroll to next button on answer reveal, scope review cache by userId

* fix(quiz): restore type imports and userId cache key after merge conflict

* fix: restore type imports, sync @swc/helpers, fix indentation after merge

* feat(quiz): add violations counter UI, fix disqualification threshold

- Add ViolationsCounter component with color escalation (green/yellow/red)
- Sticky top bar keeps counter visible on scroll (mobile/tablet)
- Add i18n counter keys for en/uk/pl with ICU plural forms
- Fix threshold bug: violations warning now triggers at 4+ (was 3+)
  to match actual integrity score calculation (100 - violations * 10 < 70)

* fix(quiz): fix points mismatch between leaderboard and dashboard

Dashboard showed raw pointsEarned from last quiz_attempt, while
leaderboard summed improvement deltas from point_transactions.
Additionally, orphaned transactions from re-seeded quizzes inflated
leaderboard totals (12 rows, 83 ghost points cleaned up in DB).

- Dashboard query now joins point_transactions to show actual awarded
  points per quiz instead of raw attempt score
- Leaderboard query filters out orphaned transactions where the
  source attempt no longer exists in quiz_attempts

* OBfix(quiz): fix points mismatch, consistent status badges, mobile UX

Dashboard showed raw pointsEarned from last attempt while leaderboard
summed improvement deltas from point_transactions. Orphaned transactions
from re-seeded quizzes inflated leaderboard totals (cleaned up in DB).

- Dashboard query joins point_transactions for actual awarded points
- Leaderboard query filters orphaned transactions (source_id not in quiz_attempts)
- Quiz cards use 3-level badges (Mastered/Review/Study) matching dashboard
- Mobile quiz results show dash for zero points, added chevron indicator

* fix(quiz): add breadcrumbs to review page, fix recommendation tautology

* fix(quiz): align result messages with status badges, persist result on locale switch
@ViktorSvertoka ViktorSvertoka self-assigned this Feb 14, 2026
@vercel
Copy link
Contributor

vercel bot commented Feb 14, 2026

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
devlovers-net Ready Ready Preview, Comment Feb 14, 2026 0:00am

@netlify
Copy link

netlify bot commented Feb 14, 2026

Deploy Preview for develop-devlovers ready!

Name Link
🔨 Latest commit f56413e
🔍 Latest deploy log https://app.netlify.com/projects/develop-devlovers/deploys/698fbb2593791b0008da023b
😎 Deploy Preview https://deploy-preview-326--develop-devlovers.netlify.app
📱 Preview on mobile
Toggle QR Code...

QR Code

Use your smartphone camera to open QR code link.

To edit notification comments on pull requests, go to your Netlify project configuration.

@coderabbitai
Copy link
Contributor

coderabbitai bot commented Feb 14, 2026

📝 Walkthrough

Walkthrough

This comprehensive 1.0.0 production release introduces homepage redesign with interactive components, quiz results dashboard integration, Monobank payment janitor system for reconciliation, touch-based interactions, pagination controls, extensive internationalization updates, database schema changes, and removal of legacy seed scripts.

Changes

Cohort / File(s) Summary
Changelog & Version
CHANGELOG.md, frontend/package.json, studio/package.json
Version bumped to 1.0.0 with comprehensive feature changelog documenting platform capabilities, security, infrastructure, and improvements.
Homepage Redesign
frontend/app/[locale]/page.tsx, frontend/components/home/*
Replaced HeroSection with modular WelcomeHeroSection and FeaturesHeroSection; introduced new components FlipCardQA (interactive card flip with auto-advance), FloatingCode (animated code snippets), InteractiveConstellation (particle system), HomePageScroll (scroll snap control), and WelcomeHeroBackground. Refactored InteractiveCTAButton with spring-driven hover effects. Deleted CodeCard and HeroCodeCards. Added 3D CSS utilities (perspective-1000, preserve-3d, backface-hidden).
Dashboard Quiz Results
frontend/app/[locale]/dashboard/page.tsx, frontend/components/dashboard/*
Integrated QuizResultsSection into dashboard page; extended user data with id and image fields. Enhanced ProfileCard with UserAvatar component and deterministic avatar generation. Updated QuizResultRow with responsive grid layout and conditional chevron rendering. Modified ExplainedTermsCard with comprehensive touch-drag support including drag indicators and term reordering persistence.
Quiz Review & Navigation
frontend/app/[locale]/dashboard/quiz-review/[attemptId]/page.tsx, frontend/components/header/*
Added breadcrumb navigation to quiz review pages with translations. Extended header navigation to include Blog links (AppMobileMenu, DesktopNav, DesktopActions). Refined NavLink active-state logic for quiz routes. Updated MainSwitcher to support leaderboard path styling and dynamic locale checking.
Q&A Pagination & Selection
frontend/components/q&a/*
Enhanced Pagination component with page size controls (pageSize, pageSizeOptions, onPageSizeChange). Wired useQaTabs hook to expose page size state and handlers; added URL synchronization for size parameter. Updated AccordionList with dynamic FloatingExplainButton placement ('above'/'below') based on viewport width. Enhanced SelectableText with mobile-aware Y positioning. Added touch support to AIWordHelper (touchStart, touchMove, touchEnd handlers) with touch-none class.
Monobank Payment Janitor
frontend/app/api/shop/internal/monobank/janitor/route.ts, frontend/lib/services/orders/*, frontend/db/schema/shop.ts, frontend/drizzle/*
New internal API route for janitor operations with authentication, validation, rate-limiting (429 responses), and dry-run support. Implemented 4 janitor jobs: Job1 (claim/process stale attempts), Job2 (handle stale creating attempts without invoices), Job3 (apply stored events in webhook mode), Job4 (report needs-review candidates). Added MonobankJanitorJob3ModeError for mode validation. Enhanced payment_attempts schema with janitorClaimedUntil, janitorClaimedBy columns and index. New Monobank event claiming and webhook application helpers.
Quiz Session & State
frontend/hooks/useQuizSession.ts, frontend/lib/quiz/quiz-session.ts, frontend/components/quiz/QuizContainer.tsx, frontend/components/quiz/QuizResult.tsx
Extended QuizState with pointsAwarded, attemptId, isIncomplete fields. Updated session restoration logic to halt at 'rules' status. Revised quiz result thresholds and messaging: <70% (study/red), 70-<100% (review/amber), 100%+ (mastered/emerald). Increased violations threshold from 3 to 4 for disqualification. Added ViolationsCounter component with dynamic color coding. Modified QuizContainer to include ViolationsCounter in header and clear session on back-to-topics.
Quiz Display & Category
frontend/app/[locale]/quiz/[slug]/page.tsx, frontend/components/quiz/QuizCard.tsx, frontend/components/quiz/QuizQuestion.tsx
Stricter categorySlug validation with fallback to null when style not found. Added dynamic status badge logic to QuizCard based on progress percentage (mastered, needsReview, study). Enhanced QuizQuestion with auto-scroll to Next button on reveal via useRef and useEffect.
Shop Product Localization
frontend/app/[locale]/shop/products/[slug]/page.tsx
Replaced static product descriptions with locale-driven getMessages/productDescriptions lookup; falls back to product.description if unavailable. Renders descriptions as multiple paragraphs split on newlines.
API & Pagination
frontend/app/api/questions/[category]/route.ts
Increased maximum pagination limit from 50 to 100 for question fetching.
Leaderboard & Database Queries
frontend/components/leaderboard/*, frontend/db/queries/*
Updated LeaderboardClient gradient classes (gradient-to-r syntax). Added colgroup column sizing to LeaderboardTable (15% / auto / 25%). Refactored leaderboard query to use pt_valid subquery aggregating points from point_transactions instead of direct joins. Enhanced quiz queries to compute pointsEarned from pt_sum subquery per user. Extended cache key logic with userId scope for attempt reviews.
Quiz Redis Caching
frontend/lib/quiz/quiz-answers-redis.ts
Added Redis-backed caching for AttemptReview data (48-hour TTL) with userId-scoped cache keys; exposed getCachedAttemptReview and cacheAttemptReview functions.
Internationalization
frontend/messages/en.json, frontend/messages/pl.json, frontend/messages/uk.json
Extensive i18n additions: homepage (welcomeDescription, featuresHeading, flipCard with Q&A samples), quiz results (study, review, mastered statuses), dashboard (quizResults, quizReview sections), blog (productDescriptions catalog), Q&A (itemsPerPage, itemsPerPageAria), and anti-cheat (counter field). Updated Ukrainian about features and expanded all locales with rich descriptive content.
Layout & Styling
frontend/app/layout.tsx, frontend/app/globals.css, frontend/data/categoryStyles.ts, frontend/components/shared/*
Reorganized imports with type-only separation; expanded Geist font subsets to include Cyrillic. Enhanced Footer with dynamic visibility based on route, forceVisible prop, and className composition. Updated Footer public API. Changed LanguageSwitcher z-index from arbitrary z-[70] to fixed z-70. Updated categoryStyles "next" accent color from #111111 to #A1A1AA. Added Loader startup warmup (100 frames) before animation loop.
Instrumentation
frontend/instrumentation-client.ts, frontend/i18n/config.ts
Exported onRouterTransitionStart alias for Sentry router transition hook. Reordered locales array to [`en`, `uk`, `pl`]. Updated default LinkedIn follower count to 1500.
Component Updates
frontend/components/home/HeroSection.tsx, frontend/components/about/HeroSection.tsx, frontend/components/shared/Loader.tsx, frontend/components/ui/particle-canvas.tsx
Renamed HeroBackground to WelcomeHeroBackground; updated LinkedIn follower default to 1.5k+. Fixed particle-canvas formatting (EOL newline).
Legacy Seed Scripts Removal
frontend/db/seed-*.ts, frontend/db/seed-quiz-types.ts, frontend/db/seed-demo-leaderboard.ts
Removed 16 legacy seed scripts and types (seed-users, seed-quiz-react, seed-quiz-javascript/typescript/angular/css/html/git/node/vue/verify, seed-quiz-types, seed-demo-leaderboard, seed-quiz-from-json). Refactored seed-questions with locale normalization, question deduplication, and guarded translations insertion.
Monobank Tests
frontend/lib/tests/shop/monobank-*.test.ts
Added comprehensive test suites: event claiming with lease expiration, janitor job1–job4 (Job1 success/PSP expiration/idempotency, Job2 stale-creating handling/idempotency/dryRun, Job3 event application/grouping/mode validation, Job4 needs-review reporting with age/reason aggregation). Added route handler tests for authorization, validation, rate-limiting, and per-job success paths.
Q&A Tests
frontend/components/tests/q&a/*.test.tsx
Added pagination page-size change test. Added useQaTabs page-size change test with URL synchronization verification.

Sequence Diagram(s)

sequenceDiagram
    participant User
    participant Frontend as Frontend App
    participant API as Janitor API
    participant DB as Database
    participant PSP as Payment Service<br/>(Monobank)
    
    User->>Frontend: Dashboard Load
    Frontend->>API: POST /api/shop/internal/monobank/janitor<br/>(job1, dryRun, limit, secret)
    API->>API: Validate auth & payload
    API->>DB: Acquire job slot<br/>(rate-limiting gate)
    alt Rate Limited
        API-->>Frontend: 429 Retry-After
    else Allowed
        API->>DB: Query stale payment attempts<br/>(Job1: active, > grace period)
        DB-->>API: Candidate list
        loop For each candidate
            API->>PSP: getInvoiceStatus(invoiceId)
            alt Invoice Paid
                API->>DB: Update: order→PAID<br/>attempt→succeeded
            else Invoice Expired
                API->>DB: Restock inventory<br/>Order→INVENTORY_FAILED<br/>Attempt→failed
            end
            API->>API: Collect counters<br/>(processed, applied, noop, failed)
        end
        API->>DB: Release job lease
        API-->>Frontend: 200 { processed, applied,<br/>noop, failed, metrics }
        Frontend->>Frontend: Display results
    end
Loading

Estimated code review effort

🎯 5 (Critical) | ⏱️ ~110 minutes

Possibly related PRs

Suggested labels

release, feature-complete, schema-migration

Suggested reviewers

  • AM1007

Poem

🐰 Hop hop, the DevLovers fly,
With janitor jobs reaching sky,
Flip cards twirl, code floats free,
Quiz results bloom on the dashboard tree,
One-oh-oh brings the vision to life!

🚥 Pre-merge checks | ✅ 3 | ❌ 1
❌ Failed checks (1 warning)
Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (3 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title 'Release v1.0.0' clearly and concisely summarizes the main change - a production release at version 1.0.0.
Merge Conflict Detection ✅ Passed ✅ No merge conflicts detected when merging into main

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing touches
  • 📝 Generate docstrings
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch develop

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.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@ViktorSvertoka ViktorSvertoka merged commit 01e741c into main Feb 14, 2026
14 of 15 checks passed
Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 (2)
frontend/db/queries/quiz.ts (1)

450-488: ⚠️ Potential issue | 🟠 Major

Inconsistent pointsEarned source between getUserLastAttemptPerQuiz and getAttemptReviewDetails.

getUserLastAttemptPerQuiz (line 465) computes pointsEarned via COALESCE(pt_sum.total, 0) from aggregated point_transactions, while getAttemptReviewDetails (line 505) reads pointsEarned directly from the quizAttempts.pointsEarned column (used at lines 551 and 633). If these values diverge (e.g., orphaned transactions were filtered, or the column wasn't backfilled), the dashboard and review pages will show different point totals for the same quiz.

Align both queries to use the same source of truth for pointsEarned.

Also applies to: 490-640

frontend/app/layout.tsx (1)

68-68: ⚠️ Potential issue | 🟡 Minor

Hardcoded lang="uk" applies to all locales; should be dynamic based on [locale] param.

The root layout sets <html lang="uk"> statically, but the app supports en, uk, and pl via the [locale] dynamic route. In Next.js App Router, only the root layout can render the <html> element, so the locale-aware [locale]/layout.tsx cannot override this attribute. This means all pages report themselves as Ukrainian to search engines and assistive technologies, regardless of the actual locale being viewed.

The root layout should receive the locale parameter and set lang dynamically—for example, by using the locale from segment parameters or through a wrapper pattern that threads the locale context to the root layout.

🤖 Fix all issues with AI agents
In `@frontend/components/header/DesktopNav.tsx`:
- Around line 44-55: Add a click handler for the Blog HeaderButton that mirrors
the Shop behavior: create a handleBlogClick function that accepts (e:
React.MouseEvent<HTMLAnchorElement>), calls e.preventDefault(), then calls
startNavigation('/blog'); then pass that handler into the Blog HeaderButton via
the onLinkClick prop on the existing <HeaderButton href="/blog" icon={BookOpen}
showArrow ...> so clicking Blog closes the mobile menu consistently (same
pattern used for the Shop button).

In `@frontend/components/home/FlipCardQA.tsx`:
- Line 347: The aria-label prop currently uses a hardcoded English string ("Go
to question ${index + 1}") which breaks i18n; replace this literal with a
translated string from your i18n system (e.g., use the existing t function or
translation hook used in FlipCardQA.tsx) and pass the question index as a
variable to the translation key (update the aria-label on the element that sets
aria-label={`Go to question ${index + 1}`} to something like t('yourKey', {
number: index + 1 }) so it uses the translation key and preserves
accessibility).
- Line 40: The shouldReduceMotion value from useReducedMotion() is collected but
not used; update FlipCardQA to respect it by gating motion features: when
shouldReduceMotion is true, disable the auto-advance interval (where
start/clearInterval or useEffect sets rotation/autoplay), stop any 3D
tilt/pointer-move handlers (e.g. onPointerMove / Tilt component /
handlePointerMove), and avoid animated flip transitions (bypass
react-spring/useSpring or replace animated class toggles with instant state
changes). Locate the shouldReduceMotion variable in FlipCardQA and add
conditional branches to skip starting timers, skip attaching tilt handlers or
pass a reducedMotion prop to child Tilt/Flip components, and change flip logic
to an immediate state toggle without animation.

In `@frontend/components/home/FloatingCode.tsx`:
- Around line 146-161: The jitter is caused by calling Math.random() directly in
the render for the Framer Motion transition.duration inside the FloatingCode
component's motion.div; compute the duration once per snippet (e.g., in a useRef
or useMemo tied to the snippet identity) and replace the inline Math.random()
expression with that stable value so transition.duration uses the persisted
duration instead of a new random value on every re-render.

In `@frontend/components/home/HomePageScroll.tsx`:
- Line 25: The comparison using secondStep.offsetTop assumes the scroll
container is the element's offsetParent; update the scroll container so it's a
positioned element (e.g., add the "relative" class to the container element used
in HomePageScroll) so offsetTop is measured relative to that container, or
alternatively compute positions using getBoundingClientRect relative to the
container if you prefer not to change layout classes; ensure the variable/logic
around shouldEnableSnap (the line computing container.scrollTop <=
secondStep.offsetTop + 4) uses the corrected measurement after this change.

In `@frontend/components/home/InteractiveConstellation.tsx`:
- Around line 30-125: The icons map is recreated on every render inside
InteractiveConstellation which forces unnecessary allocations and can change
useEffect dependencies; move the const icons: Record<IconType, ...> declaration
out of the InteractiveConstellation component into module scope (top-level) so
it is defined once, then update any references inside InteractiveConstellation
(e.g., where icons is used in the canvas drawing useEffect) to use the top-level
icons and remove it from the effect dependency list if present.

In `@frontend/components/home/InteractiveCTAButton.tsx`:
- Line 18: The isFirstRender state and its setter (isFirstRender,
setIsFirstRender) in InteractiveCTAButton are dead code — remove the useState
declaration and the related useEffect that sets setIsFirstRender(false) so
nothing references them; update imports if useState or useEffect become unused
and remove them accordingly to keep the component clean.

In `@frontend/db/queries/leaderboard.ts`:
- Around line 20-29: Replace the raw sql-based subquery used in leftJoin with a
Drizzle-built subquery: build a db.select from pointTransactions selecting
userId and total (use sql`SUM(${pointTransactions.points})::int` for the
aggregate), add the where clause combining eq(pointTransactions.source, 'quiz')
and the or(isNull(pointTransactions.sourceId),
inSubquery(pointTransactions.sourceId, db.select({id:
quizAttempts.id}).from(quizAttempts))), groupBy(pointTransactions.userId), then
call .as('pt_valid') to alias it (referenced as ptValid) and pass that alias
into leftJoin(ptValid, eq(ptValid.userId, users.id)); finally use
coalesce(ptValid.total, 0) in orderBy as before.

In `@frontend/db/queries/quiz.ts`:
- Around line 55-76: The in-memory Map attemptReviewCache (used by
getAttemptReviewCacheKey, getCachedAttemptReview, and cacheAttemptReview) is
unbounded and can leak memory; fix by replacing it with a bounded LRU/TTL cache
(e.g., use an lru-cache instance configured with a sensible max and ttl) or
delegate these functions to the existing Redis layer in quiz-answers-redis.ts
(call the Redis get/set with a 48h TTL instead of using attemptReviewCache).
Update getCachedAttemptReview and cacheAttemptReview to use the new cache API
(or Redis client) and remove/replace the global attemptReviewCache Map to ensure
eviction.
- Around line 473-478: The subquery casting (metadata->>'quizId')::uuid can
throw if metadata->>'quizId' is NULL or malformed; update the pt_sum subquery
(alias pt_sum, table point_transactions, joined to qa.quiz_id) to guard the cast
by adding a WHERE condition such as AND metadata->>'quizId' IS NOT NULL and
(optionally) a UUID-validating regex like (metadata->>'quizId') ~
'^[0-9a-fA-F0-9-]{36}$' (or a stricter UUID regex) before performing the ::uuid
cast so only non-null, well-formed values are cast and aggregated.

In `@frontend/db/seed-questions.ts`:
- Around line 62-73: The seed script currently creates or queries the question
row (using sortOrder, existingQuestion, and the questions table) before
verifying that q.translations exists; move the check for q.translations (and the
early-warning/continue logic) to before any DB insert/select for the question so
you never create an orphan question without translations, and apply the same
change to the later block that also creates questions (the other insert/reuse
code around the translations handling) so both paths validate q.translations
first.
- Around line 75-85: The current construction that computes questionId uses a
non-null assertion on the first element of the array returned by
db.insert(...).returning(...) which can throw if the DB returns no rows; update
the code around questionId/existingQuestion to capture the result of the insert
into a variable (e.g., insertResult = await
db.insert(questions).values({...}).returning({id: questions.id})), check
insertResult.length (or insertResult[0]) and handle the empty case with a clear
error or fallback before accessing .id, then use insertResult[0].id when
present; reference the questionId variable, existingQuestion, and the
db.insert(...).returning(...) call when making the change.

In `@frontend/lib/quiz/quiz-answers-redis.ts`:
- Around line 289-291: The current getAttemptReviewCacheKey(attemptId: string,
userId: string | undefined, locale: string) builds a key containing the literal
"undefined" when userId is missing; change the function to return null (or
undefined) instead of a string when userId is not provided and update all
callers to check the return value and skip caching/lookup when null. Concretely:
modify getAttemptReviewCacheKey to early-return null if userId is falsy, change
its return type to string | null, and ensure callers that call
getAttemptReviewCacheKey(...) guard the result (if key == null -> bypass cache
logic) so unauthenticated users do not share cached review data.
- Around line 293-325: Remove the dead Redis-backed cache functions
getCachedAttemptReview and cacheAttemptReview (and their direct exports) since
the codebase uses the private Map-based cache versions; delete these two
functions and any now-unused constants that only served them (verify
ATTEMPT_REVIEW_TTL if unused), then run a quick search to confirm there are no
remaining imports or references and ensure the project builds.

In `@frontend/lib/services/orders/monobank-janitor.ts`:
- Around line 752-773: The current flow calls
atomicCancelOrderAndFailCreatingAttempt(...) then restockOrder(...), which can
leave inventory leaked if restockOrder throws because the order is already
CANCELED and won't be re-picked by the existing Job2 candidate query (o.status
not in ('PAID', 'CANCELED')); fix by either (A) making the cancel+restock
atomic: move both operations into one DB transaction/atomic operation so that
atomicCancelOrderAndFailCreatingAttempt and the subsequent restock are committed
together and the cancel is rolled back on restock failure, or (B) implement a
recovery sweep/job in monobank-janitor that queries CANCELED monobank orders
where stockRestored = false (or provider = 'monobank' and stockRestored = false)
and re-attempts restockOrder for them, ensuring idempotency and logging;
reference atomicCancelOrderAndFailCreatingAttempt, restockOrder, the Job2
candidate status filter, and the stockRestored flag when adding the new recovery
job or changing transaction boundaries.
🧹 Nitpick comments (38)
frontend/components/quiz/QuizQuestion.tsx (1)

39-39: Inconsistent indentation on nextButtonRef declaration.

Line 39 has extra leading whitespace compared to the rest of the component body (e.g., lines 33–35, 41). This looks like an accidental formatting issue.

🔧 Fix indentation
-    const nextButtonRef = useRef<HTMLButtonElement>(null);
+  const nextButtonRef = useRef<HTMLButtonElement>(null);
frontend/components/q&a/FloatingExplainButton.tsx (1)

75-78: Consider using a single translate for clarity.

The two separate transform functions (translate + translateY) compose correctly, but could be combined into a single translate3d or a single translate call for readability. Very minor.

-        transform:
-          placement === 'below'
-            ? 'translate(-50%, 0) translateY(8px)'
-            : 'translate(-50%, -100%) translateY(-8px)',
+        transform:
+          placement === 'below'
+            ? 'translate(-50%, 8px)'
+            : 'translate(-50%, calc(-100% - 8px))',
frontend/components/shared/Loader.tsx (1)

49-53: Pre-existing: particle array not cleared on effect re-run.

If size changes, the effect re-runs but particlesRef.current retains stale particles from the previous cycle. The warmup loop then pushes ~200 new particles on top. Consider resetting the array at the start of the effect:

     const particles = particlesRef.current;
+    particles.length = 0;
     const min = width * 0.5;

Not introduced by this PR, so deferring is fine.

frontend/db/seed-questions.ts (1)

112-127: Consider batching translation upserts instead of one DB call per locale.

Each translation triggers an individual INSERT … ON CONFLICT DO UPDATE round-trip. For a seed script this is tolerable, but a single batched upsert with a loop over translations would reduce DB round-trips and speed up seeding noticeably when the dataset grows.

♻️ Batch upsert approach
+    import { sql } from 'drizzle-orm';
+
     const insertedLocales: string[] = [];
-    for (const translation of translations) {
-      const [inserted] = await db
-        .insert(questionTranslations)
-        .values(translation)
-        .onConflictDoUpdate({
-          target: [questionTranslations.questionId, questionTranslations.locale],
-          set: {
-            question: translation.question,
-            answerBlocks: translation.answerBlocks,
-          },
-        })
-        .returning({ locale: questionTranslations.locale });
-      if (inserted?.locale) {
-        insertedLocales.push(inserted.locale);
-      }
-    }
+    const insertedRows = await db
+      .insert(questionTranslations)
+      .values(translations)
+      .onConflictDoUpdate({
+        target: [questionTranslations.questionId, questionTranslations.locale],
+        set: {
+          question: sql`excluded.question`,
+          answerBlocks: sql`excluded.answer_blocks`,
+        },
+      })
+      .returning({ locale: questionTranslations.locale });
+    const insertedLocales = insertedRows.map(r => r.locale);

Drizzle-orm 0.45.x fully supports this batch pattern with excluded references.

frontend/components/leaderboard/LeaderboardTable.tsx (1)

42-46: Colgroup widths duplicate existing <th> width classes.

Both the <colgroup> (lines 42–46) and the <th> elements (lines 50, 53, 56) declare the same widths (w-[15%], w-auto, w-[25%]). With table-fixed, column widths are resolved from <colgroup> first, making the <th> width classes redundant. Consider removing the w-* classes from the <th> elements to keep a single source of truth for column sizing.

frontend/db/queries/leaderboard.ts (2)

17-17: Repeated COALESCE(pt_valid.total, 0) expression — extract to a variable.

The same COALESCE(pt_valid.total, 0) SQL fragment appears in both the select (line 17) and orderBy (line 30). Extract it into a shared constant to keep them in sync and reduce duplication.

♻️ Proposed refactor
+    const totalPoints = sql<number>`COALESCE(pt_valid.total, 0)`;
+
     const dbUsers = await db
       .select({
         id: users.id,
         username: users.name,
         avatar: users.image,
-        points: sql<number>`COALESCE(pt_valid.total, 0)`,
+        points: totalPoints,
       })
       .from(users)
       .leftJoin(
         ...
       )
-      .orderBy(desc(sql`COALESCE(pt_valid.total, 0)`))
+      .orderBy(desc(totalPoints))
       .limit(50);

Also applies to: 30-30


22-26: IN (SELECT id FROM quiz_attempts) may be slow on large tables — acceptable given caching, but worth noting.

The uncorrelated IN (SELECT id FROM quiz_attempts) scans the entire quiz_attempts table. For the current 1-hour cache window this is fine, but if this pattern is reused elsewhere without caching, consider rewriting as EXISTS (SELECT 1 FROM quiz_attempts qa WHERE qa.id = pt.source_id) which can short-circuit, or ensure an index on quiz_attempts(id) (likely already the PK).

frontend/components/header/MainSwitcher.tsx (1)

9-51: Consider extracting shared segment parsing to reduce repetition.

Every is*Path helper repeats pathname.split('/').filter(Boolean). A small shared helper (e.g., getSegments) or a single function that parses once and returns a record of booleans would reduce duplication and the per-render allocation cost of repeated split/filter calls.

This is a minor DRY nit — no urgency.

frontend/package.json (1)

62-62: stripe is pinned to an exact version without a caret.

All other dependencies use ^ ranges, but stripe is pinned to 20.0.0 exactly. If this is intentional (e.g., to avoid breaking changes in the Stripe SDK), a brief comment in the codebase or PR description would help future maintainers understand the rationale.

frontend/components/header/MobileMenuContext.tsx (1)

76-76: Hardcoded locale list in regex — inconsistent with MainSwitcher.tsx.

The regex /(en|uk|pl)/ hardcodes locale values, while MainSwitcher.tsx (line 29) dynamically uses the imported locales array from @/i18n/config. If a locale is added or removed, this regex will silently break.

Consider building the regex dynamically:

Proposed fix
+import { locales } from '@/i18n/config';
 ...
-      const strippedPathname = pathname.replace(/^\/(en|uk|pl)/, '') || '/';
+      const localePattern = new RegExp(`^/(${locales.join('|')})`);
+      const strippedPathname = pathname.replace(localePattern, '') || '/';
frontend/app/globals.css (1)

280-292: Replace custom 3D utilities with Tailwind v4 built-ins to reduce custom CSS.

These custom utilities duplicate Tailwind CSS v4's built-in support:

  • backface-hidden class → use the built-in backface-hidden utility
  • perspective-1000 class → use perspective-[1000px] with arbitrary values
  • preserve-3d class → use the built-in transform-3d utility
frontend/components/home/WelcomeHeroBackground.tsx (1)

7-9: Inconsistent Tailwind v4 CSS variable syntax.

These lines use the v3-style bg-[var(--accent-primary)]/20 bracket notation, while other components in this PR (e.g., ProfileCard.tsx at lines 32, 39, 62) use the idiomatic v4 parentheses syntax bg-(--accent-primary)/20. Consider aligning for consistency.

♻️ Suggested diff
-        <div className="absolute top-[-10rem] left-[-10rem] h-[30rem] w-[30rem] rounded-full bg-[var(--accent-primary)]/20 blur-3xl" />
-        <div className="absolute bottom-[-12rem] left-1/4 h-[22rem] w-[22rem] rounded-full bg-[var(--accent-hover)]/15 blur-3xl" />
-        <div className="absolute right-0 bottom-[-10rem] h-[26rem] w-[26rem] rounded-full bg-[var(--accent-primary)]/25 blur-3xl" />
+        <div className="absolute top-[-10rem] left-[-10rem] h-[30rem] w-[30rem] rounded-full bg-(--accent-primary)/20 blur-3xl" />
+        <div className="absolute bottom-[-12rem] left-1/4 h-[22rem] w-[22rem] rounded-full bg-(--accent-hover)/15 blur-3xl" />
+        <div className="absolute right-0 bottom-[-10rem] h-[26rem] w-[26rem] rounded-full bg-(--accent-primary)/25 blur-3xl" />

The same applies to the star-dot elements on lines 13–17.

frontend/lib/services/orders/monobank-events-claim.ts (1)

33-38: Consider adding a brief doc comment to readRows.

This helper exists to handle drizzle-orm's varying result shapes (raw array vs. { rows: [...] }). A short JSDoc would help future maintainers understand why this normalization is needed, since it's not immediately obvious.

frontend/components/home/HomePageScroll.tsx (1)

20-29: Cache the second-step element reference instead of querying the DOM on every scroll event.

querySelectorAll('[data-home-step]') runs on every scroll and resize event. Since the step elements don't change after mount, cache the reference once and reuse it.

♻️ Suggested refactor
   useEffect(() => {
     const container = containerRef.current;
     if (!container) return;
 
+    const secondStep = container.querySelectorAll<HTMLElement>('[data-home-step]')[1];
+    if (!secondStep) return;
+
     const updateSnapMode = () => {
-      const steps = container.querySelectorAll<HTMLElement>('[data-home-step]');
-      const secondStep = steps[1];
-      if (!secondStep) return;
-
       const shouldEnableSnap = container.scrollTop <= secondStep.offsetTop + 4;
       setSnapEnabled(prev =>
         prev === shouldEnableSnap ? prev : shouldEnableSnap
       );
     };
frontend/components/quiz/QuizResult.tsx (1)

170-186: Navigation via window.location.href causes full page reloads.

Lines 173–174, 181, and 196 all use window.location.href for navigation. For the login/signup redirects this is likely intentional (auth flow may need a full reload), but the quiz-review link on line 196 navigates within the app and could benefit from Next.js client-side routing (e.g., useRouter().push(...)) for a smoother UX.

frontend/components/dashboard/ExplainedTermsCard.tsx (1)

99-108: setTermRef creates a new inner function per index per render.

setTermRef(index) returns a new closure each render, causing React to call the previous ref with null and the new one with the element on every re-render. This is functionally correct since the Map handles set/delete, but could cause unnecessary ref churn during re-renders if the list is long.

Consider caching per-index callbacks in a map to avoid re-creating them:

♻️ Optional: cache per-index ref callbacks
-  const setTermRef = useCallback(
-    (index: number) => (el: HTMLDivElement | null) => {
-      if (el) {
-        termRefs.current.set(index, el);
-      } else {
-        termRefs.current.delete(index);
-      }
-    },
-    []
-  );
+  const termRefCallbacks = useRef(new Map<number, (el: HTMLDivElement | null) => void>());
+
+  const setTermRef = useCallback((index: number) => {
+    let cb = termRefCallbacks.current.get(index);
+    if (!cb) {
+      cb = (el: HTMLDivElement | null) => {
+        if (el) termRefs.current.set(index, el);
+        else termRefs.current.delete(index);
+      };
+      termRefCallbacks.current.set(index, cb);
+    }
+    return cb;
+  }, []);
frontend/lib/services/orders/monobank-webhook.ts (1)

1008-1057: Return value may diverge from persisted state after restock failure.

When restock fails, appliedResult in the DB is overwritten to 'applied_with_issue' (line 1033), but the function still returns the original appliedResult (line 1056). Callers (both applyMonoWebhookEvent and applyStoredMonobankEvent) will propagate the original value (e.g., 'applied') while the DB now says 'applied_with_issue'.

If this is intentional (webhook succeeded, restock is a secondary side-effect), consider adding a brief comment at line 1056. If not, return the updated result:

♻️ Option: return the actual persisted result
-  return appliedResult;
+  return (restockReason && restockOrderId)
+    ? 'applied_with_issue' as ApplyResult
+    : appliedResult;

Or, if the current behavior is intentional, a clarifying comment would help:

+  // Return the original outcome — restock failure is persisted in the DB
+  // but does not change the caller-visible result.
   return appliedResult;
frontend/components/home/InteractiveConstellation.tsx (1)

169-298: O(n²) work per frame is acceptable at this scale but cache Date.now().

The nested loops for inter-particle repulsion (lines 210-226) and connection drawing (lines 270-297) run O(n²) per frame. With the density-based particle count (~20-50), this is fine for performance.

Minor: Date.now() is called per-particle at line 181 inside the loop. Caching it once at the top of draw() would give consistent pulse timing across all particles within a single frame:

 const draw = () => {
   if (!ctx || !canvas) return;
   ctx.clearRect(0, 0, canvas.width, canvas.height);
+  const now = Date.now();
   // ...
   particles.forEach((p, i) => {
-    const pulse = Math.sin((Date.now() * 0.002) + p.rotation * 5) * 0.5 + 0.5;
+    const pulse = Math.sin((now * 0.002) + p.rotation * 5) * 0.5 + 0.5;
frontend/components/home/InteractiveCTAButton.tsx (2)

86-92: Manual ref merging works but consider a helper for clarity.

The inline ref callback manually handles both function and object ref types. This is correct, but directly assigning to forwardedRef.current is technically a mutation of a read-only ref in React 19's type system (though it works at runtime).

If this pattern appears elsewhere, a small mergeRefs utility would reduce repetition and handle edge cases (e.g., null forwarded ref):

function mergeRefs<T>(...refs: (React.Ref<T> | null)[]) {
  return (node: T | null) => {
    refs.forEach(ref => {
      if (typeof ref === 'function') ref(node);
      else if (ref) (ref as React.MutableRefObject<T | null>).current = node;
    });
  };
}

76-83: Consider requestAnimationFrame instead of setInterval for gradient rotation.

Using setInterval(…, 16) approximates 60fps but doesn't sync with the browser's paint cycle. requestAnimationFrame would be more efficient and produce smoother results, especially if the tab is backgrounded (rAF pauses, setInterval doesn't).

♻️ Optional rAF replacement
     useEffect(() => {
         if (isHovered) {
-             const interval = setInterval(() => {
-                 rotate.set((rotate.get() + 2) % 360);
-             }, 16);
-             return () => clearInterval(interval);
+             let frameId: number;
+             const tick = () => {
+                 rotate.set((rotate.get() + 2) % 360);
+                 frameId = requestAnimationFrame(tick);
+             };
+             frameId = requestAnimationFrame(tick);
+             return () => cancelAnimationFrame(frameId);
         }
     }, [isHovered, rotate]);
frontend/messages/uk.json (1)

674-696: Product descriptions are hardcoded in the translation file.

These are substantial marketing copy blocks embedded directly in the locale JSON. This is fine for now, but if product descriptions are expected to grow or be managed by non-developers, consider moving them to a CMS or a separate data source in the future.

frontend/components/quiz/QuizCard.tsx (2)

47-56: Consider extracting getStatusBadge as a shared utility.

The status badge logic here (lines 47-54) duplicates the getStatus function in QuizResultRow.tsx (lines 41-44) with the same thresholds (100% → success, ≥70% → warning, else danger). Consider extracting a shared helper to keep these in sync.


84-89: Indentation mismatch in the status badge JSX block.

The opening {statusBadge && ( at line 84 is indented at 10 spaces, but the closing )} at line 89 is at 8 spaces. This doesn't affect functionality but breaks visual alignment with the surrounding JSX.

🔧 Suggested fix
-          {statusBadge && (
-          <Badge variant={statusBadge.variant} className="gap-1.5 rounded-full">
-            <span className={`h-1.5 w-1.5 rounded-full ${statusBadge.dot}`} />
-            {statusBadge.label}
-          </Badge>
-        )}
+          {statusBadge && (
+            <Badge variant={statusBadge.variant} className="gap-1.5 rounded-full">
+              <span className={`h-1.5 w-1.5 rounded-full ${statusBadge.dot}`} />
+              {statusBadge.label}
+            </Badge>
+          )}
frontend/app/[locale]/dashboard/quiz-review/[attemptId]/page.tsx (1)

129-137: The cn() call with 'iconClassName' in categoryStyle may pass unexpected values.

On line 135, the expression 'iconClassName' in categoryStyle && categoryStyle.iconClassName will pass a string | undefined to cn() when the property exists. If iconClassName is undefined, cn (via clsx) will ignore it, so this is safe in practice. However, using optional chaining would be cleaner:

♻️ Suggested simplification
-                className={cn('object-contain', 'iconClassName' in categoryStyle && categoryStyle.iconClassName)}
+                className={cn('object-contain', categoryStyle.iconClassName)}
frontend/components/dashboard/QuizResultRow.tsx (1)

90-96: Consider using a <Link> instead of a div with role="link" for better semantics and accessibility.

The current approach manually implements keyboard navigation (onKeyDown with Enter) and uses role="link" on a div. A <Link> from @/i18n/routing (or a wrapping <a>) would provide native link behavior (middle-click to open in new tab, right-click context menu, etc.) for free.

This is fine for now and works correctly, but worth considering for a follow-up.

frontend/components/q&a/Pagination.tsx (1)

110-123: Minor mismatch between disabled condition and visual styling for the Previous button.

Line 112 disables the button when currentPage <= 1, but line 116 applies the disabled visual style only when currentPage === 1. The Next button (lines 165, 169) consistently uses currentPage >= effectiveTotalPages for both. Align them for consistency:

♻️ Suggested fix
-            currentPage === 1
+            currentPage <= 1
frontend/messages/en.json (1)

674-695: Product descriptions are hardcoded in the translation file.

These multi-line descriptions are product-catalog content rather than UI chrome. Embedding them in translation JSON works for i18n but makes content updates coupled to code deployments. This is acceptable for a v1.0 release but worth noting for future CMS or database migration.

frontend/app/[locale]/dashboard/page.tsx (1)

49-50: Consider parallelizing independent database queries.

getUserQuizStats and getUserLastAttemptPerQuiz are independent and could run concurrently to reduce page load time:

♻️ Suggested refactor
-  const attempts = await getUserQuizStats(session.id);
-  const lastAttempts = await getUserLastAttemptPerQuiz(session.id, locale);
+  const [attempts, lastAttempts] = await Promise.all([
+    getUserQuizStats(session.id),
+    getUserLastAttemptPerQuiz(session.id, locale),
+  ]);
frontend/app/[locale]/shop/products/[slug]/page.tsx (2)

137-148: Extract description rendering from the IIFE for readability.

The inline IIFE in JSX adds cognitive overhead. A simple variable extraction before the return would be cleaner and is consistent with how badgeLabel is already handled above.

♻️ Suggested refactor

Move this above the return statement (e.g., after line 70):

+  const description =
+    (productDescriptions[slug] as string) || product.description || null;

Then in JSX, replace lines 137–148 with:

-          {(() => {
-            const desc =
-              (productDescriptions[slug] as string) || product.description;
-            if (!desc) return null;
-            return (
-              <div className="text-muted-foreground mt-6 space-y-2">
-                {desc.split('\n').map((line: string, i: number) => (
-                  <p key={i}>{line}</p>
-                ))}
-              </div>
-            );
-          })()}
+          {description && (
+            <div className="text-muted-foreground mt-6 space-y-2">
+              {description.split('\n').map((line: string, i: number) => (
+                <p key={i}>{line}</p>
+              ))}
+            </div>
+          )}

57-59: Pervasive as any casts reduce type safety.

Lines 42, 45, 49, and now 59 all use as any. This is an existing pattern in the file, but the new (messages as any).shop?.productDescriptions is adding to it. Consider typing the messages properly using next-intl's Messages type or at least adding a minimal type narrowing utility to avoid silent breakage if translation keys are restructured.

frontend/lib/tests/shop/monobank-events-claim.test.ts (1)

48-65: Stale hardcoded cleanup keys — consider removing.

Lines 53–54 and 63–64 clean up rows matching 'test:event_key:1' and 'test:raw_sha256:1', but these values are never inserted in this test file. They appear to be leftovers from another test or earlier iteration. Removing them would reduce cleanup scope to only what this suite owns.

🧹 Remove unused cleanup predicates
     await db.execute(sql`
       delete from monobank_events
       where event_key like ${`${EVENT_KEY_PREFIX}:%`}
          or raw_sha256 like ${`${EVENT_KEY_PREFIX}:%`}
-         or event_key = 'test:event_key:1'
-         or raw_sha256 = 'test:raw_sha256:1'
     `);

Apply the same change in both beforeEach and afterEach.

frontend/components/home/FloatingCode.tsx (1)

163-186: displayedCode.split('\n') computed twice per render.

Lines 165 and 176 both call displayedCode.split('\n'). During the typing animation, re-renders are frequent. Extract the split result into a local variable.

♻️ Extract split result
       <pre className="font-mono text-[11px] leading-relaxed text-gray-600 dark:text-gray-400">
         <code>
-          {displayedCode.split('\n').map((line, i) => (
+          {(() => {
+            const lines = displayedCode.split('\n');
+            return lines.map((line, i) => (
             <div key={i} className="flex min-h-[1.5em]">
               <span className="mr-3 w-3 select-none text-right opacity-30 text-xs">{i + 1}</span>
               <span 
                 style={{ 
                   color: i === 0 ? snippet.color : 'inherit',
                   textShadow: i === 0 ? `0 0 10px ${snippet.color}40` : 'none',
                   fontWeight: i === 0 ? 600 : 400,
                 }}
               >
                 {line}
-                {i === displayedCode.split('\n').length - 1 && (
+                {i === lines.length - 1 && (
                   <motion.span
                     animate={{ opacity: [1, 0, 1] }}
                     transition={{ duration: 0.8, repeat: Infinity }}
                     className="ml-0.5 inline-block h-3 w-1.5 align-middle bg-current opacity-70"
                   />
                 )}
               </span>
             </div>
-          ))}
+            ));
+          })()}
         </code>
       </pre>

Or more cleanly, extract lines as a const before the JSX return.

frontend/components/home/FeaturesHeroSection.tsx (1)

36-60: Consider extracting the repeated badge markup into a small helper.

The three badge blocks (blue, purple, emerald) share identical DOM structure — only the color tokens, icon, and translation key differ. Extracting a FeatureBadge component (or an inline map) would reduce ~70 lines of near-duplicate JSX to ~15.

♻️ Example extraction
const badges = [
  { icon: MessageCircleQuestion, color: 'blue', labelKey: 'featureBadges.smartQA' },
  { icon: BrainCircuit, color: 'purple', labelKey: 'featureBadges.adaptiveQuizzes' },
  { icon: TrendingUp, color: 'emerald', labelKey: 'featureBadges.performance' },
] as const;

// Then map inside JSX:
{badges.map(({ icon: Icon, color, labelKey }) => (
  <div key={labelKey} className={`group relative overflow-hidden rounded-full border border-${color}-200/60 ...`}>
    {/* ... */}
    <Icon className="h-4 w-4" />
    <span className="whitespace-nowrap">{t(labelKey)}</span>
  </div>
))}

Note: Tailwind doesn't support dynamic class construction by default (e.g., border-${color}-200/60). You'd need to use a lookup map of full class strings per color, or use @source inline(...) to safelist the dynamic classes.

frontend/lib/tests/shop/monobank-janitor-job3.test.ts (2)

39-51: as any assertions suppress type checking on DB inserts.

Lines 51 and 65 use as any on the .values(...) argument. While common in test helpers, this means schema changes (e.g., new required columns) won't produce compile-time errors in tests. Consider typing these more strictly if the schema is still evolving, or add a comment explaining the intent.

Also applies to: 53-65


298-302: Roundabout Date conversion — consider direct .getTime() call.

new Date(String(processing?.appliedAt)).getTime() is defensive but fragile if the string format isn't ISO 8601. If drizzle returns Date objects for timestamp columns, processing?.appliedAt?.getTime() is simpler and safer.

♻️ Simplify timestamp comparison
-      const processingAt = new Date(String(processing?.appliedAt)).getTime();
-      const successAt = new Date(String(success?.appliedAt)).getTime();
+      const processingAt = processing!.appliedAt!.getTime();
+      const successAt = success!.appliedAt!.getTime();
       expect(processingAt).toBeLessThanOrEqual(successAt);

The non-null assertions are safe here since lines 297–298 already assert these values are truthy.

frontend/app/api/shop/internal/monobank/janitor/route.ts (2)

304-407: Consider extracting shared response construction for job1–job3.

The response-building logic for job1, job2, and job3 is identical — only job4 adds a report field. This could be consolidated into a helper to reduce the ~75 lines of duplication.

♻️ Suggested refactor
+function buildJobSuccessPayload(
+  job: JobName,
+  result: { processed: number; applied: number; noop: number; failed: number; report?: unknown },
+  opts: { dryRun: boolean; limit: number; requestId: string }
+) {
+  return {
+    success: true,
+    job,
+    dryRun: opts.dryRun,
+    limit: opts.limit,
+    processed: result.processed,
+    applied: result.applied,
+    noop: result.noop,
+    failed: result.failed,
+    ...(result.report !== undefined ? { report: result.report } : {}),
+    requestId: opts.requestId,
+  };
+}

Then each job branch becomes:

const result = await runMonobankJanitorJob1({ dryRun, limit, requestId, runId, baseMeta });
return noStoreJson(
  buildJobSuccessPayload(job, result, { dryRun, limit, requestId }),
  requestId,
  { status: 200 }
);

431-443: MONO_WEBHOOK_MODE_NOT_STORE branch silently returns 409 — consider adding a log.

Unlike the generic error path which calls logError, this branch returns a 409 without any log entry. For operational visibility, a logWarn here would help track how often this condition occurs.

frontend/lib/services/orders/monobank-janitor.ts (1)

343-356: Job3 claims events sequentially — O(limit) DB round-trips.

claimJob3Events makes one claimNextMonobankEvent call per iteration up to limit (max 500). For a background janitor this is acceptable, but if throughput becomes a concern, consider batch-claiming similar to the Job1/Job2 CTE approach.

Comment on lines +44 to +55
{SITE_LINKS.filter(
link => link.href !== '/shop' && link.href !== '/blog'
).map(link => (
<NavLink key={link.href} href={link.href}>
{t(link.labelKey)}
</NavLink>
))}
</div>

<HeaderButton href="/blog" icon={BookOpen} showArrow>
{t('blog')}
</HeaderButton>
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

rg -n "startNavigation" -C 3 --type=ts --type=tsx | head -80

Repository: DevLoversTeam/devlovers.net

Length of output: 98


🏁 Script executed:

# Check how blog variant/route triggers are handled
rg -n "variant.*blog|blog.*variant" -C 2 --type=ts --type=tsx | head -60

Repository: DevLoversTeam/devlovers.net

Length of output: 98


🏁 Script executed:

# Check the useMobileMenu context and startNavigation definition
fd -e tsx -e ts | xargs rg -l "useMobileMenu" | head -5

Repository: DevLoversTeam/devlovers.net

Length of output: 285


🏁 Script executed:

rg "startNavigation" -C 3 | head -80

Repository: DevLoversTeam/devlovers.net

Length of output: 5371


🏁 Script executed:

cat -n frontend/components/header/DesktopNav.tsx

Repository: DevLoversTeam/devlovers.net

Length of output: 2306


🏁 Script executed:

rg "variant.*blog" -C 2 | head -40

Repository: DevLoversTeam/devlovers.net

Length of output: 2444


Blog HeaderButton should use startNavigation handler to close mobile menu, consistent with Shop button.

The Blog button at line 53 navigates to /blog via standard href but lacks the onLinkClick handler used by the Shop button. The Shop button calls startNavigation('/shop') to close the mobile menu during navigation, but the Blog button has no equivalent handler. Both buttons serve identical roles in the platform variant—routing to variant-specific layouts—so they should consistently manage mobile menu state.

Add a handleBlogClick handler and apply it to the Blog HeaderButton:

const handleBlogClick = (e: React.MouseEvent<HTMLAnchorElement>) => {
  e.preventDefault();
  startNavigation('/blog');
};

Then update line 53:

<HeaderButton href="/blog" icon={BookOpen} showArrow onLinkClick={handleBlogClick}>
🤖 Prompt for AI Agents
In `@frontend/components/header/DesktopNav.tsx` around lines 44 - 55, Add a click
handler for the Blog HeaderButton that mirrors the Shop behavior: create a
handleBlogClick function that accepts (e: React.MouseEvent<HTMLAnchorElement>),
calls e.preventDefault(), then calls startNavigation('/blog'); then pass that
handler into the Blog HeaderButton via the onLinkClick prop on the existing
<HeaderButton href="/blog" icon={BookOpen} showArrow ...> so clicking Blog
closes the mobile menu consistently (same pattern used for the Shop button).

const [isMobile, setIsMobile] = useState(false);
const [progress, setProgress] = useState(0);
const [isPaused, setIsPaused] = useState(false);
const shouldReduceMotion = useReducedMotion();
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

shouldReduceMotion is fetched but never consumed.

useReducedMotion() is called and the result stored, but no animation logic branches on it. Users with prefers-reduced-motion will still see the full 3D tilt, flip, and auto-advance animations. At minimum, consider disabling auto-advance and tilt when reduced motion is preferred.

🤖 Prompt for AI Agents
In `@frontend/components/home/FlipCardQA.tsx` at line 40, The shouldReduceMotion
value from useReducedMotion() is collected but not used; update FlipCardQA to
respect it by gating motion features: when shouldReduceMotion is true, disable
the auto-advance interval (where start/clearInterval or useEffect sets
rotation/autoplay), stop any 3D tilt/pointer-move handlers (e.g. onPointerMove /
Tilt component / handlePointerMove), and avoid animated flip transitions (bypass
react-spring/useSpring or replace animated class toggles with instant state
changes). Locate the shouldReduceMotion variable in FlipCardQA and add
conditional branches to skip starting timers, skip attaching tilt handlers or
pass a reducedMotion prop to child Tilt/Flip components, and change flip logic
to an immediate state toggle without animation.

Comment on lines +96 to +138
useEffect(() => {
const duration = 8000;
const interval = 50;
let elapsed = 0;

const timer = setInterval(() => {
if (isFlippedRef.current || isPausedRef.current) {
elapsed = 0;
setProgress(0);
return;
}

elapsed += interval;
const newProgress = (elapsed / duration) * 100;

if (newProgress >= 100) {
setIsFlipped(true);
elapsed = 0;

if (nextCardTimerRef.current) clearTimeout(nextCardTimerRef.current);
if (resetTimerRef.current) clearTimeout(resetTimerRef.current);

nextCardTimerRef.current = setTimeout(() => {
setCurrentIndex((prev) => (prev + 1) % questions.length);
}, 300);

resetTimerRef.current = setTimeout(() => {
setIsFlipped(false);
setProgress(0);
}, 600);
} else {
setProgress(newProgress);
}
}, interval);

flipTimerRef.current = timer;

return () => {
clearInterval(timer);
if (nextCardTimerRef.current) clearTimeout(nextCardTimerRef.current);
if (resetTimerRef.current) clearTimeout(resetTimerRef.current);
};
}, [questions.length]); // Only depend on props/stable values
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Auto-advance sequence briefly exposes the next question's answer.

The timer at 300ms updates currentIndex while the card is still flipped, then at 600ms unflips it. Between 300–600ms, the answer side shows the new question's answer. Depending on the spring animation timing, this may or may not be perceptible, but the state transition order is logically inverted.

A safer approach would be to unflip first, then advance the index after the unflip animation completes:

♻️ Suggested fix
-        nextCardTimerRef.current = setTimeout(() => {
-          setCurrentIndex((prev) => (prev + 1) % questions.length);
-        }, 300);
-
-        resetTimerRef.current = setTimeout(() => {
-          setIsFlipped(false);
-          setProgress(0);
-        }, 600);
+        resetTimerRef.current = setTimeout(() => {
+          setIsFlipped(false);
+          setProgress(0);
+        }, 300);
+
+        nextCardTimerRef.current = setTimeout(() => {
+          setCurrentIndex((prev) => (prev + 1) % questions.length);
+        }, 600);
📝 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.

Suggested change
useEffect(() => {
const duration = 8000;
const interval = 50;
let elapsed = 0;
const timer = setInterval(() => {
if (isFlippedRef.current || isPausedRef.current) {
elapsed = 0;
setProgress(0);
return;
}
elapsed += interval;
const newProgress = (elapsed / duration) * 100;
if (newProgress >= 100) {
setIsFlipped(true);
elapsed = 0;
if (nextCardTimerRef.current) clearTimeout(nextCardTimerRef.current);
if (resetTimerRef.current) clearTimeout(resetTimerRef.current);
nextCardTimerRef.current = setTimeout(() => {
setCurrentIndex((prev) => (prev + 1) % questions.length);
}, 300);
resetTimerRef.current = setTimeout(() => {
setIsFlipped(false);
setProgress(0);
}, 600);
} else {
setProgress(newProgress);
}
}, interval);
flipTimerRef.current = timer;
return () => {
clearInterval(timer);
if (nextCardTimerRef.current) clearTimeout(nextCardTimerRef.current);
if (resetTimerRef.current) clearTimeout(resetTimerRef.current);
};
}, [questions.length]); // Only depend on props/stable values
useEffect(() => {
const duration = 8000;
const interval = 50;
let elapsed = 0;
const timer = setInterval(() => {
if (isFlippedRef.current || isPausedRef.current) {
elapsed = 0;
setProgress(0);
return;
}
elapsed += interval;
const newProgress = (elapsed / duration) * 100;
if (newProgress >= 100) {
setIsFlipped(true);
elapsed = 0;
if (nextCardTimerRef.current) clearTimeout(nextCardTimerRef.current);
if (resetTimerRef.current) clearTimeout(resetTimerRef.current);
resetTimerRef.current = setTimeout(() => {
setIsFlipped(false);
setProgress(0);
}, 300);
nextCardTimerRef.current = setTimeout(() => {
setCurrentIndex((prev) => (prev + 1) % questions.length);
}, 600);
} else {
setProgress(newProgress);
}
}, interval);
flipTimerRef.current = timer;
return () => {
clearInterval(timer);
if (nextCardTimerRef.current) clearTimeout(nextCardTimerRef.current);
if (resetTimerRef.current) clearTimeout(resetTimerRef.current);
};
}, [questions.length]); // Only depend on props/stable values

key={index}
onClick={() => handleNavigate(index)}
className="relative focus:outline-none focus:ring-2 focus:ring-[var(--accent-primary)] focus:ring-offset-2"
aria-label={`Go to question ${index + 1}`}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Hardcoded English in aria-label — not internationalized.

The string "Go to question ${index + 1}" should use a translation key for consistency with the rest of the component's i18n approach.

🤖 Prompt for AI Agents
In `@frontend/components/home/FlipCardQA.tsx` at line 347, The aria-label prop
currently uses a hardcoded English string ("Go to question ${index + 1}") which
breaks i18n; replace this literal with a translated string from your i18n system
(e.g., use the existing t function or translation hook used in FlipCardQA.tsx)
and pass the question index as a variable to the translation key (update the
aria-label on the element that sets aria-label={`Go to question ${index + 1}`}
to something like t('yourKey', { number: index + 1 }) so it uses the translation
key and preserves accessibility).

Comment on lines +146 to +161
<motion.div
className="relative overflow-hidden rounded-lg border bg-white/80 p-3 backdrop-blur-md dark:bg-gray-950/60"
style={{
borderColor: `${snippet.color}30`,
boxShadow: `0 8px 32px -4px ${snippet.color}15`,
}}
animate={{
y: [0, -12, 0],
rotate: [snippet.rotate, snippet.rotate + 2, snippet.rotate],
}}
transition={{
duration: 5 + Math.random() * 3,
repeat: Infinity,
ease: 'easeInOut',
delay: snippet.delay,
}}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Math.random() in render path causes animation jitter on every re-render.

Line 157 calls Math.random() inside the component body, so each re-render (triggered by the typing state updates) produces a new duration value. Framer-motion may restart or adjust the infinite bob animation mid-cycle, causing visible jitter. Move the random duration into a stable ref or memo.

🔧 Stabilize animation duration

Compute the random duration once per snippet, for example via useMemo or useRef:

 function CodeBlock({ snippet }: { snippet: CodeSnippet }) {
   const [displayedCode, setDisplayedCode] = useState('');
   const [isTyping, setIsTyping] = useState(false);
+  const bobDuration = useRef(5 + Math.random() * 3).current;

   // Refs for cleanup

Then use it on line 157:

         transition={{
-          duration: 5 + Math.random() * 3, 
+          duration: bobDuration,
           repeat: Infinity,
🤖 Prompt for AI Agents
In `@frontend/components/home/FloatingCode.tsx` around lines 146 - 161, The jitter
is caused by calling Math.random() directly in the render for the Framer Motion
transition.duration inside the FloatingCode component's motion.div; compute the
duration once per snippet (e.g., in a useRef or useMemo tied to the snippet
identity) and replace the inline Math.random() expression with that stable value
so transition.duration uses the persisted duration instead of a new random value
on every re-render.

Comment on lines +62 to +73
const sortOrder = q.order ?? 0;

const [existingQuestion] = await db
.select({ id: questions.id })
.from(questions)
.where(
and(
eq(questions.categoryId, category.id),
eq(questions.sortOrder, sortOrder)
)
)
.limit(1);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

A question with no translations is still persisted in the DB.

Lines 62–85 insert (or reuse) the question row before checking whether translations exist (line 104). If q.translations is empty, the script logs a warning and continues, leaving an orphan question with no translations. This is fine if you intend questions to exist independently, but if a question without translations is meaningless, consider checking for translations before inserting the question.

Also applies to: 104-108

🤖 Prompt for AI Agents
In `@frontend/db/seed-questions.ts` around lines 62 - 73, The seed script
currently creates or queries the question row (using sortOrder,
existingQuestion, and the questions table) before verifying that q.translations
exists; move the check for q.translations (and the early-warning/continue logic)
to before any DB insert/select for the question so you never create an orphan
question without translations, and apply the same change to the later block that
also creates questions (the other insert/reuse code around the translations
handling) so both paths validate q.translations first.

Comment on lines +75 to +85
const questionId =
existingQuestion?.id ??
(
await db
.insert(questions)
.values({
categoryId: category.id,
sortOrder,
})
.returning({ id: questions.id })
)[0]!.id;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Non-null assertion [0]!.id could crash if returning() yields an empty array.

If the insert unexpectedly returns no rows, this will throw a runtime error with an unhelpful message. A small guard would make debugging easier.

🛡️ Proposed defensive fix
-    const questionId =
-      existingQuestion?.id ??
-      (
-        await db
-          .insert(questions)
-          .values({
-            categoryId: category.id,
-            sortOrder,
-          })
-          .returning({ id: questions.id })
-      )[0]!.id;
+    let questionId = existingQuestion?.id;
+    if (!questionId) {
+      const [inserted] = await db
+        .insert(questions)
+        .values({
+          categoryId: category.id,
+          sortOrder,
+        })
+        .returning({ id: questions.id });
+      if (!inserted) {
+        console.error(
+          `[seed] Failed to insert question for category ${q.category}, sortOrder ${sortOrder}`
+        );
+        continue;
+      }
+      questionId = inserted.id;
+    }
📝 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.

Suggested change
const questionId =
existingQuestion?.id ??
(
await db
.insert(questions)
.values({
categoryId: category.id,
sortOrder,
})
.returning({ id: questions.id })
)[0]!.id;
let questionId = existingQuestion?.id;
if (!questionId) {
const [inserted] = await db
.insert(questions)
.values({
categoryId: category.id,
sortOrder,
})
.returning({ id: questions.id });
if (!inserted) {
console.error(
`[seed] Failed to insert question for category ${q.category}, sortOrder ${sortOrder}`
);
continue;
}
questionId = inserted.id;
}
🤖 Prompt for AI Agents
In `@frontend/db/seed-questions.ts` around lines 75 - 85, The current construction
that computes questionId uses a non-null assertion on the first element of the
array returned by db.insert(...).returning(...) which can throw if the DB
returns no rows; update the code around questionId/existingQuestion to capture
the result of the insert into a variable (e.g., insertResult = await
db.insert(questions).values({...}).returning({id: questions.id})), check
insertResult.length (or insertResult[0]) and handle the empty case with a clear
error or fallback before accessing .id, then use insertResult[0].id when
present; reference the questionId variable, existingQuestion, and the
db.insert(...).returning(...) call when making the change.

Comment on lines +289 to +291
function getAttemptReviewCacheKey(attemptId: string, userId: string | undefined, locale: string): string {
return `quiz:attempt-review:${attemptId}:${userId}:${locale}`;
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Cache key includes literal "undefined" when userId is absent.

When userId is undefined, template interpolation produces the key quiz:attempt-review:<attemptId>:undefined:<locale>. This means all unauthenticated users share one cache entry per attempt+locale, potentially serving one user's review data to another. Either reject undefined early (return null / skip caching) or require userId to be a non-optional string.

Proposed fix
-function getAttemptReviewCacheKey(attemptId: string, userId: string | undefined, locale: string): string {
-  return `quiz:attempt-review:${attemptId}:${userId}:${locale}`;
-}
+function getAttemptReviewCacheKey(attemptId: string, userId: string, locale: string): string {
+  return `quiz:attempt-review:${attemptId}:${userId}:${locale}`;
+}

And guard callers:

 export async function getCachedAttemptReview(
   attemptId: string,
-  userId: string | undefined,
+  userId: string | undefined,
   locale: string
 ): Promise<AttemptReview | null> {
   const redis = getRedisClient();
-  if (!redis) return null;
+  if (!redis || !userId) return null;
 export async function cacheAttemptReview(
   attemptId: string,
-  userId: string | undefined,
+  userId: string | undefined,
   locale: string,
   data: AttemptReview
 ): Promise<void> {
   const redis = getRedisClient();
-  if (!redis) return;
+  if (!redis || !userId) return;
🤖 Prompt for AI Agents
In `@frontend/lib/quiz/quiz-answers-redis.ts` around lines 289 - 291, The current
getAttemptReviewCacheKey(attemptId: string, userId: string | undefined, locale:
string) builds a key containing the literal "undefined" when userId is missing;
change the function to return null (or undefined) instead of a string when
userId is not provided and update all callers to check the return value and skip
caching/lookup when null. Concretely: modify getAttemptReviewCacheKey to
early-return null if userId is falsy, change its return type to string | null,
and ensure callers that call getAttemptReviewCacheKey(...) guard the result (if
key == null -> bypass cache logic) so unauthenticated users do not share cached
review data.

Comment on lines +293 to +325
export async function getCachedAttemptReview(
attemptId: string,
userId: string | undefined,
locale: string
): Promise<AttemptReview | null> {
const redis = getRedisClient();
if (!redis) return null;

try {
return await redis.get<AttemptReview>(getAttemptReviewCacheKey(attemptId, userId, locale));
} catch (err) {
console.warn('Redis attempt review cache read failed:', err);
return null;
}
}

export async function cacheAttemptReview(
attemptId: string,
userId: string | undefined,
locale: string,
data: AttemptReview
): Promise<void> {
const redis = getRedisClient();
if (!redis) return;

try {
await redis.set(getAttemptReviewCacheKey(attemptId, userId, locale), data, {
ex: ATTEMPT_REVIEW_TTL,
});
} catch (err) {
console.warn('Redis attempt review cache write failed:', err);
}
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Check which files import getCachedAttemptReview or cacheAttemptReview and from where
rg -n --type=ts "getCachedAttemptReview|cacheAttemptReview" -g '!node_modules'

Repository: DevLoversTeam/devlovers.net

Length of output: 680


🏁 Script executed:

#!/bin/bash
# Look at the structure of quiz.ts to find the in-memory cache functions
rg -n "getCachedAttemptReview|cacheAttemptReview" frontend/db/queries/quiz.ts -A 5 -B 2

Repository: DevLoversTeam/devlovers.net

Length of output: 1105


🏁 Script executed:

#!/bin/bash
# Confirm the Redis implementations exist
rg -n "export.*function.*getCachedAttemptReview|export.*function.*cacheAttemptReview" frontend/lib/quiz/quiz-answers-redis.ts -A 10

Repository: DevLoversTeam/devlovers.net

Length of output: 792


🏁 Script executed:

#!/bin/bash
# Check if quiz-answers-redis is imported anywhere
rg -n "from.*quiz-answers-redis|import.*quiz-answers-redis" --type=ts

Repository: DevLoversTeam/devlovers.net

Length of output: 459


🏁 Script executed:

#!/bin/bash
# Check for any imports or requires related to quiz-answers-redis
rg "quiz-answers-redis" --type=ts

Repository: DevLoversTeam/devlovers.net

Length of output: 544


🏁 Script executed:

#!/bin/bash
# Check specific imports from quiz-answers-redis in api route
sed -n '1,20p' frontend/app/api/quiz/verify-answer/route.ts | grep -A 5 "quiz-answers-redis"

Repository: DevLoversTeam/devlovers.net

Length of output: 261


🏁 Script executed:

#!/bin/bash
# Check the import in actions/quiz.ts
sed -n '230,250p' frontend/actions/quiz.ts

Repository: DevLoversTeam/devlovers.net

Length of output: 719


🏁 Script executed:

#!/bin/bash
# Check the full import statement from the api route
sed -n '1,15p' frontend/app/api/quiz/verify-answer/route.ts

Repository: DevLoversTeam/devlovers.net

Length of output: 467


Remove dead cache functions — getCachedAttemptReview and cacheAttemptReview are unexported from quiz-answers-redis.ts and never imported.

These two functions are exported from quiz-answers-redis.ts but have zero usages across the codebase. The actual caching is handled by private functions with identical names in frontend/db/queries/quiz.ts (lines 61, 69), which use an in-memory Map. The Redis-backed versions appear to be unused dead code and should be removed to eliminate the duplication and avoid future confusion about which cache layer is active.

🤖 Prompt for AI Agents
In `@frontend/lib/quiz/quiz-answers-redis.ts` around lines 293 - 325, Remove the
dead Redis-backed cache functions getCachedAttemptReview and cacheAttemptReview
(and their direct exports) since the codebase uses the private Map-based cache
versions; delete these two functions and any now-unused constants that only
served them (verify ATTEMPT_REVIEW_TTL if unused), then run a quick search to
confirm there are no remaining imports or references and ensure the project
builds.

Comment on lines +752 to +773
for (const attempt of claimed) {
processed += 1;

try {
const transitionedOrderId = await atomicCancelOrderAndFailCreatingAttempt({
attemptId: attempt.id,
runId: args.runId,
ttlSeconds,
now: new Date(),
});

if (!transitionedOrderId) {
noop += 1;
continue;
}

await restockOrder(transitionedOrderId, {
reason: 'canceled',
workerId: 'monobank',
});

applied += 1;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Check if there's already a recovery mechanism for canceled orders with unreleased stock
rg -n "stockRestored" --type=ts -C3

Repository: DevLoversTeam/devlovers.net

Length of output: 50383


🏁 Script executed:

#!/bin/bash
rg -n "CANCELED.*stock" --type=ts -C3

Repository: DevLoversTeam/devlovers.net

Length of output: 53


🏁 Script executed:

#!/bin/bash
rg -n "restock.*cancel" --type=ts -C3

Repository: DevLoversTeam/devlovers.net

Length of output: 1096


🏁 Script executed:

#!/bin/bash
# Also search for the Job2 query to verify the candidate filter
rg -n "status not in.*PAID.*CANCELED" --type=ts -C3

Repository: DevLoversTeam/devlovers.net

Length of output: 1981


🏁 Script executed:

#!/bin/bash
# Check the monobank-janitor.ts file structure around the mentioned lines
sed -n '700,800p' frontend/lib/services/orders/monobank-janitor.ts

Repository: DevLoversTeam/devlovers.net

Length of output: 2505


🏁 Script executed:

#!/bin/bash
# Check sweeps.ts for CANCELED status handling and recovery logic
sed -n '1,400p' frontend/lib/services/orders/sweeps.ts | grep -A 10 -B 10 "CANCELED"

Repository: DevLoversTeam/devlovers.net

Length of output: 53


🏁 Script executed:

#!/bin/bash
# Search for any job/sweep that targets CANCELED orders with stockRestored=false
rg -n "CANCELED.*stockRestored|stockRestored.*CANCELED" --type=ts

Repository: DevLoversTeam/devlovers.net

Length of output: 53


🏁 Script executed:

#!/bin/bash
# Find atomicCancelOrderAndFailCreatingAttempt to see what status it sets
rg -n "atomicCancelOrderAndFailCreatingAttempt" --type=ts -A 30 | head -100

Repository: DevLoversTeam/devlovers.net

Length of output: 5319


🏁 Script executed:

#!/bin/bash
# Check if there are any recovery jobs for CANCELED orders specifically
fd -e "\.ts" -path "*/services/orders/*" | xargs grep -l "CANCELED.*stock\|recovery.*cancel" 2>/dev/null

Repository: DevLoversTeam/devlovers.net

Length of output: 241


🏁 Script executed:

#!/bin/bash
# Search for any code that targets CANCELED orders in recovery/cleanup
rg -n "CANCELED" frontend/lib/services/orders/ --type=ts | grep -i "stock\|restock\|recovery" | head -20

Repository: DevLoversTeam/devlovers.net

Length of output: 344


🏁 Script executed:

#!/bin/bash
# Look at complete sweeps.ts to find all recovery jobs and their conditions
wc -l frontend/lib/services/orders/sweeps.ts

Repository: DevLoversTeam/devlovers.net

Length of output: 113


🏁 Script executed:

#!/bin/bash
# Check if there's a specific recovery path for CANCELED orders
rg -n "'CANCELED'" frontend/lib/services/orders/sweeps.ts -A 5 -B 5

Repository: DevLoversTeam/devlovers.net

Length of output: 53


🏁 Script executed:

#!/bin/bash
# Search all of orders directory for jobs/sweeps that handle stockRestored=false
rg -n "stockRestored.*false|ne.*stockRestored" frontend/lib/services/orders/ --type=ts -B 3 -A 3

Repository: DevLoversTeam/devlovers.net

Length of output: 4113


🏁 Script executed:

#!/bin/bash
# Check restock.ts to understand how restockOrder handles failures and what conditions it checks
sed -n '40,160p' frontend/lib/services/orders/restock.ts

Repository: DevLoversTeam/devlovers.net

Length of output: 3390


🏁 Script executed:

#!/bin/bash
# Check if any sweep targets CANCELED status specifically
sed -n '50,100p' frontend/lib/services/orders/sweeps.ts

Repository: DevLoversTeam/devlovers.net

Length of output: 1515


🏁 Script executed:

#!/bin/bash
# Look for all status conditions in sweeps to verify CANCELED is never targeted
rg -n "status.*in|status.*eq" frontend/lib/services/orders/sweeps.ts | head -30

Repository: DevLoversTeam/devlovers.net

Length of output: 53


🏁 Script executed:

#!/bin/bash
# Get full sweeps.ts to see all sweep functions and what statuses they target
cat frontend/lib/services/orders/sweeps.ts | grep -n "export async function\|inArray(orders.paymentProvider\|inArray(orders.status\|'CANCELED'" | head -30

Repository: DevLoversTeam/devlovers.net

Length of output: 265


🏁 Script executed:

#!/bin/bash
# Check if there's any monobank-specific recovery for CANCELED orders
rg -n "monobank.*CANCELED|CANCELED.*monobank" frontend/lib/services/orders/ --type=ts

Repository: DevLoversTeam/devlovers.net

Length of output: 53


🏁 Script executed:

#!/bin/bash
# Check if restockOrder is wrapped in a transaction anywhere or if it's always separate
rg -n "atomicCancelOrderAndFailCreatingAttempt\|restockOrder" frontend/lib/services/orders/monobank-janitor.ts -A 2 -B 2

Repository: DevLoversTeam/devlovers.net

Length of output: 53


🏁 Script executed:

#!/bin/bash
# Get details on all three restock sweep functions to see their target statuses
sed -n '10,130p' frontend/lib/services/orders/sweeps.ts

Repository: DevLoversTeam/devlovers.net

Length of output: 3360


🏁 Script executed:

#!/bin/bash
# Also check function signatures for the other sweeps
sed -n '139,270p' frontend/lib/services/orders/sweeps.ts

Repository: DevLoversTeam/devlovers.net

Length of output: 3415


🏁 Script executed:

#!/bin/bash
# Check lines 267 onwards for the no-payment sweep
sed -n '267,380p' frontend/lib/services/orders/sweeps.ts

Repository: DevLoversTeam/devlovers.net

Length of output: 2920


🏁 Script executed:

#!/bin/bash
# Search for where restockOrder is called in monobank-janitor with context
rg -n "restockOrder" frontend/lib/services/orders/monobank-janitor.ts -B 5 -A 5

Repository: DevLoversTeam/devlovers.net

Length of output: 819


Restock failure after atomic cancel leaves inventory unrecoverable for monobank orders.

If restockOrder throws after atomicCancelOrderAndFailCreatingAttempt completes, the order transitions to CANCELED state and won't match the Job2 candidate query (o.status not in ('PAID', 'CANCELED')) on the next run. Additionally, no recovery sweep targets monobank CANCELED orders with stockRestored = false—the existing sweeps only handle Stripe and no-payment providers. This leaves reserved inventory permanently leaked until manual intervention.

Consider either: (a) wrapping the cancel and restock in a single transaction so the cancel is rolled back if restock fails, or (b) adding a recovery job that finds CANCELED orders (or more broadly, monobank orders) with stockRestored = false.

🤖 Prompt for AI Agents
In `@frontend/lib/services/orders/monobank-janitor.ts` around lines 752 - 773, The
current flow calls atomicCancelOrderAndFailCreatingAttempt(...) then
restockOrder(...), which can leave inventory leaked if restockOrder throws
because the order is already CANCELED and won't be re-picked by the existing
Job2 candidate query (o.status not in ('PAID', 'CANCELED')); fix by either (A)
making the cancel+restock atomic: move both operations into one DB
transaction/atomic operation so that atomicCancelOrderAndFailCreatingAttempt and
the subsequent restock are committed together and the cancel is rolled back on
restock failure, or (B) implement a recovery sweep/job in monobank-janitor that
queries CANCELED monobank orders where stockRestored = false (or provider =
'monobank' and stockRestored = false) and re-attempts restockOrder for them,
ensuring idempotency and logging; reference
atomicCancelOrderAndFailCreatingAttempt, restockOrder, the Job2 candidate status
filter, and the stockRestored flag when adding the new recovery job or changing
transaction boundaries.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants