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 diff --git a/.claude/hooks/plan-iterator.sh b/.claude/hooks/plan-iterator.sh index b77b149..991bf2c 100755 --- a/.claude/hooks/plan-iterator.sh +++ b/.claude/hooks/plan-iterator.sh @@ -37,21 +37,21 @@ 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 # Find next task NEXT_TASK=$(grep -m1 '^\s*- \[ \]' "$PLAN_FILE" | sed 's/.*\[ \] //' | sed 's/"/\\"/g') -# Build the system message -read -r -d '' MESSAGE << MSGEOF +# Build the reason message for Claude +read -r -d '' REASON << MSGEOF PLAN ITERATOR: Continue with the next task. ## Current Progress @@ -76,8 +76,8 @@ $NEXT_TASK 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\"}" diff --git a/.mcp.json b/.mcp.json new file mode 100644 index 0000000..6342799 --- /dev/null +++ b/.mcp.json @@ -0,0 +1,24 @@ +{ + "mcpServers": { + "atlassian": { + "type": "sse", + "url": "https://mcp.atlassian.com/v1/sse" + }, + "github": { + "type": "stdio", + "command": "npx", + "args": ["-y", "@modelcontextprotocol/server-github"], + "env": { + "GITHUB_PERSONAL_ACCESS_TOKEN": "${GITHUB_PERSONAL_ACCESS_TOKEN}" + } + }, + "postman": { + "type": "stdio", + "command": "npx", + "args": ["@postman/postman-mcp-server@latest"], + "env": { + "POSTMAN_API_KEY": "${POSTMAN_API_KEY}" + } + } + } +} diff --git a/docs/plan.md b/docs/plan.md index 5d2a326..f0ffc2b 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` +- [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 -- 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/docs/scratchpad.md b/docs/scratchpad.md index ca45b41..b6d09b2 100644 --- a/docs/scratchpad.md +++ b/docs/scratchpad.md @@ -1,13 +1,15 @@ +Attendance is not showing names? (check Airtable) -td elements reduce padding +Not coming status -Checkin page show who I am -When checked in disable button or show that I already logged in. Now allows multiple +Login page pretty +Staff - Apprentice pulse, now has the student email. Use this for checkin as a student, not as an external + Show mii plaza @@ -15,8 +17,6 @@ Integration with LUMA -security for not checkin if not close to date. - On readme how give permissions \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index a25b13b..c0e33ed 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1520,7 +1520,6 @@ "integrity": "sha512-Vp3zX/qlwerQmHMP6x0Ry1oY7eKKRcOWGc2P59srOp4zcqyn+etJyQpELgOi4+ZSUgteX8Y387NuwruLgGXLUQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@standard-schema/spec": "^1.0.0", "@sveltejs/acorn-typescript": "^1.0.5", @@ -1560,7 +1559,6 @@ "integrity": "sha512-YZs/OSKOQAQCnJvM/P+F1URotNnYNeU3P2s4oIpzm1uFaqUEqRxUB0g5ejMjEb5Gjb9/PiBI5Ktrq4rUUF8UVQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@sveltejs/vite-plugin-svelte-inspector": "^5.0.0", "debug": "^4.4.1", @@ -1976,7 +1974,6 @@ "integrity": "sha512-6/cmF2piao+f6wSxUsJLZjck7OQsYyRtcOZS02k7XINSNlz93v6emM8WutDQSXnroG2xwYlEVHJI+cPA7CPM3Q==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.50.0", "@typescript-eslint/types": "8.50.0", @@ -2217,7 +2214,6 @@ "integrity": "sha512-I2Fy/ANdphi1yI46d15o0M1M4M0UJrUiVKkH5oKeRZZCdPg0fw/cfTKZzv9Ge9eobtJYp4BGblMzXdXH0vcl5g==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@vitest/browser": "4.0.16", "@vitest/mocker": "4.0.16", @@ -2370,7 +2366,6 @@ "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "license": "MIT", - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -2837,7 +2832,6 @@ "integrity": "sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -4044,7 +4038,6 @@ "integrity": "sha512-ilYQj1s8sr2ppEJ2YVadYBN0Mb3mdo9J0wQ+UuDhzYqURwSoW4n1Xs5vs7ORwgDGmyEh33tRMeS8KhdkMoLXQw==", "dev": true, "license": "Apache-2.0", - "peer": true, "dependencies": { "playwright-core": "1.57.0" }, @@ -4101,7 +4094,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", @@ -4544,7 +4536,6 @@ "integrity": "sha512-ZhLtvroYxUxr+HQJfMZEDRsGsmU46x12RvAv/zi9584f5KOX7bUrEbhPJ7cKFmUvZTJXi/CFZUYwDC6M1FigPw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@jridgewell/remapping": "^2.3.4", "@jridgewell/sourcemap-codec": "^1.5.0", @@ -4762,7 +4753,6 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "dev": true, "license": "Apache-2.0", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -4847,7 +4837,6 @@ "integrity": "sha512-dZwN5L1VlUBewiP6H9s2+B3e3Jg96D0vzN+Ry73sOefebhYr9f94wwkMNN/9ouoU8pV1BqA1d1zGk8928cx0rg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "esbuild": "^0.27.0", "fdir": "^6.5.0", @@ -4958,7 +4947,6 @@ "integrity": "sha512-E4t7DJ9pESL6E3I8nFjPa4xGUd3PmiWDLsDztS2qXSJWfHtbQnwAWylaBvSNY48I3vr8PTqIZlyK8TE3V3CA4Q==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@vitest/expect": "4.0.16", "@vitest/mocker": "4.0.16", diff --git a/src/lib/airtable/attendance.spec.ts b/src/lib/airtable/attendance.spec.ts index 03f0720..56869dd 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', () => { @@ -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]) }) @@ -421,4 +451,125 @@ 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.APPRENTICE]: ['recApprentice1'], + [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..6856155 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) { @@ -63,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); + }); } /** @@ -87,6 +93,77 @@ export function createAttendanceClient(apiKey: string, baseId: string) { return records.length > 0; } + /** + * Get a user's attendance record for a specific event (if exists) + */ + async function getUserAttendanceForEvent(eventId: string, apprenticeId: string): Promise { + const records = await attendanceTable + .select({ + returnFieldsByFieldId: true, + }) + .all(); + + const record = records.find((r) => { + const eventLink = r.get(ATTENDANCE_FIELDS.EVENT) as string[] | undefined; + const apprenticeLink = r.get(ATTENDANCE_FIELDS.APPRENTICE) as string[] | undefined; + return eventLink?.includes(eventId) && apprenticeLink?.includes(apprenticeId); + }); + + if (!record) { + return null; + } + + return { + id: record.id, + eventId, + apprenticeId, + checkinTime: record.get(ATTENDANCE_FIELDS.CHECKIN_TIME) as string, + status: (record.get(ATTENDANCE_FIELDS.STATUS) as Attendance['status']) ?? 'Present', + }; + } + + /** + * Mark a user as "Not Coming" for an event + * - Creates new attendance record if none exists + * - Returns existing record if already marked "Not Coming" + * - Throws error if user already checked in (Present/Late) + */ + async function markNotComing(input: CreateAttendanceInput): Promise { + // Validate event exists + const eventInfo = await getEventInfo(input.eventId); + if (!eventInfo.exists) { + throw new Error('Event not found'); + } + + // Check if user already has an attendance record + const existing = await getUserAttendanceForEvent(input.eventId, input.apprenticeId); + if (existing) { + if (existing.status === 'Not Coming') { + // Already marked as not coming - idempotent + return existing; + } + // Already checked in (Present/Late/Absent/Excused) + throw new Error('User already has an attendance record for this event'); + } + + // Create new attendance record with "Not Coming" status + const fields: Airtable.FieldSet = { + [ATTENDANCE_FIELDS.EVENT]: [input.eventId], + [ATTENDANCE_FIELDS.APPRENTICE]: [input.apprenticeId], + [ATTENDANCE_FIELDS.STATUS]: 'Not Coming', + }; + + const record = await attendanceTable.create(fields); + + return { + id: record.id, + eventId: input.eventId, + apprenticeId: input.apprenticeId, + checkinTime: '', // No check-in time for "Not Coming" + status: 'Not Coming', + }; + } + /** * Create attendance record for a registered user * @throws Error if event doesn't exist or user already checked in @@ -331,6 +408,7 @@ export function createAttendanceClient(apiKey: string, baseId: string) { const late = attendanceRecords.filter(a => a.status === 'Late').length; const absent = attendanceRecords.filter(a => a.status === 'Absent').length; const excused = attendanceRecords.filter(a => a.status === 'Excused').length; + const notComing = attendanceRecords.filter(a => a.status === 'Not Coming').length; const attended = present + late; const attendanceRate = totalEvents > 0 @@ -344,6 +422,7 @@ export function createAttendanceClient(apiKey: string, baseId: string) { late, absent, excused, + notComing, attendanceRate, }; } @@ -633,11 +712,121 @@ 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(); + + // 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({ + 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) { + 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', + }); + } + } + + // 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[] = 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()); + + return history; + } + return { hasUserCheckedIn, hasExternalCheckedIn, + getUserAttendanceForEvent, createAttendance, createExternalAttendance, + markNotComing, updateAttendance, getAttendanceForEvent, getAttendanceByIds, @@ -647,5 +836,6 @@ export function createAttendanceClient(apiKey: string, baseId: string) { getApprenticeAttendanceStats, getCohortAttendanceStats, getAttendanceSummary, + getApprenticeAttendanceHistory, }; } diff --git a/src/lib/airtable/events.ts b/src/lib/airtable/events.ts index 197020a..2c38399 100644 --- a/src/lib/airtable/events.ts +++ b/src/lib/airtable/events.ts @@ -193,10 +193,44 @@ export function createEventsClient(apiKey: string, baseId: string) { }; } + /** + * Get all public events with a given check-in code for today and tomorrow + */ + async function getEventsByCode(code: number): Promise { + const now = new Date(); + const today = now.toISOString().split('T')[0]; + const dayAfterTomorrow = new Date(now.getTime() + 2 * 24 * 60 * 60 * 1000).toISOString().split('T')[0]; + + const records = await eventsTable + .select({ + filterByFormula: `AND({Number} = ${code}, {Public} = TRUE(), IS_AFTER({Date Time}, "${today}"), IS_BEFORE({Date Time}, "${dayAfterTomorrow}"))`, + returnFieldsByFieldId: true, + }) + .all(); + + return records.map((record) => { + const cohortLookup = record.get(EVENT_FIELDS.COHORT) as string[] | undefined; + const attendanceLinks = record.get(EVENT_FIELDS.ATTENDANCE) as string[] | undefined; + return { + id: record.id, + name: record.get(EVENT_FIELDS.NAME) as string, + dateTime: record.get(EVENT_FIELDS.DATE_TIME) as string, + endDateTime: record.get(EVENT_FIELDS.END_DATE_TIME) as string | undefined, + cohortIds: cohortLookup ?? [], + eventType: record.get(EVENT_FIELDS.EVENT_TYPE) as EventType, + surveyUrl: record.get(EVENT_FIELDS.SURVEY) as string | undefined, + isPublic: true, + checkInCode: record.get(EVENT_FIELDS.CHECK_IN_CODE) as number | undefined, + attendanceCount: attendanceLinks?.length ?? 0, + }; + }); + } + return { listEvents, getEvent, getEventByCode, + getEventsByCode, createEvent, updateEvent, deleteEvent, diff --git a/src/lib/airtable/sveltekit-wrapper.ts b/src/lib/airtable/sveltekit-wrapper.ts index a7aafa6..a6aa0b2 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); @@ -35,6 +44,7 @@ export const getApprenticesByIds = client.getApprenticesByIds; export const listEvents = eventsClient.listEvents; export const getEvent = eventsClient.getEvent; export const getEventByCode = eventsClient.getEventByCode; +export const getEventsByCode = eventsClient.getEventsByCode; export const createEvent = eventsClient.createEvent; export const updateEvent = eventsClient.updateEvent; export const deleteEvent = eventsClient.deleteEvent; @@ -42,8 +52,16 @@ export const deleteEvent = eventsClient.deleteEvent; // Attendance export const hasUserCheckedIn = attendanceClient.hasUserCheckedIn; export const hasExternalCheckedIn = attendanceClient.hasExternalCheckedIn; +export const getUserAttendanceForEvent = attendanceClient.getUserAttendanceForEvent; export const createAttendance = attendanceClient.createAttendance; export const createExternalAttendance = attendanceClient.createExternalAttendance; +export const markNotComing = attendanceClient.markNotComing; 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..3597860 --- /dev/null +++ b/src/lib/components/ApprenticeAttendanceCard.svelte @@ -0,0 +1,95 @@ + + + diff --git a/src/lib/types/attendance.ts b/src/lib/types/attendance.ts index 776d341..861fa0c 100644 --- a/src/lib/types/attendance.ts +++ b/src/lib/types/attendance.ts @@ -1,5 +1,5 @@ // Attendance status options -export const ATTENDANCE_STATUSES = ['Present', 'Absent', 'Late', 'Excused'] as const; +export const ATTENDANCE_STATUSES = ['Present', 'Absent', 'Late', 'Excused', 'Not Coming'] as const; export type AttendanceStatus = typeof ATTENDANCE_STATUSES[number]; export interface Attendance { @@ -43,6 +43,7 @@ export interface AttendanceStats { late: number; absent: number; excused: number; + notComing: number; attendanceRate: number; // 0-100 percentage } @@ -79,3 +80,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/+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..2710c01 --- /dev/null +++ b/src/routes/admin/attendance/apprentices/+page.server.ts @@ -0,0 +1,83 @@ +import type { PageServerLoad } from './$types'; +import { + listCohorts, + getApprenticesByCohortId, + getApprenticeAttendanceStats, +} from '$lib/airtable/sveltekit-wrapper'; +import type { ApprenticeAttendanceStats } from '$lib/types/attendance'; + +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 new file mode 100644 index 0000000..a267a02 --- /dev/null +++ b/src/routes/admin/attendance/apprentices/+page.svelte @@ -0,0 +1,419 @@ + + +
+
+ ← Back to Admin +

