From 0b9cfd955c3579ac0aacf05442272fd0e1079c35 Mon Sep 17 00:00:00 2001 From: Alexander Date: Wed, 7 Jan 2026 21:32:04 +0000 Subject: [PATCH 01/30] feat(attendance): add Cohort Overview stats panel - Add CohortOverviewStats interface and calculateCohortOverview helper - Create CohortOverviewCard component mirroring ApprenticeAttendanceCard - Display overall attendance %, status breakdown, and apprentices at risk - Integrate into Cohort Attendance page above Individual Apprentices table - Add "Individual Apprentices" section title --- .claude/loop | 0 docs/plan.md | 230 +++---------------- src/lib/components/CohortOverviewCard.svelte | 87 +++++++ src/lib/types/attendance.ts | 57 +++++ src/routes/admin/attendance/+page.svelte | 12 +- 5 files changed, 181 insertions(+), 205 deletions(-) create mode 100644 .claude/loop create mode 100644 src/lib/components/CohortOverviewCard.svelte diff --git a/.claude/loop b/.claude/loop new file mode 100644 index 0000000..e69de29 diff --git a/docs/plan.md b/docs/plan.md index 9af41e4..71c8422 100644 --- a/docs/plan.md +++ b/docs/plan.md @@ -1,212 +1,34 @@ -# Plan: Consolidate Login & Fix Navigation +# AP-29 Add Cohort Overview stats panel to attendance page -## Overview +> Add a cohort-level summary card to the Cohort Attendance page showing aggregated statistics (overall attendance %, status breakdown, apprentices at risk), mirroring the individual apprentice's "Attendance Stats" card pattern. -Merge staff and student login into a single `/login` page and fix the navigation structure so each user type has a clear flow. +## Tasks -## Current State (Problems) +1. [x] **Add cohort-level stats type and aggregation** + - [x] 1.1 Create `CohortOverviewStats` interface in `attendance.ts` with aggregated counts and apprentice-at-risk count + - [x] 1.2 Create `calculateCohortOverview` helper function to aggregate stats from apprentice array -1. **Two login pages**: `/login` (students) and `/admin/login` (staff) -2. **Useless home page**: `/` just shows "logged in as X" with logout link -3. **Confusing nav**: Admin dashboard has "Back to Home" but `/` is pointless for staff -4. **Broken student flow**: Students land on `/` after login, must manually find `/checkin` -5. **No nav on checkin**: No way to logout or navigate elsewhere +2. [x] **Create CohortOverviewCard component** + - [x] 2.1 Create `CohortOverviewCard.svelte` component mirroring `ApprenticeAttendanceCard` layout + - [x] 2.2 Display overall attendance percentage with color coding + - [x] 2.3 Show Attended group (Present + Late) with totals + - [x] 2.4 Show Excused standalone + - [x] 2.5 Show Missed group (Not Check-in + Absent) with totals + - [x] 2.6 Add "Apprentices at Risk" indicator (count below 80%) -## Desired State +3. [x] **Integrate into Cohort Attendance page** + - [x] 3.1 Import and add `CohortOverviewCard` component above the apprentice table + - [x] 3.2 Calculate cohort overview stats from existing apprentice data + - [x] 3.3 Rename existing table section to "Individual Apprentices" -| User Type | Login | Landing | Primary View | -|-----------|-------|---------|--------------| -| Student | `/login` | `/checkin` | `/checkin` | -| Staff | `/login` | `/admin` | `/admin/*` | +4. [x] **Validation and polish** + - [x] 4.1 Run linter and fix any errors + - [x] 4.2 Verify stats update correctly when filters change + - [x] 4.3 Test with multiple cohorts selected -**Single login flow:** -1. User enters email at `/login` -2. Backend checks Staff table first, then Apprentices table -3. Magic link sent with user type encoded -4. After verification, redirect based on type +## Notes ---- - -## Implementation Steps - -### Step 1: Consolidate Login API - -**File:** `src/routes/api/auth/login/+server.ts` (new unified endpoint) - -Create a single login endpoint that: -1. Checks Staff table first (by collaborator email) -2. If not found, checks Apprentices table (by learner email) -3. Returns appropriate user type in token - -```typescript -export async function POST({ request }) { - const { email } = await request.json(); - - // Try staff first (higher privilege) - const staff = await getStaffByEmail(email); - if (staff) { - const token = await generateToken({ email, type: 'staff' }); - await sendMagicLink(email, token); - return json({ success: true }); - } - - // Try apprentice - const apprentice = await getApprenticeByEmail(email); - if (apprentice) { - const token = await generateToken({ email, type: 'student' }); - await sendMagicLink(email, token); - return json({ success: true }); - } - - return json({ success: false, error: 'Email not found' }, { status: 404 }); -} -``` - -### Step 2: Update Verify Endpoint Redirect - -**File:** `src/routes/api/auth/verify/+server.ts` - -Update redirect after verification: -```typescript -// After setting session cookie -if (user.type === 'staff') { - redirect(303, '/admin'); -} else { - redirect(303, '/checkin'); -} -``` - -### Step 3: Update Login Page - -**File:** `src/routes/login/+page.svelte` - -- Remove any student-specific wording -- Make it generic: "Enter your email to sign in" -- Update form to POST to `/api/auth/login` - -### Step 4: Delete `/admin/login` Route - -Delete the entire `src/routes/admin/login/` folder. No redirects needed - we're building from scratch. - -### Step 5: Update Home Page Redirect - -**File:** `src/routes/+page.server.ts` - -Redirect authenticated users to their landing page: -```typescript -export function load({ locals }) { - if (locals.user) { - if (locals.user.type === 'staff') { - redirect(303, '/admin'); - } else { - redirect(303, '/checkin'); - } - } - // Unauthenticated: show login options or redirect to /login - redirect(303, '/login'); -} -``` - -### Step 6: Update Auth Routes in Hooks - -**File:** `src/hooks.server.ts` - -Simplify AUTH_ROUTES since there's only one login page now: -```typescript -const AUTH_ROUTES = ['/login']; -``` - -### Step 7: Fix Admin Dashboard Navigation - -**File:** `src/routes/admin/+page.svelte` - -Replace "Back to Home" with a proper header: -```svelte -
-
-

Admin Dashboard

-

Welcome, {data.user?.email}

-
- - Logout - -
-``` - -### Step 8: Add Navigation to Checkin Page - -**File:** `src/routes/checkin/+page.svelte` - -Add header with logout (and admin link for staff): -```svelte -
-

Check In

-
- {#if data.user?.type === 'staff'} - Admin - {/if} - Logout -
-
-``` - -### Step 9: Clean Up Old Routes - -Delete these folders: -- `src/routes/api/auth/staff/` -- `src/routes/api/auth/student/` -- `src/routes/admin/login/` - -### Step 10: Update README - -**File:** `README.md` - -Update authentication section to reflect single login: -- Remove separate login page references -- Update route table -- Simplify flow diagram - ---- - -## Files to Modify - -| File | Change | -|------|--------| -| `src/routes/api/auth/login/+server.ts` | New unified login endpoint | -| `src/routes/api/auth/verify/+server.ts` | Update redirect logic | -| `src/routes/login/+page.svelte` | Make generic, update API call | -| `src/routes/+page.svelte` | Remove content | -| `src/routes/+page.server.ts` | Add redirect logic | -| `src/hooks.server.ts` | Simplify AUTH_ROUTES | -| `src/routes/admin/+page.svelte` | Replace "Back to Home" with logout | -| `src/routes/checkin/+page.svelte` | Add header with nav | -| `README.md` | Update auth documentation | - -## Files to Delete - -| File | Reason | -|------|--------| -| `src/routes/api/auth/staff/` | Replaced by unified endpoint | -| `src/routes/api/auth/student/` | Replaced by unified endpoint | -| `src/routes/admin/login/` | Single login at `/login` | - ---- - -## Testing - -1. **Staff login**: Enter staff email → lands on `/admin` -2. **Student login**: Enter student email → lands on `/checkin` -3. **Unknown email**: Shows error "Email not found" -4. **Already logged in**: Visiting `/login` redirects to appropriate landing -5. **Direct URL access**: `/admin/login` redirects to `/login` -6. **Home page**: `/` redirects to landing or login -7. **Logout**: Works from both admin and checkin pages -8. **Staff on checkin**: Sees "Admin" link in header - ---- - -## Risks & Considerations - -1. **Email in both tables**: Not possible - staff use collaborator emails, apprentices use learner emails -2. **Magic links in transit**: Old links will break (low risk - 15min expiry) -3. **Caching**: Clear any cached auth routes after deploy +- Reuse existing `STATUS_STYLES` from `attendance.ts` for consistent colors +- The `ApprenticeAttendanceStats` already contains all the data needed (present, late, absent, excused, notComing) +- Page structure after: Filters → Cohort Overview → Individual Apprentices → Attendance Trend +- Low attendance threshold is 80% (already defined in `ApprenticeAttendanceCard`) diff --git a/src/lib/components/CohortOverviewCard.svelte b/src/lib/components/CohortOverviewCard.svelte new file mode 100644 index 0000000..8c83640 --- /dev/null +++ b/src/lib/components/CohortOverviewCard.svelte @@ -0,0 +1,87 @@ + + +
+
+
+

Cohort Overview

+

{stats.apprenticeCount} apprentice{stats.apprenticeCount !== 1 ? 's' : ''}

+
+
+ + {stats.attendanceRate.toFixed(0)}% + + {#if hasAtRiskApprentices} + + ({stats.apprenticesAtRisk} at risk) + + {/if} +
+
+ +
+ +
+
+
+
{stats.present}
+
Present
+
+
+
{stats.late}
+
Late
+
+
+
+
{stats.attended}
+
Attended
+
+
+ + +
+
{stats.excused}
+
Excused
+
+ + +
+
+
+
{stats.absent}
+
Not Check-in
+
+
+
{stats.notComing}
+
Absent
+
+
+
+
{stats.absent + stats.notComing}
+
Missed
+
+
+
+
diff --git a/src/lib/types/attendance.ts b/src/lib/types/attendance.ts index 6331b17..2c3a851 100644 --- a/src/lib/types/attendance.ts +++ b/src/lib/types/attendance.ts @@ -86,6 +86,63 @@ export interface CohortAttendanceStats extends AttendanceStats { trend: AttendanceTrend; } +/** Aggregated cohort overview stats for the summary card */ +export interface CohortOverviewStats extends AttendanceStats { + apprenticeCount: number; + apprenticesAtRisk: number; // Count of apprentices below 80% attendance +} + +const LOW_ATTENDANCE_THRESHOLD = 80; + +/** + * Calculate aggregated cohort overview stats from an array of apprentice stats + */ +export function calculateCohortOverview(apprentices: ApprenticeAttendanceStats[]): CohortOverviewStats { + if (apprentices.length === 0) { + return { + totalEvents: 0, + attended: 0, + present: 0, + late: 0, + absent: 0, + excused: 0, + notComing: 0, + attendanceRate: 0, + apprenticeCount: 0, + apprenticesAtRisk: 0, + }; + } + + // Aggregate all stats + const totals = apprentices.reduce( + (acc, a) => ({ + totalEvents: acc.totalEvents + a.totalEvents, + attended: acc.attended + a.attended, + present: acc.present + a.present, + late: acc.late + a.late, + absent: acc.absent + a.absent, + excused: acc.excused + a.excused, + notComing: acc.notComing + a.notComing, + }), + { totalEvents: 0, attended: 0, present: 0, late: 0, absent: 0, excused: 0, notComing: 0 }, + ); + + // Count apprentices at risk (below threshold) + const apprenticesAtRisk = apprentices.filter(a => a.attendanceRate < LOW_ATTENDANCE_THRESHOLD).length; + + // Calculate overall attendance rate + const attendanceRate = totals.totalEvents > 0 + ? (totals.attended / totals.totalEvents) * 100 + : 0; + + return { + ...totals, + attendanceRate, + apprenticeCount: apprentices.length, + apprenticesAtRisk, + }; +} + /** Overall attendance summary for dashboard */ export interface AttendanceSummary { overall: AttendanceStats; diff --git a/src/routes/admin/attendance/+page.svelte b/src/routes/admin/attendance/+page.svelte index f39cc25..9719a8f 100644 --- a/src/routes/admin/attendance/+page.svelte +++ b/src/routes/admin/attendance/+page.svelte @@ -4,12 +4,13 @@ import { navigating, page } from '$app/state'; import { SvelteSet, SvelteMap } from 'svelte/reactivity'; import type { ApprenticeAttendanceStats, AttendanceHistoryEntry } from '$lib/types/attendance'; - import { calculateMonthlyAttendance } from '$lib/types/attendance'; + import { calculateMonthlyAttendance, calculateCohortOverview } from '$lib/types/attendance'; import type { Cohort, Term } from '$lib/airtable/sveltekit-wrapper'; import type { AttendanceFilters } from '$lib/types/filters'; import { filtersToParams, parseFiltersFromParams } from '$lib/types/filters'; import AttendanceFiltersComponent from '$lib/components/AttendanceFilters.svelte'; import AttendanceChart from '$lib/components/AttendanceChart.svelte'; + import CohortOverviewCard from '$lib/components/CohortOverviewCard.svelte'; let { data } = $props(); @@ -26,6 +27,9 @@ const monthlyChartData = $derived(calculateMonthlyAttendance(combinedHistory)); const showAll = $derived(data.showAll as boolean); + // Calculate cohort overview stats + const cohortOverview = $derived(calculateCohortOverview(apprentices)); + // Current filters from URL params const currentFilters = $derived(parseFiltersFromParams(page.url.searchParams)); @@ -325,7 +329,13 @@ /> + +
+ +
+
+

Individual Apprentices

From 50f5f2769e25c067e98fd8d754c3d3216e25d004 Mon Sep 17 00:00:00 2001 From: Alexander Date: Wed, 7 Jan 2026 21:33:57 +0000 Subject: [PATCH 02/30] fix(attendance): use consistent 'Attendance Stats' title for both cards --- .claude/loop | 0 src/lib/components/CohortOverviewCard.svelte | 2 +- 2 files changed, 1 insertion(+), 1 deletion(-) delete mode 100644 .claude/loop diff --git a/.claude/loop b/.claude/loop deleted file mode 100644 index e69de29..0000000 diff --git a/src/lib/components/CohortOverviewCard.svelte b/src/lib/components/CohortOverviewCard.svelte index 8c83640..4bf08c0 100644 --- a/src/lib/components/CohortOverviewCard.svelte +++ b/src/lib/components/CohortOverviewCard.svelte @@ -26,7 +26,7 @@ >
-

Cohort Overview

+

Attendance Stats

{stats.apprenticeCount} apprentice{stats.apprenticeCount !== 1 ? 's' : ''}

From 682d87f0afef2f3f51c0b59f8fdbad4c7c3b227d Mon Sep 17 00:00:00 2001 From: Alexander Date: Wed, 7 Jan 2026 21:39:43 +0000 Subject: [PATCH 03/30] feat(attendance): add Event Breakdown panel to cohort view - Add EventBreakdownEntry interface and calculateEventBreakdown helper - Create EventBreakdownCard component showing per-event status counts - Display Present, Late, Excused, Not Check-in, Absent for each event - Place between Attendance Stats and Individual Apprentices sections --- src/lib/components/EventBreakdownCard.svelte | 83 ++++++++++++++++++++ src/lib/types/attendance.ts | 64 +++++++++++++++ src/routes/admin/attendance/+page.svelte | 13 ++- 3 files changed, 158 insertions(+), 2 deletions(-) create mode 100644 src/lib/components/EventBreakdownCard.svelte diff --git a/src/lib/components/EventBreakdownCard.svelte b/src/lib/components/EventBreakdownCard.svelte new file mode 100644 index 0000000..0fda7bb --- /dev/null +++ b/src/lib/components/EventBreakdownCard.svelte @@ -0,0 +1,83 @@ + + +
+

Event Breakdown

+ + {#if events.length === 0} +
+

No events found for selected period

+
+ {:else} +
+ + + + + + + + + + + + + + {#each events as event (event.eventId)} + + + + + + + + + + {/each} + +
EventDatePresentLateExcusedNot Check-inAbsent
+
{event.eventName}
+
+ {formatDate(event.eventDateTime)} + + + {event.present} + + + + {event.late} + + + + {event.excused} + + + + {event.notCheckin} + + + + {event.absent} + +
+
+ {/if} +
diff --git a/src/lib/types/attendance.ts b/src/lib/types/attendance.ts index 2c3a851..3533e3c 100644 --- a/src/lib/types/attendance.ts +++ b/src/lib/types/attendance.ts @@ -162,6 +162,70 @@ export interface AttendanceHistoryEntry { attendanceId: string | null; // Null when no attendance record exists (defaults to 'Not Check-in') } +/** Event breakdown stats for cohort view */ +export interface EventBreakdownEntry { + eventId: string; + eventName: string; + eventDateTime: string; + present: number; + late: number; + excused: number; + notCheckin: number; + absent: number; + total: number; +} + +/** + * Calculate event breakdown from attendance history + * Groups by event and counts each status type + */ +export function calculateEventBreakdown(history: AttendanceHistoryEntry[]): EventBreakdownEntry[] { + if (history.length === 0) return []; + + const eventMap = new Map(); + + for (const entry of history) { + if (!eventMap.has(entry.eventId)) { + eventMap.set(entry.eventId, { + eventId: entry.eventId, + eventName: entry.eventName, + eventDateTime: entry.eventDateTime, + present: 0, + late: 0, + excused: 0, + notCheckin: 0, + absent: 0, + total: 0, + }); + } + + const event = eventMap.get(entry.eventId)!; + event.total++; + + switch (entry.status) { + case 'Present': + event.present++; + break; + case 'Late': + event.late++; + break; + case 'Excused': + event.excused++; + break; + case 'Not Check-in': + event.notCheckin++; + break; + case 'Absent': + event.absent++; + break; + } + } + + // Sort by date (newest first) + return Array.from(eventMap.values()) + .sort((a, b) => new Date(b.eventDateTime).getTime() - new Date(a.eventDateTime).getTime()); +} + /** Monthly attendance data point for charts */ export interface MonthlyAttendancePoint { month: string; // Display format: "Jan 2025" diff --git a/src/routes/admin/attendance/+page.svelte b/src/routes/admin/attendance/+page.svelte index 9719a8f..a52c18f 100644 --- a/src/routes/admin/attendance/+page.svelte +++ b/src/routes/admin/attendance/+page.svelte @@ -4,13 +4,14 @@ import { navigating, page } from '$app/state'; import { SvelteSet, SvelteMap } from 'svelte/reactivity'; import type { ApprenticeAttendanceStats, AttendanceHistoryEntry } from '$lib/types/attendance'; - import { calculateMonthlyAttendance, calculateCohortOverview } from '$lib/types/attendance'; + import { calculateMonthlyAttendance, calculateCohortOverview, calculateEventBreakdown } from '$lib/types/attendance'; import type { Cohort, Term } from '$lib/airtable/sveltekit-wrapper'; import type { AttendanceFilters } from '$lib/types/filters'; import { filtersToParams, parseFiltersFromParams } from '$lib/types/filters'; import AttendanceFiltersComponent from '$lib/components/AttendanceFilters.svelte'; import AttendanceChart from '$lib/components/AttendanceChart.svelte'; import CohortOverviewCard from '$lib/components/CohortOverviewCard.svelte'; + import EventBreakdownCard from '$lib/components/EventBreakdownCard.svelte'; let { data } = $props(); @@ -30,6 +31,9 @@ // Calculate cohort overview stats const cohortOverview = $derived(calculateCohortOverview(apprentices)); + // Calculate event breakdown + const eventBreakdown = $derived(calculateEventBreakdown(combinedHistory)); + // Current filters from URL params const currentFilters = $derived(parseFiltersFromParams(page.url.searchParams)); @@ -329,11 +333,16 @@ />
- +
+ +
+ +
+

Individual Apprentices

From 33d27b5a43792ed2ed561b2bc4d027c970fdaff7 Mon Sep 17 00:00:00 2001 From: Alexander Date: Wed, 7 Jan 2026 21:43:50 +0000 Subject: [PATCH 04/30] feat(attendance): consolidate filters into unified card - Create Filters card containing cohorts and time period sections - Move cohort selection display from Individual Apprentices to top - Simplify Individual Apprentices header (title + count only) - Rename "Filter by" to "Time Period" for clarity --- src/lib/components/AttendanceFilters.svelte | 4 +- src/routes/admin/attendance/+page.svelte | 69 +++++++++++---------- 2 files changed, 39 insertions(+), 34 deletions(-) diff --git a/src/lib/components/AttendanceFilters.svelte b/src/lib/components/AttendanceFilters.svelte index f6add07..457dfe9 100644 --- a/src/lib/components/AttendanceFilters.svelte +++ b/src/lib/components/AttendanceFilters.svelte @@ -163,8 +163,8 @@
-
- Filter by: +
+ Time Period: