Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
103 changes: 101 additions & 2 deletions src/components/CertDashboardIsland.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import { useEffect, useState, type CSSProperties } from 'react'
import {
ArrowRight,
FileText,
History,
RotateCw,
Target,
} from 'lucide-react'
Expand All @@ -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. */
Expand Down Expand Up @@ -108,6 +110,11 @@ function CertDashboard({ cert }: { cert: CertDashboardCert }) {
const [domainProgress, setDomainProgress] = useState<DomainProgress[]>([])
const [recentAttempts, setRecentAttempts] = useState<RecentAttempt[]>([])
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<DueCountRow[]>([])
// 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
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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 (
<div className="max-w-6xl mx-auto pt-6 md:pt-10 pb-16 md:pb-24 space-y-8 md:space-y-10 stagger" aria-busy={dataLoading}>
{dataLoading && <p className="sr-only" role="status">Loading your dashboard</p>}
Expand Down Expand Up @@ -305,6 +336,48 @@ function CertDashboard({ cert }: { cert: CertDashboardCert }) {
</div>
)}

{/* 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 && (
<section aria-labelledby="review-queue-heading">
<div className="bg-bg-card border border-border-hairline rounded-2xl shadow-card p-5 md:p-6 flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
<div className="flex items-start gap-3 min-w-0">
<span className="mt-0.5 inline-flex h-10 w-10 flex-shrink-0 items-center justify-center rounded-xl bg-bg-dark border border-border-hairline">
<History className="w-5 h-5" style={{ color: levelAccent }} aria-hidden="true" />
</span>
<div className="min-w-0">
<p id="review-queue-heading" className="font-mono text-[11px] font-bold uppercase tracking-[0.16em] text-text-muted">
Review queue
</p>
<p className="mt-1.5 text-text-primary font-medium">
<span className="font-semibold tabular-nums">{dueCounts.dueForReview}</span>{' '}
{dueCounts.dueForReview === 1 ? 'question' : 'questions'} due for review
{dueCounts.missedReadyToRetry > 0 && (
<span className="text-text-muted">
{' · '}
<span className="font-semibold tabular-nums text-text-primary">{dueCounts.missedReadyToRetry}</span>{' '}
missed, ready to retry
</span>
)}
</p>
</div>
</div>
<a
href={`${certPath}/domain-practice`}
onClick={() => 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
</a>
</div>
</section>
)}

{/* Practice Modes: the two primary actions, full-width prominent cards. */}
<section>
<h2 className="text-xl md:text-2xl font-semibold tracking-[-0.01em] text-text-primary mb-4">
Expand Down Expand Up @@ -557,6 +630,32 @@ function CertDashboard({ cert }: { cert: CertDashboardCert }) {
)}
</section>
)}

{/* 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. 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 && (
<section aria-label="Other certifications">
<a
href={`/${crossCert.provider}/${crossCert.code}`}
onClick={() => 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"
>
<p className="text-sm text-text-muted">
Studying more than one exam?{' '}
<span className="font-medium text-text-primary">{crossCert.shortName}</span>{' '}
practice is free too, same question bank, same zero cost.
</p>
<ArrowRight className="w-4 h-4 flex-shrink-0 text-text-muted transition-transform duration-200 group-hover:translate-x-1" aria-hidden="true" />
</a>
</section>
)}
</div>
)
}
Expand Down
34 changes: 34 additions & 0 deletions src/data/certifications.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import {
getCertTotalQuestions,
getCertsByProvider,
getLevelLabel,
getCrossCertSuggestion,
getProviderInfo,
getProviderLabel,
getSortedCerts,
Expand Down Expand Up @@ -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
Expand Down
19 changes: 19 additions & 0 deletions src/data/certifications.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 4 additions & 0 deletions src/lib/analytics.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
78 changes: 78 additions & 0 deletions src/lib/spacedRepetition.test.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
import { describe, it, expect } from 'vitest'
import {
selectQuestions,
computeDueCounts,
type MasteryRow,
type DueCountRow,
} from './spacedRepetition'
import type { Question } from '../types'

Expand Down Expand Up @@ -146,3 +148,79 @@ describe('selectQuestions', () => {
expect(new Set(ids).size).toBe(ids.length)
})
})

describe('computeDueCounts', () => {
const row = (
over: Partial<DueCountRow> = {},
): 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,
})
})
})
50 changes: 50 additions & 0 deletions src/lib/spacedRepetition.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading
Loading