You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
GET /api/v1/stats returns a real StatsPayload aggregated from Mongo. The stats dashboard now shows real numbers.
Tasks
Confirm the StatsPayload shape
Open src/lib/stats/types.ts and verify the payload includes: totals (object with applicants, submitted, admitted, waitlisted, declined, rsvpYes, rsvpNo), statusBreakdown (array of { status, count }), decisionBreakdown (same shape), rsvpBreakdown (same shape), demographics (object with one entry per demographic dimension), timeline (array of { date, count }), generatedAt (ISO timestamp).
What this accomplishes: The frontend dashboard already expects this shape; locking it in upfront means the implementation can be checked against it.
Implement each aggregation
Each one lives in src/lib/stats/aggregations.ts as a pure function (db) => Promise<X>.
getTotals(db): returns { applicants, submitted, admitted, waitlisted, declined, rsvpYes, rsvpNo }. Implement with seven parallel countDocuments calls, each with the appropriate filter.
What this accomplishes: The headline numbers on the dashboard. Running them in parallel keeps the function fast.
getStatusBreakdown(db): returns [{ status, count }, ...]. Aggregate with $group on applicationStatus.
What this accomplishes: The data behind the status donut chart.
getDecisionBreakdown(db): same pattern on decisionStatus, filtered to only submitted applications.
What this accomplishes: The data behind the decision chart. Only counting submitted applicants makes the percentages meaningful.
getRsvpBreakdown(db): same pattern on rsvpStatus, filtered to only admitted applicants.
What this accomplishes: Of admitted applicants, how many are coming. Drives capacity planning.
getDemographics(db): returns an object like { school: [{ value, count }, ...], year: [...], major: [...] }. For each dimension, run $group on applicationResponses.<questionId>.
What this accomplishes: The data behind the demographics chart. The exact question IDs depend on what's frozen in questions-v1; coordinate with the question schema.
getTimeline(db, days): returns [{ date, count }, ...] for the last N days. Use $match to filter by appSubmissionTime >= now - N days, then $group by date (truncate timestamps to day).
What this accomplishes: The data behind the submissions-over-time line chart.
Implement the service
In src/lib/stats/service.ts, write getStats(): Promise<StatsPayload>.
Call all six aggregations in parallel with Promise.all.
What this accomplishes: Total response time = the slowest single aggregation, not the sum. With six aggregations this can be a 5-6x speedup.
Assemble the results into the typed StatsPayload shape, set generatedAt = new Date().toISOString().
Add a simple cache
Wrap getStats in a 60-second in-memory cache: store the last result and timestamp; if called again within 60s, return the cached version.
What this accomplishes: During admissions decisions, admins refresh the stats page constantly. Without a cache, each refresh runs six aggregations against Mongo. With a 60s cache, repeat loads are instant.
Cache is per-process, no need for Redis at this scale. Document the limitation in a comment.
Add a comment // TODO: gate with requireAdmin() once Ticket 1 ships its helpers.
Index the collection
Confirm indexes exist on applicationStatus, decisionStatus, rsvpStatus, and appSubmissionTime
What this accomplishes: Aggregations on un-indexed fields scan the whole collection. With indexes, the queries are fast even on tens of thousands of records.
Test each aggregation
Write unit tests for each aggregation against a seeded fixture. Seed exactly 10 applicants with known statuses, decisions, and submission times, then assert each aggregation returns the expected values.
What this accomplishes: Every metric has a regression test on known data. If someone refactors the aggregation pipeline, the test fails before it ships.
Test via curl
curl localhost:3000/api/v1/stats returns a full payload with non-mock numbers.
Confirm totals.applicants matches a manual db.applicant_data.countDocuments() against the same DB.
Confirm timeline returns entries for recent days with non-zero appSubmissionTime.
Test from the dashboard
Start the dev server, go to /admin/stats. Confirm all numbers and charts show real data. Hit the refresh button; confirm the timestamp updates.
Refresh again within 60 seconds; confirm the timestamp doesn't change (cache hit).
What this accomplishes: Proves both the data layer and the cache work as designed.
Parity sanity check
If the legacy production database is accessible (read-only is fine), spot-check that the new stats endpoint returns the same totals as the legacy stats endpoint against the same data.
What this accomplishes: Confirms the migration isn't silently miscounting. If numbers differ, document why (e.g., new system excludes test accounts, etc.).
Definition of done
/admin/stats shows real numbers backed by Mongo aggregations.
Each aggregation has a unit test on a fixture of 10 known applicants.
The 60-second cache works (verified by checking generatedAt doesn't change on rapid repeat requests).
Goal
GET /api/v1/statsreturns a realStatsPayloadaggregated from Mongo. The stats dashboard now shows real numbers.Tasks
Confirm the
StatsPayloadshapesrc/lib/stats/types.tsand verify the payload includes:totals(object withapplicants,submitted,admitted,waitlisted,declined,rsvpYes,rsvpNo),statusBreakdown(array of{ status, count }),decisionBreakdown(same shape),rsvpBreakdown(same shape),demographics(object with one entry per demographic dimension),timeline(array of{ date, count }),generatedAt(ISO timestamp).Implement each aggregation
Each one lives in
src/lib/stats/aggregations.tsas a pure function(db) => Promise<X>.getTotals(db): returns{ applicants, submitted, admitted, waitlisted, declined, rsvpYes, rsvpNo }. Implement with seven parallelcountDocumentscalls, each with the appropriate filter.getStatusBreakdown(db): returns[{ status, count }, ...]. Aggregate with$grouponapplicationStatus.getDecisionBreakdown(db): same pattern ondecisionStatus, filtered to only submitted applications.getRsvpBreakdown(db): same pattern onrsvpStatus, filtered to only admitted applicants.getDemographics(db): returns an object like{ school: [{ value, count }, ...], year: [...], major: [...] }. For each dimension, run$grouponapplicationResponses.<questionId>.questions-v1; coordinate with the question schema.getTimeline(db, days): returns[{ date, count }, ...]for the last N days. Use$matchto filter byappSubmissionTime >= now - N days, then$groupby date (truncate timestamps to day).Implement the service
src/lib/stats/service.ts, writegetStats(): Promise<StatsPayload>.Promise.all.StatsPayloadshape, setgeneratedAt = new Date().toISOString().Add a simple cache
getStatsin a 60-second in-memory cache: store the last result and timestamp; if called again within 60s, return the cached version.Wire the API endpoint
src/app/api/v1/stats/route.ts. ImplementGET: callgetStats, return JSON.// TODO: gate with requireAdmin() once Ticket 1 ships its helpers.Index the collection
applicationStatus,decisionStatus,rsvpStatus, andappSubmissionTimeTest each aggregation
Test via curl
curl localhost:3000/api/v1/statsreturns a full payload with non-mock numbers.totals.applicantsmatches a manualdb.applicant_data.countDocuments()against the same DB.timelinereturns entries for recent days with non-zeroappSubmissionTime.Test from the dashboard
/admin/stats. Confirm all numbers and charts show real data. Hit the refresh button; confirm the timestamp updates.Parity sanity check
Definition of done
/admin/statsshows real numbers backed by Mongo aggregations.generatedAtdoesn't change on rapid repeat requests).