Skip to content

Stats — read endpoint with all aggregations #487

Description

@adityapat24

Goal

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.

Wire the API endpoint

  • Update src/app/api/v1/stats/route.ts. Implement GET: call getStats, return JSON.
  • 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).
  • Required Mongo indexes are confirmed to exist.

Metadata

Metadata

Assignees

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions