From f2a2a529c4015b39d42c94fefbef876625d3492d Mon Sep 17 00:00:00 2001 From: Alex Santonastaso <99670332+nastaso@users.noreply.github.com> Date: Sat, 4 Jul 2026 20:52:49 +0100 Subject: [PATCH 1/2] feat(dashboard,exam): add return-loop surfaces (review queue + cross-cert nudge) Surface the live-but-invisible spaced-repetition state on the cert dashboard as an action-framed review-queue count, and nudge the sibling cert on both the dashboard and the post-pass exam results. - computeDueCounts(rows): pure, exported helper deriving 'N due for review / N missed ready to retry' from the user's own question_mastery rows. Reuses the live in_exclusion_window / is_mastered / last_was_wrong signals rather than re-deriving an SM-2 schedule client-side. Unit-tested. - getCrossCertSuggestion(certCode): deterministic sibling-cert lookup (same provider, active, indexable). Unit-tested. - Dashboard: one extra question_mastery select folded into the EXISTING one-shot Promise.all, inheriting the #159-hardened fetch gating (no new effect, no user-object-keyed refetch). Review-queue card deep-links into the existing domain-practice route; quiet cross-cert nudge at the foot. - Mock exam: quiet cross-cert nudge on the 700+ pass results branch only. - Register the two new low-cardinality analytics events in KNOWN_EVENTS. RLS scopes both mastery reads to the caller's own rows. No schema change, no new RPC, no scoring/question-schema/dup-submit changes, no new deps. --- src/components/CertDashboardIsland.tsx | 100 ++++++++++++++++++++++++- src/data/certifications.test.ts | 34 +++++++++ src/data/certifications.ts | 19 +++++ src/lib/analytics.ts | 4 + src/lib/spacedRepetition.test.ts | 78 +++++++++++++++++++ src/lib/spacedRepetition.ts | 50 +++++++++++++ src/pages/_MockExam.tsx | 28 ++++++- 7 files changed, 309 insertions(+), 4 deletions(-) diff --git a/src/components/CertDashboardIsland.tsx b/src/components/CertDashboardIsland.tsx index 164d0a2..f6b177a 100644 --- a/src/components/CertDashboardIsland.tsx +++ b/src/components/CertDashboardIsland.tsx @@ -20,6 +20,7 @@ import { useEffect, useState, type CSSProperties } from 'react' import { ArrowRight, FileText, + History, RotateCw, Target, } from 'lucide-react' @@ -30,9 +31,10 @@ import { getSupabase } from '../lib/supabase' import { formatRelativeDate } from '../lib/formatting' import { formatTime } from '../lib/scoring' import { logError } from '../lib/logger' -import { CERTIFICATIONS } from '../data/certifications' +import { CERTIFICATIONS, getCrossCertSuggestion } from '../data/certifications' import { LEVEL_ACCENT_UI_HEX, LEVEL_ACCENT_UI_RGB } from '../lib/levelAccent' import { calculateDomainMastery, findNextDomainAction } from '../lib/domainStats' +import { computeDueCounts, type DueCountRow } from '../lib/spacedRepetition' import type { DomainProgress } from '../types' /** Minimal domain shape needed by the dashboard sidebar. */ @@ -108,6 +110,11 @@ function CertDashboard({ cert }: { cert: CertDashboardCert }) { const [domainProgress, setDomainProgress] = useState([]) const [recentAttempts, setRecentAttempts] = useState([]) const [examCount, setExamCount] = useState(0) + // The user's own spaced-repetition rows for THIS cert, fed to computeDueCounts + // for the review-queue surface. Fetched in the SAME one-shot Promise.all as the + // rest of the dashboard data (never its own effect), so it inherits the exact + // #159-hardened gating and adds no extra render-triggered query. + const [masteryRows, setMasteryRows] = useState([]) // Data-fetch status for the data-dependent sections (stat strip, domain // mastery, recent attempts). While loading, they render a skeleton instead // of a flash of all-zeros (which reads as a wiped account to a returning @@ -154,14 +161,31 @@ function CertDashboard({ cert }: { cert: CertDashboardCert }) { .eq('cert_code', cert.code) .order('attempted_at', { ascending: false }) .limit(5), + supabase + .from('question_mastery') + // Only the three columns computeDueCounts reads. RLS scopes this to + // the caller's own rows (user_id = auth.uid()); the explicit user_id + // filter is belt-and-suspenders, not the security boundary. + .select('is_mastered, last_was_wrong, in_exclusion_window') + .eq('user_id', user.id) + .eq('cert_code', cert.code), ]) - .then(([progressRes, attemptsRes]) => { + .then(([progressRes, attemptsRes, masteryRes]) => { if (cancelled) return if (progressRes.error) logError('CertDashboard.loadProgress', progressRes.error) if (attemptsRes.error) logError('CertDashboard.loadAttempts', attemptsRes.error) if (progressRes.data) setDomainProgress(progressRes.data as DomainProgress[]) if (attemptsRes.data) setRecentAttempts(attemptsRes.data as RecentAttempt[]) if (typeof attemptsRes.count === 'number') setExamCount(attemptsRes.count) + // The review-queue surface is supplementary: a mastery-query failure + // just hides it (empty rows -> 0 due), it never trips the dashboard + // error state that domain_progress + exam_attempts own. + if (masteryRes.error) { + logError('CertDashboard.loadMastery', masteryRes.error) + setMasteryRows([]) + } else { + setMasteryRows((masteryRes.data ?? []) as DueCountRow[]) + } // Surface an error only when BOTH queries failed; a partial failure // still has useful data to show. Either way the skeleton resolves. if (progressRes.error && attemptsRes.error) { @@ -242,6 +266,13 @@ function CertDashboard({ cert }: { cert: CertDashboardCert }) { })) const nextDomain = nextAction ? cert.domains.find(d => d.id === nextAction.domainId) : undefined + // Return-loop surfaces. `dueCounts` turns the (otherwise invisible) spaced- + // repetition state into an action count; `crossCert` is the sibling cert to + // nudge once this one is rolling. Both are pure derivations off already-loaded + // data - no extra fetch, no effect. + const dueCounts = computeDueCounts(masteryRows) + const crossCert = getCrossCertSuggestion(cert.code) + return (
{dataLoading &&

Loading your dashboard

} @@ -305,6 +336,48 @@ function CertDashboard({ cert }: { cert: CertDashboardCert }) {
)} + {/* Review queue: surfaces the (otherwise invisible) spaced-repetition + state as ONE action count and deep-links into the existing domain- + practice flow (which runs the SM-2 selection). Rendered only once the + fetch resolves and there is genuinely something due, so a new user or + a mastery-query failure simply never sees it (no zero/empty clutter, + no false error). Action-framed, distinct from the two primary + practice cards below. */} + {!dataError && !dataLoading && dueCounts.dueForReview > 0 && ( +
+
+
+ + +
+

+ Review queue +

+

+ {dueCounts.dueForReview}{' '} + {dueCounts.dueForReview === 1 ? 'question' : 'questions'} due for review + {dueCounts.missedReadyToRetry > 0 && ( + + {' · '} + {dueCounts.missedReadyToRetry}{' '} + missed, ready to retry + + )} +

+
+
+ trackEvent('review_queue_cta_clicked', { surface: 'dashboard' })} + className="inline-flex min-h-[44px] flex-shrink-0 items-center justify-center rounded-full bg-cta px-6 text-sm font-medium text-on-cta transition-colors duration-200 hover:bg-cta-hover" + > + Review now + +
+
+ )} + {/* Practice Modes: the two primary actions, full-width prominent cards. */}

@@ -557,6 +630,29 @@ function CertDashboard({ cert }: { cert: CertDashboardCert }) { )}

)} + + {/* Cross-cert nudge (return loop 2): a quiet, secondary line pointing at + the sibling cert once this dashboard is in use. Deliberately recessed + (page-tinted panel, muted copy) so it never competes with the primary + practice actions above. Hidden under the dashboard error state to keep + that view focused; independent of the data fetch otherwise. Only + renders when a real sibling cert exists (getCrossCertSuggestion). */} + {!dataError && crossCert && ( +
+ trackEvent('cross_cert_nudge_clicked', { surface: 'dashboard', to: crossCert.code })} + className="group flex items-center justify-between gap-4 rounded-2xl border border-border-hairline bg-bg-dark/50 px-5 py-4 transition-colors duration-200 hover:border-text-muted/40" + > +

+ Studying more than one exam?{' '} + {crossCert.shortName}{' '} + practice is free too, same question bank, same zero cost. +

+
+
+ )} ) } diff --git a/src/data/certifications.test.ts b/src/data/certifications.test.ts index bbb2fe3..79e4389 100644 --- a/src/data/certifications.test.ts +++ b/src/data/certifications.test.ts @@ -13,6 +13,7 @@ import { getCertTotalQuestions, getCertsByProvider, getLevelLabel, + getCrossCertSuggestion, getProviderInfo, getProviderLabel, getSortedCerts, @@ -305,6 +306,39 @@ describe('SAA-C03 exam config matches the official AWS spec', () => { }) }) +describe('getCrossCertSuggestion (return-loop nudge target)', () => { + it('returns null for an unknown cert code', () => { + expect(getCrossCertSuggestion('nope-x99')).toBeNull() + }) + + it('never suggests the cert itself', () => { + for (const cert of CERTIFICATION_LIST) { + const suggestion = getCrossCertSuggestion(cert.code) + if (suggestion) expect(suggestion.code).not.toBe(cert.code) + } + }) + + it('only ever suggests an indexable, same-provider cert', () => { + for (const cert of CERTIFICATION_LIST) { + const suggestion = getCrossCertSuggestion(cert.code) + if (!suggestion) continue + expect(suggestion.provider).toBe(cert.provider) + expect(isCertNoindex(suggestion)).toBe(false) + } + }) + + it('cross-links the two active AWS foundational certs to each other', () => { + expect(getCrossCertSuggestion('clf-c02')?.code).toBe('aif-c01') + expect(getCrossCertSuggestion('aif-c01')?.code).toBe('clf-c02') + }) + + it('is stable across repeated calls', () => { + expect(getCrossCertSuggestion('clf-c02')?.code).toBe( + getCrossCertSuggestion('clf-c02')?.code, + ) + }) +}) + describe('examFormat stays consistent with the canonical top-level fields', () => { // Guards the L1/L2 desync bug: AIF-C01 shipped examFormat // { questionCount: 50, timeMinutes: 85 } while the top-level fields and AWS diff --git a/src/data/certifications.ts b/src/data/certifications.ts index d6465dc..ce17489 100644 --- a/src/data/certifications.ts +++ b/src/data/certifications.ts @@ -711,6 +711,25 @@ export function getCertByPath( return cert } +/** + * Pick another practice-ready cert to cross-promote from a given cert's + * dashboard and post-pass results (the "return loop" nudge). Returns the first + * OTHER same-provider cert that is itself indexable/available (active and not + * under review, i.e. `!isCertNoindex`), in the canonical `getSortedCerts` + * order, or null when there is no sensible sibling to suggest. Deterministic, + * so the nudge is stable across renders. Same provider keeps the URL prefix and + * the "same bank, same zero cost" framing honest. + */ +export function getCrossCertSuggestion(certCode: string): Certification | null { + const current = CERTIFICATIONS[certCode] + if (!current) return null + return ( + getSortedCerts(current.provider).find( + c => c.code !== current.code && !isCertNoindex(c), + ) ?? null + ) +} + /** * Resolve a provider segment (e.g. `'aws'`) to the list of certs for that * provider, or null when the provider is unknown. Used by the provider landing diff --git a/src/lib/analytics.ts b/src/lib/analytics.ts index da14b9c..d4c1e17 100644 --- a/src/lib/analytics.ts +++ b/src/lib/analytics.ts @@ -87,6 +87,10 @@ export const KNOWN_EVENTS = [ 'unlock_cta_clicked', // guest sign-in nudge in practice/exam/header 'weakest_domain_cta_clicked', // "next up" CTA (surface: dashboard | exam_results; // variant: weakest | unstarted) + 'review_queue_cta_clicked', // dashboard "Review now" CTA on the due-for-review + // surface (surface: dashboard) + 'cross_cert_nudge_clicked', // sibling-cert nudge on the dashboard + post-pass + // results (surface: dashboard | exam_results; to: cert code) 'share_result', // results-screen share, pass-only (method: web_share | clipboard; params: cert, authed) 'post_share_clicked', // blog post share/copy-link row (method: web_share | clipboard) 'report_question_clicked', diff --git a/src/lib/spacedRepetition.test.ts b/src/lib/spacedRepetition.test.ts index df53204..4452955 100644 --- a/src/lib/spacedRepetition.test.ts +++ b/src/lib/spacedRepetition.test.ts @@ -1,7 +1,9 @@ import { describe, it, expect } from 'vitest' import { selectQuestions, + computeDueCounts, type MasteryRow, + type DueCountRow, } from './spacedRepetition' import type { Question } from '../types' @@ -146,3 +148,79 @@ describe('selectQuestions', () => { expect(new Set(ids).size).toBe(ids.length) }) }) + +describe('computeDueCounts', () => { + const row = ( + over: Partial = {}, + ): DueCountRow => ({ + is_mastered: false, + last_was_wrong: false, + in_exclusion_window: false, + ...over, + }) + + it('returns zero for no rows (new user)', () => { + expect(computeDueCounts([])).toEqual({ + dueForReview: 0, + missedReadyToRetry: 0, + }) + }) + + it('counts a seen, un-mastered, cooled-down row as due', () => { + expect(computeDueCounts([row()])).toEqual({ + dueForReview: 1, + missedReadyToRetry: 0, + }) + }) + + it('excludes rows still in their cooldown (exclusion) window', () => { + const counts = computeDueCounts([ + row({ in_exclusion_window: true }), + row({ in_exclusion_window: true, last_was_wrong: true }), + ]) + expect(counts).toEqual({ dueForReview: 0, missedReadyToRetry: 0 }) + }) + + it('excludes mastered rows even when their last answer was wrong', () => { + const counts = computeDueCounts([ + row({ is_mastered: true }), + row({ is_mastered: true, last_was_wrong: true }), + ]) + expect(counts).toEqual({ dueForReview: 0, missedReadyToRetry: 0 }) + }) + + it('counts a last-wrong, cooled-down row as both due and missed', () => { + expect(computeDueCounts([row({ last_was_wrong: true })])).toEqual({ + dueForReview: 1, + missedReadyToRetry: 1, + }) + }) + + it('missedReadyToRetry is a subset of dueForReview on a mixed set', () => { + const counts = computeDueCounts([ + row({ last_was_wrong: true }), // due + missed + row(), // due only + row({ last_was_wrong: true }), // due + missed + row({ in_exclusion_window: true, last_was_wrong: true }), // cooling down + row({ is_mastered: true }), // locked in + ]) + expect(counts).toEqual({ dueForReview: 3, missedReadyToRetry: 2 }) + expect(counts.missedReadyToRetry).toBeLessThanOrEqual(counts.dueForReview) + }) + + it('accepts full MasteryRow shapes (extra columns ignored)', () => { + const full: MasteryRow = { + question_id: 'q1', + correct_streak: 0, + last_was_wrong: true, + last_seen_at: '2026-01-01T00:00:00Z', + is_mastered: false, + in_exclusion_window: false, + weight: 5, + } + expect(computeDueCounts([full])).toEqual({ + dueForReview: 1, + missedReadyToRetry: 1, + }) + }) +}) diff --git a/src/lib/spacedRepetition.ts b/src/lib/spacedRepetition.ts index 3611b28..a906476 100644 --- a/src/lib/spacedRepetition.ts +++ b/src/lib/spacedRepetition.ts @@ -13,6 +13,56 @@ export interface MasteryRow { weight: number | null } +/** Review-queue counts derived from a user's own question_mastery rows. */ +export interface DueCounts { + /** + * Seen questions still in rotation (not yet mastered) whose cooldown window + * has elapsed - ready to resurface for review right now. + */ + dueForReview: number + /** + * The subset of the above that the user last answered incorrectly - the + * highest-value "missed, ready to retry" items. + */ + missedReadyToRetry: number +} + +/** The only mastery columns computeDueCounts reads (keeps the select narrow). */ +export type DueCountRow = Pick< + MasteryRow, + 'is_mastered' | 'last_was_wrong' | 'in_exclusion_window' +> + +/** + * Derive review-queue counts from a signed-in user's own question_mastery rows. + * + * Pure and synchronous so it unit-tests without a live Supabase session. It + * REUSES the live spaced-repetition signals the banks already maintain rather + * than re-deriving an SM-2 schedule on the client (which would drift from the + * server's own scheduling): `in_exclusion_window` is the machinery's own "still + * cooling down, do not resurface yet" flag, `is_mastered` its "locked in" flag, + * and `last_was_wrong` the last outcome. A row is DUE when it is out of its + * exclusion window and not yet mastered; MISSED-ready-to-retry narrows that to + * rows whose last answer was wrong. + * + * There is no `now` argument on purpose: the exclusion window is computed and + * stored server-side, so there is no interval for the client to recompute. + * Reading the stored flag can only UNDERCOUNT (a stale-true flag hides a due + * row); it never inflates the nudge, which is the safe direction for a prompt. + */ +export function computeDueCounts(rows: readonly DueCountRow[]): DueCounts { + let dueForReview = 0 + let missedReadyToRetry = 0 + + for (const row of rows) { + if (row.in_exclusion_window || row.is_mastered) continue + dueForReview++ + if (row.last_was_wrong) missedReadyToRetry++ + } + + return { dueForReview, missedReadyToRetry } +} + function weightedDraw( pool: Array<{ question: Question; weight: number }>, count: number diff --git a/src/pages/_MockExam.tsx b/src/pages/_MockExam.tsx index a22fcb2..122fc4e 100644 --- a/src/pages/_MockExam.tsx +++ b/src/pages/_MockExam.tsx @@ -1,6 +1,6 @@ import { useState, useEffect, useRef, Fragment } from 'react' import { useLocation, useNavigate } from 'react-router-dom' -import { Flag, AlertCircle, LayoutGrid, Heart, ArrowLeft } from 'lucide-react' +import { Flag, AlertCircle, LayoutGrid, Heart, ArrowLeft, ArrowRight } from 'lucide-react' import { Button } from '../components/Button' import { Card } from '../components/Card' import { Alert } from '../components/Alert' @@ -34,7 +34,7 @@ import { MAX_MULTI_ANSWER, TIMER_PULSE_THRESHOLD } from '../lib/constants' import { registerExamLeaveHandler, confirmExamLeave, isIntentionalLeave, SIGN_OUT_SENTINEL, markIntentionalLeave } from '../lib/examGuard' import { useSignOut } from '../hooks/useSignOut' import { storePendingAttempt, consumePendingAttemptSavedNotice, markPendingAttemptSaveIntent, PENDING_ATTEMPT_SAVED_EVENT, peekPendingAttempt, hasPendingAttemptSaveIntent, type PendingAttempt } from '../lib/pendingAttempt' -import { getProviderLabel } from '../data/certifications' +import { getProviderLabel, getCrossCertSuggestion } from '../data/certifications' import { findNextDomainAction } from '../lib/domainStats' type ExamScreen = 'start' | 'exam' | 'results' | 'review' @@ -660,6 +660,9 @@ export function MockExam() { const currentType = currentQuestion ? getQuestionType(currentQuestion) : 'single' const answeredCount = Array.from(answers.values()).filter(isQuestionAnswered).length const flaggedCount = Array.from(answers.values()).filter(s => s.flagged).length + // Sibling cert to nudge on a PASS (return loop 2). Pure lookup off the current + // cert code; rendered only on the pass results branch below. + const crossCert = getCrossCertSuggestion(cert.code) // Guest-save loader, hoisted ABOVE the screen switch (hardening F3). The // arm effect is deliberately screen-agnostic (start OR results), but the @@ -978,6 +981,27 @@ export function MockExam() { + {/* Cross-cert nudge on a PASS (return loop 2): one quiet, secondary + line pointing at the sibling cert at the moment the user has just + cleared this one. Pass-only (a fail stays focused on its weakest- + domain / sign-in actions) and only when a real sibling exists. + Recessed styling keeps it well below the primary result actions + and the donate ask; cert-aware copy (CLF pass -> AIF, and back). */} + {results!.passed && crossCert && ( + trackEvent('cross_cert_nudge_clicked', { surface: 'exam_results', to: crossCert.code })} + className="group flex items-center justify-between gap-4 rounded-2xl border border-border-hairline bg-bg-dark/50 px-5 py-4 transition-colors duration-200 hover:border-text-muted/40" + > +

+ Passed {cert.shortName}?{' '} + {crossCert.shortName}{' '} + practice is ready, same question bank, same zero cost. +

+
+ )} + {/* Quiet support ask at the highest-intent moment (just finished an exam). Results screen only, never mid-exam, no orange (that is for exam CTAs). One of three donate surfaces; distinct location. */} From c22ac01795ba1e12fc28b6cc72c8c8433ac06da8 Mon Sep 17 00:00:00 2001 From: Alex Santonastaso <99670332+nastaso@users.noreply.github.com> Date: Sun, 5 Jul 2026 02:28:57 +0100 Subject: [PATCH 2/2] fix(dashboard): gate cross-cert nudge on settled load so it cannot flash on error (audit #178) --- src/components/CertDashboardIsland.tsx | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/src/components/CertDashboardIsland.tsx b/src/components/CertDashboardIsland.tsx index f6b177a..1bea6cb 100644 --- a/src/components/CertDashboardIsland.tsx +++ b/src/components/CertDashboardIsland.tsx @@ -634,10 +634,13 @@ function CertDashboard({ cert }: { cert: CertDashboardCert }) { {/* Cross-cert nudge (return loop 2): a quiet, secondary line pointing at the sibling cert once this dashboard is in use. Deliberately recessed (page-tinted panel, muted copy) so it never competes with the primary - practice actions above. Hidden under the dashboard error state to keep - that view focused; independent of the data fetch otherwise. Only - renders when a real sibling cert exists (getCrossCertSuggestion). */} - {!dataError && crossCert && ( + practice actions above. Gated on the load having settled (not + dataLoading) AND succeeded (not dataError) - crossCert is a pure + derivation with no fetch of its own, so without the dataLoading + check it would render immediately on mount and then vanish if the + dashboard fetch subsequently failed (a flash-on-then-disappear). + Only renders when a real sibling cert exists (getCrossCertSuggestion). */} + {!dataLoading && !dataError && crossCert && (