Skip to content

Add Week 1 → Week 2 drop-off analytics widget#50

Open
dhamariT wants to merge 2 commits intomainfrom
feat/dropoff-widget
Open

Add Week 1 → Week 2 drop-off analytics widget#50
dhamariT wants to merge 2 commits intomainfrom
feat/dropoff-widget

Conversation

@dhamariT
Copy link
Copy Markdown
Collaborator

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.

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.
Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 DropOffWidget to both /app/admin and /app/ambassador dashboards.
  • Introduces /api/analytics/dropoff to fetch and aggregate retention/drop-off metrics from Airtable (excluding rejected records in code).
  • Adds a new DropOffWidget.svelte component with a stacked bar chart + breakdown table, and introduces d3 dependencies.

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;
Copy link

Copilot AI Mar 29, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
await new Promise<void>((resolve, reject) => {
base(DROPOFF_TABLE_ID)
.select({
fields: [FIELD_EMAIL, FIELD_WEEK, FIELD_PATHWAY, FIELD_REJECTED]
Copy link

Copilot AI Mar 29, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
fields: [FIELD_EMAIL, FIELD_WEEK, FIELD_PATHWAY, FIELD_REJECTED]
fields: [FIELD_EMAIL, FIELD_WEEK, FIELD_PATHWAY, FIELD_REJECTED],
filterByFormula: `NOT({${FIELD_REJECTED}})`

Copilot uses AI. Check for mistakes.
Comment on lines +54 to +58
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) {
Copy link

Copilot AI Mar 29, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
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) {

Copilot uses AI. Check for mistakes.
Comment on lines +83 to +92
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);
}
Copy link

Copilot AI Mar 29, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
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);

Copilot uses AI. Check for mistakes.
Comment on lines +148 to +150
tooltip
.html(`<strong>${d.data.pathway}</strong><br/>${legendLabels[key]}: ${d[1] - d[0]}`)
.style('opacity', 1);
Copy link

Copilot AI Mar 29, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
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);

Copilot uses AI. Check for mistakes.
Comment on lines +10 to +15
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';
Copy link

Copilot AI Mar 29, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants