diff --git a/README.md b/README.md index a74a42a..7e840f6 100644 --- a/README.md +++ b/README.md @@ -182,14 +182,14 @@ The generated schema file can be used to update `src/lib/airtable/config.ts` wit ### Event Types -Event types are defined as a single source of truth in `src/lib/types/event.ts`: +Event types are now managed dynamically through Airtable's "Event types - Apprentice Pulse" table. This provides: -```typescript -export const EVENT_TYPES = ['Regular class', 'Workshop', 'Hackathon'] as const; -export type EventType = typeof EVENT_TYPES[number]; -``` +- **Single source of truth**: Event types defined in Airtable only +- **No code deployments**: Add/remove event types without touching code +- **Flexible management**: Administrators can manage types directly in Airtable +- **Automatic validation**: API endpoints validate against current Airtable data -To add a new event type, update the `EVENT_TYPES` array - all forms and validation will automatically use the new values. +Event types are cached for performance (5-minute cache) and include automatic color assignment for UI consistency. ### Default Values diff --git a/docs/plan-search-apprentice.md b/docs/plan-search-apprentice.md new file mode 100644 index 0000000..1dcafee --- /dev/null +++ b/docs/plan-search-apprentice.md @@ -0,0 +1,204 @@ +# Search Apprentice Feature - Implementation Plan + +## Overview +Add a Search Apprentice card to the admin dashboard that allows users to quickly search for apprentices by name and navigate directly to their detail page. + +## User Flow +1. User types in search field on admin dashboard +2. System shows live search results matching the query +3. User clicks on an apprentice from results +4. System navigates to apprentice detail page (`/admin/apprentices/[id]`) + +## Technical Architecture + +### 1. Search API Endpoint +**File**: `src/routes/api/apprentices/search/+server.ts` + +- **Method**: GET +- **Query params**: `q` (search query) +- **Returns**: Array of matching apprentices with id, name, email, cohort +- **Search logic**: + - Case-insensitive search + - Matches against apprentice name + - Returns up to 10 results + - Orders by relevance/alphabetical + +### 2. Search Component +**File**: `src/lib/components/SearchApprentice.svelte` + +**Features**: +- Text input field with search icon +- Debounced search (300ms) to avoid excessive API calls +- Loading state while searching +- Dropdown results list +- Keyboard navigation (arrow keys + enter) +- Click outside to close +- Empty state when no results +- Error handling + +**Props**: +- No props needed (self-contained) + +**State**: +- `searchQuery`: Current search text +- `searchResults`: Array of apprentice results +- `isSearching`: Loading state +- `showResults`: Dropdown visibility +- `selectedIndex`: For keyboard navigation + +### 3. Dashboard Integration +**File**: `src/routes/admin/+page.svelte` + +**Add new card**: +```svelte + + + +``` + +**Card design**: +- Title: "Search Apprentice" +- Description: "Find and view apprentice details" +- Icon: 🔍 (magnifying glass) +- Color scheme: Purple (to differentiate from other cards) + +### 4. Data Types +**File**: `src/lib/types/apprentice.ts` + +```typescript +interface ApprenticeSearchResult { + id: string; + name: string; + email: string; + cohortNumber?: number; + status: 'Active' | 'On Leave' | 'Off-boarded'; +} +``` + +## Implementation Steps + +### Phase 1: Backend +1. Create search API endpoint +2. Implement Airtable search logic +3. Add proper error handling +4. Test with various search queries + +### Phase 2: Search Component +1. Create SearchApprentice component structure +2. Implement search input with debouncing +3. Add API integration +4. Implement results dropdown +5. Add keyboard navigation +6. Style component to match existing design + +### Phase 3: Dashboard Integration +1. Add new card to admin dashboard +2. Integrate SearchApprentice component +3. Style card to match existing cards +4. Test navigation to apprentice details + +### Phase 4: Polish & Testing +1. Add loading states +2. Implement error handling +3. Add empty states +4. Test edge cases +5. Ensure mobile responsiveness + +## Component Structure + +```svelte +
+
+ + ... +
+ + {#if showResults} +
+ {#if isSearching} +
Searching...
+ {:else if searchResults.length > 0} + + {:else if searchQuery} +
No apprentices found
+ {/if} +
+ {/if} +
+``` + +## Styling Considerations +- Match existing card styles (rounded corners, shadows, hover effects) +- Use purple color scheme for differentiation +- Dropdown should have z-index to appear above other content +- Mobile-first responsive design +- Smooth transitions for dropdown appearance + +## API Response Example +```json +{ + "success": true, + "apprentices": [ + { + "id": "recXXXXXXXXXXXXX", + "name": "John Doe", + "email": "john.doe@example.com", + "cohortNumber": 12, + "status": "Active" + } + ] +} +``` + +## Error Handling +- Network errors: Show "Failed to search" message +- Empty results: Show "No apprentices found" +- Rate limiting: Implement debouncing +- Invalid input: Minimum 2 characters to search + +## Performance Considerations +- Debounce search input (300ms) +- Limit results to 10 items +- Cancel previous search requests if new one initiated +- Cache recent searches (optional enhancement) + +## Accessibility +- Proper ARIA labels for search input +- Keyboard navigation support +- Screen reader announcements for results +- Focus management when opening/closing dropdown + +## Testing Checklist +- [ ] Search returns correct results +- [ ] Clicking result navigates to apprentice page +- [ ] Keyboard navigation works +- [ ] Click outside closes dropdown +- [ ] Debouncing prevents excessive API calls +- [ ] Error states display correctly +- [ ] Loading state shows during search +- [ ] Mobile responsive design works +- [ ] Accessibility features work + +## Future Enhancements +- Search by email as well as name +- Search by cohort number +- Recent searches history +- Advanced filters (status, cohort, etc.) +- Fuzzy matching for typos \ No newline at end of file diff --git a/docs/plan.md b/docs/plan.md index 9af41e4..e69de29 100644 --- a/docs/plan.md +++ b/docs/plan.md @@ -1,212 +0,0 @@ -# Plan: Consolidate Login & Fix Navigation - -## Overview - -Merge staff and student login into a single `/login` page and fix the navigation structure so each user type has a clear flow. - -## Current State (Problems) - -1. **Two login pages**: `/login` (students) and `/admin/login` (staff) -2. **Useless home page**: `/` just shows "logged in as X" with logout link -3. **Confusing nav**: Admin dashboard has "Back to Home" but `/` is pointless for staff -4. **Broken student flow**: Students land on `/` after login, must manually find `/checkin` -5. **No nav on checkin**: No way to logout or navigate elsewhere - -## Desired State - -| User Type | Login | Landing | Primary View | -|-----------|-------|---------|--------------| -| Student | `/login` | `/checkin` | `/checkin` | -| Staff | `/login` | `/admin` | `/admin/*` | - -**Single login flow:** -1. User enters email at `/login` -2. Backend checks Staff table first, then Apprentices table -3. Magic link sent with user type encoded -4. After verification, redirect based on type - ---- - -## Implementation Steps - -### Step 1: Consolidate Login API - -**File:** `src/routes/api/auth/login/+server.ts` (new unified endpoint) - -Create a single login endpoint that: -1. Checks Staff table first (by collaborator email) -2. If not found, checks Apprentices table (by learner email) -3. Returns appropriate user type in token - -```typescript -export async function POST({ request }) { - const { email } = await request.json(); - - // Try staff first (higher privilege) - const staff = await getStaffByEmail(email); - if (staff) { - const token = await generateToken({ email, type: 'staff' }); - await sendMagicLink(email, token); - return json({ success: true }); - } - - // Try apprentice - const apprentice = await getApprenticeByEmail(email); - if (apprentice) { - const token = await generateToken({ email, type: 'student' }); - await sendMagicLink(email, token); - return json({ success: true }); - } - - return json({ success: false, error: 'Email not found' }, { status: 404 }); -} -``` - -### Step 2: Update Verify Endpoint Redirect - -**File:** `src/routes/api/auth/verify/+server.ts` - -Update redirect after verification: -```typescript -// After setting session cookie -if (user.type === 'staff') { - redirect(303, '/admin'); -} else { - redirect(303, '/checkin'); -} -``` - -### Step 3: Update Login Page - -**File:** `src/routes/login/+page.svelte` - -- Remove any student-specific wording -- Make it generic: "Enter your email to sign in" -- Update form to POST to `/api/auth/login` - -### Step 4: Delete `/admin/login` Route - -Delete the entire `src/routes/admin/login/` folder. No redirects needed - we're building from scratch. - -### Step 5: Update Home Page Redirect - -**File:** `src/routes/+page.server.ts` - -Redirect authenticated users to their landing page: -```typescript -export function load({ locals }) { - if (locals.user) { - if (locals.user.type === 'staff') { - redirect(303, '/admin'); - } else { - redirect(303, '/checkin'); - } - } - // Unauthenticated: show login options or redirect to /login - redirect(303, '/login'); -} -``` - -### Step 6: Update Auth Routes in Hooks - -**File:** `src/hooks.server.ts` - -Simplify AUTH_ROUTES since there's only one login page now: -```typescript -const AUTH_ROUTES = ['/login']; -``` - -### Step 7: Fix Admin Dashboard Navigation - -**File:** `src/routes/admin/+page.svelte` - -Replace "Back to Home" with a proper header: -```svelte -
-
-

Admin Dashboard

-

Welcome, {data.user?.email}

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

Check In

-
- {#if data.user?.type === 'staff'} - Admin - {/if} - Logout -
-
-``` - -### Step 9: Clean Up Old Routes - -Delete these folders: -- `src/routes/api/auth/staff/` -- `src/routes/api/auth/student/` -- `src/routes/admin/login/` - -### Step 10: Update README - -**File:** `README.md` - -Update authentication section to reflect single login: -- Remove separate login page references -- Update route table -- Simplify flow diagram - ---- - -## Files to Modify - -| File | Change | -|------|--------| -| `src/routes/api/auth/login/+server.ts` | New unified login endpoint | -| `src/routes/api/auth/verify/+server.ts` | Update redirect logic | -| `src/routes/login/+page.svelte` | Make generic, update API call | -| `src/routes/+page.svelte` | Remove content | -| `src/routes/+page.server.ts` | Add redirect logic | -| `src/hooks.server.ts` | Simplify AUTH_ROUTES | -| `src/routes/admin/+page.svelte` | Replace "Back to Home" with logout | -| `src/routes/checkin/+page.svelte` | Add header with nav | -| `README.md` | Update auth documentation | - -## Files to Delete - -| File | Reason | -|------|--------| -| `src/routes/api/auth/staff/` | Replaced by unified endpoint | -| `src/routes/api/auth/student/` | Replaced by unified endpoint | -| `src/routes/admin/login/` | Single login at `/login` | - ---- - -## Testing - -1. **Staff login**: Enter staff email → lands on `/admin` -2. **Student login**: Enter student email → lands on `/checkin` -3. **Unknown email**: Shows error "Email not found" -4. **Already logged in**: Visiting `/login` redirects to appropriate landing -5. **Direct URL access**: `/admin/login` redirects to `/login` -6. **Home page**: `/` redirects to landing or login -7. **Logout**: Works from both admin and checkin pages -8. **Staff on checkin**: Sees "Admin" link in header - ---- - -## Risks & Considerations - -1. **Email in both tables**: Not possible - staff use collaborator emails, apprentices use learner emails -2. **Magic links in transit**: Old links will break (low risk - 15min expiry) -3. **Caching**: Clear any cached auth routes after deploy diff --git a/docs/schema.md b/docs/schema.md index 7a8cce7..dbb96d4 100644 --- a/docs/schema.md +++ b/docs/schema.md @@ -16,9 +16,13 @@ Apprentices (tbl0HJM700Jmd5Oob) - primary learner record ├── Learner Touchpoint Log (tbl2O3QcYpss1SaP4) └── Cohorts (tbllAnSw8VPYFAa1a) └── Events - Apprentice Pulse (tblkbskw4fuTq0E9p) + └── Event Type → Event types - Apprentice Pulse (tblr9z9uU0wdsTL1Q) Progress Reviews (Employer Feedback) (tblwdSYGgGMXJ1DX7) └── Apprentice record → links to Apprentices + +Event types - Apprentice Pulse (tblr9z9uU0wdsTL1Q) - lookup table for event types + └── Events - Apprentice Pulse (tblkbskw4fuTq0E9p) ``` --- @@ -67,6 +71,7 @@ Attendance tracking using a junction table pattern (one record per apprentice pe | FAC Cohort (from Event) | `fldkc9zLJe7NZVAz1` | multipleLookupValues | Cohort lookup from Event | | External Name | `fldIhZnMxfjh9ps78` | singleLineText | Name for non-registered attendees | | External Email | `fldHREfpkx1bGv3K3` | email | Email for non-registered attendees | +| Reason | `fldmJtM87FmcnTTfK` | multilineText | Reason for absence (optional) | --- @@ -210,6 +215,19 @@ Cohort reference for filtering and grouping. --- +## Event types - Apprentice Pulse + +**Table ID:** `tblr9z9uU0wdsTL1Q` + +Event type definitions used by events. + +| Field | ID | Type | Purpose | +|-------|-----|------|---------| +| Name | `fldDKZF5NI280rIQb` | singleLineText | Event type name (Regular Class, Workshop, Online Class) | +| Events - Apprentice Pulse | `fldyhEXD7CEWFVneJ` | multipleRecordLinks | Links to Events using this type | + +--- + ## Events - Apprentice Pulse **Table ID:** `tblkbskw4fuTq0E9p` @@ -221,12 +239,13 @@ Scheduled events/sessions for attendance tracking. | Name | `fldMCZijN6TJeUdFR` | singleLineText | Event name | | FAC Cohort | `fldcXDEDkeHvWTnxE` | multipleRecordLinks | Links to Cohorts | | Date Time | `fld8AkM3EanzZa5QX` | dateTime | Event start date and time | -| Event Type | `fldo7fwAsFhkA1icC` | singleSelect | Regular class, Workshop, Hackathon | +| End Date Time | `fldpBorZFMxhgNhNR` | dateTime | Event end date and time | +| Event Type | `fldo7fwAsFhkA1icC` | multipleRecordLinks | Links to Event types | | Survey | `fld9XBHnCWBtZiZah` | url | Optional survey form URL | | Attendance | `fldcPf53fVfStFZsa` | multipleRecordLinks | Linked attendance records | | Name - Date | `fld7POykodV0LGsg1` | formula | Display name with date | | Public | `fldatQzdAo8evWlNc` | checkbox | Visible on public check-in page | -| Check-in Code | `fldKMWSFmYONkvYMK` | number | 4-digit code for external attendees | +| Number | `fldKMWSFmYONkvYMK` | number | 4-digit check-in code for external attendees | --- diff --git a/docs/scratchpad.md b/docs/scratchpad.md index ef87441..d721675 100644 --- a/docs/scratchpad.md +++ b/docs/scratchpad.md @@ -1,18 +1,18 @@ -attendace send email +add externals by email as staff +are we sure we want to give access to airtable? + -survey send email -link surveys with student. track not fulfilled -Per week email to Jess. Absent, not survey, whoever marked "In need of support" -Absent reason. +attendace send email -Events view, list of people, show link to apprentices to go to personalised +survey send email +link surveys with student. track not fulfilled -survey URL is OK? +Per week email to Jess. Absent, not survey, whoever marked "In need of support" @@ -23,8 +23,4 @@ Show mii plaza Integration with LUMA -are we sure we want to give access to airtable? - - -On readme how give permissions - +On readme how give permissions \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index 4bec732..397e241 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1527,7 +1527,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", @@ -1567,7 +1566,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", @@ -1983,7 +1981,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", @@ -2224,7 +2221,6 @@ "integrity": "sha512-I2Fy/ANdphi1yI46d15o0M1M4M0UJrUiVKkH5oKeRZZCdPg0fw/cfTKZzv9Ge9eobtJYp4BGblMzXdXH0vcl5g==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@vitest/browser": "4.0.16", "@vitest/mocker": "4.0.16", @@ -2377,7 +2373,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" }, @@ -2856,7 +2851,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", @@ -4063,7 +4057,6 @@ "integrity": "sha512-ilYQj1s8sr2ppEJ2YVadYBN0Mb3mdo9J0wQ+UuDhzYqURwSoW4n1Xs5vs7ORwgDGmyEh33tRMeS8KhdkMoLXQw==", "dev": true, "license": "Apache-2.0", - "peer": true, "dependencies": { "playwright-core": "1.57.0" }, @@ -4120,7 +4113,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", @@ -4563,7 +4555,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", @@ -4781,7 +4772,6 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "dev": true, "license": "Apache-2.0", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -4866,7 +4856,6 @@ "integrity": "sha512-dZwN5L1VlUBewiP6H9s2+B3e3Jg96D0vzN+Ry73sOefebhYr9f94wwkMNN/9ouoU8pV1BqA1d1zGk8928cx0rg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "esbuild": "^0.27.0", "fdir": "^6.5.0", @@ -4977,7 +4966,6 @@ "integrity": "sha512-E4t7DJ9pESL6E3I8nFjPa4xGUd3PmiWDLsDztS2qXSJWfHtbQnwAWylaBvSNY48I3vr8PTqIZlyK8TE3V3CA4Q==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@vitest/expect": "4.0.16", "@vitest/mocker": "4.0.16", diff --git a/scripts/schema-2026-01-08-10-04-39.md b/scripts/schema-2026-01-08-10-04-39.md new file mode 100644 index 0000000..ad5ff85 --- /dev/null +++ b/scripts/schema-2026-01-08-10-04-39.md @@ -0,0 +1,21 @@ +# Airtable Schema + +## Learners / Attendace - Apprentice Pulse + +Table ID: `tblkDbhJcuT9TTwFc` + +| Field | ID | Type | +|-------|-----|------| +| Id | `fldGdpuw6SoHkQbOs` | autoNumber | +| Apprentice | `fldOyo3hlj9Ht0rfZ` | multipleRecordLinks | +| Cohort | `fldn53kWDE8GHg2Yy` | multipleLookupValues | +| Checkin Time | `fldvXHPmoLlEA8EuN` | dateTime | +| Status | `fldew45fDGpgl1aRr` | singleSelect | +| Event | `fldiHd75LYtopwyN9` | multipleRecordLinks | +| Date Time (from Event) | `fldokfSk68MhJGlm6` | multipleLookupValues | +| FAC Cohort (from FAC Cohort) (from Event) | `fldE783vnY3SLjmh7` | multipleLookupValues | +| FAC Cohort (from Event) | `fldkc9zLJe7NZVAz1` | multipleLookupValues | +| External Name | `fldIhZnMxfjh9ps78` | singleLineText | +| External Email | `fldHREfpkx1bGv3K3` | email | +| Reason | `fldmJtM87FmcnTTfK` | multilineText | + diff --git a/scripts/schema-2026-01-08-12-01-55.md b/scripts/schema-2026-01-08-12-01-55.md new file mode 100644 index 0000000..e83adc8 --- /dev/null +++ b/scripts/schema-2026-01-08-12-01-55.md @@ -0,0 +1,29 @@ +# Airtable Schema + +## Learners / Event types - Apprentice Pulse + +Table ID: `tblr9z9uU0wdsTL1Q` + +| Field | ID | Type | +|-------|-----|------| +| Name | `fldDKZF5NI280rIQb` | singleLineText | +| Events - Apprentice Pulse | `fldyhEXD7CEWFVneJ` | multipleRecordLinks | + +## Learners / Events - Apprentice Pulse + +Table ID: `tblkbskw4fuTq0E9p` + +| Field | ID | Type | +|-------|-----|------| +| Name | `fldMCZijN6TJeUdFR` | singleLineText | +| FAC Cohort | `fldcXDEDkeHvWTnxE` | multipleRecordLinks | +| FAC Cohort (from FAC Cohort) | `fldsLUl1MrhhsVBe7` | multipleLookupValues | +| Date Time | `fld8AkM3EanzZa5QX` | dateTime | +| End Date Time | `fldpBorZFMxhgNhNR` | dateTime | +| Survey | `fld9XBHnCWBtZiZah` | url | +| Event Type | `fldo7fwAsFhkA1icC` | multipleRecordLinks | +| Attendace - Apprentice Pulse | `fldcPf53fVfStFZsa` | multipleRecordLinks | +| Name - Date | `fld7POykodV0LGsg1` | formula | +| Public | `fldatQzdAo8evWlNc` | checkbox | +| Number | `fldKMWSFmYONkvYMK` | number | + diff --git a/scripts/schema-2026-01-08-12-25-54.md b/scripts/schema-2026-01-08-12-25-54.md new file mode 100644 index 0000000..5879b04 --- /dev/null +++ b/scripts/schema-2026-01-08-12-25-54.md @@ -0,0 +1,12 @@ +# Airtable Schema + +## Learners / Event types - Apprentice Pulse + +Table ID: `tblr9z9uU0wdsTL1Q` + +| Field | ID | Type | +|-------|-----|------| +| Name | `fldDKZF5NI280rIQb` | singleLineText | +| Default Survey | `fldrLmYVz2ZE52Ewh` | url | +| Events - Apprentice Pulse | `fldyhEXD7CEWFVneJ` | multipleRecordLinks | + diff --git a/src/lib/airtable/attendance.ts b/src/lib/airtable/attendance.ts index ba2dee2..e59cea6 100644 --- a/src/lib/airtable/attendance.ts +++ b/src/lib/airtable/attendance.ts @@ -151,6 +151,7 @@ export function createAttendanceClient(apiKey: string, baseId: string) { [ATTENDANCE_FIELDS.EVENT]: [input.eventId], [ATTENDANCE_FIELDS.APPRENTICE]: [input.apprenticeId], [ATTENDANCE_FIELDS.STATUS]: 'Absent', + ...(input.reason && { [ATTENDANCE_FIELDS.REASON]: input.reason }), }; const record = await attendanceTable.create(fields); @@ -285,6 +286,10 @@ export function createAttendanceClient(apiKey: string, baseId: string) { fields[ATTENDANCE_FIELDS.CHECKIN_TIME] = input.checkinTime; } + if (input.reason !== undefined) { + fields[ATTENDANCE_FIELDS.REASON] = input.reason || undefined; + } + const record = await attendanceTable.update(attendanceId, fields); const apprenticeLink = record.get(ATTENDANCE_FIELDS.APPRENTICE) as string[] | undefined; @@ -298,6 +303,7 @@ export function createAttendanceClient(apiKey: string, baseId: string) { externalEmail: record.get(ATTENDANCE_FIELDS.EXTERNAL_EMAIL) as string | undefined, checkinTime: record.get(ATTENDANCE_FIELDS.CHECKIN_TIME) as string, status: record.get(ATTENDANCE_FIELDS.STATUS) as Attendance['status'], + reason: record.get(ATTENDANCE_FIELDS.REASON) as string | undefined, }; } @@ -360,6 +366,7 @@ export function createAttendanceClient(apiKey: string, baseId: string) { externalEmail: record.get(ATTENDANCE_FIELDS.EXTERNAL_EMAIL) as string | undefined, checkinTime: record.get(ATTENDANCE_FIELDS.CHECKIN_TIME) as string, status: (record.get(ATTENDANCE_FIELDS.STATUS) as Attendance['status']) ?? 'Present', + reason: record.get(ATTENDANCE_FIELDS.REASON) as string | undefined, }; }); } @@ -850,6 +857,7 @@ export function createAttendanceClient(apiKey: string, baseId: string) { status: attendance ? attendance.status : 'Not Check-in', checkinTime: attendance?.checkinTime ?? null, attendanceId: attendance?.id ?? null, + reason: attendance?.reason ?? null, }; }); diff --git a/src/lib/airtable/config.ts b/src/lib/airtable/config.ts index d54b2ed..d152110 100644 --- a/src/lib/airtable/config.ts +++ b/src/lib/airtable/config.ts @@ -9,6 +9,7 @@ export const TABLES = { APPRENTICES: 'tbl0HJM700Jmd5Oob', STAFF: 'tblJjn62ExE1LVjmx', EVENTS: 'tblkbskw4fuTq0E9p', + EVENT_TYPES: 'tblr9z9uU0wdsTL1Q', ATTENDANCE: 'tblkDbhJcuT9TTwFc', TERMS: 'tbl4gkcG92Bc8gFU7', } as const; @@ -40,7 +41,7 @@ export const EVENT_FIELDS = { COHORT: 'fldcXDEDkeHvWTnxE', // multipleRecordLinks to Cohorts DATE_TIME: 'fld8AkM3EanzZa5QX', // dateTime (start time) END_DATE_TIME: 'fldpBorZFMxhgNhNR', // dateTime (end time) - EVENT_TYPE: 'fldo7fwAsFhkA1icC', // singleSelect (Regular Class, Workshop, Hackathon) + EVENT_TYPE: 'fldo7fwAsFhkA1icC', // multipleRecordLinks to Event Types SURVEY: 'fld9XBHnCWBtZiZah', // url (optional survey form) ATTENDANCE: 'fldcPf53fVfStFZsa', // multipleRecordLinks to Attendance (reverse link) NAME_DATE: 'fld7POykodV0LGsg1', // formula (display name) @@ -57,6 +58,14 @@ export const ATTENDANCE_FIELDS = { STATUS: 'fldew45fDGpgl1aRr', // singleSelect (Present/Not Check-in/Late/Excused/Absent) EXTERNAL_NAME: 'fldIhZnMxfjh9ps78', // singleLineText (for non-registered attendees) EXTERNAL_EMAIL: 'fldHREfpkx1bGv3K3', // email (for non-registered attendees) + REASON: 'fldmJtM87FmcnTTfK', // multilineText (reason for absence) +} as const; + +// Fields - Event Types +export const EVENT_TYPE_FIELDS = { + NAME: 'fldDKZF5NI280rIQb', // singleLineText + DEFAULT_SURVEY: 'fldrLmYVz2ZE52Ewh', // url + EVENTS: 'fldyhEXD7CEWFVneJ', // multipleRecordLinks to Events } as const; // Fields - Terms diff --git a/src/lib/airtable/event-types.ts b/src/lib/airtable/event-types.ts new file mode 100644 index 0000000..0ffffc8 --- /dev/null +++ b/src/lib/airtable/event-types.ts @@ -0,0 +1,92 @@ +import Airtable from 'airtable'; + +import { TABLES, EVENT_TYPE_FIELDS } from './config.ts'; + +export interface EventTypeRecord { + id: string; + name: string; + defaultSurveyUrl?: string; +} + +export function createEventTypesClient(apiKey: string, baseId: string) { + Airtable.configure({ apiKey }); + const base = Airtable.base(baseId); + const eventTypesTable = base(TABLES.EVENT_TYPES); + + /** + * Get all event types + */ + async function listEventTypes(): Promise { + const records = await eventTypesTable + .select({ + returnFieldsByFieldId: true, + }) + .all(); + + return records.map(record => ({ + id: record.id, + name: record.get(EVENT_TYPE_FIELDS.NAME) as string, + defaultSurveyUrl: record.get(EVENT_TYPE_FIELDS.DEFAULT_SURVEY) as string | undefined, + })); + } + + /** + * Get event type by ID + */ + async function getEventType(id: string): Promise { + try { + // Use select() with RECORD_ID filter to get returnFieldsByFieldId support + const records = await eventTypesTable + .select({ + filterByFormula: `RECORD_ID() = "${id}"`, + maxRecords: 1, + returnFieldsByFieldId: true, + }) + .all(); + + if (records.length === 0) { + return null; + } + + const record = records[0]; + return { + id: record.id, + name: record.get(EVENT_TYPE_FIELDS.NAME) as string, + defaultSurveyUrl: record.get(EVENT_TYPE_FIELDS.DEFAULT_SURVEY) as string | undefined, + }; + } + catch { + return null; + } + } + + /** + * Find event type by name + */ + async function findEventTypeByName(name: string): Promise { + const records = await eventTypesTable + .select({ + filterByFormula: `{Name} = "${name}"`, + maxRecords: 1, + returnFieldsByFieldId: true, + }) + .all(); + + if (records.length === 0) { + return null; + } + + const record = records[0]; + return { + id: record.id, + name: record.get(EVENT_TYPE_FIELDS.NAME) as string, + defaultSurveyUrl: record.get(EVENT_TYPE_FIELDS.DEFAULT_SURVEY) as string | undefined, + }; + } + + return { + listEventTypes, + getEventType, + findEventTypeByName, + }; +} diff --git a/src/lib/airtable/events.spec.ts b/src/lib/airtable/events.spec.ts index e3edad0..dc2f668 100644 --- a/src/lib/airtable/events.spec.ts +++ b/src/lib/airtable/events.spec.ts @@ -23,6 +23,19 @@ vi.mock('airtable', () => { }; }); +// Mock event types service +vi.mock('./event-types', () => ({ + createEventTypesClient: vi.fn(() => ({ + getEventType: vi.fn().mockResolvedValue({ id: 'recEventType1', name: 'Regular Class' }), + findEventTypeByName: vi.fn((name: string) => { + if (name === 'Workshop') { + return Promise.resolve({ id: 'recEventTypeWorkshop', name: 'Workshop' }); + } + return Promise.resolve({ id: 'recEventType1', name: 'Regular Class' }); + }), + })), +})); + import Airtable from 'airtable'; describe('events', () => { @@ -70,8 +83,9 @@ describe('events', () => { id: 'rec123', name: 'Week 1 Monday', dateTime: '2025-01-06T10:00:00.000Z', + endDateTime: undefined, cohortIds: ['recCohort1'], - eventType: 'Regular class', + eventType: 'Regular Class', surveyUrl: 'https://survey.example.com', isPublic: false, checkInCode: undefined, @@ -106,8 +120,9 @@ describe('events', () => { id: 'rec123', name: 'Workshop', dateTime: '2025-01-07T14:00:00.000Z', + endDateTime: undefined, cohortIds: ['recCohort2'], - eventType: 'Workshop', + eventType: 'Regular Class', surveyUrl: undefined, isPublic: false, checkInCode: undefined, @@ -142,7 +157,12 @@ describe('events', () => { expect(event).toEqual({ id: 'recNew123', - ...input, + name: 'New Event', + dateTime: '2025-01-08T09:00:00.000Z', + endDateTime: undefined, + cohortIds: ['recCohort1'], + eventType: 'Workshop', + surveyUrl: 'https://survey.example.com', isPublic: false, checkInCode: undefined, }); diff --git a/src/lib/airtable/events.ts b/src/lib/airtable/events.ts index 2c38399..9933e0b 100644 --- a/src/lib/airtable/events.ts +++ b/src/lib/airtable/events.ts @@ -1,12 +1,34 @@ import Airtable from 'airtable'; import { TABLES, EVENT_FIELDS } from './config.ts'; +import { createEventTypesClient } from './event-types.ts'; import type { Event, EventFilters, EventType, CreateEventInput, UpdateEventInput } from '../types/event.ts'; export function createEventsClient(apiKey: string, baseId: string) { Airtable.configure({ apiKey }); const base = Airtable.base(baseId); const eventsTable = base(TABLES.EVENTS); + const eventTypesClient = createEventTypesClient(apiKey, baseId); + + /** + * Helper function to resolve event type from record link + */ + async function resolveEventType(eventTypeLinks: string[] | undefined): Promise { + if (!eventTypeLinks || eventTypeLinks.length === 0) { + return 'Regular Class'; // Default fallback + } + + const eventType = await eventTypesClient.getEventType(eventTypeLinks[0]); + return (eventType?.name as EventType) ?? 'Regular Class'; + } + + /** + * Helper function to find event type ID by name + */ + async function findEventTypeId(eventTypeName: EventType): Promise { + const eventType = await eventTypesClient.findEventTypeByName(eventTypeName); + return eventType?.id ?? null; + } /** * List events with optional filters @@ -35,22 +57,25 @@ export function createEventsClient(apiKey: string, baseId: string) { }) .all(); - return records.map((record) => { + return Promise.all(records.map(async (record) => { const cohortLookup = record.get(EVENT_FIELDS.COHORT) as string[] | undefined; + const eventTypeLinks = record.get(EVENT_FIELDS.EVENT_TYPE) as string[] | undefined; const attendanceLinks = record.get(EVENT_FIELDS.ATTENDANCE) as string[] | undefined; + const eventType = await resolveEventType(eventTypeLinks); + 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, + eventType, surveyUrl: record.get(EVENT_FIELDS.SURVEY) as string | undefined, isPublic: (record.get(EVENT_FIELDS.PUBLIC) as boolean) ?? false, checkInCode: record.get(EVENT_FIELDS.CHECK_IN_CODE) as number | undefined, attendanceCount: attendanceLinks?.length ?? 0, }; - }); + })); } /** @@ -73,14 +98,17 @@ export function createEventsClient(apiKey: string, baseId: string) { const record = records[0]; const cohortLookup = record.get(EVENT_FIELDS.COHORT) as string[] | undefined; + const eventTypeLinks = record.get(EVENT_FIELDS.EVENT_TYPE) as string[] | undefined; const attendanceLinks = record.get(EVENT_FIELDS.ATTENDANCE) as string[] | undefined; + const eventType = await resolveEventType(eventTypeLinks); + 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, + eventType, surveyUrl: record.get(EVENT_FIELDS.SURVEY) as string | undefined, isPublic: (record.get(EVENT_FIELDS.PUBLIC) as boolean) ?? false, checkInCode: record.get(EVENT_FIELDS.CHECK_IN_CODE) as number | undefined, @@ -96,10 +124,15 @@ export function createEventsClient(apiKey: string, baseId: string) { * Create a new event */ async function createEvent(data: CreateEventInput): Promise { + const eventTypeId = await findEventTypeId(data.eventType); + if (!eventTypeId) { + throw new Error(`Event type "${data.eventType}" not found`); + } + const fields: Airtable.FieldSet = { [EVENT_FIELDS.NAME]: data.name, [EVENT_FIELDS.DATE_TIME]: data.dateTime, - [EVENT_FIELDS.EVENT_TYPE]: data.eventType, + [EVENT_FIELDS.EVENT_TYPE]: [eventTypeId], }; if (data.endDateTime) fields[EVENT_FIELDS.END_DATE_TIME] = data.endDateTime; @@ -133,13 +166,21 @@ export function createEventsClient(apiKey: string, baseId: string) { if (data.dateTime !== undefined) fields[EVENT_FIELDS.DATE_TIME] = data.dateTime; if (data.endDateTime !== undefined) fields[EVENT_FIELDS.END_DATE_TIME] = data.endDateTime; if (data.cohortIds !== undefined) fields[EVENT_FIELDS.COHORT] = data.cohortIds; - if (data.eventType !== undefined) fields[EVENT_FIELDS.EVENT_TYPE] = data.eventType; + if (data.eventType !== undefined) { + const eventTypeId = await findEventTypeId(data.eventType); + if (!eventTypeId) { + throw new Error(`Event type "${data.eventType}" not found`); + } + fields[EVENT_FIELDS.EVENT_TYPE] = [eventTypeId]; + } if (data.surveyUrl !== undefined) fields[EVENT_FIELDS.SURVEY] = data.surveyUrl; if (data.isPublic !== undefined) fields[EVENT_FIELDS.PUBLIC] = data.isPublic; if (data.checkInCode !== undefined) fields[EVENT_FIELDS.CHECK_IN_CODE] = data.checkInCode; const record = await eventsTable.update(id, fields); const cohortLookup = record.get(EVENT_FIELDS.COHORT) as string[] | undefined; + const eventTypeLinks = record.get(EVENT_FIELDS.EVENT_TYPE) as string[] | undefined; + const eventType = await resolveEventType(eventTypeLinks); return { id: record.id, @@ -147,7 +188,7 @@ export function createEventsClient(apiKey: string, baseId: 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, + eventType, surveyUrl: record.get(EVENT_FIELDS.SURVEY) as string | undefined, isPublic: (record.get(EVENT_FIELDS.PUBLIC) as boolean) ?? false, checkInCode: record.get(EVENT_FIELDS.CHECK_IN_CODE) as number | undefined, @@ -180,13 +221,16 @@ export function createEventsClient(apiKey: string, baseId: string) { const record = records[0]; const cohortLookup = record.get(EVENT_FIELDS.COHORT) as string[] | undefined; + const eventTypeLinks = record.get(EVENT_FIELDS.EVENT_TYPE) as string[] | undefined; + const eventType = await resolveEventType(eventTypeLinks); + 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, + eventType, surveyUrl: record.get(EVENT_FIELDS.SURVEY) as string | undefined, isPublic: true, checkInCode: record.get(EVENT_FIELDS.CHECK_IN_CODE) as number | undefined, @@ -208,22 +252,25 @@ export function createEventsClient(apiKey: string, baseId: string) { }) .all(); - return records.map((record) => { + return Promise.all(records.map(async (record) => { const cohortLookup = record.get(EVENT_FIELDS.COHORT) as string[] | undefined; + const eventTypeLinks = record.get(EVENT_FIELDS.EVENT_TYPE) as string[] | undefined; const attendanceLinks = record.get(EVENT_FIELDS.ATTENDANCE) as string[] | undefined; + const eventType = await resolveEventType(eventTypeLinks); + 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, + 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 { @@ -234,5 +281,6 @@ export function createEventsClient(apiKey: string, baseId: string) { createEvent, updateEvent, deleteEvent, + eventTypesClient, }; } diff --git a/src/lib/components/ApprenticeAttendanceCard.svelte b/src/lib/components/ApprenticeAttendanceCard.svelte index d40b9e5..e281cf8 100644 --- a/src/lib/components/ApprenticeAttendanceCard.svelte +++ b/src/lib/components/ApprenticeAttendanceCard.svelte @@ -45,15 +45,24 @@
{apprentice.present}
Present
+
+ {apprentice.totalEvents > 0 ? ((apprentice.present / apprentice.totalEvents) * 100).toFixed(0) : 0}% +
{apprentice.late}
Late
+
+ {apprentice.totalEvents > 0 ? ((apprentice.late / apprentice.totalEvents) * 100).toFixed(0) : 0}% +
{apprentice.attended}
Attended
+
+ {apprentice.totalEvents > 0 ? ((apprentice.attended / apprentice.totalEvents) * 100).toFixed(0) : 0}% +
@@ -61,23 +70,35 @@
{apprentice.excused}
Excused
+
+ {apprentice.totalEvents > 0 ? ((apprentice.excused / apprentice.totalEvents) * 100).toFixed(0) : 0}% +
- +
{apprentice.absent}
Not Check-in
+
+ {apprentice.totalEvents > 0 ? ((apprentice.absent / apprentice.totalEvents) * 100).toFixed(0) : 0}% +
{apprentice.notComing}
Absent
+
+ {apprentice.totalEvents > 0 ? ((apprentice.notComing / apprentice.totalEvents) * 100).toFixed(0) : 0}% +
{apprentice.absent + apprentice.notComing}
-
Missed
+
Did not attend
+
+ {apprentice.totalEvents > 0 ? (((apprentice.absent + apprentice.notComing) / apprentice.totalEvents) * 100).toFixed(0) : 0}% +
diff --git a/src/lib/components/AttendanceFilters.svelte b/src/lib/components/AttendanceFilters.svelte index f6add07..c73ade4 100644 --- a/src/lib/components/AttendanceFilters.svelte +++ b/src/lib/components/AttendanceFilters.svelte @@ -45,6 +45,20 @@ return stagedStartDate !== appliedStartDate || stagedEndDate !== appliedEndDate; }); + // Helper to add/subtract days from a date string (YYYY-MM-DD) + function addDays(dateStr: string, days: number): string { + // eslint-disable-next-line svelte/prefer-svelte-reactivity -- pure function, not reactive state + const date = new Date(dateStr); + date.setDate(date.getDate() + days); + return date.toISOString().split('T')[0]; + } + + // Calculate minimum end date (day after start date) + const minEndDate = $derived(stagedStartDate ? addDays(stagedStartDate, 1) : ''); + + // Calculate maximum start date (day before end date) + const maxStartDate = $derived(stagedEndDate ? addDays(stagedEndDate, -1) : ''); + // Sync staged values when filters prop changes $effect(() => { stagedTermIds = [...appliedTermIds]; @@ -163,8 +177,7 @@
-
- Filter by: +
@@ -301,6 +305,7 @@ id="endDate" type="date" bind:value={stagedEndDate} + min={minEndDate} class="w-full border rounded px-3 py-2 text-sm focus:border-blue-300 focus:ring focus:ring-blue-200 focus:ring-opacity-50" />
diff --git a/src/lib/components/CohortOverviewCard.svelte b/src/lib/components/CohortOverviewCard.svelte new file mode 100644 index 0000000..99f5767 --- /dev/null +++ b/src/lib/components/CohortOverviewCard.svelte @@ -0,0 +1,81 @@ + + +
+
+
+

Attendance Stats

+

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

+
+
+ + Global Attendance: + {stats.attendanceRate.toFixed(0)}% + + + Global Lateness: + {latenessRate.toFixed(0)}% + +
+
+ +
+ +
+
+
+
{stats.present}
+
Present
+
+
+
{stats.late}
+
Late
+
+
+
+
{stats.attended}
+
Attended
+
+
+ + +
+
{stats.excused}
+
Excused
+
+ + +
+
+
+
{stats.absent}
+
Not Check-in
+
+
+
{stats.notComing}
+
Absent
+
+
+
+
{stats.absent + stats.notComing}
+
Did not attend
+
+
+
+
diff --git a/src/lib/components/EventBreakdownCard.svelte b/src/lib/components/EventBreakdownCard.svelte new file mode 100644 index 0000000..2b9c38f --- /dev/null +++ b/src/lib/components/EventBreakdownCard.svelte @@ -0,0 +1,86 @@ + + +
+
+

Event Breakdown

+ {events.length} event{events.length !== 1 ? 's' : ''} +
+ + {#if events.length === 0} +
+

No events found for selected period

+
+ {:else} +
+ + + + + + + + + + + + + + {#each events as event (event.eventId)} + + + + + + + + + + {/each} + +
EventDatePresentLateExcusedNot Check-inAbsent
+
{event.eventName}
+
+ {formatDate(event.eventDateTime)} + + + {event.present} + + + + {event.late} + + + + {event.excused} + + + + {event.notCheckin} + + + + {event.absent} + +
+
+ {/if} +
diff --git a/src/lib/components/ExpandableAttendanceFilters.svelte b/src/lib/components/ExpandableAttendanceFilters.svelte new file mode 100644 index 0000000..ee305d2 --- /dev/null +++ b/src/lib/components/ExpandableAttendanceFilters.svelte @@ -0,0 +1,58 @@ + + + +
+
+ Time Period: + {timePeriodDisplay} + +
+ {#if timePeriodExpanded} +
+ +
+ {/if} +
diff --git a/src/lib/components/SearchApprentice.svelte b/src/lib/components/SearchApprentice.svelte new file mode 100644 index 0000000..ad64f87 --- /dev/null +++ b/src/lib/components/SearchApprentice.svelte @@ -0,0 +1,178 @@ + + +
+
+ searchQuery.length >= 2 && (showResults = true)} + placeholder="Search apprentices..." + class="w-full pl-10 pr-10 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-purple-500 focus:border-transparent" + /> + + + +
+ + {#if showResults} +
+ {#if isSearching} +
+
+ Searching... +
+ {:else if searchResults.length > 0} +
    + {#each searchResults as result, index (result.id)} +
  • + +
  • + {/each} +
+ {:else if searchQuery.length >= 2} +
+ No apprentices found matching "{searchQuery}" +
+ {/if} +
+ {/if} +
+ + diff --git a/src/lib/components/UnifiedAttendanceStatsCard.svelte b/src/lib/components/UnifiedAttendanceStatsCard.svelte new file mode 100644 index 0000000..a464ba8 --- /dev/null +++ b/src/lib/components/UnifiedAttendanceStatsCard.svelte @@ -0,0 +1,114 @@ + + +
+
+

{title}

+
+ + Global Attendance: + {stats.attendanceRate.toFixed(0)}% + + + Global Lateness: + {latenessRate.toFixed(0)}% + + {#if showLowAttendanceWarning && isLowAttendance(stats.attendanceRate)} + + {/if} +
+
+ +
+ +
+
+
+
{stats.present}
+
Present
+
+ {stats.totalEvents > 0 ? ((stats.present / stats.totalEvents) * 100).toFixed(0) : 0}% +
+
+
+
{stats.late}
+
Late
+
+ {stats.totalEvents > 0 ? ((stats.late / stats.totalEvents) * 100).toFixed(0) : 0}% +
+
+
+
+
{stats.attended}
+
Attended
+
+ {stats.totalEvents > 0 ? ((stats.attended / stats.totalEvents) * 100).toFixed(0) : 0}% +
+
+
+ + +
+
{stats.excused}
+
Excused
+
+ {stats.totalEvents > 0 ? ((stats.excused / stats.totalEvents) * 100).toFixed(0) : 0}% +
+
+ + +
+
+
+
{stats.absent}
+
Not Check-in
+
+ {stats.totalEvents > 0 ? ((stats.absent / stats.totalEvents) * 100).toFixed(0) : 0}% +
+
+
+
{stats.notComing}
+
Absent
+
+ {stats.totalEvents > 0 ? ((stats.notComing / stats.totalEvents) * 100).toFixed(0) : 0}% +
+
+
+
+
{stats.absent + stats.notComing}
+
Did not attend
+
+ {stats.totalEvents > 0 ? (((stats.absent + stats.notComing) / stats.totalEvents) * 100).toFixed(0) : 0}% +
+
+
+
+
diff --git a/src/lib/services/event-types.ts b/src/lib/services/event-types.ts new file mode 100644 index 0000000..8549b11 --- /dev/null +++ b/src/lib/services/event-types.ts @@ -0,0 +1,148 @@ +import { createEventTypesClient } from '$lib/airtable/event-types'; +import { AIRTABLE_API_KEY, AIRTABLE_BASE_ID_LEARNERS } from '$env/static/private'; + +export interface EventTypeOption { + id: string; + name: string; + color: string; + tailwindClass: string; + defaultSurveyUrl?: string; +} + +// Default colors for known event types (fallback system) +const DEFAULT_COLORS: Record = { + 'Regular Class': { main: '#3b82f6', tailwind: 'text-blue-600' }, + 'Workshop': { main: '#10b981', tailwind: 'text-emerald-600' }, + 'Online Class': { main: '#f59e0b', tailwind: 'text-amber-600' }, +}; + +// Color palette for unknown event types +const FALLBACK_COLORS = [ + { main: '#8b5cf6', tailwind: 'text-violet-600' }, + { main: '#ef4444', tailwind: 'text-red-600' }, + { main: '#06b6d4', tailwind: 'text-cyan-600' }, + { main: '#84cc16', tailwind: 'text-lime-600' }, + { main: '#f97316', tailwind: 'text-orange-600' }, + { main: '#ec4899', tailwind: 'text-pink-600' }, + { main: '#6b7280', tailwind: 'text-gray-600' }, +]; + +class EventTypesService { + private client = createEventTypesClient(AIRTABLE_API_KEY, AIRTABLE_BASE_ID_LEARNERS); + private cache: EventTypeOption[] | null = null; + private cacheTimestamp: number = 0; + private readonly CACHE_DURATION = 5 * 60 * 1000; // 5 minutes + + /** + * Get all available event types with colors + */ + async getEventTypes(): Promise { + // Check cache + const now = Date.now(); + if (this.cache && (now - this.cacheTimestamp) < this.CACHE_DURATION) { + return this.cache; + } + + try { + const types = await this.client.listEventTypes(); + + const options: EventTypeOption[] = types.map((type, index) => { + // Use default color if available, otherwise use fallback color + const colorConfig = DEFAULT_COLORS[type.name] || FALLBACK_COLORS[index % FALLBACK_COLORS.length]; + + return { + id: type.id, + name: type.name, + color: colorConfig.main, + tailwindClass: colorConfig.tailwind, + defaultSurveyUrl: type.defaultSurveyUrl, + }; + }); + + // Update cache + this.cache = options; + this.cacheTimestamp = now; + + return options; + } + catch (error) { + console.error('Failed to fetch event types:', error); + + // Return hardcoded fallback if Airtable is unavailable + return [ + { + id: 'fallback-regular', + name: 'Regular Class', + color: DEFAULT_COLORS['Regular Class'].main, + tailwindClass: DEFAULT_COLORS['Regular Class'].tailwind, + }, + { + id: 'fallback-workshop', + name: 'Workshop', + color: DEFAULT_COLORS['Workshop'].main, + tailwindClass: DEFAULT_COLORS['Workshop'].tailwind, + }, + { + id: 'fallback-online', + name: 'Online Class', + color: DEFAULT_COLORS['Online Class'].main, + tailwindClass: DEFAULT_COLORS['Online Class'].tailwind, + }, + ]; + } + } + + /** + * Get event type names only (for validation) + */ + async getEventTypeNames(): Promise { + const types = await this.getEventTypes(); + return types.map(t => t.name); + } + + /** + * Get color for a specific event type name + */ + async getColorForEventType(eventTypeName: string): Promise<{ main: string; tailwind: string }> { + const types = await this.getEventTypes(); + const type = types.find(t => t.name === eventTypeName); + + if (type) { + return { + main: type.color, + tailwind: type.tailwindClass, + }; + } + + // Fallback for unknown types + return DEFAULT_COLORS[eventTypeName] || FALLBACK_COLORS[0]; + } + + /** + * Clear cache (useful for testing or forcing refresh) + */ + clearCache(): void { + this.cache = null; + this.cacheTimestamp = 0; + } + + /** + * Validate if an event type name exists + */ + async isValidEventType(eventTypeName: string): Promise { + const names = await this.getEventTypeNames(); + return names.includes(eventTypeName); + } + + /** + * Get default survey URL for a specific event type + */ + async getDefaultSurveyForEventType(eventTypeName: string): Promise { + const types = await this.getEventTypes(); + const type = types.find(t => t.name === eventTypeName); + return type?.defaultSurveyUrl || null; + } +} + +// Singleton instance +export const eventTypesService = new EventTypesService(); diff --git a/src/lib/styles/tailwind-classes.ts b/src/lib/styles/tailwind-classes.ts new file mode 100644 index 0000000..c11c355 --- /dev/null +++ b/src/lib/styles/tailwind-classes.ts @@ -0,0 +1,89 @@ +// Shared Tailwind CSS classes for consistent styling across the application + +export const styles = { + // Layout + container: { + narrow: 'p-6 max-w-2xl mx-auto', + standard: 'p-6 max-w-4xl mx-auto', + wide: 'p-6 max-w-6xl mx-auto', + }, + + // Cards + card: { + base: 'bg-white border border-gray-200 rounded-xl shadow-sm', + hover: 'hover:shadow-lg hover:border-blue-300 transition-all', + success: 'bg-green-50 border-green-300', + warning: 'bg-orange-50 border-orange-300', + error: 'bg-red-50 border-red-300', + }, + + // Headers + header: { + section: 'mb-6 flex justify-between items-start', + title: 'text-2xl font-bold', + subtitle: 'text-gray-600 mt-1', + }, + + // Buttons + button: { + base: 'px-4 py-2 rounded-lg font-medium transition-colors', + primary: 'bg-blue-600 text-white hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed', + secondary: 'border border-gray-300 hover:bg-gray-50', + danger: 'bg-red-600 text-white hover:bg-red-700 disabled:opacity-50 disabled:cursor-not-allowed', + success: 'bg-green-600 text-white', + warning: 'bg-orange-600 text-white', + disabled: 'bg-gray-300 text-gray-500 cursor-not-allowed', + link: 'text-blue-600 hover:underline', + large: 'px-4 py-3 rounded-lg font-medium transition-colors', + }, + + // Forms + form: { + label: 'block text-sm font-medium text-gray-700 mb-2', + input: 'w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 disabled:bg-gray-50 disabled:text-gray-500', + inputLarge: 'w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 disabled:bg-gray-50 disabled:text-gray-500', + error: 'mt-4 p-3 bg-red-50 border border-red-200 rounded-lg text-red-700 text-sm', + }, + + // Badges + badge: { + base: 'inline-flex items-center px-2 py-1 rounded-full text-xs font-medium', + gray: 'bg-gray-100 text-gray-800', + blue: 'bg-blue-100 text-blue-800', + green: 'bg-green-100 text-green-800', + red: 'bg-red-100 text-red-800', + orange: 'bg-orange-100 text-orange-800', + purple: 'bg-purple-100 text-purple-800', + }, + + // Status indicators + status: { + tag: 'inline-flex items-center px-3 py-1 rounded-lg text-sm font-medium', + late: 'bg-red-100 text-red-800', + soon: 'bg-orange-100 text-orange-800', + normal: 'bg-blue-100 text-blue-800', + }, + + // Alerts + alert: { + success: 'p-4 bg-green-50 border border-green-200 rounded-xl text-green-700', + error: 'p-4 bg-red-50 border border-red-200 rounded-xl text-red-700', + warning: 'p-4 bg-orange-50 border border-orange-200 rounded-xl text-orange-700', + info: 'p-4 bg-blue-50 border border-blue-200 rounded-xl text-blue-700', + }, + + // Utility + link: 'text-blue-600 hover:underline', + textMuted: 'text-gray-500', + textSmall: 'text-sm', + spacing: { + section: 'mb-6', + item: 'mb-4', + compact: 'mb-2', + }, +} as const; + +// Helper function to combine classes +export function cn(...classes: (string | undefined | false)[]) { + return classes.filter(Boolean).join(' '); +} diff --git a/src/lib/types/attendance.ts b/src/lib/types/attendance.ts index 6331b17..72ac91a 100644 --- a/src/lib/types/attendance.ts +++ b/src/lib/types/attendance.ts @@ -26,11 +26,13 @@ export interface Attendance { externalEmail?: string; // For unregistered users checkinTime: string; // ISO datetime status: AttendanceStatus; + reason?: string; // Reason for absence/excuse } export interface CreateAttendanceInput { eventId: string; apprenticeId: string; + reason?: string; } export interface CreateExternalAttendanceInput { @@ -42,6 +44,7 @@ export interface CreateExternalAttendanceInput { export interface UpdateAttendanceInput { status: AttendanceStatus; checkinTime?: string; // ISO datetime, required when setting to Present + reason?: string; // Reason for absence/excuse } // Attendance statistics types @@ -86,6 +89,63 @@ export interface CohortAttendanceStats extends AttendanceStats { trend: AttendanceTrend; } +/** Aggregated cohort overview stats for the summary card */ +export interface CohortOverviewStats extends AttendanceStats { + apprenticeCount: number; + apprenticesAtRisk: number; // Count of apprentices below 80% attendance +} + +const LOW_ATTENDANCE_THRESHOLD = 80; + +/** + * Calculate aggregated cohort overview stats from an array of apprentice stats + */ +export function calculateCohortOverview(apprentices: ApprenticeAttendanceStats[]): CohortOverviewStats { + if (apprentices.length === 0) { + return { + totalEvents: 0, + attended: 0, + present: 0, + late: 0, + absent: 0, + excused: 0, + notComing: 0, + attendanceRate: 0, + apprenticeCount: 0, + apprenticesAtRisk: 0, + }; + } + + // Aggregate all stats + const totals = apprentices.reduce( + (acc, a) => ({ + totalEvents: acc.totalEvents + a.totalEvents, + attended: acc.attended + a.attended, + present: acc.present + a.present, + late: acc.late + a.late, + absent: acc.absent + a.absent, + excused: acc.excused + a.excused, + notComing: acc.notComing + a.notComing, + }), + { totalEvents: 0, attended: 0, present: 0, late: 0, absent: 0, excused: 0, notComing: 0 }, + ); + + // Count apprentices at risk (below threshold) + const apprenticesAtRisk = apprentices.filter(a => a.attendanceRate < LOW_ATTENDANCE_THRESHOLD).length; + + // Calculate overall attendance rate + const attendanceRate = totals.totalEvents > 0 + ? (totals.attended / totals.totalEvents) * 100 + : 0; + + return { + ...totals, + attendanceRate, + apprenticeCount: apprentices.length, + apprenticesAtRisk, + }; +} + /** Overall attendance summary for dashboard */ export interface AttendanceSummary { overall: AttendanceStats; @@ -103,6 +163,71 @@ export interface AttendanceHistoryEntry { status: AttendanceStatus; checkinTime: string | null; attendanceId: string | null; // Null when no attendance record exists (defaults to 'Not Check-in') + reason: string | null; // Reason for absence/excuse +} + +/** Event breakdown stats for cohort view */ +export interface EventBreakdownEntry { + eventId: string; + eventName: string; + eventDateTime: string; + present: number; + late: number; + excused: number; + notCheckin: number; + absent: number; + total: number; +} + +/** + * Calculate event breakdown from attendance history + * Groups by event and counts each status type + */ +export function calculateEventBreakdown(history: AttendanceHistoryEntry[]): EventBreakdownEntry[] { + if (history.length === 0) return []; + + const eventMap = new Map(); + + for (const entry of history) { + if (!eventMap.has(entry.eventId)) { + eventMap.set(entry.eventId, { + eventId: entry.eventId, + eventName: entry.eventName, + eventDateTime: entry.eventDateTime, + present: 0, + late: 0, + excused: 0, + notCheckin: 0, + absent: 0, + total: 0, + }); + } + + const event = eventMap.get(entry.eventId)!; + event.total++; + + switch (entry.status) { + case 'Present': + event.present++; + break; + case 'Late': + event.late++; + break; + case 'Excused': + event.excused++; + break; + case 'Not Check-in': + event.notCheckin++; + break; + case 'Absent': + event.absent++; + break; + } + } + + // Sort by date (newest first) + return Array.from(eventMap.values()) + .sort((a, b) => new Date(b.eventDateTime).getTime() - new Date(a.eventDateTime).getTime()); } /** Monthly attendance data point for charts */ diff --git a/src/lib/types/event.ts b/src/lib/types/event.ts index 18615e1..c8e65a6 100644 --- a/src/lib/types/event.ts +++ b/src/lib/types/event.ts @@ -1,10 +1,10 @@ -// Single source of truth for event types -// Must match Airtable single-select options exactly (case-sensitive) -export const EVENT_TYPES = ['Regular Class', 'Workshop', 'Hackathon'] as const; -export type EventType = typeof EVENT_TYPES[number]; +// Event types are now dynamic from Airtable +// Use string instead of constrained union for flexibility +export type EventType = string; + +// Color configuration for event types is now handled dynamically +// See src/lib/services/event-types.ts for color management -// Color configuration for each event type -// Used for calendar display and UI styling export interface EventTypeColor { main: string; // Primary color (hex) container: string; // Background color for calendar events @@ -12,27 +12,6 @@ export interface EventTypeColor { tailwind: string; // Tailwind text class for list styling } -export const EVENT_TYPE_COLORS: Record = { - 'Regular Class': { - main: '#3b82f6', - container: '#dbeafe', - onContainer: '#1e40af', - tailwind: 'text-blue-600', - }, - 'Workshop': { - main: '#10b981', - container: '#d1fae5', - onContainer: '#065f46', - tailwind: 'text-emerald-600', - }, - 'Hackathon': { - main: '#f59e0b', - container: '#fef3c7', - onContainer: '#92400e', - tailwind: 'text-amber-600', - }, -}; - export interface Event { id: string; name: string; diff --git a/src/routes/admin/+page.svelte b/src/routes/admin/+page.svelte index dfc81e3..8d12066 100644 --- a/src/routes/admin/+page.svelte +++ b/src/routes/admin/+page.svelte @@ -1,5 +1,6 @@ @@ -21,7 +22,7 @@ class="group block p-6 bg-white border border-gray-200 rounded-xl shadow-sm hover:shadow-lg hover:border-blue-300 transition-all" >
-
+
📅
@@ -35,14 +36,28 @@ class="group block p-6 bg-white border border-gray-200 rounded-xl shadow-sm hover:shadow-lg hover:border-green-300 transition-all" >
-
+

Attendance

-

Track individual apprentice attendance rates and history

+

Track cohorts attendance rates and history

+
+
+
+ 🔍 +
+
+

Search Apprentice

+

Find and view apprentice details

+
+
+ +
diff --git a/src/routes/admin/attendance/+page.svelte b/src/routes/admin/attendance/+page.svelte index f39cc25..d4c7c09 100644 --- a/src/routes/admin/attendance/+page.svelte +++ b/src/routes/admin/attendance/+page.svelte @@ -4,12 +4,14 @@ import { navigating, page } from '$app/state'; import { SvelteSet, SvelteMap } from 'svelte/reactivity'; import type { ApprenticeAttendanceStats, AttendanceHistoryEntry } from '$lib/types/attendance'; - import { calculateMonthlyAttendance } from '$lib/types/attendance'; + import { calculateMonthlyAttendance, calculateCohortOverview, calculateEventBreakdown } from '$lib/types/attendance'; import type { Cohort, Term } from '$lib/airtable/sveltekit-wrapper'; import type { AttendanceFilters } from '$lib/types/filters'; import { filtersToParams, parseFiltersFromParams } from '$lib/types/filters'; - import AttendanceFiltersComponent from '$lib/components/AttendanceFilters.svelte'; + import ExpandableAttendanceFilters from '$lib/components/ExpandableAttendanceFilters.svelte'; import AttendanceChart from '$lib/components/AttendanceChart.svelte'; + import UnifiedAttendanceStatsCard from '$lib/components/UnifiedAttendanceStatsCard.svelte'; + import EventBreakdownCard from '$lib/components/EventBreakdownCard.svelte'; let { data } = $props(); @@ -26,6 +28,12 @@ const monthlyChartData = $derived(calculateMonthlyAttendance(combinedHistory)); const showAll = $derived(data.showAll as boolean); + // Calculate cohort overview stats + const cohortOverview = $derived(calculateCohortOverview(apprentices)); + + // Calculate event breakdown + const eventBreakdown = $derived(calculateEventBreakdown(combinedHistory)); + // Current filters from URL params const currentFilters = $derived(parseFiltersFromParams(page.url.searchParams)); @@ -233,10 +241,11 @@
- ← Back to Admin {#if needsSelection} + ← Back to Admin

Attendance

{:else} +

Cohort Attendance

{/if}
@@ -316,41 +325,49 @@
{:else} - -
- +
+

Filters

+ + +
+ Cohorts: + {#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} +
+ + +
-
- -
-
- 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} - -
+ +
+ +
-
+ +
+ +
+ +
+
+

Individual Apprentices

+ {sortedApprentices.length} apprentice{sortedApprentices.length !== 1 ? 's' : ''} -
+
@@ -381,7 +398,7 @@ > Attendance Rate{getSortIndicator('attendanceRate')} - Attended + Lateness Rate Actions @@ -398,12 +415,9 @@ {apprentice.attendanceRate.toFixed(0)}% - {#if isLowAttendance(apprentice.attendanceRate)} - - {/if} - - {apprentice.attended}/{apprentice.totalEvents} + + {apprentice.totalEvents > 0 ? ((apprentice.late / apprentice.totalEvents) * 100).toFixed(0) : 0}% (''); let statusUpdateLoading = $state(false); + // Reason editing state (separate from status editing) + let editingReasonFor = $state(null); + let reasonInput = $state(''); + // When status changes to Present/Late and no check-in time is set, populate with event start time $effect(() => { if (editingEntryId && (editingStatus === 'Present' || editingStatus === 'Late') && !editingCheckinTime) { @@ -113,6 +120,60 @@ editingCheckinTime = ''; } + // Start editing reason only (separate from status editing) + function startEditingReason(entry: AttendanceHistoryEntry) { + editingReasonFor = entry.eventId; + reasonInput = entry.reason || ''; + } + + // Save reason only + async function saveReasonChange() { + if (!editingReasonFor) return; + + const entry = history.find(h => h.eventId === editingReasonFor); + if (!entry || !entry.attendanceId) return; + + statusUpdateLoading = true; + try { + const response = await fetch(`/api/attendance/${entry.attendanceId}`, { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + status: entry.status, + reason: reasonInput.trim() || null, + }), + }); + + const result = await response.json(); + + if (response.ok && result.success) { + // Update the history entry + history = history.map(entry => + entry.eventId === editingReasonFor + ? { ...entry, reason: reasonInput.trim() || null } + : entry, + ); + cancelReasonEditing(); + } + else { + console.error('Failed to update reason:', result.error); + // Could add error handling here + } + } + catch (error) { + console.error('Network error updating reason:', error); + } + finally { + statusUpdateLoading = false; + } + } + + // Cancel reason editing + function cancelReasonEditing() { + editingReasonFor = null; + reasonInput = ''; + } + // Handle Escape key to cancel editing $effect(() => { if (!editingEntryId) return; @@ -215,7 +276,7 @@ } -
+
{#if isLoading}
@@ -230,7 +291,7 @@
- ← Back to Cohort Attendance + ← Back to {fromSearch ? 'Admin Dashboard' : 'Cohort Attendance'}

{stats.apprenticeName} - Attendance

{#if stats.cohortName}

{stats.cohortName}

@@ -240,7 +301,7 @@
-
- +
-

Attendance History

+
+

Attendance History

+ {history.length} event{history.length !== 1 ? 's' : ''} +
{#if history.length === 0}
@@ -268,7 +332,8 @@ Event Date & Time Status - Check-in Time + Check-in Time + Reason @@ -300,7 +365,7 @@ {/if} - + {#if editingEntryId === entry.eventId}
{#if editingStatus === 'Present' || editingStatus === 'Late'} @@ -332,6 +397,46 @@ {/if} + + {#if entry.status === 'Absent' || entry.status === 'Excused'} + {#if editingReasonFor === entry.eventId} +
+ +
+ + +
+
+ {:else} + + {/if} + {:else} + + {/if} + {/each} diff --git a/src/routes/admin/events/+page.server.ts b/src/routes/admin/events/+page.server.ts index 6f3618f..754b692 100644 --- a/src/routes/admin/events/+page.server.ts +++ b/src/routes/admin/events/+page.server.ts @@ -1,13 +1,15 @@ import type { PageServerLoad } from './$types'; import { listEvents, listCohorts } from '$lib/airtable/sveltekit-wrapper'; import { DEFAULTS } from '$lib/airtable/config'; +import { eventTypesService } from '$lib/services/event-types'; export const load: PageServerLoad = async ({ url }) => { const cohortId = url.searchParams.get('cohort') ?? undefined; - const [events, cohorts] = await Promise.all([ + const [events, cohorts, eventTypes] = await Promise.all([ listEvents({ cohortId }), listCohorts(), + eventTypesService.getEventTypes(), ]); // Sort cohorts reverse alphabetically (newest cohorts first) @@ -16,6 +18,7 @@ export const load: PageServerLoad = async ({ url }) => { return { events, cohorts, + eventTypes, selectedCohortId: cohortId, defaultSurveyUrl: DEFAULTS.SURVEY_URL, }; diff --git a/src/routes/admin/events/+page.svelte b/src/routes/admin/events/+page.svelte index 5446853..8df24c7 100644 --- a/src/routes/admin/events/+page.svelte +++ b/src/routes/admin/events/+page.svelte @@ -4,7 +4,7 @@ import { tick } from 'svelte'; import { slide } from 'svelte/transition'; import { SvelteSet } from 'svelte/reactivity'; - import { EVENT_TYPES, EVENT_TYPE_COLORS, type EventType, type Event as AppEvent } from '$lib/types/event'; + import { type EventType, type Event as AppEvent } from '$lib/types/event'; import { ATTENDANCE_STATUSES, getStatusBadgeClass, type AttendanceStatus } from '$lib/types/attendance'; import { Calendar, DayGrid, Interaction } from '@event-calendar/core'; import '@event-calendar/core/index.css'; @@ -13,6 +13,23 @@ let { data } = $props(); + // Extract event types from server data + const eventTypes = $derived(data.eventTypes); + const eventTypeColors = $derived(() => { + const colorMap: Record = {}; + eventTypes.forEach((type) => { + colorMap[type.name] = { main: type.color, tailwind: type.tailwindClass }; + }); + return colorMap; + }); + + // Helper function to get default survey URL for an event type + function getDefaultSurveyUrl(eventTypeName: string): string { + if (!eventTypeName) return ''; + const type = eventTypes.find(t => t.name === eventTypeName); + return type?.defaultSurveyUrl || data.defaultSurveyUrl; + } + // Sorting state type SortColumn = 'name' | 'dateTime' | 'eventType' | 'cohort' | 'attendance'; type SortDirection = 'asc' | 'desc'; @@ -164,7 +181,7 @@ title: event.name || '(Untitled)', start: start.toISOString().slice(0, 16).replace('T', ' '), end: end.toISOString().slice(0, 16).replace('T', ' '), - color: EVENT_TYPE_COLORS[event.eventType]?.main || '#3b82f6', + color: eventTypeColors()[event.eventType]?.main || '#3b82f6', }; }); @@ -256,17 +273,16 @@ // Inline event creation state let isAddingEvent = $state(false); let tableContainer = $state(null); - // svelte-ignore state_referenced_locally let newEvent = $state({ name: '', date: '', startTime: '10:00', endTime: '14:00', cohortIds: [] as string[], - eventType: EVENT_TYPES[0] as EventType, + eventType: '', isPublic: false, checkInCode: '' as string | number, - surveyUrl: data.defaultSurveyUrl, + surveyUrl: '', }); let addEventError = $state(''); let addEventSubmitting = $state(false); @@ -283,6 +299,32 @@ } }); + // Auto-populate survey URL when event type changes for regular event + $effect(() => { + if (newEvent.eventType) { + newEvent.surveyUrl = getDefaultSurveyUrl(newEvent.eventType); + } + }); + + // ESC key handler for canceling forms + $effect(() => { + function handleKeydown(event: KeyboardEvent) { + if (event.key === 'Escape') { + if (isAddingEvent) { + cancelAddEvent(); + } + else if (isCreatingSeries) { + cancelSeriesForm(); + } + } + } + + if (isAddingEvent || isCreatingSeries) { + document.addEventListener('keydown', handleKeydown); + return () => document.removeEventListener('keydown', handleKeydown); + } + }); + // Auto-generate check-in code when isPublic is checked, clear when unchecked let prevNewEventIsPublic = $state(false); $effect(() => { @@ -303,7 +345,7 @@ startTime: '', endTime: '', cohortIds: [] as string[], - eventType: EVENT_TYPES[0] as EventType, + eventType: eventTypes[0]?.name || '', isPublic: false, checkInCode: '' as string | number, surveyUrl: '', @@ -349,11 +391,17 @@ let seriesTime = $state('10:00'); let seriesEndTime = $state('11:00'); let seriesCohortIds = $state([]); - let seriesEventType = $state(EVENT_TYPES[0]); + let seriesEventType = $state(''); let seriesIsPublic = $state(false); let seriesCheckInCode = $state(''); - // svelte-ignore state_referenced_locally - let seriesSurveyUrl = $state(data.defaultSurveyUrl); + let seriesSurveyUrl = $state(''); + + // Auto-populate survey URL when event type changes for series + $effect(() => { + if (seriesEventType) { + seriesSurveyUrl = getDefaultSurveyUrl(seriesEventType); + } + }); let seriesError = $state(''); let seriesSubmitting = $state(false); let seriesProgress = $state<{ created: number; total: number } | null>(null); @@ -608,7 +656,7 @@ startTime: '10:00', endTime: '14:00', cohortIds: [], - eventType: EVENT_TYPES[0], + eventType: eventTypes[0]?.name || '', isPublic: false, checkInCode: '' as string | number, surveyUrl: data.defaultSurveyUrl, @@ -622,10 +670,10 @@ seriesTime = '10:00'; seriesEndTime = '11:00'; seriesCohortIds = []; - seriesEventType = EVENT_TYPES[0]; + seriesEventType = ''; seriesIsPublic = false; seriesCheckInCode = ''; - seriesSurveyUrl = data.defaultSurveyUrl; + seriesSurveyUrl = ''; selectedDates = []; seriesError = ''; seriesProgress = null; @@ -912,7 +960,7 @@ } -
+
← Back to Admin

Events

@@ -959,7 +1007,8 @@ await tick(); tableContainer?.scrollTo({ top: 0, behavior: 'smooth' }); }} - class="bg-blue-600 text-white px-4 py-2 rounded hover:bg-blue-700" + disabled={isCreatingSeries} + class="bg-blue-600 text-white px-4 py-2 rounded hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed disabled:hover:bg-blue-600" > + Add Event @@ -1144,8 +1193,9 @@ bind:value={newEvent.eventType} class="w-full border rounded px-2 py-1 text-sm" > - {#each EVENT_TYPES as type (type)} - + + {#each eventTypes as type (type.name)} + {/each} @@ -1233,20 +1283,29 @@ {#if showNewEventSurvey} -
- + +
+ + +
{/if}
@@ -1317,8 +1376,8 @@ class="w-full border rounded px-2 py-1 text-sm" onclick={e => e.stopPropagation()} > - {#each EVENT_TYPES as type (type)} - + {#each eventTypes as type (type.name)} + {/each} @@ -1418,24 +1477,36 @@ {#if showEditEventSurvey} -
- + +
+ + +
{/if}
@@ -1517,7 +1588,7 @@ {#if event.eventType} - + {event.eventType} {:else} @@ -1717,7 +1788,8 @@ {#if !isCreatingSeries} @@ -1794,8 +1866,9 @@ required class="w-full border rounded px-3 py-2" > - {#each EVENT_TYPES as type (type)} - + + {#each eventTypes as type (type.name)} + {/each}
@@ -1863,13 +1936,24 @@ - +
+ + {#if seriesSurveyUrl} + + {/if} +
{#if seriesIsPublic} @@ -1956,13 +2040,13 @@
- {#each EVENT_TYPES as type (type)} + {#each eventTypes as type (type.name)}
- {type} + {type.name}
{/each} {#if isCreatingSeries} diff --git a/src/routes/api/apprentices/search/+server.ts b/src/routes/api/apprentices/search/+server.ts new file mode 100644 index 0000000..549c1b3 --- /dev/null +++ b/src/routes/api/apprentices/search/+server.ts @@ -0,0 +1,76 @@ +import { json } from '@sveltejs/kit'; +import type { RequestHandler } from './$types'; +import { AIRTABLE_API_KEY, AIRTABLE_BASE_ID_LEARNERS } from '$env/static/private'; +import Airtable from 'airtable'; +import { TABLES, APPRENTICE_FIELDS, COHORT_FIELDS } from '$lib/airtable/config'; + +export const GET: RequestHandler = async ({ url }) => { + const query = url.searchParams.get('q')?.trim(); + + // Require at least 2 characters to search + if (!query || query.length < 2) { + return json({ success: true, apprentices: [] }); + } + + try { + // Configure Airtable + Airtable.configure({ apiKey: AIRTABLE_API_KEY }); + const base = Airtable.base(AIRTABLE_BASE_ID_LEARNERS); + const apprenticesTable = base(TABLES.APPRENTICES); + const cohortsTable = base(TABLES.COHORTS); + + // Get all apprentices + const apprenticeRecords = await apprenticesTable + .select({ + returnFieldsByFieldId: true, + }) + .all(); + + // Get all cohorts to map cohort IDs to numbers + const cohortRecords = await cohortsTable + .select({ + returnFieldsByFieldId: true, + }) + .all(); + + const cohortMap = new Map(); + cohortRecords.forEach((record) => { + const cohortNumber = record.get(COHORT_FIELDS.NUMBER) as string; + cohortMap.set(record.id, parseInt(cohortNumber) || 0); + }); + + // Filter and format results + const searchResults = apprenticeRecords + .filter((record) => { + const name = record.get(APPRENTICE_FIELDS.NAME) as string; + return name && name.toLowerCase().includes(query.toLowerCase()); + }) + .slice(0, 10) // Limit to 10 results + .map((record) => { + // Email is a lookup field, returns array + const emailLookup = record.get(APPRENTICE_FIELDS.EMAIL) as string[] | undefined; + const cohortIds = record.get(APPRENTICE_FIELDS.COHORT) as string[] | undefined; + const cohortNumbers = cohortIds?.map(id => cohortMap.get(id)).filter(Boolean) || []; + + return { + id: record.id, + name: record.get(APPRENTICE_FIELDS.NAME) as string, + email: emailLookup?.[0] || '', + status: (record.get(APPRENTICE_FIELDS.STATUS) as string) || 'Active', + cohortNumbers, + }; + }); + + return json({ + success: true, + apprentices: searchResults, + }); + } + catch (error) { + console.error('Failed to search apprentices:', error); + return json({ + success: false, + error: 'Failed to search apprentices', + }, { status: 500 }); + } +}; diff --git a/src/routes/api/attendance/[id]/+server.ts b/src/routes/api/attendance/[id]/+server.ts index 5862168..dc1b571 100644 --- a/src/routes/api/attendance/[id]/+server.ts +++ b/src/routes/api/attendance/[id]/+server.ts @@ -30,6 +30,7 @@ export const PATCH: RequestHandler = async ({ params, request }) => { const attendance = await updateAttendance(id, { status: body.status, checkinTime: body.checkinTime, + reason: body.reason, }); return json({ success: true, attendance }); diff --git a/src/routes/api/checkin/absent/+server.ts b/src/routes/api/checkin/absent/+server.ts index a3109e5..27b1b2b 100644 --- a/src/routes/api/checkin/absent/+server.ts +++ b/src/routes/api/checkin/absent/+server.ts @@ -10,7 +10,7 @@ export const POST: RequestHandler = async ({ cookies, request }) => { return json({ success: false, error: 'Authentication required' }, { status: 401 }); } - let body: { eventId?: string }; + let body: { eventId?: string; reason?: string }; try { body = await request.json(); } @@ -18,7 +18,7 @@ export const POST: RequestHandler = async ({ cookies, request }) => { return json({ success: false, error: 'Invalid JSON body' }, { status: 400 }); } - const { eventId } = body; + const { eventId, reason } = body; if (!eventId) { return json({ success: false, error: 'eventId is required' }, { status: 400 }); @@ -47,6 +47,7 @@ export const POST: RequestHandler = async ({ cookies, request }) => { const attendance = await markNotComing({ eventId, apprenticeId: apprentice.id, + reason, }); return json({ diff --git a/src/routes/api/event-types/+server.ts b/src/routes/api/event-types/+server.ts new file mode 100644 index 0000000..7bb0537 --- /dev/null +++ b/src/routes/api/event-types/+server.ts @@ -0,0 +1,14 @@ +import { json } from '@sveltejs/kit'; +import type { RequestHandler } from './$types'; +import { eventTypesService } from '$lib/services/event-types'; + +export const GET: RequestHandler = async () => { + try { + const eventTypes = await eventTypesService.getEventTypes(); + return json({ success: true, eventTypes }); + } + catch (error) { + console.error('Failed to fetch event types:', error); + return json({ success: false, error: 'Failed to fetch event types' }, { status: 500 }); + } +}; diff --git a/src/routes/api/events/+server.ts b/src/routes/api/events/+server.ts index eaeea28..81d7905 100644 --- a/src/routes/api/events/+server.ts +++ b/src/routes/api/events/+server.ts @@ -1,7 +1,7 @@ import { json } from '@sveltejs/kit'; import type { RequestHandler } from './$types'; import { createEvent, listEvents } from '$lib/airtable/sveltekit-wrapper'; -import { EVENT_TYPES } from '$lib/types/event'; +import { eventTypesService } from '$lib/services/event-types'; export const GET: RequestHandler = async () => { try { @@ -29,12 +29,14 @@ export const POST: RequestHandler = async ({ request }) => { return json({ success: false, error: 'Event type is required' }, { status: 400 }); } - // Case-insensitive event type matching - normalize to Airtable's exact value - const normalizedEventType = EVENT_TYPES.find( - t => t.toLowerCase() === body.eventType.toLowerCase(), - ); - if (!normalizedEventType) { - return json({ success: false, error: 'Invalid event type' }, { status: 400 }); + // Validate event type against Airtable data + const isValid = await eventTypesService.isValidEventType(body.eventType); + if (!isValid) { + const validTypes = await eventTypesService.getEventTypeNames(); + return json({ + success: false, + error: `Invalid event type. Must be one of: ${validTypes.join(', ')}`, + }, { status: 400 }); } const event = await createEvent({ @@ -42,7 +44,7 @@ export const POST: RequestHandler = async ({ request }) => { dateTime: body.dateTime, endDateTime: body.endDateTime || undefined, cohortIds: body.cohortIds || undefined, - eventType: normalizedEventType, + eventType: body.eventType, surveyUrl: body.surveyUrl || undefined, isPublic: body.isPublic ?? false, checkInCode: body.checkInCode || undefined, diff --git a/src/routes/api/events/[id]/+server.ts b/src/routes/api/events/[id]/+server.ts index bb775ad..369c0e2 100644 --- a/src/routes/api/events/[id]/+server.ts +++ b/src/routes/api/events/[id]/+server.ts @@ -1,7 +1,7 @@ import { json } from '@sveltejs/kit'; import type { RequestHandler } from './$types'; import { updateEvent, deleteEvent, getEvent } from '$lib/airtable/sveltekit-wrapper'; -import { EVENT_TYPES } from '$lib/types/event'; +import { eventTypesService } from '$lib/services/event-types'; export const PUT: RequestHandler = async ({ params, request }) => { try { @@ -16,8 +16,12 @@ export const PUT: RequestHandler = async ({ params, request }) => { const body = await request.json(); // Validate event type if provided - if (body.eventType && !EVENT_TYPES.includes(body.eventType)) { - return json({ success: false, error: 'Invalid event type' }, { status: 400 }); + if (body.eventType && !(await eventTypesService.isValidEventType(body.eventType))) { + const validTypes = await eventTypesService.getEventTypeNames(); + return json({ + success: false, + error: `Invalid event type. Must be one of: ${validTypes.join(', ')}`, + }, { status: 400 }); } const event = await updateEvent(id, { diff --git a/src/routes/checkin/+page.svelte b/src/routes/checkin/+page.svelte index 2192d50..f8fc605 100644 --- a/src/routes/checkin/+page.svelte +++ b/src/routes/checkin/+page.svelte @@ -20,6 +20,10 @@ let markingNotComing = $state(null); let checkInError = $state(null); + // Absence reason state + let showingReasonFor = $state(null); + let absenceReason = $state(''); + // Guest check-in state let guestStep = $state<'code' | 'events' | 'details' | 'success'>('code'); let guestCode = $state(''); @@ -125,8 +129,21 @@ } } + // Show absence reason input + function showReasonInput(eventId: string) { + showingReasonFor = eventId; + absenceReason = ''; + checkInError = null; + } + + // Hide absence reason input + function hideReasonInput() { + showingReasonFor = null; + absenceReason = ''; + } + // Authenticated user mark as absent - async function handleNotComing(eventId: string) { + async function handleNotComing(eventId: string, reason?: string) { // Prevent double-clicking by checking if already processing this event if (markingNotComing === eventId || checkingIn === eventId) { return; @@ -139,7 +156,7 @@ const response = await fetch('/api/checkin/absent', { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ eventId }), + body: JSON.stringify({ eventId, reason }), }); const result = await response.json(); @@ -167,6 +184,7 @@ } finally { markingNotComing = null; + hideReasonInput(); } } @@ -268,111 +286,153 @@ Check In - Apprentice Pulse -
+
{#if data.authenticated} -
- - +