From 308ee9bcc1ab8c7c266e54bf06a56349ee1e7379 Mon Sep 17 00:00:00 2001 From: Alexander Date: Mon, 5 Jan 2026 19:09:41 +0000 Subject: [PATCH 01/24] feat: Create route and page structure for `/admin/attendance/apprentices` --- .claude/loop | 0 docs/plan.md | 34 ++- src/routes/admin/+page.svelte | 7 + .../attendance/apprentices/+page.server.ts | 10 + .../admin/attendance/apprentices/+page.svelte | 206 ++++++++++++++++++ 5 files changed, 238 insertions(+), 19 deletions(-) create mode 100644 .claude/loop create mode 100644 src/routes/admin/attendance/apprentices/+page.server.ts create mode 100644 src/routes/admin/attendance/apprentices/+page.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 5d2a326..2a41615 100644 --- a/docs/plan.md +++ b/docs/plan.md @@ -1,26 +1,22 @@ -# AP-27 Attendance service - aggregate queries +# AP-25 Individual apprentice attendance view -> Extend attendance service with aggregate query functions for dashboard metrics. Functions needed: getApprenticeAttendanceStats, getCohortAttendanceStats, getAttendanceSummary. Returns: total events, attended count, attendance rate, late count, excused count, trend data. +> Create a view to track individual apprentice attendance history and rates. List apprentices with metrics, per-apprentice attendance rate, history of events attended/missed, filter by cohort, sort options, and visual indicator for low attendance. ## Tasks -- [x] Define TypeScript types for attendance stats (AttendanceStats, ApprenticeAttendanceStats, CohortAttendanceStats, AttendanceSummary) -- [x] Add getAllAttendance() helper to fetch all attendance records -- [x] Add getAllEvents() helper to fetch all events (needed for total event counts) -- [x] Implement getApprenticeAttendanceStats(apprenticeId) - individual apprentice stats -- [x] Implement getCohortAttendanceStats(cohortId) - cohort aggregate stats -- [x] Implement getAttendanceSummary() - overall summary for dashboard card -- [x] Add trend calculation helper (compare last 4 weeks vs previous 4 weeks) -- [x] Write tests for getApprenticeAttendanceStats -- [x] Write tests for getCohortAttendanceStats -- [x] Write tests for getAttendanceSummary +- [x] Create route and page structure for `/admin/attendance/apprentices` +- [ ] Add server load function to fetch all apprentices with their attendance stats +- [ ] Create ApprenticeAttendanceCard component to display individual metrics +- [ ] Implement attendance history list showing events attended/missed per apprentice +- [ ] Add cohort filter dropdown +- [ ] Add sort functionality (by name, by attendance rate) +- [ ] Add visual indicator for low attendance (below 80%) +- [ ] Add click-through to detailed apprentice view +- [ ] Write tests for the attendance apprentices page ## Notes -- Existing attendance service is in `src/lib/airtable/attendance.ts` -- Existing types in `src/lib/types/attendance.ts` -- Cohort data available via `listCohorts()` and `getApprenticesByCohortId()` in airtable.ts -- Events have cohortIds array (multi-cohort support) -- Status values: Present, Absent, Late, Excused -- Client-side aggregation approach (no Airtable schema changes) -- Attendance rate = (Present + Late) / Total events for cohort +- Use existing `getApprenticeAttendanceStats` from attendance service (AP-27) +- Attendance rate = (attended / total events) * 100 +- Low attendance threshold: 80% +- Filter by cohort uses existing cohort data diff --git a/src/routes/admin/+page.svelte b/src/routes/admin/+page.svelte index bfcccb1..ec3e8b0 100644 --- a/src/routes/admin/+page.svelte +++ b/src/routes/admin/+page.svelte @@ -18,5 +18,12 @@

Events

Create, edit, and manage events for cohorts

+ +

Apprentice Attendance

+

Track individual apprentice attendance rates and history

+
diff --git a/src/routes/admin/attendance/apprentices/+page.server.ts b/src/routes/admin/attendance/apprentices/+page.server.ts new file mode 100644 index 0000000..8dd8cdb --- /dev/null +++ b/src/routes/admin/attendance/apprentices/+page.server.ts @@ -0,0 +1,10 @@ +import type { PageServerLoad } from './$types'; + +// TODO: Implement data loading in next task +export const load: PageServerLoad = async () => { + return { + apprentices: [], + cohorts: [], + selectedCohortId: undefined as string | undefined, + }; +}; diff --git a/src/routes/admin/attendance/apprentices/+page.svelte b/src/routes/admin/attendance/apprentices/+page.svelte new file mode 100644 index 0000000..89bd00e --- /dev/null +++ b/src/routes/admin/attendance/apprentices/+page.svelte @@ -0,0 +1,206 @@ + + +
+
+ ← Back to Admin +

Apprentice Attendance

+

Track individual apprentice attendance history and rates