Apprentice Attendance

+

Track individual apprentice attendance history and rates

+
+ + + {#if isLoading} +
+
+
+

Loading attendance data...

+

This may take a moment

+
+
+ {/if} + + + {#if needsSelection} +
+

Select Cohorts

+

Choose one or more cohorts to view apprentice attendance data.

+ +
+ {#each groupedCohorts as group (group.prefix)} +
+ +
+ {#each group.cohorts as cohort (cohort.id)} + + {/each} +
+
+ {/each} +
+ +
+ + + +
+ + + {#if showAllWarning} +
+
+

Load All Apprentices?

+

+ Loading attendance data for all cohorts may take 30 seconds or more depending on the number of apprentices. +

+
+ + +
+
+
+ {/if} +
+ {:else} + +
+
+ Showing: + {#if showAll} + All Cohorts + {:else} + {#each selectedCohortIds as cohortId (cohortId)} + {@const cohort = cohorts.find(c => c.id === cohortId)} + {#if cohort} + {cohort.name} + {/if} + {/each} + {/if} + +
+ +
+ {sortedApprentices.length} apprentice{sortedApprentices.length !== 1 ? 's' : ''} +
+
+ + + {#if sortedApprentices.length === 0} +
+

No apprentices found

+
+ {:else} +
+ + + + + + + + + + + + + {#each sortedApprentices as apprentice (apprentice.apprenticeId)} + + + + + + + + + {/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)} + + + + View Details + +
+
+ {/if} + {/if} +
diff --git a/src/routes/admin/attendance/apprentices/[id]/+page.server.ts b/src/routes/admin/attendance/apprentices/[id]/+page.server.ts new file mode 100644 index 0000000..ffbd7b0 --- /dev/null +++ b/src/routes/admin/attendance/apprentices/[id]/+page.server.ts @@ -0,0 +1,25 @@ +import type { PageServerLoad } from './$types'; +import { error } from '@sveltejs/kit'; +import { + getApprenticeAttendanceStats, + getApprenticeAttendanceHistory, +} from '$lib/airtable/sveltekit-wrapper'; + +export const load: PageServerLoad = async ({ params }) => { + const { id } = params; + + // Fetch apprentice stats + const stats = await getApprenticeAttendanceStats(id); + + if (!stats) { + throw error(404, 'Apprentice not found'); + } + + // Fetch attendance history + const history = await getApprenticeAttendanceHistory(id); + + return { + stats, + history, + }; +}; diff --git a/src/routes/admin/attendance/apprentices/[id]/+page.svelte b/src/routes/admin/attendance/apprentices/[id]/+page.svelte new file mode 100644 index 0000000..ad54e5d --- /dev/null +++ b/src/routes/admin/attendance/apprentices/[id]/+page.svelte @@ -0,0 +1,101 @@ + + +
+
+ ← 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} +
+
diff --git a/src/routes/admin/events/+page.svelte b/src/routes/admin/events/+page.svelte index c05cbe0..49dbb6c 100644 --- a/src/routes/admin/events/+page.svelte +++ b/src/routes/admin/events/+page.svelte @@ -368,7 +368,7 @@ }); // Expandable row state - type AttendanceStatus = 'Present' | 'Absent' | 'Late' | 'Excused'; + type AttendanceStatus = 'Present' | 'Absent' | 'Late' | 'Excused' | 'Not Coming'; interface RosterEntry { id: string; // Apprentice ID or external attendance ID attendanceId?: string; // Attendance record ID (undefined if not checked in yet) @@ -380,10 +380,11 @@ } const statusStyles: Record = { - Present: 'bg-green-100 text-green-700', - Absent: 'bg-red-100 text-red-700', - Late: 'bg-yellow-100 text-yellow-700', - Excused: 'bg-blue-100 text-blue-700', + 'Present': 'bg-green-100 text-green-700', + 'Absent': 'bg-red-100 text-red-700', + 'Late': 'bg-yellow-100 text-yellow-700', + 'Excused': 'bg-blue-100 text-blue-700', + 'Not Coming': 'bg-orange-100 text-orange-700', }; let expandedEventId = $state(null); let expandedEventDateTime = $state(null); diff --git a/src/routes/api/checkin/+server.ts b/src/routes/api/checkin/+server.ts index c8e1df7..e54bea0 100644 --- a/src/routes/api/checkin/+server.ts +++ b/src/routes/api/checkin/+server.ts @@ -5,10 +5,24 @@ import { getApprenticeByEmail, createAttendance, createExternalAttendance, - hasUserCheckedIn, + getUserAttendanceForEvent, hasExternalCheckedIn, + updateAttendance, + getEvent, } from '$lib/airtable/sveltekit-wrapper'; +/** + * Determine attendance status based on check-in time vs event start time + */ +function determineStatus(eventDateTime: string | null): 'Present' | 'Late' { + if (!eventDateTime) { + return 'Present'; + } + const eventTime = new Date(eventDateTime); + const now = new Date(); + return now > eventTime ? 'Late' : 'Present'; +} + export const POST: RequestHandler = async ({ cookies, request }) => { const session = getSession(cookies); @@ -36,8 +50,33 @@ export const POST: RequestHandler = async ({ cookies, request }) => { if (apprentice) { // Apprentice flow: check in using apprentice ID - const alreadyCheckedIn = await hasUserCheckedIn(eventId, apprentice.id); - if (alreadyCheckedIn) { + const existingAttendance = await getUserAttendanceForEvent(eventId, apprentice.id); + + if (existingAttendance) { + // Handle "Not Coming" → "Check In" transition + if (existingAttendance.status === 'Not Coming') { + const event = await getEvent(eventId); + const status = determineStatus(event?.dateTime ?? null); + + const updatedAttendance = await updateAttendance(existingAttendance.id, { + status, + checkinTime: new Date().toISOString(), + }); + + return json({ + success: true, + checkInMethod: 'apprentice', + attendance: { + id: updatedAttendance.id, + eventId: updatedAttendance.eventId, + apprenticeId: updatedAttendance.apprenticeId, + checkinTime: updatedAttendance.checkinTime, + status: updatedAttendance.status, + }, + }); + } + + // Already checked in with another status return json({ success: false, error: 'Already checked in to this event' }, { status: 409 }); } diff --git a/src/routes/api/checkin/not-coming/+server.ts b/src/routes/api/checkin/not-coming/+server.ts new file mode 100644 index 0000000..9fa57df --- /dev/null +++ b/src/routes/api/checkin/not-coming/+server.ts @@ -0,0 +1,58 @@ +import { json } from '@sveltejs/kit'; +import type { RequestHandler } from './$types'; +import { getSession } from '$lib/server/session'; +import { getApprenticeByEmail, markNotComing } from '$lib/airtable/sveltekit-wrapper'; + +export const POST: RequestHandler = async ({ cookies, request }) => { + const session = getSession(cookies); + + if (!session) { + return json({ success: false, error: 'Authentication required' }, { status: 401 }); + } + + let body: { eventId?: string }; + try { + body = await request.json(); + } + catch { + return json({ success: false, error: 'Invalid JSON body' }, { status: 400 }); + } + + const { eventId } = body; + + if (!eventId) { + return json({ success: false, error: 'eventId is required' }, { status: 400 }); + } + + try { + // Only registered apprentices can mark "Not Coming" + const apprentice = await getApprenticeByEmail(session.email); + + if (!apprentice) { + return json({ + success: false, + error: 'Only registered apprentices can mark as not coming', + }, { status: 403 }); + } + + const attendance = await markNotComing({ + eventId, + apprenticeId: apprentice.id, + }); + + return json({ + success: true, + attendance: { + id: attendance.id, + eventId: attendance.eventId, + apprenticeId: attendance.apprenticeId, + status: attendance.status, + }, + }); + } + catch (error) { + console.error('Failed to mark as not coming:', error); + const message = error instanceof Error ? error.message : 'Failed to mark as not coming'; + return json({ success: false, error: message }, { status: 500 }); + } +}; diff --git a/src/routes/api/checkin/validate-code/+server.ts b/src/routes/api/checkin/validate-code/+server.ts index c9fe83a..3d49e4e 100644 --- a/src/routes/api/checkin/validate-code/+server.ts +++ b/src/routes/api/checkin/validate-code/+server.ts @@ -1,6 +1,6 @@ import { json } from '@sveltejs/kit'; import type { RequestHandler } from './$types'; -import { getEventByCode } from '$lib/airtable/sveltekit-wrapper'; +import { getEventsByCode } from '$lib/airtable/sveltekit-wrapper'; export const POST: RequestHandler = async ({ request }) => { let body: { code?: string }; @@ -24,20 +24,24 @@ export const POST: RequestHandler = async ({ request }) => { } try { - const event = await getEventByCode(codeNum); + const events = await getEventsByCode(codeNum); - if (!event) { - return json({ valid: false, error: 'Invalid code or event not found' }, { status: 404 }); + if (events.length === 0) { + return json({ valid: false, error: 'Invalid code or no events found for today/tomorrow' }, { status: 404 }); } + // Sort by date (most recent first) + events.sort((a, b) => new Date(a.dateTime).getTime() - new Date(b.dateTime).getTime()); + return json({ valid: true, - event: { + events: events.map(event => ({ id: event.id, name: event.name, dateTime: event.dateTime, eventType: event.eventType, - }, + attendanceCount: event.attendanceCount ?? 0, + })), }); } catch (error) { diff --git a/src/routes/api/events/[id]/roster/+server.ts b/src/routes/api/events/[id]/roster/+server.ts index abf05ab..e08e7e4 100644 --- a/src/routes/api/events/[id]/roster/+server.ts +++ b/src/routes/api/events/[id]/roster/+server.ts @@ -101,8 +101,8 @@ export const GET: RequestHandler = async ({ params }) => { } } - // Sort: Present first, then Late, Excused, Absent last; then alphabetically - const statusOrder: Record = { Present: 0, Late: 1, Excused: 2, Absent: 3 }; + // Sort: Present first, then Late, Excused, Not Coming, Absent last; then alphabetically + const statusOrder: Record = { 'Present': 0, 'Late': 1, 'Excused': 2, 'Not Coming': 3, 'Absent': 4 }; roster.sort((a, b) => { const statusDiff = statusOrder[a.status] - statusOrder[b.status]; if (statusDiff !== 0) return statusDiff; diff --git a/src/routes/checkin/+page.server.ts b/src/routes/checkin/+page.server.ts index 288126f..49e6a71 100644 --- a/src/routes/checkin/+page.server.ts +++ b/src/routes/checkin/+page.server.ts @@ -1,5 +1,7 @@ import type { PageServerLoad } from './$types'; -import { getApprenticeByEmail, listEvents, hasUserCheckedIn, hasExternalCheckedIn } from '$lib/airtable/sveltekit-wrapper'; +import { getApprenticeByEmail, listEvents, listCohorts, getUserAttendanceForEvent, hasExternalCheckedIn } from '$lib/airtable/sveltekit-wrapper'; + +export type AttendanceStatusUI = 'none' | 'checked-in' | 'not-coming'; export interface CheckinEvent { id: string; @@ -7,7 +9,9 @@ export interface CheckinEvent { dateTime: string; eventType: string; isPublic: boolean; - alreadyCheckedIn: boolean; + attendanceStatus: AttendanceStatusUI; + attendanceCount: number; + expectedCount: number; } export const load: PageServerLoad = async ({ locals }) => { @@ -19,17 +23,22 @@ export const load: PageServerLoad = async ({ locals }) => { authenticated: false, events: [] as CheckinEvent[], checkInMethod: null, + user: null, }; } // Authenticated - fetch events based on apprentice record const apprentice = await getApprenticeByEmail(user.email); - // Get events - filter by cohort if apprentice has one + // Get events and cohorts const now = new Date(); - const allEvents = await listEvents({ - startDate: now.toISOString().split('T')[0], - }); + const [allEvents, cohorts] = await Promise.all([ + listEvents({ startDate: now.toISOString().split('T')[0] }), + listCohorts(), + ]); + + // Build cohort lookup for expected counts + const cohortApprenticeCount = new Map(cohorts.map(c => [c.id, c.apprenticeCount])); // Filter events based on user type let availableEvents; @@ -47,29 +56,50 @@ export const load: PageServerLoad = async ({ locals }) => { // Check attendance status for each event const eventsWithStatus: CheckinEvent[] = await Promise.all( availableEvents.map(async (event) => { - let alreadyCheckedIn = false; + let attendanceStatus: AttendanceStatusUI = 'none'; if (apprentice) { - alreadyCheckedIn = await hasUserCheckedIn(event.id, apprentice.id); + const attendance = await getUserAttendanceForEvent(event.id, apprentice.id); + if (attendance) { + attendanceStatus = attendance.status === 'Not Coming' ? 'not-coming' : 'checked-in'; + } } else { - alreadyCheckedIn = await hasExternalCheckedIn(event.id, user.email); + const hasCheckedIn = await hasExternalCheckedIn(event.id, user.email); + if (hasCheckedIn) { + attendanceStatus = 'checked-in'; + } } + // Calculate expected count from cohorts + const expectedCount = event.cohortIds.reduce( + (sum, cohortId) => sum + (cohortApprenticeCount.get(cohortId) || 0), + 0, + ); + return { id: event.id, name: event.name, dateTime: event.dateTime, eventType: event.eventType, isPublic: event.isPublic, - alreadyCheckedIn, + attendanceStatus, + attendanceCount: event.attendanceCount ?? 0, + expectedCount, }; }), ); + // 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, 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..3f3354b 100644 --- a/src/routes/checkin/+page.svelte +++ b/src/routes/checkin/+page.svelte @@ -5,16 +5,27 @@ let { data }: { data: PageData } = $props(); + // Make events reactive - need $state for mutation in event handlers + // eslint-disable-next-line svelte/prefer-writable-derived + let events = $state([]); + + // Update events when data changes (page navigation) + $effect(() => { + events = [...data.events]; + }); + // Check-in state for authenticated users let checkingIn = $state(null); + let markingNotComing = $state(null); let checkInError = $state(null); // Guest check-in state - let guestStep = $state<'code' | 'details' | 'success'>('code'); + let guestStep = $state<'code' | 'events' | 'details' | 'success'>('code'); let guestCode = $state(''); let guestName = $state(''); let guestEmail = $state(''); - let guestEvent = $state<{ id: string; name: string; dateTime: string } | null>(null); + let guestEvents = $state<{ id: string; name: string; dateTime: string; eventType: string; attendanceCount: number }[]>([]); + let guestSelectedEvent = $state<{ id: string; name: string; dateTime: string; eventType: string; attendanceCount: number } | null>(null); let guestError = $state(''); let guestLoading = $state(false); @@ -25,11 +36,19 @@ }, 1000); onDestroy(() => clearInterval(timerInterval)); + // Check if event is today + function isToday(dateTime: string): boolean { + const eventDate = new Date(dateTime); + const today = new Date(now); + return eventDate.toDateString() === today.toDateString(); + } + // Calculate countdown/late status for an event - function getTimeStatus(dateTime: string): { text: string; isLate: boolean; isStartingSoon: boolean } { + function getTimeStatus(dateTime: string): { text: string; isLate: boolean; isStartingSoon: boolean; canCheckIn: boolean } { const eventTime = new Date(dateTime).getTime(); const diff = eventTime - now; const absDiff = Math.abs(diff); + const eventIsToday = isToday(dateTime); const hours = Math.floor(absDiff / (1000 * 60 * 60)); const minutes = Math.floor((absDiff % (1000 * 60 * 60)) / (1000 * 60)); @@ -41,7 +60,7 @@ if (hours > 0) text += `${hours}h `; if (hours > 0 || minutes > 0) text += `${minutes}m `; text += `${seconds}s`; - return { text, isLate: false, isStartingSoon: diff < 10 * 60 * 1000 }; // < 10 min + return { text, isLate: false, isStartingSoon: diff < 10 * 60 * 1000, canCheckIn: eventIsToday }; } else { // Event has started - user is late @@ -50,7 +69,7 @@ if (hours > 0 || minutes > 0) text += `${minutes}m `; if (hours === 0) text += `${seconds}s `; text += 'late'; - return { text, isLate: true, isStartingSoon: false }; + return { text, isLate: true, isStartingSoon: false, canCheckIn: true }; } } @@ -68,6 +87,11 @@ // Authenticated user check-in async function handleCheckIn(eventId: string) { + // Prevent double-clicking by checking if already processing this event + if (checkingIn === eventId || markingNotComing === eventId) { + return; + } + checkingIn = eventId; checkInError = null; @@ -82,9 +106,11 @@ if (response.ok && result.success) { // Update the event in the list to show as checked in - data.events = data.events.map(e => - e.id === eventId ? { ...e, alreadyCheckedIn: true } : e, + events = events.map(e => + e.id === eventId ? { ...e, attendanceStatus: 'checked-in' as const } : e, ); + checkInError = null; // Clear any previous errors + console.log('Check-in successful, updated UI for event:', eventId); } else { checkInError = result.error || 'Check-in failed'; @@ -98,6 +124,51 @@ } } + // Authenticated user mark as not coming + async function handleNotComing(eventId: string) { + // Prevent double-clicking by checking if already processing this event + if (markingNotComing === eventId || checkingIn === eventId) { + return; + } + + markingNotComing = eventId; + checkInError = null; + + try { + const response = await fetch('/api/checkin/not-coming', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ eventId }), + }); + + const result = await response.json(); + + if (response.ok && result.success) { + // Update the event in the list to show as not coming + events = events.map(e => + e.id === eventId ? { ...e, attendanceStatus: 'not-coming' as const } : e, + ); + checkInError = null; // Clear any previous errors + } + else if (result.error?.includes('already has an attendance record')) { + // User already has a record - update UI to show as checked in + events = events.map(e => + e.id === eventId ? { ...e, attendanceStatus: 'checked-in' as const } : e, + ); + checkInError = 'You already have an attendance record for this event.'; + } + else { + checkInError = result.error || 'Failed to mark as not coming'; + } + } + catch { + checkInError = 'Network error. Please try again.'; + } + finally { + markingNotComing = null; + } + } + // Guest: validate code async function handleCodeSubmit(e: SubmitEvent) { e.preventDefault(); @@ -113,9 +184,9 @@ const result = await response.json(); - if (result.valid && result.event) { - guestEvent = result.event; - guestStep = 'details'; + if (result.valid && result.events) { + guestEvents = result.events; + guestStep = 'events'; } else { guestError = result.error || 'Invalid code'; @@ -129,6 +200,12 @@ } } + // Guest: select event to check in to + function selectGuestEvent(event: typeof guestEvents[0]) { + guestSelectedEvent = event; + guestStep = 'details'; + } + // Guest: submit check-in async function handleGuestCheckIn(e: SubmitEvent) { e.preventDefault(); @@ -140,7 +217,7 @@ method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ - eventId: guestEvent?.id, + eventId: guestSelectedEvent?.id, code: guestCode, name: guestName, email: guestEmail, @@ -173,7 +250,15 @@ guestCode = ''; guestName = ''; guestEmail = ''; - guestEvent = null; + guestEvents = []; + guestSelectedEvent = null; + guestError = ''; + } + + // Go back to event selection + function backToEventSelection() { + guestStep = 'events'; + guestSelectedEvent = null; guestError = ''; } @@ -183,24 +268,46 @@
-

Check In

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

No events available for check-in right now.

{:else}
- {#each data.events as event (event.id)} + {#each events as event (event.id)} {@const timeStatus = getTimeStatus(event.dateTime)} -
+

{event.name}

{formatDate(event.dateTime)}

-

{event.eventType}

- {#if !event.alreadyCheckedIn} +
+ {event.eventType} + {#if event.expectedCount > 0} + + {event.attendanceCount}/{event.expectedCount} + + {:else if event.attendanceCount > 0} + + {event.attendanceCount} checked in + + {/if} +
+ {#if event.attendanceStatus === 'none'}

- {#if event.alreadyCheckedIn} - Checked In - {:else if checkingIn === event.id} - - {:else} - + {/if} + {:else if checkingIn === event.id || markingNotComing === event.id} + + {:else if !timeStatus.canCheckIn} + + {:else} +
+ + {#if data.checkInMethod === 'apprentice'} + + {/if} +
{/if}
@@ -232,6 +357,10 @@ {:else} +
{#if guestStep === 'code'}

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

@@ -265,11 +394,56 @@ for easier check-in.

+ {:else if guestStep === 'events'} +

Select the event you want to check in to:

+ +
+ {#each guestEvents as event (event.id)} + {@const timeStatus = getTimeStatus(event.dateTime)} +
+
+

{event.name}

+

{formatDate(event.dateTime)}

+
+ {event.eventType} + {#if event.attendanceCount > 0} + + {event.attendanceCount} checked in + + {/if} +
+

+ {timeStatus.text} +

+
+
+ {#if !timeStatus.canCheckIn} + + {:else} + + {/if} +
+
+ {/each} +
+ + + {:else if guestStep === 'details'} - {@const guestTimeStatus = guestEvent ? getTimeStatus(guestEvent.dateTime) : null} + {@const guestTimeStatus = guestSelectedEvent ? getTimeStatus(guestSelectedEvent.dateTime) : null}
-

{guestEvent?.name}

-

{guestEvent ? formatDate(guestEvent.dateTime) : ''}

+

{guestSelectedEvent?.name}

+

{guestSelectedEvent ? formatDate(guestSelectedEvent.dateTime) : ''}

{#if guestTimeStatus}

{guestError}

{/if} - {:else if guestStep === 'success'}

You're checked in!

-

Welcome to {guestEvent?.name}

+

Welcome to {guestSelectedEvent?.name}

{/if} @@ -329,12 +503,57 @@