Conversation
Add a drop-off analysis section showing participant retention between Week 1 and Week 2, with a D3 stacked bar chart and breakdown table by pathway. Data is pulled from Airtable (excluding rejected records) and grouped by unique email per week. Visible to admins (on admin dashboard) and ambassadors (on ambassador dashboard). The API endpoint gates access to users with either role.
There was a problem hiding this comment.
Pull request overview
Adds a Week 1 → Week 2 drop-off analytics widget to the admin and ambassador dashboards, backed by a new authenticated analytics API endpoint that pulls data from Airtable and visualizes it with D3.
Changes:
- Adds
DropOffWidgetto both/app/adminand/app/ambassadordashboards. - Introduces
/api/analytics/dropoffto fetch and aggregate retention/drop-off metrics from Airtable (excluding rejected records in code). - Adds a new
DropOffWidget.sveltecomponent with a stacked bar chart + breakdown table, and introducesd3dependencies.
Reviewed changes
Copilot reviewed 5 out of 6 changed files in this pull request and generated 6 comments.
Show a summary per file
| File | Description |
|---|---|
| resolution-frontend/src/routes/app/ambassador/+page.svelte | Renders the new drop-off widget on the ambassador dashboard. |
| resolution-frontend/src/routes/app/admin/+page.svelte | Renders the new drop-off widget on the admin dashboard. |
| resolution-frontend/src/routes/api/analytics/dropoff/+server.ts | New analytics endpoint that gates access, queries Airtable, and computes retention/drop-off aggregates. |
| resolution-frontend/src/lib/components/DropOffWidget.svelte | New UI widget that fetches analytics and renders chart + table using D3. |
| resolution-frontend/package.json | Adds d3 and @types/d3. |
| resolution-frontend/bun.lock | Lockfile updated (includes substantial dependency churn beyond d3). |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| const FIELD_PATHWAY = 'fldKcikYfiJIQzEwT'; | ||
| const FIELD_REJECTED = 'fldWImB6tXMwhXcKn'; | ||
|
|
||
| const PATHWAYS = ['RUST', 'GENERAL_CODING', 'PYTHON', 'HARDWARE', 'GAME_DEV', 'DESIGN'] as const; |
There was a problem hiding this comment.
This file redefines a local PATHWAYS list, duplicating the canonical pathway IDs already exported from $lib/pathways (and used elsewhere like the review submissions endpoint). Duplicating this list risks drift when pathways change; import and reuse the shared constants/types instead.
| await new Promise<void>((resolve, reject) => { | ||
| base(DROPOFF_TABLE_ID) | ||
| .select({ | ||
| fields: [FIELD_EMAIL, FIELD_WEEK, FIELD_PATHWAY, FIELD_REJECTED] |
There was a problem hiding this comment.
The Airtable query does not apply a server-side filter for rejected records; it fetches everything and then filters in application code. Using filterByFormula (as done in other Airtable endpoints) to exclude rejected records would reduce Airtable API usage and response time, especially as the table grows.
| fields: [FIELD_EMAIL, FIELD_WEEK, FIELD_PATHWAY, FIELD_REJECTED] | |
| fields: [FIELD_EMAIL, FIELD_WEEK, FIELD_PATHWAY, FIELD_REJECTED], | |
| filterByFormula: `NOT({${FIELD_REJECTED}})` |
| const email = record.get(FIELD_EMAIL) as string; | ||
| const week = record.get(FIELD_WEEK) as number; | ||
| const pathway = record.get(FIELD_PATHWAY) as string; | ||
|
|
||
| if (email && week && pathway) { |
There was a problem hiding this comment.
record.get(...) as number/string is a TypeScript assertion and does not validate/coerce the Airtable field at runtime. If the Week/Email/Pathway fields come back as unexpected types (e.g. Week as a string from a select field), the r.week === 1/2 checks will silently fail and analytics will undercount. Consider runtime coercion/validation (e.g. parse week to an integer, trim/lowercase email, and ensure pathway is a known ID) before pushing into records.
| const email = record.get(FIELD_EMAIL) as string; | |
| const week = record.get(FIELD_WEEK) as number; | |
| const pathway = record.get(FIELD_PATHWAY) as string; | |
| if (email && week && pathway) { | |
| const rawEmail = record.get(FIELD_EMAIL); | |
| const rawWeek = record.get(FIELD_WEEK); | |
| const rawPathway = record.get(FIELD_PATHWAY); | |
| const email = | |
| typeof rawEmail === 'string' ? rawEmail.trim().toLowerCase() : ''; | |
| let week: number | null = null; | |
| if (typeof rawWeek === 'number') { | |
| week = rawWeek; | |
| } else if (typeof rawWeek === 'string') { | |
| const parsed = parseInt(rawWeek, 10); | |
| if (!Number.isNaN(parsed)) { | |
| week = parsed; | |
| } | |
| } | |
| const pathwayRawString = | |
| typeof rawPathway === 'string' ? rawPathway.trim().toUpperCase() : ''; | |
| const pathway = PATHWAYS.includes( | |
| pathwayRawString as (typeof PATHWAYS)[number] | |
| ) | |
| ? pathwayRawString | |
| : ''; | |
| if (email && week !== null && pathway) { |
| if (r.week === 1) { | ||
| week1Emails.add(r.email); | ||
| if (r.pathway in week1ByPathway) { | ||
| week1ByPathway[r.pathway].add(r.email); | ||
| } | ||
| } else if (r.week === 2) { | ||
| week2Emails.add(r.email); | ||
| if (r.pathway in week2ByPathway) { | ||
| week2ByPathway[r.pathway].add(r.email); | ||
| } |
There was a problem hiding this comment.
Totals (week1Emails/week2Emails) include all records, but pathway breakdown counts only include records whose pathway exists in week1ByPathway/week2ByPathway. That can make overall totals disagree with the sum of pathway rows when Airtable contains an unexpected/new pathway value. Consider either (a) restricting totals to known pathways too, or (b) adding an OTHER bucket / deriving pathways dynamically so totals and breakdown stay consistent.
| if (r.week === 1) { | |
| week1Emails.add(r.email); | |
| if (r.pathway in week1ByPathway) { | |
| week1ByPathway[r.pathway].add(r.email); | |
| } | |
| } else if (r.week === 2) { | |
| week2Emails.add(r.email); | |
| if (r.pathway in week2ByPathway) { | |
| week2ByPathway[r.pathway].add(r.email); | |
| } | |
| // Only include records whose pathway is one of the known PATHWAYS | |
| if (!PATHWAYS.includes(r.pathway as (typeof PATHWAYS)[number])) { | |
| continue; | |
| } | |
| const pw = r.pathway as (typeof PATHWAYS)[number]; | |
| if (r.week === 1) { | |
| week1Emails.add(r.email); | |
| week1ByPathway[pw].add(r.email); | |
| } else if (r.week === 2) { | |
| week2Emails.add(r.email); | |
| week2ByPathway[pw].add(r.email); |
| tooltip | ||
| .html(`<strong>${d.data.pathway}</strong><br/>${legendLabels[key]}: ${d[1] - d[0]}`) | ||
| .style('opacity', 1); |
There was a problem hiding this comment.
The tooltip content is built with selection.html(...) and includes d.data.pathway, which ultimately comes from Airtable-driven data. Using .html here makes the widget vulnerable to XSS if that value ever contains markup. Prefer setting text content (or explicitly escaping/sanitizing) when rendering tooltip labels.
| tooltip | |
| .html(`<strong>${d.data.pathway}</strong><br/>${legendLabels[key]}: ${d[1] - d[0]}`) | |
| .style('opacity', 1); | |
| // Clear existing tooltip content and rebuild it using text nodes to avoid XSS. | |
| tooltip.html(''); | |
| tooltip | |
| .append('strong') | |
| .text(d.data.pathway); | |
| tooltip.append('br'); | |
| tooltip | |
| .append('span') | |
| .text(`${legendLabels[key]}: ${d[1] - d[0]}`); | |
| tooltip.style('opacity', 1); |
| const DROPOFF_BASE_ID = 'appBNiSU5dhKUDwMq'; | ||
| const DROPOFF_TABLE_ID = 'tbljEanB1Q0Rup8go'; | ||
| const FIELD_EMAIL = 'fldZXPMbMtKP0EwRW'; | ||
| const FIELD_WEEK = 'fldFDNFjl12NpCCJT'; | ||
| const FIELD_PATHWAY = 'fldKcikYfiJIQzEwT'; | ||
| const FIELD_REJECTED = 'fldWImB6tXMwhXcKn'; |
There was a problem hiding this comment.
The Airtable base/table/field IDs are hard-coded here, while other Airtable endpoints in this repo use env-configured IDs (e.g. env.AIRTABLE_BASE_ID / env.AIRTABLE_YSWS_TABLE_ID). This makes deployments/staging harder and risks committing sensitive identifiers. Consider moving these IDs to private env vars (and failing fast if missing), similar to the existing review/submit endpoints.
Add a drop-off analysis section showing participant retention between Week 1 and Week 2, with a D3 stacked bar chart and breakdown table by pathway. Data is pulled from Airtable (excluding rejected records) and grouped by unique email per week.
Visible to admins (on admin dashboard) and ambassadors (on ambassador dashboard). The API endpoint gates access to users with either role.