+
+ + +
+
+ + +
+ +
+ {sortedApprentices.length} apprentice{sortedApprentices.length !== 1 ? 's' : ''} +
+
+ + + {#if sortedApprentices.length === 0} +
+

No apprentices found

+ {#if selectedCohortId} +

Try selecting a different cohort or view all cohorts

+ {/if} +
+ {:else} +
+ + + + + + + + + + + + + {#each sortedApprentices as apprentice} + + + + + + + + + {/each} + +
toggleSort('name')} + > + Name{getSortIndicator('name')} + toggleSort('cohort')} + > + Cohort{getSortIndicator('cohort')} + toggleSort('attendanceRate')} + > + Attendance Rate{getSortIndicator('attendanceRate')} + AttendedTrendActions
+
{apprentice.apprenticeName}
+
+ {apprentice.cohortName || '—'} + + + {apprentice.attendanceRate.toFixed(0)}% + + {#if isLowAttendance(apprentice.attendanceRate)} + + {/if} + + {apprentice.attended}/{apprentice.totalEvents} + + + {getTrendIcon(apprentice.trend.direction)} + + + + +
+
+ {/if} +
From 5fe2c08fc79453c676884256fe6ec61035f360a4 Mon Sep 17 00:00:00 2001 From: Alexander Date: Mon, 5 Jan 2026 19:10:11 +0000 Subject: [PATCH 02/24] docs: clarify two-step workflow in README (plan then start) --- .claude/README.md | 16 +++++++++++----- 1 file changed, 11 insertions(+), 5 deletions(-) diff --git a/.claude/README.md b/.claude/README.md index 318a83d..758a02a 100644 --- a/.claude/README.md +++ b/.claude/README.md @@ -12,20 +12,26 @@ Custom automation for working with Jira tasks. ## Complete Workflow -### 1. Start a Task +### 1. Create the Plan ``` /plan AP-23 ``` This will: -- Fetch AP-23 from Jira +- Fetch AP-23 from Jira (if connection fails, you'll be asked to run `/mcp` → `atlassian` → `4. Reconnect`) - Move it to "In Progress" - Create branch: `feature/ap-23-{slugified-summary}` - Write `docs/plan.md` with checkbox tasks - Create `.claude/loop` (activates the iterator) -### 2. Automatic Loop +**Claude stops here.** Review the plan if you want. + +### 2. Start Implementation + +Say `start` (or any prompt to begin working). This triggers the first task. + +### 3. Automatic Loop The hook (`.claude/hooks/plan-iterator.sh`) runs after each Claude response: @@ -45,13 +51,13 @@ For each task, Claude: 4. Evaluates if `/update-report` is needed 5. Hook triggers → continues to next task -### 3. Loop Ends +### 4. Loop Ends **Automatic:** When all tasks are `[x]`, `.claude/loop` is deleted and loop stops. Claude does NOT auto-start the next Jira ticket. **Manual:** Run `/stop` or `rm .claude/loop` -### 4. Finish (manual steps) +### 5. Finish (manual steps) When the loop ends, you decide what to do next: - Create PR From 77b648307148dd15e259ccc89b8574c1ce84c511 Mon Sep 17 00:00:00 2001 From: Alexander Date: Mon, 5 Jan 2026 19:12:03 +0000 Subject: [PATCH 03/24] feat: Create route and page structure for `/admin/attendance/apprentices` --- .claude/hooks/plan-iterator.sh | 38 +++++++++++----------------------- 1 file changed, 12 insertions(+), 26 deletions(-) diff --git a/.claude/hooks/plan-iterator.sh b/.claude/hooks/plan-iterator.sh index b77b149..b5f349c 100755 --- a/.claude/hooks/plan-iterator.sh +++ b/.claude/hooks/plan-iterator.sh @@ -50,34 +50,20 @@ fi # Find next task NEXT_TASK=$(grep -m1 '^\s*- \[ \]' "$PLAN_FILE" | sed 's/.*\[ \] //' | sed 's/"/\\"/g') -# Build the system message -read -r -d '' MESSAGE << MSGEOF -PLAN ITERATOR: Continue with the next task. +# Build the reason message for Claude +read -r -d '' REASON << MSGEOF +Plan has $REMAINING remaining tasks. Next task: $NEXT_TASK -## Current Progress -Completed: $COMPLETED | Remaining: $REMAINING +After completing this task: +1. Mark it done with [x] in docs/plan.md +2. Consider if it's evidence for assessment criteria (docs/Assessment-criteria.md) - if yes, run /update-report +3. Keep changes small, consistent with existing patterns, no AI attribution -## Next Task -$NEXT_TASK - -## Post-Task Checklist (do after completing the task above) - -1. **Mark done**: Change \`- [ ]\` to \`- [x]\` for the completed task in docs/plan.md - -2. **Report evaluation**: Consider if what you just implemented could serve as evidence for any criteria in docs/Assessment-criteria.md (P1-P11, D1-D4). If yes, run /update-report. If not, move on. - -3. **Working preferences**: - - Keep changes small and focused - - Ensure code is consistent with existing patterns - - Use proven, well-established approaches - - Never add AI attribution - -## To Stop Early -Run /stop or delete .claude/loop +To stop early: run /stop or delete .claude/loop MSGEOF -# Escape message for JSON -ESCAPED_MSG=$(echo "$MESSAGE" | python3 -c 'import sys,json; print(json.dumps(sys.stdin.read())[1:-1])') +# Escape reason for JSON +ESCAPED_REASON=$(echo "$REASON" | python3 -c 'import sys,json; print(json.dumps(sys.stdin.read())[1:-1])') -# Return JSON to continue -echo "{\"continue\": true, \"systemMessage\": \"$ESCAPED_MSG\"}" +# Return JSON with decision: block to prevent Claude from stopping +echo "{\"decision\": \"block\", \"reason\": \"$ESCAPED_REASON\"}" From e30ea515cae789600bdc752caf5598818d0eb7f5 Mon Sep 17 00:00:00 2001 From: Alexander Date: Mon, 5 Jan 2026 19:14:40 +0000 Subject: [PATCH 04/24] feat: Create route and page structure for `/admin/attendance/apprentices` --- .claude/hooks/plan-iterator.sh | 26 +++++++++++++---- .claude/loop | 52 ++++++++++++++++++++++++++++++++++ 2 files changed, 72 insertions(+), 6 deletions(-) diff --git a/.claude/hooks/plan-iterator.sh b/.claude/hooks/plan-iterator.sh index b5f349c..775c8b2 100755 --- a/.claude/hooks/plan-iterator.sh +++ b/.claude/hooks/plan-iterator.sh @@ -52,14 +52,28 @@ NEXT_TASK=$(grep -m1 '^\s*- \[ \]' "$PLAN_FILE" | sed 's/.*\[ \] //' | sed 's/"/ # Build the reason message for Claude read -r -d '' REASON << MSGEOF -Plan has $REMAINING remaining tasks. Next task: $NEXT_TASK +PLAN ITERATOR: Continue with the next task. -After completing this task: -1. Mark it done with [x] in docs/plan.md -2. Consider if it's evidence for assessment criteria (docs/Assessment-criteria.md) - if yes, run /update-report -3. Keep changes small, consistent with existing patterns, no AI attribution +## Current Progress +Completed: $COMPLETED | Remaining: $REMAINING -To stop early: run /stop or delete .claude/loop +## Next Task +$NEXT_TASK + +## Post-Task Checklist (do after completing the task above) + +1. **Mark done**: Change \`- [ ]\` to \`- [x]\` for the completed task in docs/plan.md + +2. **Report evaluation**: Consider if what you just implemented could serve as evidence for any criteria in docs/Assessment-criteria.md (P1-P11, D1-D4). If yes, run /update-report. If not, move on. + +3. **Working preferences**: + - Keep changes small and focused + - Ensure code is consistent with existing patterns + - Use proven, well-established approaches + - Never add AI attribution + +## To Stop Early +Run /stop or delete .claude/loop MSGEOF # Escape reason for JSON diff --git a/.claude/loop b/.claude/loop index e69de29..123ee2f 100644 --- a/.claude/loop +++ b/.claude/loop @@ -0,0 +1,52 @@ + # Find next task + 51 NEXT_TASK=$(grep -m1 '^\s*- \[ \]' "$PLAN_FILE" | sed 's/.*\[ \] //' | sed + 's/"/\\"/g') + 52 + 53 -# Build the system message + 54 -read -r -d '' MESSAGE << MSGEOF + 55 -PLAN ITERATOR: Continue with the next task. + 53 +# Build the reason message for Claude + 54 +read -r -d '' REASON << MSGEOF + 55 +Plan has $REMAINING remaining tasks. Next task: $NEXT_TASK + 56 + 57 -## Current Progress + 58 -Completed: $COMPLETED | Remaining: $REMAINING + 57 +After completing this task: + 58 +1. Mark it done with [x] in docs/plan.md + 59 +2. Consider if it's evidence for assessment criteria + +(docs/Assessment-criteria.md) - if yes, run /update-report + 60 +3. Keep changes small, consistent with existing patterns, no AI attribution + 61 + 62 -## Next Task + 63 -$NEXT_TASK + 64 - + 65 -## Post-Task Checklist (do after completing the task above) + 66 - + 67 -1. **Mark done**: Change \`- [ ]\` to \`- [x]\` for the completed task in + -docs/plan.md + 68 - + 69 -2. **Report evaluation**: Consider if what you just implemented could serve as + -evidence for any criteria in docs/Assessment-criteria.md (P1-P11, D1-D4). If + -yes, run /update-report. If not, move on. + 70 - + 71 -3. **Working preferences**: + 72 - - Keep changes small and focused + 73 - - Ensure code is consistent with existing patterns + 74 - - Use proven, well-established approaches + 75 - - Never add AI attribution + 76 - + 77 -## To Stop Early + 78 -Run /stop or delete .claude/loop + 62 +To stop early: run /stop or delete .claude/loop + 63 MSGEOF + 64 + 65 -# Escape message for JSON + 66 -ESCAPED_MSG=$(echo "$MESSAGE + -" | python3 -c 'import sys,json; print(json.dumps(sys.stdin.read())[1:-1])') + 65 +# Escape reason for JSON + 66 +ESCAPED_REASON=$(echo "$REASON + +" | python3 -c 'import sys,json; print(json.dumps(sys.stdin.read())[1:-1])') + 67 + 68 -# Return JSON to continue + 69 -echo "{\"continue\": true, \"systemMessage\": \"$ESCAPED_MSG\"}" + 68 +# Return JSON with decision: block to prevent Claude from stoppin \ No newline at end of file From 46533c26bcd18db8b79b769cc8d76e018ec7e1bb Mon Sep 17 00:00:00 2001 From: Alexander Date: Mon, 5 Jan 2026 19:14:46 +0000 Subject: [PATCH 05/24] feat: Create route and page structure for `/admin/attendance/apprentices` --- .claude/loop | 52 ---------------------------------------------------- 1 file changed, 52 deletions(-) diff --git a/.claude/loop b/.claude/loop index 123ee2f..e69de29 100644 --- a/.claude/loop +++ b/.claude/loop @@ -1,52 +0,0 @@ - # Find next task - 51 NEXT_TASK=$(grep -m1 '^\s*- \[ \]' "$PLAN_FILE" | sed 's/.*\[ \] //' | sed - 's/"/\\"/g') - 52 - 53 -# Build the system message - 54 -read -r -d '' MESSAGE << MSGEOF - 55 -PLAN ITERATOR: Continue with the next task. - 53 +# Build the reason message for Claude - 54 +read -r -d '' REASON << MSGEOF - 55 +Plan has $REMAINING remaining tasks. Next task: $NEXT_TASK - 56 - 57 -## Current Progress - 58 -Completed: $COMPLETED | Remaining: $REMAINING - 57 +After completing this task: - 58 +1. Mark it done with [x] in docs/plan.md - 59 +2. Consider if it's evidence for assessment criteria - +(docs/Assessment-criteria.md) - if yes, run /update-report - 60 +3. Keep changes small, consistent with existing patterns, no AI attribution - 61 - 62 -## Next Task - 63 -$NEXT_TASK - 64 - - 65 -## Post-Task Checklist (do after completing the task above) - 66 - - 67 -1. **Mark done**: Change \`- [ ]\` to \`- [x]\` for the completed task in - -docs/plan.md - 68 - - 69 -2. **Report evaluation**: Consider if what you just implemented could serve as - -evidence for any criteria in docs/Assessment-criteria.md (P1-P11, D1-D4). If - -yes, run /update-report. If not, move on. - 70 - - 71 -3. **Working preferences**: - 72 - - Keep changes small and focused - 73 - - Ensure code is consistent with existing patterns - 74 - - Use proven, well-established approaches - 75 - - Never add AI attribution - 76 - - 77 -## To Stop Early - 78 -Run /stop or delete .claude/loop - 62 +To stop early: run /stop or delete .claude/loop - 63 MSGEOF - 64 - 65 -# Escape message for JSON - 66 -ESCAPED_MSG=$(echo "$MESSAGE - -" | python3 -c 'import sys,json; print(json.dumps(sys.stdin.read())[1:-1])') - 65 +# Escape reason for JSON - 66 +ESCAPED_REASON=$(echo "$REASON - +" | python3 -c 'import sys,json; print(json.dumps(sys.stdin.read())[1:-1])') - 67 - 68 -# Return JSON to continue - 69 -echo "{\"continue\": true, \"systemMessage\": \"$ESCAPED_MSG\"}" - 68 +# Return JSON with decision: block to prevent Claude from stoppin \ No newline at end of file From b120f567acb5b445737b9b93eaa2db944ade1699 Mon Sep 17 00:00:00 2001 From: Alexander Date: Mon, 5 Jan 2026 19:15:09 +0000 Subject: [PATCH 06/24] feat: Create route and page structure for `/admin/attendance/apprentices` --- .claude/hooks/plan-iterator.sh | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.claude/hooks/plan-iterator.sh b/.claude/hooks/plan-iterator.sh index 775c8b2..991bf2c 100755 --- a/.claude/hooks/plan-iterator.sh +++ b/.claude/hooks/plan-iterator.sh @@ -37,13 +37,13 @@ COMPLETED=$(grep -c '^\s*- \[x\]' "$PLAN_FILE" 2>/dev/null | head -1 || echo "0" COMPLETED=${COMPLETED:-0} # Stage all changes FIRST -git add -A 2>/dev/null || true +git add -A >/dev/null 2>&1 || true # Then commit if there are staged changes if ! git diff --cached --quiet 2>/dev/null; then if [[ "$COMPLETED" -gt 0 ]]; then LAST_DONE=$(grep -E '^\s*- \[x\]' "$PLAN_FILE" | tail -1 | sed 's/.*\[x\] //') - git commit -m "feat: $LAST_DONE" 2>/dev/null || true + git commit -m "feat: $LAST_DONE" >/dev/null 2>&1 || true fi fi From a2565c7b93cc1380000120b867c1376cb3fc1511 Mon Sep 17 00:00:00 2001 From: Alexander Date: Mon, 5 Jan 2026 19:44:51 +0000 Subject: [PATCH 07/24] feat(attendance): implement apprentice attendance view with cohort selection - Add cohort selection UI on initial page load (no data fetch until selected) - Support multiple cohort selection with visual feedback - Add "Show All Apprentices" option with warning modal for slow fetch - Add loading spinner overlay during data fetch - Create ApprenticeAttendanceCard component for displaying metrics - Add detail page for individual apprentice attendance history - Add getApprenticeAttendanceHistory function with tests - Sort cohort tiles alphabetically - Add AttendanceHistoryEntry type --- docs/plan.md | 16 +- src/lib/airtable/attendance.spec.ts | 122 ++++++- src/lib/airtable/attendance.ts | 82 +++++ src/lib/airtable/sveltekit-wrapper.ts | 17 +- .../ApprenticeAttendanceCard.svelte | 91 +++++ src/lib/types/attendance.ts | 9 + .../attendance/apprentices/+page.server.ts | 87 ++++- .../admin/attendance/apprentices/+page.svelte | 327 ++++++++++++------ .../apprentices/[id]/+page.server.ts | 25 ++ .../attendance/apprentices/[id]/+page.svelte | 100 ++++++ 10 files changed, 752 insertions(+), 124 deletions(-) create mode 100644 src/lib/components/ApprenticeAttendanceCard.svelte create mode 100644 src/routes/admin/attendance/apprentices/[id]/+page.server.ts create mode 100644 src/routes/admin/attendance/apprentices/[id]/+page.svelte diff --git a/docs/plan.md b/docs/plan.md index 2a41615..f0ffc2b 100644 --- a/docs/plan.md +++ b/docs/plan.md @@ -5,14 +5,14 @@ ## Tasks - [x] Create route and page structure for `/admin/attendance/apprentices` -- [ ] Add server load function to fetch all apprentices with their attendance stats -- [ ] Create ApprenticeAttendanceCard component to display individual metrics -- [ ] Implement attendance history list showing events attended/missed per apprentice -- [ ] Add cohort filter dropdown -- [ ] Add sort functionality (by name, by attendance rate) -- [ ] Add visual indicator for low attendance (below 80%) -- [ ] Add click-through to detailed apprentice view -- [ ] Write tests for the attendance apprentices page +- [x] Add server load function to fetch all apprentices with their attendance stats +- [x] Create ApprenticeAttendanceCard component to display individual metrics +- [x] Implement attendance history list showing events attended/missed per apprentice +- [x] Add cohort filter dropdown +- [x] Add sort functionality (by name, by attendance rate) +- [x] Add visual indicator for low attendance (below 80%) +- [x] Add click-through to detailed apprentice view +- [x] Write tests for the attendance apprentices page ## Notes diff --git a/src/lib/airtable/attendance.spec.ts b/src/lib/airtable/attendance.spec.ts index 03f0720..12e2735 100644 --- a/src/lib/airtable/attendance.spec.ts +++ b/src/lib/airtable/attendance.spec.ts @@ -1,6 +1,6 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; import { createAttendanceClient } from './attendance'; -import { EVENT_FIELDS, ATTENDANCE_FIELDS } from './config'; +import { EVENT_FIELDS, ATTENDANCE_FIELDS, APPRENTICE_FIELDS } from './config'; // Mock Airtable vi.mock('airtable', () => { @@ -421,4 +421,124 @@ describe('attendance', () => { expect(summary.recentCheckIns).toBe(0); }); }); + + describe('getApprenticeAttendanceHistory', () => { + it('should return empty array when apprentice not found', async () => { + mockTable.select.mockReturnValue({ all: vi.fn().mockResolvedValue([]) }); + + const history = await client.getApprenticeAttendanceHistory('nonexistent'); + + expect(history).toEqual([]); + }); + + it('should return history entries with attendance status', async () => { + // Mock apprentice + const mockApprentice = { + id: 'recApprentice1', + get: vi.fn((field: string) => { + const data: Record = { + [APPRENTICE_FIELDS.NAME]: 'Test Apprentice', + [APPRENTICE_FIELDS.COHORT]: ['recCohort1'], + }; + return data[field]; + }), + }; + + // Mock events for cohort + const mockEvents = [ + { + id: 'recEvent1', + get: vi.fn((field: string) => { + const data: Record = { + [EVENT_FIELDS.NAME]: 'Monday Class', + [EVENT_FIELDS.DATE_TIME]: '2025-01-06T09:00:00.000Z', + [EVENT_FIELDS.COHORT]: ['recCohort1'], + }; + return data[field]; + }), + }, + { + id: 'recEvent2', + get: vi.fn((field: string) => { + const data: Record = { + [EVENT_FIELDS.NAME]: 'Tuesday Class', + [EVENT_FIELDS.DATE_TIME]: '2025-01-07T09:00:00.000Z', + [EVENT_FIELDS.COHORT]: ['recCohort1'], + }; + return data[field]; + }), + }, + ]; + + // Mock attendance (only for first event) + const mockAttendance = [ + { + id: 'recAtt1', + get: vi.fn((field: string) => { + const data: Record = { + [ATTENDANCE_FIELDS.EVENT]: ['recEvent1'], + [ATTENDANCE_FIELDS.CHECKIN_TIME]: '2025-01-06T09:05:00.000Z', + [ATTENDANCE_FIELDS.STATUS]: 'Present', + }; + return data[field]; + }), + }, + ]; + + mockTable.select + .mockReturnValueOnce({ all: vi.fn().mockResolvedValue([mockApprentice]) }) // get apprentice + .mockReturnValueOnce({ all: vi.fn().mockResolvedValue(mockEvents) }) // get events + .mockReturnValueOnce({ all: vi.fn().mockResolvedValue(mockAttendance) }); // get attendance + + const history = await client.getApprenticeAttendanceHistory('recApprentice1'); + + expect(history).toHaveLength(2); + // History is sorted by date descending (most recent first) + expect(history[0].eventName).toBe('Tuesday Class'); + expect(history[0].status).toBe('Missed'); // No attendance record + expect(history[1].eventName).toBe('Monday Class'); + expect(history[1].status).toBe('Present'); + expect(history[1].checkinTime).toBe('2025-01-06T09:05:00.000Z'); + }); + + it('should mark events without attendance as Missed', async () => { + // Mock apprentice without cohort + const mockApprentice = { + id: 'recApprentice1', + get: vi.fn((field: string) => { + const data: Record = { + [APPRENTICE_FIELDS.NAME]: 'Test Apprentice', + [APPRENTICE_FIELDS.COHORT]: undefined, + }; + return data[field]; + }), + }; + + // Mock events (all events since no cohort) + const mockEvents = [ + { + id: 'recEvent1', + get: vi.fn((field: string) => { + const data: Record = { + [EVENT_FIELDS.NAME]: 'Event 1', + [EVENT_FIELDS.DATE_TIME]: '2025-01-06T09:00:00.000Z', + [EVENT_FIELDS.COHORT]: [], + }; + return data[field]; + }), + }, + ]; + + mockTable.select + .mockReturnValueOnce({ all: vi.fn().mockResolvedValue([mockApprentice]) }) + .mockReturnValueOnce({ all: vi.fn().mockResolvedValue(mockEvents) }) + .mockReturnValueOnce({ all: vi.fn().mockResolvedValue([]) }); // No attendance + + const history = await client.getApprenticeAttendanceHistory('recApprentice1'); + + expect(history).toHaveLength(1); + expect(history[0].status).toBe('Missed'); + expect(history[0].checkinTime).toBeNull(); + }); + }); }); diff --git a/src/lib/airtable/attendance.ts b/src/lib/airtable/attendance.ts index 6b3f1bf..0494c2e 100644 --- a/src/lib/airtable/attendance.ts +++ b/src/lib/airtable/attendance.ts @@ -11,6 +11,7 @@ import type { ApprenticeAttendanceStats, CohortAttendanceStats, AttendanceSummary, + AttendanceHistoryEntry, } from '../types/attendance.js'; export function createAttendanceClient(apiKey: string, baseId: string) { @@ -633,6 +634,86 @@ export function createAttendanceClient(apiKey: string, baseId: string) { }; } + /** + * Get attendance history for a specific apprentice + * Returns a list of events with their attendance status + */ + async function getApprenticeAttendanceHistory(apprenticeId: string): Promise { + const apprenticesTable = base(TABLES.APPRENTICES); + + // Get apprentice info to find their cohort + const apprenticeRecords = await apprenticesTable + .select({ + filterByFormula: `RECORD_ID() = "${apprenticeId}"`, + maxRecords: 1, + returnFieldsByFieldId: true, + }) + .all(); + + if (apprenticeRecords.length === 0) { + return []; + } + + const apprentice = apprenticeRecords[0]; + const cohortLink = apprentice.get(APPRENTICE_FIELDS.COHORT) as string[] | undefined; + const cohortId = cohortLink?.[0] ?? null; + + // Get all events for this apprentice's cohort + const allEvents = await eventsTable + .select({ + returnFieldsByFieldId: true, + }) + .all(); + + const relevantEvents = cohortId + ? allEvents.filter((e) => { + const cohortIds = e.get(EVENT_FIELDS.COHORT) as string[] | undefined; + return cohortIds?.includes(cohortId); + }) + : allEvents; + + // Get all attendance records for this apprentice + const attendanceRecords = await attendanceTable + .select({ + filterByFormula: `{${ATTENDANCE_FIELDS.APPRENTICE}} = "${apprenticeId}"`, + returnFieldsByFieldId: true, + }) + .all(); + + // Create a map of eventId -> attendance record + const attendanceMap = new Map(); + for (const record of attendanceRecords) { + const eventLink = record.get(ATTENDANCE_FIELDS.EVENT) as string[] | undefined; + const eventId = eventLink?.[0]; + if (eventId) { + attendanceMap.set(eventId, { + id: record.id, + eventId, + apprenticeId, + checkinTime: record.get(ATTENDANCE_FIELDS.CHECKIN_TIME) as string, + status: (record.get(ATTENDANCE_FIELDS.STATUS) as Attendance['status']) ?? 'Present', + }); + } + } + + // Build the history entries + const history: AttendanceHistoryEntry[] = relevantEvents.map((event) => { + const attendance = attendanceMap.get(event.id); + return { + eventId: event.id, + eventName: (event.get(EVENT_FIELDS.NAME) as string) || 'Unnamed Event', + eventDateTime: event.get(EVENT_FIELDS.DATE_TIME) as string, + status: attendance ? attendance.status : 'Missed', + checkinTime: attendance?.checkinTime ?? null, + }; + }); + + // Sort by date (most recent first) + history.sort((a, b) => new Date(b.eventDateTime).getTime() - new Date(a.eventDateTime).getTime()); + + return history; + } + return { hasUserCheckedIn, hasExternalCheckedIn, @@ -647,5 +728,6 @@ export function createAttendanceClient(apiKey: string, baseId: string) { getApprenticeAttendanceStats, getCohortAttendanceStats, getAttendanceSummary, + getApprenticeAttendanceHistory, }; } diff --git a/src/lib/airtable/sveltekit-wrapper.ts b/src/lib/airtable/sveltekit-wrapper.ts index a7aafa6..913e9a6 100644 --- a/src/lib/airtable/sveltekit-wrapper.ts +++ b/src/lib/airtable/sveltekit-wrapper.ts @@ -17,7 +17,16 @@ import { createAttendanceClient } from './attendance.js'; export type { Apprentice, ApprenticeRecord, Cohort } from './airtable.js'; export type { Event, EventFilters, CreateEventInput, UpdateEventInput } from '$lib/types/event.js'; -export type { Attendance, CreateAttendanceInput, CreateExternalAttendanceInput, UpdateAttendanceInput } from '$lib/types/attendance.js'; +export type { + Attendance, + CreateAttendanceInput, + CreateExternalAttendanceInput, + UpdateAttendanceInput, + ApprenticeAttendanceStats, + CohortAttendanceStats, + AttendanceSummary, + AttendanceHistoryEntry, +} from '$lib/types/attendance.js'; const client = createAirtableClient(AIRTABLE_API_KEY, AIRTABLE_BASE_ID_LEARNERS); const eventsClient = createEventsClient(AIRTABLE_API_KEY, AIRTABLE_BASE_ID_LEARNERS); @@ -47,3 +56,9 @@ export const createExternalAttendance = attendanceClient.createExternalAttendanc export const updateAttendance = attendanceClient.updateAttendance; export const getAttendanceForEvent = attendanceClient.getAttendanceForEvent; export const getAttendanceByIds = attendanceClient.getAttendanceByIds; + +// Attendance statistics +export const getApprenticeAttendanceStats = attendanceClient.getApprenticeAttendanceStats; +export const getCohortAttendanceStats = attendanceClient.getCohortAttendanceStats; +export const getAttendanceSummary = attendanceClient.getAttendanceSummary; +export const getApprenticeAttendanceHistory = attendanceClient.getApprenticeAttendanceHistory; diff --git a/src/lib/components/ApprenticeAttendanceCard.svelte b/src/lib/components/ApprenticeAttendanceCard.svelte new file mode 100644 index 0000000..4115fb7 --- /dev/null +++ b/src/lib/components/ApprenticeAttendanceCard.svelte @@ -0,0 +1,91 @@ + + + diff --git a/src/lib/types/attendance.ts b/src/lib/types/attendance.ts index 776d341..cea6b7a 100644 --- a/src/lib/types/attendance.ts +++ b/src/lib/types/attendance.ts @@ -79,3 +79,12 @@ export interface AttendanceSummary { lowAttendanceCount: number; // Apprentices below 80% recentCheckIns: number; // Check-ins in last 7 days } + +/** Attendance history entry for a single event */ +export interface AttendanceHistoryEntry { + eventId: string; + eventName: string; + eventDateTime: string; + status: AttendanceStatus | 'Missed'; // Missed = no attendance record + checkinTime: string | null; +} diff --git a/src/routes/admin/attendance/apprentices/+page.server.ts b/src/routes/admin/attendance/apprentices/+page.server.ts index 8dd8cdb..2710c01 100644 --- a/src/routes/admin/attendance/apprentices/+page.server.ts +++ b/src/routes/admin/attendance/apprentices/+page.server.ts @@ -1,10 +1,83 @@ import type { PageServerLoad } from './$types'; +import { + listCohorts, + getApprenticesByCohortId, + getApprenticeAttendanceStats, +} from '$lib/airtable/sveltekit-wrapper'; +import type { ApprenticeAttendanceStats } from '$lib/types/attendance'; -// TODO: Implement data loading in next task -export const load: PageServerLoad = async () => { - return { - apprentices: [], - cohorts: [], - selectedCohortId: undefined as string | undefined, - }; +export const load: PageServerLoad = async ({ url }) => { + // Support multiple cohorts via comma-separated IDs + const cohortParam = url.searchParams.get('cohorts'); + const selectedCohortIds = cohortParam ? cohortParam.split(',').filter(Boolean) : []; + const showAll = url.searchParams.get('all') === 'true'; + + try { + // Always fetch cohorts for the selection UI + const cohorts = await listCohorts(); + + // If no cohort selected and not showing all, return early with just cohorts + if (selectedCohortIds.length === 0 && !showAll) { + return { + apprentices: [], + cohorts, + selectedCohortIds, + showAll: false, + needsSelection: true, + }; + } + + // Collect apprentice IDs based on selection + let apprenticeIds: string[] = []; + + if (showAll) { + // Get all apprentices from all cohorts + for (const cohort of cohorts) { + const apprentices = await getApprenticesByCohortId(cohort.id); + apprenticeIds.push(...apprentices.map(a => a.id)); + } + } + else { + // Get apprentices from selected cohorts only + for (const cohortId of selectedCohortIds) { + const apprentices = await getApprenticesByCohortId(cohortId); + apprenticeIds.push(...apprentices.map(a => a.id)); + } + } + + // Deduplicate in case an apprentice is in multiple cohorts + apprenticeIds = [...new Set(apprenticeIds)]; + + // Fetch attendance stats for each apprentice + const apprenticeStats: ApprenticeAttendanceStats[] = []; + for (const apprenticeId of apprenticeIds) { + try { + const stats = await getApprenticeAttendanceStats(apprenticeId); + if (stats) { + apprenticeStats.push(stats); + } + } + catch (err) { + console.error(`[attendance/apprentices] Error fetching stats for ${apprenticeId}:`, err); + } + } + + return { + apprentices: apprenticeStats, + cohorts, + selectedCohortIds, + showAll, + needsSelection: false, + }; + } + catch (err) { + console.error('[attendance/apprentices] Error loading data:', err); + return { + apprentices: [], + cohorts: [], + selectedCohortIds, + showAll: false, + needsSelection: true, + }; + } }; diff --git a/src/routes/admin/attendance/apprentices/+page.svelte b/src/routes/admin/attendance/apprentices/+page.svelte index 89bd00e..3656abc 100644 --- a/src/routes/admin/attendance/apprentices/+page.svelte +++ b/src/routes/admin/attendance/apprentices/+page.svelte @@ -1,23 +1,43 @@ + +
+
+ ← Back to Apprentices +

{stats.apprenticeName}

+ {#if stats.cohortName} +

{stats.cohortName}

+ {/if} +
+ + +
+ +
+ + +
+

Attendance History

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

No events found for this apprentice

+
+ {:else} +
+ + + + + + + + + + + {#each history as entry (entry.eventId)} + + + + + + + {/each} + +
EventDate & TimeStatusCheck-in Time
+
{entry.eventName}
+
+ {formatDateTime(entry.eventDateTime)} + + + {entry.status} + + + {formatCheckinTime(entry.checkinTime)} +
+
+ {/if} +
+
From 31a37d191f7973fd21034fc81538b5e2d864527f Mon Sep 17 00:00:00 2001 From: Alexander Date: Mon, 5 Jan 2026 19:48:45 +0000 Subject: [PATCH 08/24] fix(attendance): include events attended outside cohort in history Events are now included in apprentice history if: 1. Event is for apprentice's cohort (marked Missed if no attendance) 2. Apprentice has attendance record (regardless of cohort) 3. All events if apprentice has no cohort --- src/lib/airtable/attendance.ts | 56 +++++++++++++++++++++++----------- 1 file changed, 39 insertions(+), 17 deletions(-) diff --git a/src/lib/airtable/attendance.ts b/src/lib/airtable/attendance.ts index 0494c2e..1803bb6 100644 --- a/src/lib/airtable/attendance.ts +++ b/src/lib/airtable/attendance.ts @@ -665,13 +665,6 @@ export function createAttendanceClient(apiKey: string, baseId: string) { }) .all(); - const relevantEvents = cohortId - ? allEvents.filter((e) => { - const cohortIds = e.get(EVENT_FIELDS.COHORT) as string[] | undefined; - return cohortIds?.includes(cohortId); - }) - : allEvents; - // Get all attendance records for this apprentice const attendanceRecords = await attendanceTable .select({ @@ -696,17 +689,46 @@ export function createAttendanceClient(apiKey: string, baseId: string) { } } + // Include events that are either: + // 1. For the apprentice's cohort (expected events - show as Missed if no attendance) + // 2. Have an attendance record (apprentice checked in, even if not their cohort) + // 3. All events if apprentice has no cohort + const relevantEventIds = new Set(); + + if (cohortId) { + // Add cohort events + for (const event of allEvents) { + const cohortIds = event.get(EVENT_FIELDS.COHORT) as string[] | undefined; + if (cohortIds?.includes(cohortId)) { + relevantEventIds.add(event.id); + } + } + } + else { + // No cohort - include all events + for (const event of allEvents) { + relevantEventIds.add(event.id); + } + } + + // Add any events the apprentice has attendance for (regardless of cohort) + for (const eventId of attendanceMap.keys()) { + relevantEventIds.add(eventId); + } + // Build the history entries - const history: AttendanceHistoryEntry[] = relevantEvents.map((event) => { - const attendance = attendanceMap.get(event.id); - return { - eventId: event.id, - eventName: (event.get(EVENT_FIELDS.NAME) as string) || 'Unnamed Event', - eventDateTime: event.get(EVENT_FIELDS.DATE_TIME) as string, - status: attendance ? attendance.status : 'Missed', - checkinTime: attendance?.checkinTime ?? null, - }; - }); + const history: AttendanceHistoryEntry[] = allEvents + .filter((event) => relevantEventIds.has(event.id)) + .map((event) => { + const attendance = attendanceMap.get(event.id); + return { + eventId: event.id, + eventName: (event.get(EVENT_FIELDS.NAME) as string) || 'Unnamed Event', + eventDateTime: event.get(EVENT_FIELDS.DATE_TIME) as string, + status: attendance ? attendance.status : 'Missed', + checkinTime: attendance?.checkinTime ?? null, + }; + }); // Sort by date (most recent first) history.sort((a, b) => new Date(b.eventDateTime).getTime() - new Date(a.eventDateTime).getTime()); From ba82543ff0ba4965d771abb0fde50a85d1bc7767 Mon Sep 17 00:00:00 2001 From: Alexander Date: Mon, 5 Jan 2026 19:56:06 +0000 Subject: [PATCH 09/24] fix(attendance): filter attendance records in JavaScript instead of Airtable formula Airtable filterByFormula with linked fields matches display values, not record IDs. Changed to fetch all attendance and filter in JS to correctly match apprentice by record ID. --- src/lib/airtable/attendance.spec.ts | 1 + src/lib/airtable/attendance.ts | 12 +++++++++--- 2 files changed, 10 insertions(+), 3 deletions(-) diff --git a/src/lib/airtable/attendance.spec.ts b/src/lib/airtable/attendance.spec.ts index 12e2735..f3c6fad 100644 --- a/src/lib/airtable/attendance.spec.ts +++ b/src/lib/airtable/attendance.spec.ts @@ -477,6 +477,7 @@ describe('attendance', () => { get: vi.fn((field: string) => { const data: Record = { [ATTENDANCE_FIELDS.EVENT]: ['recEvent1'], + [ATTENDANCE_FIELDS.APPRENTICE]: ['recApprentice1'], [ATTENDANCE_FIELDS.CHECKIN_TIME]: '2025-01-06T09:05:00.000Z', [ATTENDANCE_FIELDS.STATUS]: 'Present', }; diff --git a/src/lib/airtable/attendance.ts b/src/lib/airtable/attendance.ts index 1803bb6..a154155 100644 --- a/src/lib/airtable/attendance.ts +++ b/src/lib/airtable/attendance.ts @@ -665,14 +665,20 @@ export function createAttendanceClient(apiKey: string, baseId: string) { }) .all(); - // Get all attendance records for this apprentice - const attendanceRecords = await attendanceTable + // Get all attendance records and filter by apprentice ID in JavaScript + // (filterByFormula with linked fields matches display value, not record ID) + const allAttendanceRecords = await attendanceTable .select({ - filterByFormula: `{${ATTENDANCE_FIELDS.APPRENTICE}} = "${apprenticeId}"`, returnFieldsByFieldId: true, }) .all(); + // Filter to only records for this apprentice + const attendanceRecords = allAttendanceRecords.filter((record) => { + const apprenticeLink = record.get(ATTENDANCE_FIELDS.APPRENTICE) as string[] | undefined; + return apprenticeLink?.includes(apprenticeId); + }); + // Create a map of eventId -> attendance record const attendanceMap = new Map(); for (const record of attendanceRecords) { From 13b938bfa3a9c0bdb5408afe5e699d6f4bfbe705 Mon Sep 17 00:00:00 2001 From: Alexander Date: Mon, 5 Jan 2026 20:04:08 +0000 Subject: [PATCH 10/24] fix(checkin): prevent duplicate check-ins by filtering in JavaScript The hasUserCheckedIn function was using Airtable filterByFormula with linked record field IDs, which doesn't work correctly. Changed to fetch all attendance and filter in JavaScript to properly match by record ID. This fixes the bug where users could check in multiple times to the same event. --- src/lib/airtable/attendance.spec.ts | 46 ++++++++++++++++++++++++----- src/lib/airtable/attendance.ts | 11 +++++-- 2 files changed, 46 insertions(+), 11 deletions(-) diff --git a/src/lib/airtable/attendance.spec.ts b/src/lib/airtable/attendance.spec.ts index f3c6fad..56869dd 100644 --- a/src/lib/airtable/attendance.spec.ts +++ b/src/lib/airtable/attendance.spec.ts @@ -35,20 +35,39 @@ describe('attendance', () => { describe('hasUserCheckedIn', () => { it('should return true when user has checked in', async () => { - const mockRecords = [{ id: 'recAttendance1' }]; + const mockRecords = [ + { + id: 'recAttendance1', + get: vi.fn((field: string) => { + const data: Record = { + [ATTENDANCE_FIELDS.EVENT]: ['recEvent1'], + [ATTENDANCE_FIELDS.APPRENTICE]: ['recApprentice1'], + }; + return data[field]; + }), + }, + ]; mockTable.select.mockReturnValue({ all: vi.fn().mockResolvedValue(mockRecords) }); const result = await client.hasUserCheckedIn('recEvent1', 'recApprentice1'); expect(result).toBe(true); - expect(mockTable.select).toHaveBeenCalledWith({ - filterByFormula: `AND({${ATTENDANCE_FIELDS.EVENT}} = "recEvent1", {${ATTENDANCE_FIELDS.APPRENTICE}} = "recApprentice1")`, - maxRecords: 1, - }); }); it('should return false when user has not checked in', async () => { - mockTable.select.mockReturnValue({ all: vi.fn().mockResolvedValue([]) }); + const mockRecords = [ + { + id: 'recAttendance1', + get: vi.fn((field: string) => { + const data: Record = { + [ATTENDANCE_FIELDS.EVENT]: ['recOtherEvent'], + [ATTENDANCE_FIELDS.APPRENTICE]: ['recOtherApprentice'], + }; + return data[field]; + }), + }, + ]; + mockTable.select.mockReturnValue({ all: vi.fn().mockResolvedValue(mockRecords) }); const result = await client.hasUserCheckedIn('recEvent1', 'recApprentice1'); @@ -121,8 +140,19 @@ describe('attendance', () => { const mockEventRecord = { get: vi.fn(() => false), }; - // Mock hasUserCheckedIn - duplicate exists - const mockExistingAttendance = [{ id: 'recExisting' }]; + // Mock hasUserCheckedIn - duplicate exists (with proper get method for JS filtering) + const mockExistingAttendance = [ + { + id: 'recExisting', + get: vi.fn((field: string) => { + const data: Record = { + [ATTENDANCE_FIELDS.EVENT]: ['recEvent1'], + [ATTENDANCE_FIELDS.APPRENTICE]: ['recApprentice1'], + }; + return data[field]; + }), + }, + ]; mockTable.select .mockReturnValueOnce({ all: vi.fn().mockResolvedValue([mockEventRecord]) }) diff --git a/src/lib/airtable/attendance.ts b/src/lib/airtable/attendance.ts index a154155..ffcdcf2 100644 --- a/src/lib/airtable/attendance.ts +++ b/src/lib/airtable/attendance.ts @@ -64,14 +64,19 @@ export function createAttendanceClient(apiKey: string, baseId: string) { * Check if a registered user has already checked in to an event */ async function hasUserCheckedIn(eventId: string, apprenticeId: string): Promise { + // Fetch all attendance for this event and filter in JavaScript + // (filterByFormula with linked fields matches display value, not record ID) const records = await attendanceTable .select({ - filterByFormula: `AND({${ATTENDANCE_FIELDS.EVENT}} = "${eventId}", {${ATTENDANCE_FIELDS.APPRENTICE}} = "${apprenticeId}")`, - maxRecords: 1, + returnFieldsByFieldId: true, }) .all(); - return records.length > 0; + return records.some((record) => { + const eventLink = record.get(ATTENDANCE_FIELDS.EVENT) as string[] | undefined; + const apprenticeLink = record.get(ATTENDANCE_FIELDS.APPRENTICE) as string[] | undefined; + return eventLink?.includes(eventId) && apprenticeLink?.includes(apprenticeId); + }); } /** From e9af3a017916d86b1ca3729b2ad65ee8c360ed72 Mon Sep 17 00:00:00 2001 From: Alexander Date: Mon, 5 Jan 2026 20:06:08 +0000 Subject: [PATCH 11/24] feat(checkin): sort events by most recent first Events on the checkin page are now sorted with the most recent events at the top for better UX. --- src/routes/checkin/+page.server.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/routes/checkin/+page.server.ts b/src/routes/checkin/+page.server.ts index 288126f..25e175c 100644 --- a/src/routes/checkin/+page.server.ts +++ b/src/routes/checkin/+page.server.ts @@ -67,6 +67,9 @@ export const load: PageServerLoad = async ({ locals }) => { }), ); + // Sort by date (most recent first) + eventsWithStatus.sort((a, b) => new Date(b.dateTime).getTime() - new Date(a.dateTime).getTime()); + return { authenticated: true, events: eventsWithStatus, From 7a677aa60600a223499968bfd312ce7555a0eae9 Mon Sep 17 00:00:00 2001 From: Alexander Date: Mon, 5 Jan 2026 20:10:24 +0000 Subject: [PATCH 12/24] feat(checkin): add styled header with user info - Added blue gradient header with welcome message - Display logged-in user's name and email - Improved visual hierarchy for the checkin page - Fixed lint error in attendance filter --- src/lib/airtable/attendance.ts | 2 +- src/routes/checkin/+page.server.ts | 5 +++ src/routes/checkin/+page.svelte | 67 ++++++++++++++++++++++++++++-- 3 files changed, 69 insertions(+), 5 deletions(-) diff --git a/src/lib/airtable/attendance.ts b/src/lib/airtable/attendance.ts index ffcdcf2..1235b23 100644 --- a/src/lib/airtable/attendance.ts +++ b/src/lib/airtable/attendance.ts @@ -729,7 +729,7 @@ export function createAttendanceClient(apiKey: string, baseId: string) { // Build the history entries const history: AttendanceHistoryEntry[] = allEvents - .filter((event) => relevantEventIds.has(event.id)) + .filter(event => relevantEventIds.has(event.id)) .map((event) => { const attendance = attendanceMap.get(event.id); return { diff --git a/src/routes/checkin/+page.server.ts b/src/routes/checkin/+page.server.ts index 25e175c..e178f78 100644 --- a/src/routes/checkin/+page.server.ts +++ b/src/routes/checkin/+page.server.ts @@ -19,6 +19,7 @@ export const load: PageServerLoad = async ({ locals }) => { authenticated: false, events: [] as CheckinEvent[], checkInMethod: null, + user: null, }; } @@ -74,5 +75,9 @@ export const load: PageServerLoad = async ({ locals }) => { authenticated: true, events: eventsWithStatus, checkInMethod: apprentice ? 'apprentice' : 'external', + user: { + name: apprentice?.name || null, + email: user.email, + }, }; }; diff --git a/src/routes/checkin/+page.svelte b/src/routes/checkin/+page.svelte index 31aff4c..919043f 100644 --- a/src/routes/checkin/+page.svelte +++ b/src/routes/checkin/+page.svelte @@ -183,10 +183,21 @@
-

Check In

- {#if data.authenticated} + + {#if data.events.length === 0}

No events available for check-in right now.

@@ -232,6 +243,10 @@ {:else} +
{#if guestStep === 'code'}

Enter the 4-digit event code displayed at the venue.

@@ -329,12 +344,56 @@