Skip to content

Stats — read endpoint with all aggregations #487

@adityapat24

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

No fields configured for Feature.

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions