diff --git a/.claude/README.md b/.claude/README.md index 758a02a..b70e2d3 100644 --- a/.claude/README.md +++ b/.claude/README.md @@ -9,6 +9,8 @@ Custom automation for working with Jira tasks. | `/plan AP-23` | Start a Jira task (fetch → branch → plan → activate loop) | | `/stop` | Stop the iterator loop early | | `/update-report` | Document meaningful changes to `docs/report.md` | +| `/evaluate-report` | Decide if the last task needs a report.md update | +| `/evaluate-tests` | Decide what tests are required for the last task | ## Complete Workflow @@ -37,11 +39,12 @@ The hook (`.claude/hooks/plan-iterator.sh`) runs after each Claude response: ``` docs/plan.md -- [x] Completed task 1 -- [x] Completed task 2 -- [ ] Current task ◄── Claude works on this -- [ ] Next task -- [ ] Final task +1. [x] Setup + - [x] 1.1 Completed task 1 + - [x] 1.2 Completed task 2 +2. [ ] Current task ◄── Claude works on this + - [ ] 2.1 Next task + - [ ] 2.2 Final task ``` For each task, Claude: @@ -73,6 +76,8 @@ When the loop ends, you decide what to do next: | `.claude/commands/plan.md` | `/plan` command definition | | `.claude/commands/stop.md` | `/stop` command definition | | `.claude/commands/update-report.md` | `/update-report` command definition | +| `.claude/commands/evaluate-report.sh` | `/evaluate-report` command definition | +| `.claude/commands/evaluate-tests.sh` | `/evaluate-tests` command definition | | `docs/plan.md` | Current task checklist (created per-task) | | `docs/report.md` | Project documentation | diff --git a/.claude/commands/analyze-plan-iterator.sh b/.claude/commands/analyze-plan-iterator.sh new file mode 100644 index 0000000..f0f65d7 --- /dev/null +++ b/.claude/commands/analyze-plan-iterator.sh @@ -0,0 +1,103 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Plan Iterator Analysis +# Mirrors the AWK parsing logic used by the plan-iterator hook + +PLAN_FILE="docs/plan.md" + +if [[ ! -f "$PLAN_FILE" ]]; then + echo "Missing $PLAN_FILE" + exit 1 +fi + +echo "=== PLAN ITERATOR ANALYSIS (AWK PARSER) ===" +echo "Plan file: $PLAN_FILE" +echo "" +echo "Expected formats:" +echo " Main tasks: 1. [ ] Task" +echo " Subtasks: - [ ] 1.1 Task" +echo "" + +IFS=$'\t' read -r REMAINING COMPLETED NEXT_TASK LAST_DONE TO_COMPLETE < <( + awk ' + function trim(s) { sub(/^[[:space:]]+/, "", s); sub(/[[:space:]]+$/, "", s); return s } + function after_checkbox(s) { + gsub(/^[[:space:]]*[0-9]+\.[[:space:]]*\[[ x]\][[:space:]]*/, "", s) + gsub(/^[[:space:]]*-[[:space:]]*\[[ x]\][[:space:]]*/, "", s) + return trim(s) + } + + BEGIN { + remaining = 0 + completed = 0 + next_task = "" + last_done = "" + } + + match($0, /^([0-9]+)\.[[:space:]]*\[ \][[:space:]]*(.*)$/, m) { + n = m[1] + main_state[n] = "incomplete" + main_text[n] = trim(m[2]) + next + } + + match($0, /^([0-9]+)\.[[:space:]]*\[x\][[:space:]]*(.*)$/, m) { + n = m[1] + main_state[n] = "complete" + main_text[n] = trim(m[2]) + next + } + + match($0, /^[[:space:]]*-[[:space:]]*\[([ x])\][[:space:]]*([0-9]+)\.[0-9]+[[:space:]]*(.*)$/, m) { + status = m[1] + n = m[2] + sub_any[n] = 1 + + if (status == "x") { + sub_complete[n]++ + completed++ + last_done = after_checkbox($0) + } else { + sub_incomplete[n]++ + remaining++ + if (next_task == "") next_task = after_checkbox($0) + } + + next + } + + END { + for (n in main_state) { + if (!sub_any[n]) { + if (main_state[n] == "complete") { + completed++ + last_done = main_text[n] + } else if (main_state[n] == "incomplete") { + remaining++ + if (next_task == "") next_task = main_text[n] + } + } + } + + to_complete = "" + for (n in sub_any) { + if (main_state[n] == "incomplete" && sub_incomplete[n] == 0 && sub_complete[n] > 0) { + to_complete = to_complete (to_complete ? " " : "") n + } + } + + printf "%d\t%d\t%s\t%s\t%s\n", remaining, completed, next_task, last_done, to_complete + } + ' "$PLAN_FILE" +) + +echo "Leaf tasks remaining: ${REMAINING:-0}" +echo "Leaf tasks completed: ${COMPLETED:-0}" +echo "Next task: ${NEXT_TASK:-}" +echo "Last done: ${LAST_DONE:-}" +if [[ -n "${TO_COMPLETE:-}" ]]; then + echo "Main tasks eligible for auto-complete: $TO_COMPLETE" +else + echo "Main tasks eligible for auto-complete: " +fi diff --git a/.claude/commands/evaluate-report.sh b/.claude/commands/evaluate-report.sh new file mode 100755 index 0000000..974de47 --- /dev/null +++ b/.claude/commands/evaluate-report.sh @@ -0,0 +1,105 @@ +#!/bin/bash + +# Evaluate Report Command +# Analyzes recent work and determines if report.md needs updating +# Usage: /evaluate-report [task-description] + +TASK="${1:-recent work}" + +# Get recent changes for context +BASE_REF="HEAD~1" +if ! git rev-parse --verify "$BASE_REF" >/dev/null 2>&1; then + BASE_REF="HEAD" +fi + +CHANGED_FILES=$(git diff --name-only "$BASE_REF" 2>/dev/null || true) +RECENT_FILES=$(printf '%s\n' "$CHANGED_FILES" | head -10 | tr '\n' ' ') +LAST_COMMIT=$(git log -1 --oneline 2>/dev/null || echo "No commits found") + +# Check for indicators that suggest report update needed +INDICATORS="" +if printf '%s\n' "$CHANGED_FILES" | grep -qE '(routes|lib|server).*\.ts$'; then + INDICATORS="${INDICATORS}[New/modified TypeScript files] " +fi +if printf '%s\n' "$CHANGED_FILES" | grep -q '\.spec\.ts$'; then + INDICATORS="${INDICATORS}[Test files] " +fi +if git log -1 --oneline 2>/dev/null | grep -qiE '(refactor|optimize|performance|security|auth|error|pattern|architecture)'; then + INDICATORS="${INDICATORS}[Significant keywords in commit] " +fi + +cat << EOF +I need to evaluate if the recent work should be documented in report.md. + +## Task Context +Task: $TASK +Last commit: $LAST_COMMIT +Base ref: $BASE_REF +Modified files: $RECENT_FILES +Auto-detected indicators: $INDICATORS + +## Evaluation Framework + +EOF + +# Include the skill content directly for self-contained execution +if [[ -f ".claude/skills/report-evaluator.md" ]]; then + echo "### Report Evaluation Guidelines:" + echo "" + cat .claude/skills/report-evaluator.md + echo "" + echo "---" + echo "" +fi + +cat << EOF + +## Evaluation Process + +Following the guidelines above: + +1. **Analyze Recent Changes**: + - Run: git diff "$BASE_REF" --stat + - Run: git log -3 --oneline + - Review the actual code changes in modified files + +2. **Identify Significant Technical Decisions**: + - Architecture patterns or design choices + - Performance optimizations + - Error handling strategies + - State management approaches + - API design decisions + - Testing strategies + - Security implementations + +3. **Map to Assessment Criteria** (docs/Assessment-criteria.md): + - Check if changes provide evidence for P1-P11 or D1-D4 + - Don't force connections - only map if naturally applicable + +4. **Make Update Decision**: + ✅ UPDATE if: + - Significant technical decisions were made + - New patterns or approaches introduced + - Performance/security improvements + - Complex problem solved + - Assessment criteria evidence exists + + ⏭️ SKIP if: + - Routine bug fixes + - Simple UI text changes + - Code formatting only + - Dependency updates + - No architectural impact + +5. **If Updating report.md**: + - Add section with clear heading + - Explain the "why" behind decisions + - Include code examples if helpful + - Reference assessment criteria where natural + - Keep it concise but complete + +## IMPORTANT +This evaluation is MANDATORY after each task. Even if no update is needed, you must explicitly state that you evaluated and determined no update was necessary. + +Please proceed with the evaluation now. +EOF diff --git a/.claude/commands/evaluate-tests.sh b/.claude/commands/evaluate-tests.sh new file mode 100755 index 0000000..3a1c748 --- /dev/null +++ b/.claude/commands/evaluate-tests.sh @@ -0,0 +1,147 @@ +#!/bin/bash + +# Evaluate Tests Command +# Analyzes recent work and determines what testing is needed +# Usage: /evaluate-tests [task-description] + +TASK="${1:-recent work}" + +# Get recent changes for context +BASE_REF="HEAD~1" +if ! git rev-parse --verify "$BASE_REF" >/dev/null 2>&1; then + BASE_REF="HEAD" +fi + +CHANGED_FILES=$(git diff --name-only "$BASE_REF" 2>/dev/null || true) +RECENT_FILES=$(printf '%s\n' "$CHANGED_FILES" | head -10 | tr '\n' ' ') +LAST_COMMIT=$(git log -1 --oneline 2>/dev/null || echo "No commits found") + +# Check for indicators that suggest specific test types needed +INDICATORS="" +if printf '%s\n' "$CHANGED_FILES" | grep -qE 'src/routes/api.*\.ts$'; then + INDICATORS="${INDICATORS}[New/modified API routes - Postman tests needed] " +fi +if printf '%s\n' "$CHANGED_FILES" | grep -qE 'src/lib/(server|airtable).*\.ts$'; then + INDICATORS="${INDICATORS}[Service/business logic - Unit tests needed] " +fi +if printf '%s\n' "$CHANGED_FILES" | grep -qE '\.svelte$'; then + INDICATORS="${INDICATORS}[Svelte components - Consider component tests] " +fi +if printf '%s\n' "$CHANGED_FILES" | grep -qE 'auth|session|security'; then + INDICATORS="${INDICATORS}[Auth/security changes - Critical testing needed] " +fi +if git log -1 --oneline 2>/dev/null | grep -qiE '(fix|bug|error|issue)'; then + INDICATORS="${INDICATORS}[Bug fix - Regression tests recommended] " +fi + +# Count existing test files +if command -v rg >/dev/null 2>&1; then + EXISTING_TESTS=$(rg --files -g '*.spec.ts' src 2>/dev/null | wc -l | tr -d ' ') +else + EXISTING_TESTS=$(find src -name "*.spec.ts" 2>/dev/null | wc -l | tr -d ' ') +fi + +cat << EOF +I need to evaluate what testing is needed for the recent work. + +## Task Context +Task: $TASK +Last commit: $LAST_COMMIT +Base ref: $BASE_REF +Modified files: $RECENT_FILES +Test indicators: $INDICATORS +Existing test files: $EXISTING_TESTS + +## Testing Framework + +EOF + +# Include the skill content directly for self-contained execution +if [[ -f ".claude/skills/test-evaluator.md" ]]; then + echo "### Testing Evaluation Guidelines:" + echo "" + cat .claude/skills/test-evaluator.md + echo "" + echo "---" + echo "" +fi + +cat << EOF + +## Evaluation Process + +Following the guidelines above: + +1. **Analyze Recent Changes**: + - Run: git diff "$BASE_REF" --stat + - Review modified files and understand what changed + - Identify the type of changes (API, business logic, UI, bug fix) + +2. **Determine Test Requirements**: + + **For API Routes** (/routes/api/*): + - Create Postman collection if none exists for this module + - Add requests for success/error scenarios + - Include authentication testing + - Test response validation + + **For Business Logic** (/lib/server/, /lib/airtable/): + - Create co-located .spec.ts files + - Test core functions with edge cases + - Mock external dependencies (Airtable, APIs) + - Verify error handling + + **For Components** (*.svelte): + - Consider component tests if complex logic exists + - Test reactive state management + - Test event handling + +3. **Check Existing Tests**: + - Run: npm test + - Ensure existing tests still pass + - Update tests if functionality changed + - Add missing test cases + +4. **Create Missing Tests**: + + **Unit Tests**: + - Create src/path/to/file.spec.ts next to source + - Follow Vitest patterns with describe/it/expect + - Test normal cases, edge cases, error cases + + **API Tests** (Use MCP Postman integration): + - Get existing collections: Use mcp__postman__getCollections + - Create new collection if needed: Use mcp__postman__createCollection + - Add test requests: Use mcp__postman__createCollectionRequest + - Include proper test scripts for validation + +5. **Run and Verify**: + - Execute: npm test + - Fix any failing tests + - Ensure new tests pass + - Verify coverage of critical paths + +## Decision Matrix + +✅ **CREATE TESTS** if: +- New API endpoints added +- Business logic/service functions created +- Authentication/security changes +- Bug fixes (regression prevention) +- Complex component logic + +⏭️ **SKIP TESTS** if: +- Simple UI text changes +- Styling/CSS only +- Documentation updates +- Configuration without logic changes + +## IMPORTANT +After evaluation, you must either: +1. **Create the identified tests** (unit tests and/or Postman collections) +2. **Explicitly state** why tests were not needed for this change + +Testing evaluation is MANDATORY - never skip this step. + +Please proceed with the evaluation now. +EOF diff --git a/.claude/commands/plan.md b/.claude/commands/plan.md index 73f5e32..2aa8c11 100644 --- a/.claude/commands/plan.md +++ b/.claude/commands/plan.md @@ -25,7 +25,8 @@ Create a new plan from a Jira task: fetch details, transition to In Progress, cr 4. **Create the feature branch**: - Generate branch name: `feature/{ticket-id-lowercase}-{slugified-summary}` - Example: `AP-23 Add Bulk Event Creation` → `feature/ap-23-add-bulk-event-creation` - - Run: `git checkout main && git pull && git checkout -b {branch-name}` + - Run: `git checkout && git pull && git checkout -b {branch-name}` + - If the default branch is not `main`, use the repo's default branch instead 5. **Write the plan**: - Create/overwrite `docs/plan.md` @@ -67,7 +68,7 @@ Create a new plan from a Jira task: fetch details, transition to In Progress, cr ## Rules -- **Checkbox format**: Must use `- [ ]` exactly (the plan-iterator hook reads this) +- **Checkbox format**: Use `1. [ ]` for main tasks and `- [ ] 1.1` for subtasks (the plan-iterator hook reads these) - **Numbered structure**: Use numbered main tasks (1, 2, 3) with numbered subtasks (1.1, 1.2, etc) - **Small tasks**: Each task = one focused change, completable in one session - **Logical order**: Order by dependency diff --git a/.claude/hooks/plan-iterator.sh b/.claude/hooks/plan-iterator.sh index 16b6cad..5158df1 100755 --- a/.claude/hooks/plan-iterator.sh +++ b/.claude/hooks/plan-iterator.sh @@ -1,4 +1,5 @@ -#!/bin/bash +#!/usr/bin/env bash +set -euo pipefail # Plan Iterator Hook (Stop event) # Automatically continues through tasks in docs/plan.md @@ -12,9 +13,7 @@ PLAN_FILE="docs/plan.md" LOOP_MARKER=".claude/loop" # Only run if loop marker exists (opt-in) -if [[ ! -f "$LOOP_MARKER" ]]; then - exit 0 -fi +[[ -f "$LOOP_MARKER" ]] || exit 0 # No plan file = stop and cleanup if [[ ! -f "$PLAN_FILE" ]]; then @@ -22,36 +21,197 @@ if [[ ! -f "$PLAN_FILE" ]]; then exit 0 fi -# Count remaining tasks (unchecked boxes) -REMAINING=$(grep -c '^\s*\([0-9]\+\.\s*\)\?\s*- \[ \]' "$PLAN_FILE" 2>/dev/null | head -1 || echo "0") -REMAINING=${REMAINING:-0} +# --- Helpers --------------------------------------------------------------- + +portable_sed_inplace() { + # Usage: portable_sed_inplace 's/old/new/' file + # Works on macOS (BSD sed) and Linux (GNU sed) + local expr="$1" + local file="$2" + sed -i.bak "$expr" "$file" + rm -f "${file}.bak" +} + +sanitize_git_subject() { + # Read stdin, output a safe one-line git subject (<=72 chars), no newlines + python3 - <<'PY' +import sys +s = sys.stdin.read() +s = s.replace("\r", " ").replace("\n", " ") +s = " ".join(s.split()) # collapse whitespace +if s.startswith("-"): + s = s.lstrip("-").strip() +if len(s) > 72: + s = s[:69] + "..." +print(s) +PY +} + +# --- Parse plan.md once (leaf-only semantics) ------------------------------ + +# AWK outputs 5 lines: +# 1) remaining_leaf_count +# 2) completed_leaf_count +# 3) next_task_text +# 4) last_done_text (leaf or standalone main) +# 5) space-separated main task numbers that should be auto-marked complete +IFS=$'\t' read -r REMAINING COMPLETED NEXT_TASK LAST_DONE TO_COMPLETE < <( + awk ' + function trim(s) { sub(/^[[:space:]]+/, "", s); sub(/[[:space:]]+$/, "", s); return s } + function after_checkbox(s) { + # strip leading numbering/bullet + checkbox, return the rest + # main: "1. [ ] Task" or "1. [x] Task" + # sub: " - [ ] 1.1 Task" or " - [x] 1.1 Task" + gsub(/^[[:space:]]*[0-9]+\.[[:space:]]*\[[ x]\][[:space:]]*/, "", s) + gsub(/^[[:space:]]*-[[:space:]]*\[[ x]\][[:space:]]*/, "", s) + return trim(s) + } + + BEGIN { + # leaf-only counts: + remaining = 0 + completed = 0 + next_task = "" + last_done = "" + + # track which mains exist and their checkbox state + # main_state[n] = "incomplete" | "complete" + # track subtasks existence + incomplete/complete counts per main + # sub_any[n] = 1 if any subtask exists for main n + # sub_incomplete[n], sub_complete[n] + } + + # Main task: incomplete (strict [ ]) + match($0, /^([0-9]+)\.[[:space:]]*\[ \][[:space:]]*(.*)$/, m) { + n = m[1] + main_state[n] = "incomplete" + main_text[n] = trim(m[2]) + next + } + + # Main task: complete (strict [x]) + match($0, /^([0-9]+)\.[[:space:]]*\[x\][[:space:]]*(.*)$/, m) { + n = m[1] + main_state[n] = "complete" + main_text[n] = trim(m[2]) + next + } + + # Subtask: strict [ ] or [x], and requires a leading main number like 1.1, 2.3 etc + match($0, /^[[:space:]]*-[[:space:]]*\[([ x])\][[:space:]]*([0-9]+)\.[0-9]+[[:space:]]*(.*)$/, m) { + status = m[1] + n = m[2] + sub_any[n] = 1 + + if (status == "x") { + sub_complete[n]++ + completed++ + last_done = after_checkbox($0) + } else { + sub_incomplete[n]++ + remaining++ + if (next_task == "") next_task = after_checkbox($0) + } + + next + } + + END { + # Standalone mains (no subtasks) are leaf/actionable + for (n in main_state) { + if (!sub_any[n]) { + if (main_state[n] == "complete") { + completed++ + last_done = main_text[n] + } else if (main_state[n] == "incomplete") { + remaining++ + if (next_task == "") next_task = main_text[n] + } + } + } + + # Determine which incomplete mains should auto-complete: + # - main exists and is incomplete + # - has subtasks + # - 0 incomplete subtasks + # - at least 1 complete subtask (avoid marking empty sections) + to_complete = "" + for (n in sub_any) { + if (main_state[n] == "incomplete" && sub_incomplete[n] == 0 && sub_complete[n] > 0) { + to_complete = to_complete (to_complete ? " " : "") n + } + } + + # Output as a single line, fields separated by tabs, then read in bash + # (Use \t to keep NEXT_TASK / LAST_DONE intact even if they contain spaces) + printf "%d\t%d\t%s\t%s\t%s\n", remaining, completed, next_task, last_done, to_complete + } + ' "$PLAN_FILE" +) + +# Auto-mark main tasks complete (portable sed). Only after parse decides. +if [[ -n "${TO_COMPLETE:-}" ]]; then + # Mark each numbered main "N. [ ]" -> "N. [x]" + for n in $TO_COMPLETE; do + portable_sed_inplace "s/^${n}\.[[:space:]]*\\[ \\]/${n}. [x]/" "$PLAN_FILE" + done + + # Re-parse after modifications to get fresh counts/next/last_done + IFS=$'\t' read -r REMAINING COMPLETED NEXT_TASK LAST_DONE _ < <( + awk ' + function trim(s) { sub(/^[[:space:]]+/, "", s); sub(/[[:space:]]+$/, "", s); return s } + function after_checkbox(s) { + gsub(/^[[:space:]]*[0-9]+\.[[:space:]]*\[[ x]\][[:space:]]*/, "", s) + gsub(/^[[:space:]]*-[[:space:]]*\[[ x]\][[:space:]]*/, "", s) + return trim(s) + } + + BEGIN { remaining=0; completed=0; next_task=""; last_done="" } + + match($0, /^([0-9]+)\.[[:space:]]*\[ \][[:space:]]*(.*)$/, m) { main_state[m[1]]="incomplete"; main_text[m[1]]=trim(m[2]); next } + match($0, /^([0-9]+)\.[[:space:]]*\[x\][[:space:]]*(.*)$/, m) { main_state[m[1]]="complete"; main_text[m[1]]=trim(m[2]); next } + + match($0, /^[[:space:]]*-[[:space:]]*\[([ x])\][[:space:]]*([0-9]+)\.[0-9]+[[:space:]]*(.*)$/, m) { + status=m[1]; n=m[2]; sub_any[n]=1 + if (status=="x") { completed++; last_done=after_checkbox($0) } + else { remaining++; if (next_task=="") next_task=after_checkbox($0) } + next + } + + END { + for (n in main_state) if (!sub_any[n]) { + if (main_state[n]=="complete") { completed++; last_done=main_text[n] } + else { remaining++; if (next_task=="") next_task=main_text[n] } + } + printf "%d\t%d\t%s\t%s\n", remaining, completed, next_task, last_done + } + ' "$PLAN_FILE" + ) +fi # All tasks complete - cleanup and exit -if [[ "$REMAINING" -eq 0 ]]; then +if [[ "${REMAINING:-0}" -eq 0 ]]; then rm -f "$LOOP_MARKER" exit 0 fi -# Count completed tasks -COMPLETED=$(grep -c '^\s*\([0-9]\+\.\s*\)\?\s*- \[x\]' "$PLAN_FILE" 2>/dev/null | head -1 || echo "0") -COMPLETED=${COMPLETED:-0} - # Stage all changes FIRST 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*\([0-9]\+\.\s*\)?\s*- \[x\]' "$PLAN_FILE" | tail -1 | sed 's/.*\[x\] //') - git commit -m "feat: $LAST_DONE" >/dev/null 2>&1 || true + if [[ "${COMPLETED:-0}" -gt 0 ]]; then + if [[ -n "${LAST_DONE:-}" ]]; then + SAFE_SUBJECT="$(printf '%s' "$LAST_DONE" | sanitize_git_subject)" + if [[ -n "$SAFE_SUBJECT" ]]; then + git commit -m "feat: $SAFE_SUBJECT" >/dev/null 2>&1 || true + fi + fi fi fi -# Find next task -NEXT_TASK=$(grep -m1 '^\s*\([0-9]\+\.\s*\)\?\s*- \[ \]' "$PLAN_FILE" | sed 's/.*\[ \] //' | sed 's/"/\\"/g') - -# Build the reason message for Claude -read -r -d '' REASON << MSGEOF +# Escape reason for JSON +REASON=$(cat < { + it('should handle normal case', () => { + expect(functionToTest('input')).toBe('expected'); + }); + + it('should handle edge cases', () => { + expect(functionToTest(null)).toBe(null); + }); +}); +``` + +**Test Location**: Co-located with source files +**Test Coverage**: Business logic, utilities, validation + +### 2. **API Tests (Postman via MCP)** +```javascript +// Postman collection structure +{ + "name": "Feature API Tests", + "item": [ + { + "name": "POST /api/endpoint - Success", + "request": { /* ... */ }, + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "pm.test('Status is 200', () => {", + " pm.response.to.have.status(200);", + "});" + ] + } + } + ] + } + ] +} +``` + +**Test Coverage**: API endpoints, authentication, error responses + +### 3. **Integration Tests (Scripts)** +```typescript +// Pattern: scripts/test-feature-integration.ts +// For testing Airtable connectivity, external services +``` + +## Decision Framework + +### Code Change Analysis +```bash +# Check what was changed +git diff HEAD~1 --name-only + +# Look for indicators: +- src/routes/api/** → Need Postman tests +- src/lib/server/** → Need unit tests +- src/lib/airtable/** → Need integration tests +- *.svelte with logic → Need component tests +``` + +### Test Priority Matrix + +| Change Type | Unit Test | API Test | Integration | Priority | +|-------------|-----------|----------|-------------|----------| +| New API route | Optional | **Required** | Optional | High | +| Service logic | **Required** | Optional | Optional | High | +| Auth changes | **Required** | **Required** | Optional | Critical | +| Bug fixes | **Required** | Depends | Optional | High | +| UI components | Optional | N/A | N/A | Low | + +## Postman Integration via MCP + +### Available MCP Commands +- `mcp__postman__createCollection` - Create test collections +- `mcp__postman__createCollectionRequest` - Add test requests +- `mcp__postman__getCollections` - List existing collections +- `mcp__postman__getCollection` - Get collection details + +### API Test Pattern +1. **Create collection** for the feature/module +2. **Add requests** for each endpoint variant: + - Success scenarios + - Error scenarios (401, 400, 500) + - Edge cases +3. **Include test scripts** for response validation +4. **Organize by workspace** (development vs staging) + +### Example API Test Structure +``` +Collection: "Attendance API Tests" +├── POST /api/checkin - Success +├── POST /api/checkin - Invalid token +├── POST /api/checkin - Missing event +├── GET /api/events - Success +└── GET /api/events - Unauthorized +``` + +## Assessment Criteria Mapping + +### P4: Testing (40% coverage target) +- Unit test coverage for core business logic +- API test coverage for all endpoints +- Integration tests for external dependencies + +### P7: Version Control (20% test-related) +- Tests committed with features +- Test updates in same commits as code changes + +### D2: Technical Investigation (30% test strategy) +- Appropriate testing strategies chosen +- Test architecture decisions documented + +## Quality Standards + +### Unit Test Quality +- **Arrange, Act, Assert** pattern +- **Descriptive test names** that explain the scenario +- **Edge cases covered** (null, empty, invalid inputs) +- **Mock external dependencies** (Airtable, APIs) + +### API Test Quality +- **Status code validation** +- **Response schema validation** +- **Authentication testing** +- **Error message validation** + +### Test Organization +- **Co-location**: Tests next to source files +- **Clear naming**: `*.spec.ts` for unit tests +- **Logical grouping**: Postman collections by feature +- **Documentation**: Test strategy in README + +## Integration with Plan Iterator + +The `/evaluate-tests` command should: +1. Analyze recent code changes +2. Determine what types of tests are needed +3. Create missing test files/collections +4. Run existing tests to ensure they pass +5. Update test documentation if needed + +## Skip Conditions + +Don't create tests for: +- Proof of concept code +- Temporary implementations +- Pure configuration changes +- Documentation-only updates +- Dependency updates without API changes \ No newline at end of file diff --git a/.mcp.json b/.mcp.json index 6342799..0a762ae 100644 --- a/.mcp.json +++ b/.mcp.json @@ -11,14 +11,6 @@ "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/.mcp.postman.json.bak b/.mcp.postman.json.bak new file mode 100644 index 0000000..435bcd4 --- /dev/null +++ b/.mcp.postman.json.bak @@ -0,0 +1,10 @@ +{ + "postman": { + "type": "stdio", + "command": "npx", + "args": ["@postman/postman-mcp-server@latest"], + "env": { + "POSTMAN_API_KEY": "${POSTMAN_API_KEY}" + } + } +} diff --git a/README.md b/README.md index e6e4322..a74a42a 100644 --- a/README.md +++ b/README.md @@ -20,46 +20,72 @@ AUTH_SECRET=your_auth_secret_min_32_chars ## Authentication -The app uses **magic link authentication** with separate login flows for staff and students: +The app uses **magic link authentication** with a single login page for all users: ### User Types -| Type | Login Page | Validates Against | Default Redirect | -|------|------------|-------------------|------------------| -| Staff | `/admin/login` | Staff table (collaborator email) | `/admin` | -| Student | `/login` | Apprentices table (learner email) | `/` | +| Type | Validates Against | Landing Page | +|------|-------------------|--------------| +| Staff | Staff table (collaborator email) | `/admin` | +| Student | Apprentices table (learner email) | `/checkin` | ### How It Works -1. User enters email on the appropriate login page -2. Server validates email exists in the corresponding Airtable table +1. User enters email at `/login` +2. Server checks Staff table first, then Apprentices table 3. JWT token generated (15-minute expiry) and emailed via Resend 4. User clicks link → token verified → session cookie set (90-day expiry) -5. User redirected to appropriate dashboard +5. User redirected based on type: staff → `/admin`, students → `/checkin` ### Route Protection The app uses SvelteKit hooks (`src/hooks.server.ts`) for centralized route protection: -- `/admin/*` → Staff only (redirects students to `/`) +- `/admin/*` → Staff only (redirects students to `/checkin`) - `/checkin` → Any authenticated user (staff or student) -- `/login`, `/admin/login` → Redirects away if already authenticated +- `/login` → Redirects authenticated users to their landing page +- `/` → Redirects to `/login` (unauthenticated) or landing page (authenticated) ### Check-in Access Both staff and students can access `/checkin`. The system determines check-in behavior based on apprentice record: -| User | Has Apprentice Record? | Events Shown | Attendance Method | -|------|------------------------|--------------|-------------------| -| Student | Yes | Cohort + public | Linked to apprentice ID | -| Staff | Yes (if also apprentice) | Cohort + public | Linked to apprentice ID | -| Staff | No | Public only | External (uses session email) | +| User | Apprentice Link? | Events Shown | Attendance Method | Absent Button | +|------|------------------|--------------|-------------------|---------------| +| Student | N/A (is apprentice) | Cohort + public | Linked to apprentice ID | Yes | +| Staff | Yes (linked) | Cohort + public | Linked to apprentice ID | Yes | +| Staff | No | Public only | External (uses session email) | No | + +See [Staff Who Are Also Apprentices](#staff-who-are-also-apprentices) for setup instructions. ### Adding Staff Members 1. Add them as a **collaborator** in the Airtable workspace 2. Add a record in the **Staff - Apprentice Pulse** table, selecting their collaborator profile -3. They can now log in at `/admin/login` using their collaborator email +3. They can now log in at `/login` using their collaborator email + +### Staff Who Are Also Apprentices + +Some staff members may also be apprentices (e.g., apprentice coaches). These users need to: +- Log in as **staff** to access admin features +- Check in as an **apprentice** to have attendance tracked against their apprentice record + +The system supports this via the **Apprentice Link** field in the Staff table: + +1. In the **Staff - Apprentice Pulse** table, link the staff record to their **Apprentice** record using the `Apprentice Link` field +2. The `Learner email` lookup field will automatically populate from the linked apprentice + +**How it works:** + +When a staff member accesses the check-in page: +1. System first tries to find an apprentice record matching their staff email +2. If not found, it checks if the staff record has a linked apprentice (via `Learner email` lookup) +3. If a linked apprentice is found, the staff member gets the full apprentice check-in experience: + - Sees their cohort events (not just public events) + - Can mark themselves as "Absent" + - Attendance is recorded against their apprentice record + +This allows staff to maintain a single login (their staff email) while still being tracked as apprentices for attendance purposes. ## Development @@ -73,21 +99,15 @@ In development, you can test login using the UI or via curl: ### Via UI 1. Start the dev server: `npm run dev` -2. Go to `/login` (students) or `/admin/login` (staff) -3. Enter an email that exists in the appropriate Airtable table +2. Go to `/login` +3. Enter an email that exists in either the Staff or Apprentices table 4. Check your email for the magic link ### Via curl ```sh -# Staff login -curl -X POST http://localhost:5173/api/auth/staff/login \ - -H "Content-Type: application/json" \ - -d '{"email": "staff@example.com"}' - -# Student login -curl -X POST http://localhost:5173/api/auth/student/login \ +curl -X POST http://localhost:5173/api/auth/login \ -H "Content-Type: application/json" \ - -d '{"email": "student@example.com"}' + -d '{"email": "your@email.com"}' ``` Then click the magic link in your email, or manually visit: diff --git a/attendance.md b/attendance.md new file mode 100644 index 0000000..f243409 --- /dev/null +++ b/attendance.md @@ -0,0 +1,467 @@ +# Attendance System Documentation + +This document describes how attendance is calculated, stored, and displayed throughout the application. + +## Table of Contents + +1. [Airtable Schema](#airtable-schema) +2. [Status Types](#status-types) +3. [Data Flow Overview](#data-flow-overview) +4. [Statistics Calculation](#statistics-calculation) +5. [Known Issues](#known-issues) +6. [API Endpoints](#api-endpoints) +7. [UI Components](#ui-components) +8. [File Reference](#file-reference) + +--- + +## Airtable Schema + +### Attendance Table (`tblkDbhJcuT9TTwFc`) + +| Field | Field ID | Type | Description | +|-------|----------|------|-------------| +| ID | `fldGdpuw6SoHkQbOs` | autoNumber | Auto-generated ID | +| Apprentice | `fldOyo3hlj9Ht0rfZ` | multipleRecordLinks | Link to Apprentices table | +| Event | `fldiHd75LYtopwyN9` | multipleRecordLinks | Link to Events table | +| Check-in Time | `fldvXHPmoLlEA8EuN` | dateTime | When the user checked in | +| 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 | + +### Related Tables + +**Events Table** has: +- `ATTENDANCE` field (`fldcPf53fVfStFZsa`) - reverse link to Attendance records +- `COHORT` field (`fldcXDEDkeHvWTnxE`) - which cohorts this event is for +- `DATE_TIME` field (`fld8AkM3EanzZa5QX`) - event start time (used for Present/Late determination) + +**Apprentices Table** has: +- `COHORT` field (`fldbSlfS7cQTl2hpF`) - which cohort the apprentice belongs to + +--- + +## Status Types + +Defined in `src/lib/types/attendance.ts`: + +```typescript +const ATTENDANCE_STATUSES = ['Present', 'Not Check-in', 'Late', 'Excused', 'Absent'] as const; +``` + +| Status | Description | Has Check-in Time | Counts as Attended | +|--------|-------------|-------------------|-------------------| +| Present | Checked in before event start | Yes | Yes | +| Late | Checked in after event start | Yes | Yes | +| Not Check-in | Did not attend (explicit or implicit) | No | No | +| Excused | Absence excused by staff | No | No | +| Absent | Pre-declared absence | No | No | + +### Status Determination Logic + +When a user checks in (`src/lib/airtable/attendance.ts:51`): + +```typescript +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'; +} +``` + +--- + +## Data Flow Overview + +### 1. Check-in Flow (Student) + +``` +POST /api/checkin + │ + ├─► getApprenticeByEmail() - Is user a registered apprentice? + │ + ├─► YES: Apprentice flow + │ │ + │ ├─► getUserAttendanceForEvent() - Already have record? + │ │ │ + │ │ ├─► Status = "Absent" → updateAttendance() to Present/Late + │ │ │ + │ │ └─► Other status → Error "Already checked in" + │ │ + │ └─► No record → createAttendance() with auto-determined status + │ + └─► NO: External flow + │ + └─► createExternalAttendance() with name/email +``` + +### 2. Mark Absent Flow + +``` +POST /api/checkin/absent + │ + ├─► getApprenticeByEmail() - Must be registered apprentice + │ + └─► markNotComing() - Creates record with status="Absent", no checkinTime +``` + +### 3. Staff Manual Check-in + +``` +POST /api/attendance + │ + ├─► createAttendance() - Auto-determines Present/Late + │ + └─► If status override provided → updateAttendance() to desired status +``` + +### 4. Status Update + +``` +PATCH /api/attendance/[id] + │ + └─► updateAttendance(id, { status, checkinTime? }) +``` + +--- + +## Statistics Calculation + +### Core Calculation Function + +Located in `src/lib/airtable/attendance.ts:407`: + +```typescript +function calculateStats(attendanceRecords: Attendance[], totalEvents: number): AttendanceStats { + const present = attendanceRecords.filter(a => a.status === 'Present').length; + const late = attendanceRecords.filter(a => a.status === 'Late').length; + const explicitNotCheckin = attendanceRecords.filter(a => a.status === 'Not Check-in').length; + const excused = attendanceRecords.filter(a => a.status === 'Excused').length; + const notComing = attendanceRecords.filter(a => a.status === 'Absent').length; + + // IMPLICIT NOT CHECK-IN: Events with no attendance record + const recordedEvents = attendanceRecords.length; + const missingEvents = totalEvents - recordedEvents; + const notCheckin = explicitNotCheckin + missingEvents; + + const attended = present + late; + + const attendanceRate = totalEvents > 0 + ? Math.round((attended / totalEvents) * 1000) / 10 + : 0; + + return { + totalEvents, + attended, + present, + late, + notCheckin, // explicitNotCheckin + missingEvents + excused, + notComing, + attendanceRate, + }; +} +``` + +### Key Formula + +``` +attended = present + late +absent = explicit_absent_records + (totalEvents - total_records) +attendanceRate = (attended / totalEvents) * 100 +``` + +### Where Stats Are Calculated + +| Function | Location | Usage | +|----------|----------|-------| +| `getApprenticeAttendanceStats()` | attendance.ts:552 | Individual apprentice page (no date filter) | +| `getApprenticeAttendanceStatsWithDateFilter()` | attendance.ts:440 | Apprentice list with term/date filtering | +| `getCohortAttendanceStats()` | attendance.ts:639 | Cohort-level stats | +| `getAttendanceSummary()` | attendance.ts:719 | Dashboard summary | + +### How Each Function Determines "Relevant Events" + +#### `getApprenticeAttendanceStats()` (no date filter) + +```typescript +// Get events for apprentice's cohort +const allEvents = await getAllEvents(); +const relevantEvents = cohortId + ? allEvents.filter(e => e.cohortIds.includes(cohortId)) + : allEvents; + +// Get ALL attendance for this apprentice (NOT FILTERED) +const allAttendance = await getAllAttendance(); +const apprenticeAttendance = allAttendance.filter(a => a.apprenticeId === apprenticeId); + +// Calculate stats +const stats = calculateStats(apprenticeAttendance, relevantEvents.length); +``` + +#### `getApprenticeAttendanceStatsWithDateFilter()` (with date filter) + +```typescript +// Get events for apprentice's cohort, filtered by date +let relevantEvents = cohortId + ? allEvents.filter(e => e.cohortIds.includes(cohortId)) + : allEvents; + +if (startDate && endDate) { + relevantEvents = relevantEvents.filter(e => { + const eventDate = new Date(e.dateTime); + return eventDate >= startDate && eventDate <= endDate; + }); +} + +// Get attendance, filtered to only relevant events +let apprenticeAttendance = allAttendance.filter(a => a.apprenticeId === apprenticeId); + +if (startDate && endDate) { + const relevantEventIds = new Set(relevantEvents.map(e => e.id)); + apprenticeAttendance = apprenticeAttendance.filter(a => relevantEventIds.has(a.eventId)); +} + +const stats = calculateStats(apprenticeAttendance, relevantEvents.length); +``` + +### Trend Calculation + +Compares last 4 weeks vs previous 4 weeks: + +```typescript +function calculateTrend(currentRate: number, previousRate: number): AttendanceTrend { + const change = currentRate - previousRate; + let direction: 'up' | 'down' | 'stable' = 'stable'; + if (change > 2) direction = 'up'; + else if (change < -2) direction = 'down'; + + return { direction, change, currentRate, previousRate }; +} +``` + +--- + +## Known Issues + +### BUG: Negative Not Check-in Count (-2 in screenshot) + +**Symptom**: The stats card shows `Not Check-in: -2` + +**Root Cause**: Mismatch between attendance records counted and events counted. + +**How it happens**: + +1. `getApprenticeAttendanceStats()` counts `relevantEvents` = events for the apprentice's cohort +2. BUT `apprenticeAttendance` includes ALL attendance records (not filtered to cohort events) +3. If apprentice attended events from OTHER cohorts, those records are counted but the events aren't + +**Example**: +- Apprentice's cohort has 2 events +- Apprentice has 4 attendance records (2 for cohort + 2 for other events they visited) +- `calculateStats(4 records, 2 events)` +- `missingEvents = 2 - 4 = -2` +- `absent = 0 + (-2) = -2` + +**Affected Functions**: +- `getApprenticeAttendanceStats()` - Does NOT filter attendance to relevant events +- `getApprenticeAttendanceStatsWithDateFilter()` - Only filters when date range is provided + +**The Fix Would Be**: Filter `apprenticeAttendance` to only include records for events in `relevantEvents`: + +```typescript +// MISSING in getApprenticeAttendanceStats(): +const relevantEventIds = new Set(relevantEvents.map(e => e.id)); +const filteredAttendance = apprenticeAttendance.filter(a => relevantEventIds.has(a.eventId)); +const stats = calculateStats(filteredAttendance, relevantEvents.length); +``` + +### BUG: Inconsistency Between Stats and History + +**Symptom**: Stats card shows different totals than history table + +**Root Cause**: `getApprenticeAttendanceHistory()` includes ALL events the apprentice attended (line 918): + +```typescript +// Add any events the apprentice has attendance for (regardless of cohort) +for (const eventId of attendanceMap.keys()) { + relevantEventIds.add(eventId); +} +``` + +But stats only count cohort events. So: +- History shows: 4 events (all attended) +- Stats show: "2 of 2 events" (only cohort events) + +--- + +## API Endpoints + +### Check-in Endpoints + +| Endpoint | Method | Description | File | +|----------|--------|-------------|------| +| `/api/checkin` | POST | Student/staff check-in | `src/routes/api/checkin/+server.ts` | +| `/api/checkin/absent` | POST | Mark as absent | `src/routes/api/checkin/absent/+server.ts` | +| `/api/checkin/validate-code` | POST | Validate guest check-in code | `src/routes/api/checkin/validate-code/+server.ts` | + +### Attendance Management + +| Endpoint | Method | Description | File | +|----------|--------|-------------|------| +| `/api/attendance` | POST | Staff creates attendance | `src/routes/api/attendance/+server.ts` | +| `/api/attendance/[id]` | PATCH | Update status | `src/routes/api/attendance/[id]/+server.ts` | +| `/api/events/[id]/roster` | GET | Event roster with attendance | `src/routes/api/events/[id]/roster/+server.ts` | + +### What Each Endpoint Writes to Airtable + +| Endpoint | Creates Record | Updates Record | Fields Written | +|----------|---------------|----------------|----------------| +| POST `/api/checkin` | Yes (if no record) | Yes (if "Absent") | APPRENTICE, EVENT, CHECKIN_TIME, STATUS | +| POST `/api/checkin/absent` | Yes | No | APPRENTICE, EVENT, STATUS="Absent" | +| POST `/api/attendance` | Yes | Yes (if status override) | APPRENTICE, EVENT, CHECKIN_TIME, STATUS | +| PATCH `/api/attendance/[id]` | No | Yes | STATUS, CHECKIN_TIME | + +--- + +## UI Components + +### ApprenticeAttendanceCard.svelte + +Location: `src/lib/components/ApprenticeAttendanceCard.svelte` + +Displays: +- Name, cohort +- Attendance rate (color-coded: green ≥90%, yellow ≥80%, red <80%) +- Trend indicator (↗ up, ↘ down, → stable) +- Grid: Present | Late | Excused | Not Check-in | Absent (with Attended below Present + Late) +- Total: "X of Y events" + +### Apprentice List Page + +Location: `src/routes/admin/attendance/apprentices/+page.svelte` + +Features: +- Cohort multi-select with group toggles +- Filter modes: Terms (multi-select) OR Custom Date Range (mutually exclusive) +- Sortable table: Name, Cohort, Attendance Rate +- Row highlighting for low attendance (<80%) + +Data loading: `+page.server.ts` calls `getApprenticeAttendanceStatsWithDateFilter()` for each apprentice + +### Apprentice Detail Page + +Location: `src/routes/admin/attendance/apprentices/[id]/+page.svelte` + +Features: +- Stats card (using ApprenticeAttendanceCard) +- Full attendance history table +- Inline status editing (dropdown) +- Check-in time editing for Present/Late + +Data loading: `+page.server.ts` calls: +- `getApprenticeAttendanceStats()` for the card +- `getApprenticeAttendanceHistory()` for the table + +--- + +## File Reference + +### Core Files + +| File | Purpose | +|------|---------| +| `src/lib/airtable/config.ts` | Airtable table/field IDs | +| `src/lib/types/attendance.ts` | TypeScript types & interfaces | +| `src/lib/airtable/attendance.ts` | All attendance business logic | +| `src/lib/airtable/sveltekit-wrapper.ts` | Exports functions for routes | + +### API Routes + +| File | Purpose | +|------|---------| +| `src/routes/api/checkin/+server.ts` | Main check-in endpoint | +| `src/routes/api/checkin/absent/+server.ts` | Mark absent | +| `src/routes/api/attendance/+server.ts` | Staff creates attendance | +| `src/routes/api/attendance/[id]/+server.ts` | Update attendance | +| `src/routes/api/events/[id]/roster/+server.ts` | Event roster with attendance | + +### UI Pages + +| File | Purpose | +|------|---------| +| `src/routes/admin/attendance/apprentices/+page.svelte` | Apprentice list | +| `src/routes/admin/attendance/apprentices/+page.server.ts` | List data loading | +| `src/routes/admin/attendance/apprentices/[id]/+page.svelte` | Apprentice detail | +| `src/routes/admin/attendance/apprentices/[id]/+page.server.ts` | Detail data loading | +| `src/routes/checkin/+page.svelte` | Student check-in page | +| `src/lib/components/ApprenticeAttendanceCard.svelte` | Stats card component | + +### Tests + +| File | Purpose | +|------|---------| +| `src/lib/airtable/attendance.spec.ts` | Unit tests for attendance functions | + +--- + +## Data Flow Diagrams + +### Stats Calculation Flow + +``` +┌─────────────────────────────────────────────────────────────────────────┐ +│ getApprenticeAttendanceStats() │ +├─────────────────────────────────────────────────────────────────────────┤ +│ │ +│ 1. Get apprentice info (name, cohortId) │ +│ ↓ │ +│ 2. getAllEvents() → filter by cohortId → relevantEvents │ +│ ↓ │ +│ 3. getAllAttendance() → filter by apprenticeId → apprenticeAttendance │ +│ ↓ │ +│ ⚠️ BUG: apprenticeAttendance NOT filtered to relevantEvents │ +│ ↓ │ +│ 4. calculateStats(apprenticeAttendance, relevantEvents.length) │ +│ ↓ │ +│ present = count where status='Present' │ +│ late = count where status='Late' │ +│ explicitNotCheckin = count where status='Not Check-in' │ +│ excused = count where status='Excused' │ +│ notComing = count where status='Absent' │ +│ ↓ │ +│ missingEvents = relevantEvents.length - apprenticeAttendance.length│ +│ notCheckin = explicitNotCheckin + missingEvents ← CAN BE NEGATIVE!│ +│ ↓ │ +│ attended = present + late │ +│ attendanceRate = (attended / totalEvents) * 100 │ +│ │ +└─────────────────────────────────────────────────────────────────────────┘ +``` + +### History Display Flow + +``` +┌─────────────────────────────────────────────────────────────────────────┐ +│ getApprenticeAttendanceHistory() │ +├─────────────────────────────────────────────────────────────────────────┤ +│ │ +│ 1. Get apprentice's cohortId │ +│ ↓ │ +│ 2. Get all events │ +│ ↓ │ +│ 3. Get all attendance for this apprentice → attendanceMap │ +│ ↓ │ +│ 4. Build relevantEventIds: │ +│ - Add all events for apprentice's cohort │ +│ - Add all events apprentice has attendance for (ANY cohort) │ +│ ↓ │ +│ 5. For each relevant event: │ +│ - If has attendance record → use that status │ +│ - If no attendance record → status = 'Not Check-in' │ +│ ↓ │ +│ 6. Sort by date (most recent first) │ +│ │ +└─────────────────────────────────────────────────────────────────────────┘ +``` diff --git a/docs/image.png b/docs/image.png new file mode 100644 index 0000000..fa9f0fb Binary files /dev/null and b/docs/image.png differ diff --git a/docs/plan.md b/docs/plan.md index f0ffc2b..9af41e4 100644 --- a/docs/plan.md +++ b/docs/plan.md @@ -1,22 +1,212 @@ -# AP-25 Individual apprentice attendance view +# Plan: Consolidate Login & Fix Navigation -> 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. +## Overview -## Tasks +Merge staff and student login into a single `/login` page and fix the navigation structure so each user type has a clear flow. -- [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 +## Current State (Problems) -## Notes +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 -- 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 +## 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/planning/sprint01_review.md b/docs/planning/sprint01_review.md index 5aa764b..9c5a6dc 100644 --- a/docs/planning/sprint01_review.md +++ b/docs/planning/sprint01_review.md @@ -95,7 +95,7 @@ Replaced the legacy date-as-columns structure with a normalized table: - Each attendance record is a row (not a column) - Links to Apprentice via `fldOyo3hlj9Ht0rfZ` - Date stored in `fldvXHPmoLlEA8EuN` -- Status (Present/Absent/Late) via `fldew45fDGpgl1aRr` +- Status (Present/Not Check-in/Late/Excused/Absent) via `fldew45fDGpgl1aRr` This eliminates the need for dynamic field creation when adding events. diff --git a/docs/planning/sprint04_plan.md b/docs/planning/sprint04_plan.md index ee6d692..41ac4b0 100644 --- a/docs/planning/sprint04_plan.md +++ b/docs/planning/sprint04_plan.md @@ -127,7 +127,7 @@ Leverage existing Attendance table with linked Apprentice and Event records. ``` Attendance record → Apprentice (link) → Cohort (link) → Event (link) → Date, Cohort - → Status (Present/Absent/Late/Excused) + → Status (Present/Not Check-in/Late/Excused/Absent) ``` ### Aggregate Queries Strategy diff --git a/docs/report.md b/docs/report.md index 4497a4f..f083474 100644 --- a/docs/report.md +++ b/docs/report.md @@ -370,4 +370,83 @@ Both `createAttendance()` and `createExternalAttendance()` now call `determineSt ## Design Decision: Service Layer Logic -The late detection happens in the attendance service, not the API endpoint. This ensures consistency regardless of how attendance is created (user check-in, admin manual entry, future bulk import). [P3 - 40%] [P5 - 50%] [D4 - 30%] \ No newline at end of file +The late detection happens in the attendance service, not the API endpoint. This ensures consistency regardless of how attendance is created (user check-in, admin manual entry, future bulk import). [P3 - 40%] [P5 - 50%] [D4 - 30%] + +# AP-26: Cohort Attendance Metrics Dashboard + +Created a comprehensive cohort metrics dashboard that provides aggregate attendance statistics with drill-down capabilities and comparison features. + +## Architecture Decisions + +### 1. Server-Side Data Aggregation +All attendance statistics are calculated server-side in `getCohortAttendanceStats()` rather than client-side. This provides: +- Consistent metrics across all views +- Reduced client-side computation +- Easier caching opportunities in the future +- Single source of truth for business logic + +### 2. Reactive State Management with Svelte 5 Runes +Used Svelte 5's new runes (`$state`, `$derived`, `$effect`) for reactive state management: + +```typescript +// Sorting and filtering computed reactively +const sortedCohortStats = $derived(() => { + const sorted = [...data.cohortStats]; + // Complex sorting logic + return sorted; +}); + +// Export data always reflects current view +const exportData = $derived(() => + sortedCohortStats.map(cohort => ({ + Cohort: cohort.cohortName, + 'Attendance Rate': `${Math.round(cohort.attendanceRate)}%`, + // ... other fields + })) +); +``` + +This ensures UI updates automatically when filters or sort orders change. [P8 - 70%] [D2 - 60%] + +### 3. Responsive Design Pattern +Implemented responsive table/card switching using Tailwind's responsive utilities: +- Desktop: Full data table with all columns +- Mobile: Card-based layout with key metrics +- No JavaScript media queries needed - pure CSS solution + +### 4. Comparison Feature with SvelteSet +Used `SvelteSet` for managing selected cohorts for comparison, wrapped in `$state` for reactivity: + +```typescript +let selectedForComparison = $state(new SvelteSet()); +``` + +This provides O(1) lookups while maintaining reactivity. The comparison view uses CSS Grid for responsive side-by-side layout. [P5 - 60%] + +### 5. CSV Export with Error Boundaries +Implemented client-side CSV generation with comprehensive error handling: +- Validation of data availability before export +- Try-catch blocks around CSV generation +- User-friendly error messages +- Automatic filename generation with timestamp + +This follows the principle of graceful degradation - if export fails, the dashboard remains functional. [P7 - 40%] [D4 - 40%] + +## Technical Implementation Details + +### Date Range Filtering +While the UI supports date range parameters, the backend `getCohortAttendanceStats()` doesn't yet filter by date. This was intentionally left as a TODO to avoid scope creep in the MVP. The architecture supports it - just needs the filter logic added to the Airtable query. + +### Performance Optimizations +- Sequential API calls for cohort stats (could be parallelized in future) +- No unnecessary re-renders thanks to `$derived` computations +- Lightweight CSV generation without external dependencies + +### Loading States and Error Handling +Implemented consistent loading/error patterns across all admin views: +- Loading overlay during navigation +- Error state with retry capability +- Empty state messaging +- Form validation feedback + +This creates a predictable user experience across the admin dashboard. [P11 - 40%] \ No newline at end of file diff --git a/docs/schema.md b/docs/schema.md index afb12fc..7a8cce7 100644 --- a/docs/schema.md +++ b/docs/schema.md @@ -62,7 +62,7 @@ Attendance tracking using a junction table pattern (one record per apprentice pe | Apprentice | `fldOyo3hlj9Ht0rfZ` | multipleRecordLinks | Links to Apprentices | | Event | `fldiHd75LYtopwyN9` | multipleRecordLinks | Links to Events | | Checkin Time | `fldvXHPmoLlEA8EuN` | dateTime | When student checked in | -| Status | `fldew45fDGpgl1aRr` | singleSelect | Attendance status (Present, Absent, Late, Excused) | +| Status | `fldew45fDGpgl1aRr` | singleSelect | Attendance status (Present, Not Check-in, Late, Excused, Absent) | | Date Time (from Event) | `fldokfSk68MhJGlm6` | multipleLookupValues | Event date/time lookup | | FAC Cohort (from Event) | `fldkc9zLJe7NZVAz1` | multipleLookupValues | Cohort lookup from Event | | External Name | `fldIhZnMxfjh9ps78` | singleLineText | Name for non-registered attendees | @@ -176,16 +176,18 @@ Intervention and support request tracking. --- -## Staff - Apprentice Pulse +## Learners / Staff - Apprentice Pulse **Table ID:** `tblJjn62ExE1LVjmx` -Staff members for authentication. Uses Airtable collaborators for email lookup. +Staff members for authentication. Uses Airtable collaborators for email lookup. Staff who are also apprentices can be linked to their apprentice record. | Field | ID | Type | Purpose | |-------|-----|------|---------| | Id | `fldbTKP32s3Soev91` | autoNumber | Record ID | -| Staff Name | `fldHEHhQInmSdipn8` | singleCollaborator | Collaborator with id, email, name | +| Staff Member | `fldHEHhQInmSdipn8` | singleCollaborator | Collaborator with id, email, name | +| Apprentice Link | `fldAMwe9jOOdwIyBY` | multipleRecordLinks | Links to Apprentices table | +| Learner email (from Apprentice Link) | `fldPjDZTSySzbefXz` | multipleLookupValues | Lookup of learner email from linked apprentice | --- @@ -268,6 +270,20 @@ Extended learner record with ILR and Skills Bootcamp data. Use Apprentices table --- +## Terms - Apprentice Pulse + +**Table ID:** `tbl4gkcG92Bc8gFU7` + +Term intervals for filtering and organizing data by academic periods. + +| Field | ID | Type | Purpose | +|-------|-----|------|---------| +| Name | `fldrnRBnBuHbscSy7` | singleLineText | Term name (e.g. "Term 1 2024", "Summer 2024") | +| Starting Date | `fldlzwlqYo7rMMSDp` | date | Term start date | +| End Date | `fldJKhrzNZNCD6SYY` | date | Term end date | + +--- + ## Alert Triggers Fields that trigger early intervention alerts: diff --git a/docs/scratchpad.md b/docs/scratchpad.md index b6d09b2..ef87441 100644 --- a/docs/scratchpad.md +++ b/docs/scratchpad.md @@ -1,14 +1,21 @@ -Attendance is not showing names? (check Airtable) +attendace send email +survey send email +link surveys with student. track not fulfilled +Per week email to Jess. Absent, not survey, whoever marked "In need of support" -Not coming status +Absent reason. + +Events view, list of people, show link to apprentices to go to personalised + + + +survey URL is OK? -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 @@ -16,7 +23,8 @@ 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 c0e33ed..4bec732 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,6 +12,7 @@ "@event-calendar/core": "^5.1.3", "@svelte-plugins/datepicker": "^1.0.11", "airtable": "^0.12.2", + "chart.js": "^4.5.1", "date-fns": "^4.1.0", "jsonwebtoken": "^9.0.3", "resend": "^6.6.0", @@ -1140,6 +1141,12 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "node_modules/@kurkle/color": { + "version": "0.3.4", + "resolved": "https://registry.npmjs.org/@kurkle/color/-/color-0.3.4.tgz", + "integrity": "sha512-M5UknZPHRu3DEDWoipU6sE8PdkZ6Z/S+v4dD+Ke8IaNlpdSQah50lz1KtcFBa2vsdOnwbbnxJwVM4wty6udA5w==", + "license": "MIT" + }, "node_modules/@polka/url": { "version": "1.0.0-next.29", "resolved": "https://registry.npmjs.org/@polka/url/-/url-1.0.0-next.29.tgz", @@ -1520,6 +1527,7 @@ "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", @@ -1559,6 +1567,7 @@ "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", @@ -1974,6 +1983,7 @@ "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", @@ -2214,6 +2224,7 @@ "integrity": "sha512-I2Fy/ANdphi1yI46d15o0M1M4M0UJrUiVKkH5oKeRZZCdPg0fw/cfTKZzv9Ge9eobtJYp4BGblMzXdXH0vcl5g==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@vitest/browser": "4.0.16", "@vitest/mocker": "4.0.16", @@ -2366,6 +2377,7 @@ "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" }, @@ -2554,6 +2566,18 @@ "dev": true, "license": "MIT" }, + "node_modules/chart.js": { + "version": "4.5.1", + "resolved": "https://registry.npmjs.org/chart.js/-/chart.js-4.5.1.tgz", + "integrity": "sha512-GIjfiT9dbmHRiYi6Nl2yFCq7kkwdkp1W/lp2J99rX0yo9tgJGn3lKQATztIjb5tVtevcBtIdICNWqlq5+E8/Pw==", + "license": "MIT", + "dependencies": { + "@kurkle/color": "^0.3.0" + }, + "engines": { + "pnpm": ">=8" + } + }, "node_modules/chokidar": { "version": "4.0.3", "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz", @@ -2832,6 +2856,7 @@ "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", @@ -4038,6 +4063,7 @@ "integrity": "sha512-ilYQj1s8sr2ppEJ2YVadYBN0Mb3mdo9J0wQ+UuDhzYqURwSoW4n1Xs5vs7ORwgDGmyEh33tRMeS8KhdkMoLXQw==", "dev": true, "license": "Apache-2.0", + "peer": true, "dependencies": { "playwright-core": "1.57.0" }, @@ -4094,6 +4120,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", @@ -4536,6 +4563,7 @@ "integrity": "sha512-ZhLtvroYxUxr+HQJfMZEDRsGsmU46x12RvAv/zi9584f5KOX7bUrEbhPJ7cKFmUvZTJXi/CFZUYwDC6M1FigPw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@jridgewell/remapping": "^2.3.4", "@jridgewell/sourcemap-codec": "^1.5.0", @@ -4753,6 +4781,7 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "dev": true, "license": "Apache-2.0", + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -4837,6 +4866,7 @@ "integrity": "sha512-dZwN5L1VlUBewiP6H9s2+B3e3Jg96D0vzN+Ry73sOefebhYr9f94wwkMNN/9ouoU8pV1BqA1d1zGk8928cx0rg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "esbuild": "^0.27.0", "fdir": "^6.5.0", @@ -4947,6 +4977,7 @@ "integrity": "sha512-E4t7DJ9pESL6E3I8nFjPa4xGUd3PmiWDLsDztS2qXSJWfHtbQnwAWylaBvSNY48I3vr8PTqIZlyK8TE3V3CA4Q==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@vitest/expect": "4.0.16", "@vitest/mocker": "4.0.16", diff --git a/package.json b/package.json index be1a7e3..38a8dd2 100644 --- a/package.json +++ b/package.json @@ -45,6 +45,7 @@ "@event-calendar/core": "^5.1.3", "@svelte-plugins/datepicker": "^1.0.11", "airtable": "^0.12.2", + "chart.js": "^4.5.1", "date-fns": "^4.1.0", "jsonwebtoken": "^9.0.3", "resend": "^6.6.0", diff --git a/scripts/fetch-schema.ts b/scripts/fetch-schema.ts index acbdaa6..d9aba5d 100644 --- a/scripts/fetch-schema.ts +++ b/scripts/fetch-schema.ts @@ -7,7 +7,7 @@ config({ path: '.env.local' }); const apiKey = process.env.AIRTABLE_API_KEY; const bases = [ { name: 'Learners', id: process.env.AIRTABLE_BASE_ID_LEARNERS }, - { name: 'Learner Feedback', id: process.env.AIRTABLE_BASE_ID_FEEDBACK }, + // { name: 'Learner Feedback', id: process.env.AIRTABLE_BASE_ID_FEEDBACK }, ]; interface AirtableField { diff --git a/scripts/schema-2026-01-06-16-07-12.md b/scripts/schema-2026-01-06-16-07-12.md new file mode 100644 index 0000000..223445d --- /dev/null +++ b/scripts/schema-2026-01-06-16-07-12.md @@ -0,0 +1,2 @@ +# Airtable Schema + diff --git a/scripts/schema-2026-01-06-16-07-53.md b/scripts/schema-2026-01-06-16-07-53.md new file mode 100644 index 0000000..b183b25 --- /dev/null +++ b/scripts/schema-2026-01-06-16-07-53.md @@ -0,0 +1,12 @@ +# Airtable Schema + +## Learners / Terms - Apprentice Pulse + +Table ID: `tbl4gkcG92Bc8gFU7` + +| Field | ID | Type | +|-------|-----|------| +| Name | `fldrnRBnBuHbscSy7` | singleLineText | +| Starting Date | `fldlzwlqYo7rMMSDp` | date | +| Starting Date copy | `fldJKhrzNZNCD6SYY` | date | + diff --git a/scripts/schema-2026-01-06-16-13-40.md b/scripts/schema-2026-01-06-16-13-40.md new file mode 100644 index 0000000..47da87a --- /dev/null +++ b/scripts/schema-2026-01-06-16-13-40.md @@ -0,0 +1,344 @@ +# Airtable Schema + +## Learners / Apprentices + +Table ID: `tbl0HJM700Jmd5Oob` + +| Field | ID | Type | +|-------|-----|------| +| Name | `fldtvHx7pP5FgUWvh` | formula | +| Cohort # | `fldbSlfS7cQTl2hpF` | multipleRecordLinks | +| Learner status | `fldFP9b6CfhNzeVNQ` | singleSelect | +| ULN | `fld4rsMY4Cuxq6wv4` | multipleLookupValues | +| Date of Birth | `fldYnpLgXYHHybjJo` | date | +| Notes | `fldFChA5tGXHzqZNu` | multilineText | +| Onboarding | `fldSvzMdBaoLMTRrH` | singleSelect | +| Current ILR status | `fldm5aszq0TFbbzNq` | singleSelect | +| Eligibility | `fldIjzoErA3UYp63O` | multipleRecordLinks | +| Gender | `fldP2NLn1N5Z0Zfb2` | multipleLookupValues | +| Training Plan | `fldzqonBP850JgRHY` | multipleAttachments | +| Training plan link | `fldomY7RvOok8hnbj` | url | +| Learner email | `fldC3xdKGd96U0aoZ` | multipleLookupValues | +| work-email | `fldDkXJbAoHCWteLQ` | email | +| Start date | `fldetrfOkKiNQe0Jg` | multipleLookupValues | +| Planned end date | `fldU3mMdadkXpop4f` | multipleLookupValues | +| Additional Support? | `fldJycmlO4jHYdl8o` | multipleLookupValues | +| Additional needs | `fldtUi59QrBRhUKrP` | multipleLookupValues | +| Planned hours | `fldIKXjH3wABaJ4cP` | multipleLookupValues | +| Planned hours (ILR) | `fldy3Ebvx41dSR7zm` | singleLineText | +| Line manager email | `fldIX7ouzUrWIBDGM` | email | +| Apprenticeship agreement | `fldycYztb3LTjrLe8` | multipleAttachments | +| Company Name | `fldvG5XPJW2FslqhE` | multipleRecordLinks | +| Grade | `fldOwQE4sbF55JjfO` | singleSelect | +| PLP | `fldAQ0Cp2ZzIG34xS` | multipleAttachments | +| Areas of concern | `fldeKeKPDtY2e3JZ2` | multipleSelects | +| Progress reviews | `fldybjnraD1iyKbLK` | multipleRecordLinks | +| Postcode | `fld9F4HAy3UJ0a0lS` | multipleLookupValues | +| prior attainment | `flddXCDtTm1F4zMZl` | multipleLookupValues | +| Apprenticeship employment (ILR entry) | `fld8AtOtTjyMTAk0w` | singleSelect | +| Created | `fldI0Lw5O4zJqFKsS` | createdTime | +| Last Modified By | `fld8KZOwlUzc2TTzd` | lastModifiedBy | +| Last Modified | `fld1XjK5eufbYHHrg` | lastModifiedTime | +| FAC Cohort (from Cohort #) | `fldV3HkiEQnNpCzIV` | multipleLookupValues | +| NI Number | `fldZU0EC0O9sFKWuj` | multipleLookupValues | +| Sex | `fldlhivAlT31YnAOt` | multipleLookupValues | +| Reference | `fldRRMU0YoSEqLcbI` | formula | +| Created By | `fldmLC8l6WoqJP74W` | createdBy | +| Levy status | `fld3s5F1hleEOFang` | singleSelect | +| LTP Name | `fldQYwCWpmcPIuqfo` | multipleRecordLinks | +| UKPRN (from LTP Name) | `fldBnRxucju9HjaLI` | multipleLookupValues | +| Given names | `fldWTDHMrE2asl2m9` | singleLineText | +| Family name | `flduP7HvIAwjjwUeb` | singleLineText | +| Apprenticeship price | `fldT5GPCb9tNJWPBz` | multipleLookupValues | +| Standard | `fldAZ1eC3McjW9CRs` | multipleLookupValues | +| Town/City | `fldJw9HXzDTgejUjC` | singleLineText | +| County | `fldxnCEpy7rTgngrj` | singleLineText | +| Country | `fldVTelOb0eWn1202` | singleLineText | +| Telephone Number | `fld07jspd1PkxmwHu` | phoneNumber | +| Receiving Funding | `fldXBt3RwiLDa4wHl` | checkbox | +| Contact First Name (from Company Name) | `flde6L2XLapThFP2J` | multipleLookupValues | +| Contact Last Name (from Company Name) | `fld6pG602GzUhITLz` | multipleLookupValues | +| Contact Position within Company (from Company Name) | `fldoPxfiIPhbzmzk5` | multipleLookupValues | +| Company Sector (from Company Name) | `fldn3TocHFJttg8rl` | multipleLookupValues | +| Company Size (from Company Name) | `fldl11IwXdbLZKyfV` | multipleLookupValues | +| Company Street Address (from Company Name) | `fldMKpG83eSkgIaZg` | multipleLookupValues | +| Company Town/City (from Company Name) | `fld1gsuNuOFtvwaUI` | multipleLookupValues | +| Company County (from Company Name) | `flds2kvD8pBzOWWoL` | multipleLookupValues | +| Company Post Code (from Company Name) | `fldtJYHjtF7XQ8uk7` | multipleLookupValues | +| Company Country (from Company Name) | `fldFRwvIs9Sr6pUns` | multipleLookupValues | +| Company Telephone Number (from Company Name) | `fldni7yS8VYruHy3S` | multipleLookupValues | +| Company Email (from Company Name) | `fldjMdVmyu47Xl2Ar` | multipleLookupValues | +| Standard code | `fldD6kWt3RhOSLTzc` | multipleLookupValues | +| Level | `fld2lOS3wJF5U30lJ` | multipleLookupValues | +| EPAO (from Cohorts) | `fldEcmS27DPkUmCHd` | multipleLookupValues | +| EPAO ID (from Cohorts) | `fldOhSMLZKNHolNjF` | multipleLookupValues | +| PO Number | `fld8Dbn6VeArIgG9K` | number | +| Invoice No | `fldnjROuSsIVunX6Z` | number | +| Apprentice commitment statement | `fldkuCZKkH45LpcLv` | singleLineText | +| Commitment statements | `fldnsv0XXeyHciITE` | singleLineText | +| Apprentice-modules | `fldvDQUSt2z5akT4w` | multipleRecordLinks | +| Apprentice-modules 2 | `fldIOj9E4rZtt0dmT` | singleLineText | +| Small employer (from Company Name) | `fldDq3UmS5OkScQ4i` | multipleLookupValues | +| Total assessment price | `fldpKoNxMsY29p2rW` | multipleLookupValues | +| Training price | `fldcPOdrif9MMwjm3` | multipleLookupValues | +| Employers 2 | `fldrNKMvreJdhNSSu` | singleLineText | +| Age at start | `fldEgA846TioEIEwg` | formula | +| Age range | `fldgQnYahzW1qAic8` | formula | +| Levy donor | `fld873G8iz7rRs2wR` | singleSelect | +| Line manager name | `fldzJmF0JDj0wZmtz` | singleLineText | +| Company number (from Company Name) | `fldy6IvrhVfzvFHAt` | multipleLookupValues | +| Exit employer ID | `fldX9NvnVkk7YSXWA` | number | +| Activity | `fldQDbAwg9hoTMk6H` | singleSelect | +| ESFA Employer ID | `fldGwXGXNKxGTkDa9` | multipleLookupValues | +| Place of birth (from Apprentice eligibility 4) | `fldxb83FqAEwTR0m6` | multipleLookupValues | +| Document number | `fldt7HIHOpLpHyDwz` | multipleLookupValues | +| Address updated | `fldENepScuCmQQTS9` | checkbox | +| Read safeguarding policy | `fldtqSwK4IZKsD3NB` | checkbox | +| Employer survey sent | `fldqfInKODx5kDEEE` | singleSelect | +| Survey responded | `fld2zy7UvrQvjlbaG` | checkbox | +| Onboarding guidance sent | `fld2kpikbRqKnJnm5` | date | +| Privacy notice | `flduMffgDVE4Qk3h6` | multipleLookupValues | +| Date of capture | `fld3VWNAwJ5wcvtOp` | multipleLookupValues | +| LRS-moving to "Eligibility" tab | `fldmJ5xVX1iPrT4Su` | singleSelect | +| Permission to share | `fldCJN3OPAqKRSGsJ` | checkbox | +| Staring salary | `fld689CsXsWANpOAV` | currency | +| Moving to "eligibility" | `fldvhXoW0wR4NsCfz` | multipleAttachments | +| Training Plan email (from Company Name) | `fldnm43imwDMNOAtW` | multipleLookupValues | +| Place of birth | `fldzuXIMlpQngrBOm` | multipleLookupValues | +| Maths Level 2 | `fldvUblFYU5CvrWRC` | multipleAttachments | +| English Level 2 | `fldnfMwfh0uinIAzA` | multipleAttachments | +| M+E Status old | `flddSGkqekFkFd55a` | multipleSelects | +| Portfolio link | `fldHVO09qQgw0CxWy` | url | +| Historic Portfolio feedback notes (1-1) | `fldE2rMX7L4JUuGWk` | multilineText | +| Portfolio status | `fldWawJgjBCG49lwL` | singleSelect | +| Portfolio doc | `fldTAg88m7x8Pf1B8` | multipleAttachments | +| Portfolio mapping | `fldI9CEdgEhrVUnwm` | multipleAttachments | +| Evidence of wage | `fldzXsXtTAsWuGXOz` | multipleAttachments | +| M&E Notes | `fldkhMpDjKvlT8YqO` | singleLineText | +| ILR airtable | `fldtnBaJ3mC7PuvVY` | checkbox | +| DAS | `fldujZxRsC92bRl0G` | checkbox | +| Eligibility copy | `fldQPqn65ixwHy6xI` | singleLineText | +| Gateway | `fld8BCJpzXf7rpCMS` | date | +| AM1: Project | `fldeNblrjTeOW68nT` | dateTime | +| Assessment | `fldFmyhh6PLpBnFqr` | dateTime | +| Project start | `fldGksSTM8UDAs1Mh` | date | +| Project submission | `fld8qozNklnBto34G` | date | +| EPA notes | `fldzi3ggiuwYqOXsp` | multilineText | +| Tripartite email sent | `fldk8QRAQZDBr8oZG` | checkbox | +| Employer survey 1 | `fldduQCsd6mFsWNgH` | singleSelect | +| Employer survey 2 | `fldTrFz3lFoD5NJc1` | singleSelect | +| Employer survey 3 | `fldMszQNrFgGXkxcv` | singleSelect | +| Apprentice survey 1 | `fld92l27LQRct03fQ` | singleSelect | +| Apprentice survey 2 | `fldFLinwdrk08822N` | singleSelect | +| Apprentice survey 3 | `fldFmb4OVgefVBXx0` | singleSelect | +| On APGateway | `fldEID2IHVMhYuzgO` | checkbox | +| Proof of redundancy | `fldORtDJf6G7rLz8j` | multipleAttachments | +| Employer follow up (tripartite) | `flds9z4J0X7LYiZxl` | checkbox | +| DAS actions | `fldYlSvFiJsrSovAX` | singleSelect | +| Onboarding email sent | `fldsPAYuE9IrWiXBV` | checkbox | +| Onboarding meeting scheduled | `fldVzFXHrGQhRLZCb` | checkbox | +| Onboarding notes | `fldslAl2peGc2pvVQ` | multilineText | +| APGateway actions | `fldiSilUgMwmRSp9B` | singleSelect | +| Apprentice exit interview notes | `fldGsvotODJzUTgAN` | multipleAttachments | +| Achievement Date | `fldekD6rnTgmIPHAV` | date | +| Apprentice survey 4? | `fld76bLjfE0jdRjwb` | singleSelect | +| Employer survey 4? | `fldxo9F804hOz3oLg` | singleSelect | +| Employer exit interview notes | `fldSZy0QMjJaIuvLS` | multipleAttachments | +| Tripartite review notes | `fldVXG3zMQfRZdmck` | multipleAttachments | +| Preferred mode of comms | `fldtnqpLdKm8NInIY` | singleSelect | +| Manual of me | `fldbIELUFGht0YWHK` | url | +| Introductory call email sent | `fldacYOfKEHwNJAoH` | checkbox | +| Introductory call scheduled | `fldjGMBLjtK2RXhVk` | checkbox | +| Introductory call notes | `fldBRXpjOWljJT3YB` | multilineText | +| Onboarding employer survey | `fldJNXHLv29izzxCm` | singleSelect | +| 15 months gateway | `fldxJTv3wzAUDpxZO` | formula | +| English PRE assessment score | `fldElFROvbcm2ikjS` | multipleAttachments | +| Functional skills outcome | `flduVfdDrDoZjoblO` | multipleSelects | +| Age | `fldiCTcFUVCb5dy0n` | formula | +| Absences | `fldRSy1gcIYRLfgML` | multilineText | +| Link to AI Prior Learning | `fldzs5aEVa4nPuoRT` | multipleRecordLinks | +| Withdrawal reason | `fldHGYjQy0Vbl5eCm` | singleSelect | +| End Date | `fldo9wYiPrudtqVHz` | formula | +| Actual hours | `fldOIUG3eht0Um6LF` | rollup | +| CV | `fldJ967ye53kNA2vj` | multipleAttachments | +| LinkedIn | `fldCP7VOl8z1iuWYK` | url | +| Link to AI Cohort Feedback | `fldZ83ehbofmCu2YO` | multipleRecordLinks | +| AI Prior Learning and Commitment copy | `fldIUImXE6cJ0T7WK` | singleLineText | +| AI Prior Learning and Commitment copy | `fldIV9LNSDVJTjGdR` | singleLineText | +| Link to AI Programme Understanding | `fldfxq7HKQLnchnWU` | multipleRecordLinks | +| Stages | `fldm5cqG4nom93hMe` | multipleRecordLinks | +| Extracurricular | `fldDJbSLqFvqXdtqp` | singleLineText | +| Extracurricular 2 | `fldSgZjiJxnKXJrLi` | multipleRecordLinks | +| Extracurricular 3 | `fldA9nUfpusjdY6kx` | singleLineText | +| Extracurricular 4 | `fldM6jFJJuFk2qkix` | singleLineText | +| Extracurricular 5 | `fldNjPbu92niljEGJ` | singleLineText | +| Link to extracurricular | `fldhEjLMlroqbTxqa` | multipleRecordLinks | +| Initials | `fldsJ6GjkAV8N0K8y` | singleLineText | +| Name (from test) | `fldmUorA09i2xQbBf` | multipleLookupValues | +| Attendance - 12 week training | `fldEwjrCYTi1Vv9jP` | multipleRecordLinks | +| AI Cohort Feedback copy | `fld8oTeXmCYIiZkM7` | multipleRecordLinks | +| Progress review 1 | `flddH4kBXyTULTKFi` | formula | +| Progress review 2 | `fldLmWtjGnL5fULiK` | formula | +| Progress review 3 | `fldQRH1IyO5BnEcLX` | formula | +| Progress review 4 | `fldlmxQBwlnldcuea` | formula | +| Progress review 5 | `flde9FXO84N6t8mXM` | formula | +| CCC record | `fldNpVQNs9NXFj8Xs` | multipleRecordLinks | +| CCC attendees 3 | `flda6EEu6x5xtCBcx` | multipleRecordLinks | +| CCC survey | `fldBa88KUPaExiQsq` | singleLineText | +| CCC attendees | `fldmvHi2HWbqWFClV` | multipleRecordLinks | +| CCC attendees 2 | `fldC6i2aRh0cOaVHa` | singleLineText | +| Tripartite comments | `fldfN6BhH9cHAFoym` | multilineText | +| 12 month gateway | `fldy7WwSQJlyJcTLr` | formula | +| Portfolio link (from Tripartites) | `fldd6XnKbgYow03rd` | multipleLookupValues | +| Portfolio document (from Tripartites) | `fldqtV7ZU7dHCWnnm` | multipleLookupValues | +| Tripartites (apprentices) | `fldEgGVOTZpsSXII5` | singleLineText | +| Prior learning (End of SB Github link) | `fldtqdLqinNmsGKxy` | multilineText | +| Ethnic group | `fldImdwF6QZP85HUt` | multipleLookupValues | +| Prior level | `fldkZCSslDn4SQZc4` | multipleLookupValues | +| Street address | `fldfyWnj1gl6W1M4Q` | multipleLookupValues | +| Tripartites (L4 Employers) | `fldgFjwRgVGk1UGZn` | multipleRecordLinks | +| AI Cohort Feedback copy | `fld1sUSB2zX4RVhGr` | multipleRecordLinks | +| AI Cohort Feedback copy 2 | `fldtQ18UU704wDmDq` | multipleRecordLinks | +| Portfolio feedback (AI) | `fldnjeBoCSXDAy3S9` | multipleAttachments | +| Portfolio feedback (Loom) | `fldR2Q8sSNi6hjdIF` | multilineText | +| Portfolio feedback status | `fldx5cSPKGuyagOQo` | multipleSelects | +| No. of weeks (islington data) | `fldsDxc9R5Xb3KXG9` | formula | +| MLX One On Ones | `fld974V1nrHR7u9An` | multipleRecordLinks | +| MLX One On Ones 2 | `fldI120CotWO2LIMx` | multipleRecordLinks | +| Apprentice handbook | `fldrxUts1hKvXe52o` | checkbox | +| Link to weekly Feedback | `flduPVofziKMgZUrn` | multipleRecordLinks | +| Borough | `fld8ImpKUDWL5EJ3j` | multipleLookupValues | +| Email | `fldOjn2l72bX68t66` | multipleLookupValues | +| Concerns status | `fldXuvUDRGoCCoyf2` | multipleSelects | +| Intervention | `fldxcqXDfAKXILqY1` | multipleSelects | +| Concern resolved date | `flddtY55VU9mV58Ar` | date | +| FAC Actions | `fldAPvyluZWHvkVLG` | multilineText | +| Who? | `fldBLP5eUDJVT3SIH` | singleCollaborator | +| Field 215 | `fldpO84XpfkGMoPbZ` | singleLineText | +| Tripartite Tracking AI | `fldOyICQutVALduJE` | singleSelect | +| MLX Workshops Attended (In-Person) | `fldq76PO6p1vFBZn8` | singleLineText | +| MLX Workshops Attended (Remote) | `fldxp9BXorcVc4DPC` | singleLineText | +| MLX Workshops Attended (Combined) | `fldZGpMBwldiMfli6` | singleLineText | +| Area of concern 2 | `fldFQ5O9KLB4imrYr` | multipleSelects | +| Concern 2 date resolved | `fldR5LVcEjwBcVvAO` | date | +| Concern 2 status | `fld2TjulXYgPTiiYX` | singleSelect | +| Start year | `flddOTTO4pZQ4OrMQ` | multipleLookupValues | +| Promotion? | `fldEeBOREFbdWxy1c` | checkbox | +| Length | `fldGogWaiYSmv05UO` | multipleLookupValues | +| Onboarding actions | `fldXF1JC4aPdB4ByI` | multipleSelects | +| Reasonable adjustments evidence | `fldTR3DJSw75WKpYe` | multipleAttachments | +| Reasonable adjustments form | `fldlfJLUthqH0o6Bz` | multipleAttachments | +| 10 year bday invite sent (Tavie) | `fldSRjokfQFzh6oUp` | checkbox | +| Attending 10th party? | `fldKPjktv9Y6yBtdA` | checkbox | +| Attendance (test) | `fld0FWdqTtAgjq6DO` | singleLineText | +| Levy? | `fldwW16vk9ORf4HRU` | multipleLookupValues | +| M+E status | `fldIdmqL2FVCdJDvb` | multipleLookupValues | +| Trading name (from Company Name) | `fldRZsrXLwU9XHNAZ` | multipleLookupValues | +| Company type (from Company Name) | `fldHW43kCKFKRqgXh` | multipleLookupValues | +| Expected QAR year | `fldCtemAmE76ROAHz` | multipleLookupValues | +| Prior learning catch up required? | `fld0yi4R3H079UhdB` | checkbox | +| English certificate | `fld0V7fLx0mYh4pvl` | multipleLookupValues | +| Maths certificate | `fldZBPInka1ClGoAK` | multipleLookupValues | +| Predicted outcome | `fld3zTFUXrry66l3w` | singleSelect | +| Remedial support given | `fldkjUaFq9ZuS3Oiy` | singleSelect | +| Classroom attendance - Autumn 2024 | `fldkbHukyhxhTuAlb` | singleLineText | +| Classroom attendance - Autumn 2024 copy | `fld1eXWnqXqwqOuKG` | multipleRecordLinks | +| Employment intensity (ILR entry) | `fld2ahktNTGjwphrG` | singleSelect | +| Source of funding (ILR) | `fldilWrSm8rZSdGXK` | multipleLookupValues | +| ILR entry tool record | `fldYzPzjLDs7MPUN6` | multipleRecordLinks | +| Programme aim 1 (ILR) | `fldQFD8569sOmxQeN` | multipleLookupValues | +| Funding module (ILR) | `fldVhZqH65mhXLcVG` | multipleLookupValues | +| Programme type (ILR) | `flduGgrXhOYrYrF2p` | multipleLookupValues | +| Programme aim 2 (ILR) | `fldKk2SRWlL4xNdOO` | multipleLookupValues | +| Apprentice standard | `fldfTVbe9ay8tcwq6` | multipleLookupValues | +| OTJ survey (L4) | `fldN5EDTbG1eSvnwt` | singleLineText | +| OTJ survey (L4) 2 | `fldLtHHwlH6CwSsok` | singleLineText | +| Withdrawn date | `fldACffPFSs6VVQVq` | date | +| OTJ survey (L4) 3 | `fldh3sjk7FAH00S1p` | multipleRecordLinks | +| ILR start date input | `fldFKYG9zYBcCEDEJ` | formula | +| Temp - on old IRL? | `flddbTLA0BVpa4msS` | checkbox | +| Apprentice surveys copy | `fldHCkMSWUaAXZWby` | singleLineText | +| Support log | `fldI2l7NtjklRmWet` | singleLineText | +| Register of attendance | `fldXiphAAQBkqRw4o` | multipleRecordLinks | +| Catch-up attendance | `fldSrXPstWRmXRFUk` | count | +| Workshop attendance | `fldGu22EgGh8DaSHX` | count | +| Portfolio support log | `fldkHdW52vARrsK3L` | singleLineText | +| Workshop hours | `fldQY3avgOByCgTeP` | formula | +| Exit interviews | `fldSEzu79GaXn4iCQ` | multipleRecordLinks | +| 6-month outcome | `fldusquqztz3uj7ek` | singleSelect | +| Planned end date ILR input | `fldoTmVPRnEivrXON` | formula | +| Register of learning | `fld0582YZFEpyCdqZ` | multipleRecordLinks | +| Pronouns (from Eligibility) | `fldxdmmjR94RD2KAe` | multipleLookupValues | +| Register of attendance copy | `fld79ejI4zaLFFs2A` | multipleRecordLinks | +| Register of attendance copy copy | `fldPaJA1sU6vxf6pc` | multipleRecordLinks | +| AI apprenticeship email sent | `fldqLzORBTVMRPq0o` | date | +| Completion year | `fldZZPLnsTl8iM37w` | singleSelect | +| End of apprenticeship feedback copy | `fldsTzITQ04sSrJIJ` | singleLineText | +| ILR data copy | `fldr0vKYsOXiBLXQ4` | singleLineText | +| ILR data 25-26 copy | `fldxGw6fS7ICw7CGu` | singleLineText | +| ILR data 25-26 copy | `fldbyZ9nOnp6QQgO2` | multipleRecordLinks | +| Employer Contact | `fldWwocu0K7fj91Tz` | formula | +| End of Apprenticeship | `fldUAyoKnBXgzbhLh` | multipleRecordLinks | +| 6-Month followup | `fldaaWFZGiE3UnjLK` | formula | +| Attendance (archive) copy | `fldFyZ8lEa3CeSNLH` | multipleRecordLinks | +| OTJ Survey | `fldfVgYW5SCqahatk` | multipleRecordLinks | +| EPA Adjustments | `fld3g6amcp6lt0NNT` | checkbox | +| Completion Notes | `fld2s1nLj6ht1N93e` | multilineText | +| Date Paused | `fldUIqvqJ5kRQ6KiO` | date | +| Days in gateway | `fldPau6pQlRkNOwpo` | formula | +| Learner Engagement Log | `fldz4VDkuRFTYEJzF` | multipleRecordLinks | +| In gateway at end of QAR year | `fldrD9tZGr4POXmqk` | checkbox | +| SEND check-in | `fldT2N5jxc4n65FJh` | date | +| SEND notes | `fldYnM5Fbm8FdS4PJ` | multilineText | +| PLP Meeting | `fldAAzjNroqKvU5Bp` | multipleLookupValues | +| QAR year | `fldIhOVvuThurKNdr` | formula | +| ILR data 25/26 copy | `fldEmiK1nPzL5QL6x` | singleLineText | +| Days Running Late | `fldm3TPOQz4vKQybL` | formula | +| Change of status | `fldjIxX9p6yJaU8cM` | formula | +| Days Late | `fld6ezsmH6VmK0RoT` | formula | +| Adjustments | `fldOFIaIt1fhL2pSr` | checkbox | +| Unpaused | `fldWmWxg3z8CJdvde` | date | +| 90 days late | `fldHoPu80tqntSR2R` | formula | +| 180 days late | `fldEeu301Z58ZVCFW` | formula | +| Current QAR year | `fldJRWxlStbB8RGVo` | formula | +| SEND? | `fldlmhhfct4RyYTki` | formula | +| Learner alerts | `fldOFaGZckHs1M3kR` | formula | +| Expected hours to date | `fld0NpaEubIH3Fj1J` | formula | +| Days on programme | `fldZAfgxQ7IwUY4yd` | formula | +| Expected programme langth in days | `fldPWmjeIUYALTAzM` | formula | +| Historical hours | `fldvM5CTRRBJnKmGt` | number | +| Progress Reviews 2 | `fldkIO19lKGR2Cs3q` | multipleRecordLinks | +| Attendace - Apprentice Pulse | `fldE32rpogay4EUXR` | multipleRecordLinks | +| Staff - Apprentice pulse | `fldQJGMcmubped6PK` | multipleRecordLinks | + +## Learners / CF Feedback + +Table ID: `tblgUqYpnw7TawBnz` + +| Field | ID | Type | +|-------|-----|------| +| No. | `fld6Y2vocox5Ac0d9` | singleLineText | +| Created By | `fld2Flr9ZNntc96x4` | createdBy | +| Week number | `fldxhzU71VjTQoWZ4` | singleSelect | +| Name | `fldqDhuyzCGEhhwNU` | multipleSelects | +| Learner teamwork | `fldBNFiEaWTj2cPaY` | multilineText | +| Learner curriculum engagement | `fldGXlR1JRKjuCisr` | multilineText | +| Issues or concerns | `fldP6LdveShiq7QKd` | multilineText | +| Curriculum concerns | `fldEcwpyU5U5gR6eP` | multilineText | +| Other feedback | `fldctj0ERN2OfLF62` | multilineText | +| Wellbeing | `fldoVDohFZWjpsay7` | rating | +| Workload | `fldtXDwyx2Bf4rC5g` | rating | +| Wellbeing challenges | `fldeZy5lQEcmZRvkH` | multilineText | +| Fac Actions? | `fldyKFSPn4HwzPOvH` | multilineText | +| Who? | `fldOZGnzJYLbb9VMv` | singleCollaborator | +| Cohort delivered to? | `fldE5o0WvQDuvQ7R8` | multipleSelects | + +## Learners / Company Sectors + +Table ID: `tblzX8k8RNdwnmOgC` + +| Field | ID | Type | +|-------|-----|------| +| Company sector | `fldiIDC8VFOmSwSIj` | multilineText | +| Companies | `fldEeYndYkf9hDOLa` | multipleRecordLinks | + diff --git a/scripts/schema-2026-01-07-14-40-23.md b/scripts/schema-2026-01-07-14-40-23.md new file mode 100644 index 0000000..9b70aac --- /dev/null +++ b/scripts/schema-2026-01-07-14-40-23.md @@ -0,0 +1,13 @@ +# Airtable Schema + +## Learners / Staff - Apprentice pulse + +Table ID: `tblJjn62ExE1LVjmx` + +| Field | ID | Type | +|-------|-----|------| +| Id | `fldbTKP32s3Soev91` | autoNumber | +| Staff Member | `fldHEHhQInmSdipn8` | singleCollaborator | +| Apprentice Link | `fldAMwe9jOOdwIyBY` | multipleRecordLinks | +| Learner email (from Apprentice Link) | `fldPjDZTSySzbefXz` | multipleLookupValues | + diff --git a/src/hooks.server.spec.ts b/src/hooks.server.spec.ts index d4d1fe7..2e47b65 100644 --- a/src/hooks.server.spec.ts +++ b/src/hooks.server.spec.ts @@ -43,7 +43,7 @@ describe('hooks.server', () => { expect(event.locals.user).toEqual(session); }); - it('should redirect unauthenticated users from /admin to /admin/login', async () => { + it('should redirect unauthenticated users from /admin to /login', async () => { mockGetSession.mockReturnValue(null); const event = createMockEvent('/admin'); @@ -51,11 +51,11 @@ describe('hooks.server', () => { await expect(handle({ event: event as never, resolve })).rejects.toMatchObject({ status: 303, - location: '/admin/login?redirect=%2Fadmin', + location: '/login?redirect=%2Fadmin', }); }); - it('should redirect students from /admin to home', async () => { + it('should redirect students from /admin to /checkin', async () => { const session = { email: 'student@example.com', type: 'student' as const }; mockGetSession.mockReturnValue(session); @@ -64,20 +64,9 @@ describe('hooks.server', () => { await expect(handle({ event: event as never, resolve })).rejects.toMatchObject({ status: 303, - location: '/', + location: '/checkin', }); }); - - it('should allow unauthenticated access to /admin/login', async () => { - mockGetSession.mockReturnValue(null); - - const event = createMockEvent('/admin/login'); - const resolve = createMockResolve(); - - await handle({ event: event as never, resolve }); - - expect(resolve).toHaveBeenCalled(); - }); }); describe('checkin route', () => { @@ -121,7 +110,7 @@ describe('hooks.server', () => { }); }); - it('should redirect authenticated students from /login to home', async () => { + it('should redirect authenticated students from /login to /checkin', async () => { const session = { email: 'student@example.com', type: 'student' as const }; mockGetSession.mockReturnValue(session); @@ -130,20 +119,7 @@ describe('hooks.server', () => { await expect(handle({ event: event as never, resolve })).rejects.toMatchObject({ status: 303, - location: '/', - }); - }); - - it('should redirect authenticated staff from /admin/login to /admin', async () => { - const session = { email: 'staff@example.com', type: 'staff' as const }; - mockGetSession.mockReturnValue(session); - - const event = createMockEvent('/admin/login'); - const resolve = createMockResolve(); - - await expect(handle({ event: event as never, resolve })).rejects.toMatchObject({ - status: 303, - location: '/admin', + location: '/checkin', }); }); }); diff --git a/src/hooks.server.ts b/src/hooks.server.ts index d0dd28d..341fa5c 100644 --- a/src/hooks.server.ts +++ b/src/hooks.server.ts @@ -3,14 +3,12 @@ import { getSession } from '$lib/server/session'; // Routes that require staff access const ADMIN_ROUTES = ['/admin']; -// Admin login page (excluded from admin protection) -const ADMIN_LOGIN = '/admin/login'; // Routes that require any authenticated user const PROTECTED_ROUTES: string[] = []; // /checkin now handles auth internally for guest support -// Login routes - redirect if already authenticated -const AUTH_ROUTES = ['/login', '/admin/login']; +// Login route - redirect if already authenticated +const AUTH_ROUTES = ['/login']; function isPathMatch(pathname: string, routes: string[]): boolean { return routes.some(route => pathname === route || pathname.startsWith(`${route}/`)); @@ -29,14 +27,14 @@ export const handle: Handle = async ({ event, resolve }) => { return resolve(event); } - // Check admin routes (excluding admin login page) - require staff - if (isPathMatch(pathname, ADMIN_ROUTES) && pathname !== ADMIN_LOGIN) { + // Check admin routes - require staff + if (isPathMatch(pathname, ADMIN_ROUTES)) { if (!session) { - redirect(303, '/admin/login?redirect=' + encodeURIComponent(pathname)); + redirect(303, '/login?redirect=' + encodeURIComponent(pathname)); } if (session.type !== 'staff') { - // Students trying to access admin get redirected to checkin or home - redirect(303, '/'); + // Students trying to access admin get redirected to checkin + redirect(303, '/checkin'); } } @@ -49,7 +47,7 @@ export const handle: Handle = async ({ event, resolve }) => { // Redirect authenticated users away from login page if (isPathMatch(pathname, AUTH_ROUTES) && session) { - const redirectTo = session.type === 'staff' ? '/admin' : '/'; + const redirectTo = session.type === 'staff' ? '/admin' : '/checkin'; redirect(303, redirectTo); } diff --git a/src/lib/airtable/airtable.ts b/src/lib/airtable/airtable.ts index add42bd..ca910a2 100644 --- a/src/lib/airtable/airtable.ts +++ b/src/lib/airtable/airtable.ts @@ -1,6 +1,6 @@ import Airtable from 'airtable'; -import { TABLES, COHORT_FIELDS, APPRENTICE_FIELDS, STAFF_FIELDS } from './config.ts'; +import { TABLES, COHORT_FIELDS, APPRENTICE_FIELDS, STAFF_FIELDS, TERM_FIELDS } from './config.ts'; export interface Apprentice { id: string; @@ -23,6 +23,20 @@ export interface Cohort { apprenticeCount: number; } +export interface Term { + id: string; + name: string; + startingDate: string; + endDate: string; +} + +export interface StaffRecord { + id: string; + name: string; + email: string; + learnerEmail: string | null; // Email of linked apprentice, if staff is also an apprentice +} + export type UserType = 'staff' | 'student'; export function createAirtableClient(apiKey: string, baseId: string) { @@ -104,9 +118,9 @@ export function createAirtableClient(apiKey: string, baseId: string) { } /** - * Check if email exists in Staff table + * Get staff record by email, including linked learner email if staff is also an apprentice */ - async function findStaffByEmail(email: string): Promise { + async function getStaffByEmail(email: string): Promise { const staffTable = base(TABLES.STAFF); // singleCollaborator field cannot be filtered, must fetch all and iterate @@ -117,13 +131,27 @@ export function createAirtableClient(apiKey: string, baseId: string) { .all(); for (const record of staffRecords) { - const collaborator = record.get(STAFF_FIELDS.COLLABORATOR) as { email: string } | undefined; + const collaborator = record.get(STAFF_FIELDS.COLLABORATOR) as { id: string; email: string; name: string } | undefined; if (collaborator?.email?.toLowerCase() === email.toLowerCase()) { - return true; + const learnerEmailLookup = record.get(STAFF_FIELDS.LEARNER_EMAIL) as string[] | undefined; + return { + id: record.id, + name: collaborator.name, + email: collaborator.email, + learnerEmail: learnerEmailLookup?.[0] ?? null, + }; } } - return false; + return null; + } + + /** + * Check if email exists in Staff table + */ + async function findStaffByEmail(email: string): Promise { + const staff = await getStaffByEmail(email); + return staff !== null; } /** @@ -196,6 +224,29 @@ export function createAirtableClient(apiKey: string, baseId: string) { }); } + /** + * List all terms + */ + async function listTerms(): Promise { + const termsTable = base(TABLES.TERMS); + + const records = await termsTable + .select({ + returnFieldsByFieldId: true, + sort: [{ field: TERM_FIELDS.STARTING_DATE, direction: 'desc' }], + }) + .all(); + + return records.map((record) => { + return { + id: record.id, + name: (record.get(TERM_FIELDS.NAME) as string) || record.id, + startingDate: (record.get(TERM_FIELDS.STARTING_DATE) as string) || '', + endDate: (record.get(TERM_FIELDS.END_DATE) as string) || '', + }; + }); + } + /** * Get apprentices by cohort record ID */ @@ -276,9 +327,11 @@ export function createAirtableClient(apiKey: string, baseId: string) { getApprenticesByFacCohort, findUserByEmail, findStaffByEmail, + getStaffByEmail, findApprenticeByEmail, getApprenticeByEmail, listCohorts, + listTerms, getApprenticesByCohortId, getApprenticesByIds, }; diff --git a/src/lib/airtable/attendance.spec.ts b/src/lib/airtable/attendance.spec.ts index 56869dd..40fe35d 100644 --- a/src/lib/airtable/attendance.spec.ts +++ b/src/lib/airtable/attendance.spec.ts @@ -418,11 +418,11 @@ describe('attendance', () => { }); }); - describe('getApprenticeAttendanceStats', () => { + describe('getApprenticeStats', () => { it('should return null when apprentice not found', async () => { mockTable.select.mockReturnValue({ all: vi.fn().mockResolvedValue([]) }); - const stats = await client.getApprenticeAttendanceStats('nonexistent'); + const stats = await client.getApprenticeStats('nonexistent'); expect(stats).toBeNull(); }); @@ -526,26 +526,26 @@ describe('attendance', () => { 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[0].status).toBe('Not Check-in'); // No attendance record = Not Check-in 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 + it('should mark events without attendance as Not Check-in', async () => { + // Mock apprentice with cohort const mockApprentice = { id: 'recApprentice1', get: vi.fn((field: string) => { const data: Record = { [APPRENTICE_FIELDS.NAME]: 'Test Apprentice', - [APPRENTICE_FIELDS.COHORT]: undefined, + [APPRENTICE_FIELDS.COHORT]: ['recCohort1'], }; return data[field]; }), }; - // Mock events (all events since no cohort) + // Mock event assigned to that cohort const mockEvents = [ { id: 'recEvent1', @@ -553,7 +553,7 @@ describe('attendance', () => { const data: Record = { [EVENT_FIELDS.NAME]: 'Event 1', [EVENT_FIELDS.DATE_TIME]: '2025-01-06T09:00:00.000Z', - [EVENT_FIELDS.COHORT]: [], + [EVENT_FIELDS.COHORT]: ['recCohort1'], // Assigned to apprentice's cohort }; return data[field]; }), @@ -568,7 +568,7 @@ describe('attendance', () => { const history = await client.getApprenticeAttendanceHistory('recApprentice1'); expect(history).toHaveLength(1); - expect(history[0].status).toBe('Missed'); + expect(history[0].status).toBe('Not Check-in'); expect(history[0].checkinTime).toBeNull(); }); }); diff --git a/src/lib/airtable/attendance.ts b/src/lib/airtable/attendance.ts index 6856155..ba2dee2 100644 --- a/src/lib/airtable/attendance.ts +++ b/src/lib/airtable/attendance.ts @@ -123,9 +123,9 @@ export function createAttendanceClient(apiKey: string, baseId: string) { } /** - * Mark a user as "Not Coming" for an event + * Mark a user as "Absent" for an event * - Creates new attendance record if none exists - * - Returns existing record if already marked "Not Coming" + * - Returns existing record if already marked "Absent" * - Throws error if user already checked in (Present/Late) */ async function markNotComing(input: CreateAttendanceInput): Promise { @@ -138,19 +138,19 @@ export function createAttendanceClient(apiKey: string, baseId: string) { // 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 + if (existing.status === 'Absent') { + // Already marked as absent - idempotent return existing; } - // Already checked in (Present/Late/Absent/Excused) + // Already checked in (Present/Late/Not Check-in/Excused) throw new Error('User already has an attendance record for this event'); } - // Create new attendance record with "Not Coming" status + // Create new attendance record with "Absent" status const fields: Airtable.FieldSet = { [ATTENDANCE_FIELDS.EVENT]: [input.eventId], [ATTENDANCE_FIELDS.APPRENTICE]: [input.apprenticeId], - [ATTENDANCE_FIELDS.STATUS]: 'Not Coming', + [ATTENDANCE_FIELDS.STATUS]: 'Absent', }; const record = await attendanceTable.create(fields); @@ -159,8 +159,8 @@ export function createAttendanceClient(apiKey: string, baseId: string) { id: record.id, eventId: input.eventId, apprenticeId: input.apprenticeId, - checkinTime: '', // No check-in time for "Not Coming" - status: 'Not Coming', + checkinTime: '', // No check-in time for "Absent" + status: 'Absent', }; } @@ -334,6 +334,7 @@ export function createAttendanceClient(apiKey: string, baseId: string) { /** Event data needed for stats calculations */ interface EventForStats { id: string; + name: string; dateTime: string; cohortIds: string[]; } @@ -377,60 +378,77 @@ export function createAttendanceClient(apiKey: string, baseId: string) { const cohortIds = record.get(EVENT_FIELDS.COHORT) as string[] | undefined; return { id: record.id, + name: (record.get(EVENT_FIELDS.NAME) as string) || 'Unnamed Event', dateTime: record.get(EVENT_FIELDS.DATE_TIME) as string, cohortIds: cohortIds ?? [], }; }); } + // ============================================ + // Filter helpers (single source of truth) + // ============================================ + /** - * Calculate trend by comparing two periods + * Get events for a specific cohort, optionally filtered by date range + * This is THE source of truth for which events count toward an apprentice's stats */ - function calculateTrend(currentRate: number, previousRate: number): AttendanceTrend { - const change = currentRate - previousRate; - let direction: AttendanceTrend['direction'] = 'stable'; - if (change > 2) direction = 'up'; - else if (change < -2) direction = 'down'; + function getEventsForCohort( + allEvents: EventForStats[], + cohortId: string | null, + options?: { startDate?: Date; endDate?: Date }, + ): EventForStats[] { + // Filter by cohort (if no cohort, return empty - apprentice must belong to a cohort) + if (!cohortId) { + return []; + } - return { - direction, - change: Math.round(change * 10) / 10, - currentRate, - previousRate, - }; + let events = allEvents.filter(e => e.cohortIds.includes(cohortId)); + + // Filter by date range if provided + if (options?.startDate && options?.endDate) { + events = events.filter((e) => { + const eventDate = new Date(e.dateTime); + return eventDate >= options.startDate! && eventDate <= options.endDate!; + }); + } + + return events; } /** - * Calculate base attendance stats from attendance records + * Filter attendance records to only include those for the specified events + * Ensures attendance count never exceeds event count */ - function calculateStats(attendanceRecords: Attendance[], totalEvents: number): AttendanceStats { - const present = attendanceRecords.filter(a => a.status === 'Present').length; - 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 - ? Math.round((attended / totalEvents) * 1000) / 10 - : 0; + function filterAttendanceToEvents( + attendance: Attendance[], + eventIds: Set, + ): Attendance[] { + return attendance.filter(a => eventIds.has(a.eventId)); + } - return { - totalEvents, - attended, - present, - late, - absent, - excused, - notComing, - attendanceRate, - }; + /** + * Calculate attendance rate from attendance records for a set of events + */ + function calculateAttendanceRate(attendance: Attendance[], eventCount: number): number { + if (eventCount === 0) return 0; + const attended = attendance.filter(a => a.status === 'Present' || a.status === 'Late').length; + return (attended / eventCount) * 100; } /** * Get attendance statistics for a specific apprentice + * Unified function that replaces getApprenticeAttendanceStats and getApprenticeAttendanceStatsWithDateFilter + * + * Key behavior: + * - Only counts events assigned to the apprentice's cohort + * - Only counts attendance for those cohort events + * - Optionally filters by date range */ - async function getApprenticeAttendanceStats(apprenticeId: string): Promise { + async function getApprenticeStats( + apprenticeId: string, + options?: { startDate?: Date; endDate?: Date }, + ): Promise { const apprenticesTable = base(TABLES.APPRENTICES); const cohortsTable = base(TABLES.COHORTS); @@ -467,15 +485,17 @@ export function createAttendanceClient(apiKey: string, baseId: string) { } } - // Get all events for this apprentice's cohort + // Get events for this cohort (with optional date filter) const allEvents = await getAllEvents(); - const relevantEvents = cohortId - ? allEvents.filter(e => e.cohortIds.includes(cohortId)) - : allEvents; + const relevantEvents = getEventsForCohort(allEvents, cohortId, options); + const relevantEventIds = new Set(relevantEvents.map(e => e.id)); - // Get attendance records for this apprentice + // Get attendance for this apprentice, filtered to cohort events only const allAttendance = await getAllAttendance(); - const apprenticeAttendance = allAttendance.filter(a => a.apprenticeId === apprenticeId); + const apprenticeAttendance = filterAttendanceToEvents( + allAttendance.filter(a => a.apprenticeId === apprenticeId), + relevantEventIds, + ); // Calculate stats const stats = calculateStats(apprenticeAttendance, relevantEvents.length); @@ -485,24 +505,31 @@ export function createAttendanceClient(apiKey: string, baseId: string) { const fourWeeksAgo = new Date(now.getTime() - 28 * 24 * 60 * 60 * 1000); const eightWeeksAgo = new Date(now.getTime() - 56 * 24 * 60 * 60 * 1000); - const recentEvents = relevantEvents.filter(e => new Date(e.dateTime) >= fourWeeksAgo); + // Constrain trend calculation to the filtered date range + const trendStartDate = options?.startDate && options.startDate > eightWeeksAgo + ? options.startDate + : eightWeeksAgo; + const trendEndDate = options?.endDate && options.endDate < now + ? options.endDate + : now; + + const recentEvents = relevantEvents.filter((e) => { + const d = new Date(e.dateTime); + return d >= fourWeeksAgo && d <= trendEndDate; + }); const previousEvents = relevantEvents.filter((e) => { const d = new Date(e.dateTime); - return d >= eightWeeksAgo && d < fourWeeksAgo; + return d >= trendStartDate && d < fourWeeksAgo; }); const recentEventIds = new Set(recentEvents.map(e => e.id)); const previousEventIds = new Set(previousEvents.map(e => e.id)); - const recentAttendance = apprenticeAttendance.filter(a => recentEventIds.has(a.eventId)); - const previousAttendance = apprenticeAttendance.filter(a => previousEventIds.has(a.eventId)); + const recentAttendance = filterAttendanceToEvents(apprenticeAttendance, recentEventIds); + const previousAttendance = filterAttendanceToEvents(apprenticeAttendance, previousEventIds); - const recentRate = recentEvents.length > 0 - ? (recentAttendance.filter(a => a.status === 'Present' || a.status === 'Late').length / recentEvents.length) * 100 - : 0; - const previousRate = previousEvents.length > 0 - ? (previousAttendance.filter(a => a.status === 'Present' || a.status === 'Late').length / previousEvents.length) * 100 - : 0; + const recentRate = calculateAttendanceRate(recentAttendance, recentEvents.length); + const previousRate = calculateAttendanceRate(previousAttendance, previousEvents.length); return { ...stats, @@ -514,6 +541,58 @@ export function createAttendanceClient(apiKey: string, baseId: string) { }; } + /** + * Calculate trend by comparing two periods + */ + function calculateTrend(currentRate: number, previousRate: number): AttendanceTrend { + const change = currentRate - previousRate; + let direction: AttendanceTrend['direction'] = 'stable'; + if (change > 2) direction = 'up'; + else if (change < -2) direction = 'down'; + + return { + direction, + change: Math.round(change * 10) / 10, + currentRate, + previousRate, + }; + } + + /** + * Calculate base attendance stats from attendance records + * Missing events (no attendance record) are counted as 'Not Check-in' + */ + function calculateStats(attendanceRecords: Attendance[], totalEvents: number): AttendanceStats { + const present = attendanceRecords.filter(a => a.status === 'Present').length; + const late = attendanceRecords.filter(a => a.status === 'Late').length; + const explicitAbsent = attendanceRecords.filter(a => a.status === 'Not Check-in').length; + const excused = attendanceRecords.filter(a => a.status === 'Excused').length; + const notComing = attendanceRecords.filter(a => a.status === 'Absent').length; + + // Count missing attendance records as 'Not Check-in' + // Guard against negative values (shouldn't happen if attendance is filtered correctly) + const recordedEvents = attendanceRecords.length; + const missingEvents = Math.max(0, totalEvents - recordedEvents); + const absent = explicitAbsent + missingEvents; + + const attended = present + late; + + const attendanceRate = totalEvents > 0 + ? Math.round((attended / totalEvents) * 1000) / 10 + : 0; + + return { + totalEvents, + attended, + present, + late, + absent, + excused, + notComing, + attendanceRate, + }; + } + /** * Get attendance statistics for a specific cohort */ @@ -714,9 +793,16 @@ export function createAttendanceClient(apiKey: string, baseId: string) { /** * Get attendance history for a specific apprentice - * Returns a list of events with their attendance status + * + * Key behavior: + * - Only shows events assigned to the apprentice's cohort + * - Events with no attendance record are shown as 'Not Check-in' (implicit) + * - Optionally filters by date range */ - async function getApprenticeAttendanceHistory(apprenticeId: string): Promise { + async function getApprenticeAttendanceHistory( + apprenticeId: string, + options?: { startDate?: Date; endDate?: Date }, + ): Promise { const apprenticesTable = base(TABLES.APPRENTICES); // Get apprentice info to find their cohort @@ -736,83 +822,36 @@ export function createAttendanceClient(apiKey: string, baseId: string) { 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(); + // Get events for this cohort (with optional date filter) + const allEvents = await getAllEvents(); + const relevantEvents = getEventsForCohort(allEvents, cohortId, options); + const relevantEventIds = new Set(relevantEvents.map(e => e.id)); - // 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); - }); + // Get attendance for this apprentice, filtered to cohort events only + const allAttendance = await getAllAttendance(); + const apprenticeAttendance = filterAttendanceToEvents( + allAttendance.filter(a => a.apprenticeId === apprenticeId), + relevantEventIds, + ); // 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', - }); - } + for (const attendance of apprenticeAttendance) { + attendanceMap.set(attendance.eventId, attendance); } - // 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, - }; - }); + // Build the history entries - one for each relevant event + const history: AttendanceHistoryEntry[] = relevantEvents.map((event) => { + const attendance = attendanceMap.get(event.id); + return { + eventId: event.id, + eventName: event.name, + eventDateTime: event.dateTime, + status: attendance ? attendance.status : 'Not Check-in', + checkinTime: attendance?.checkinTime ?? null, + attendanceId: attendance?.id ?? null, + }; + }); // Sort by date (most recent first) history.sort((a, b) => new Date(b.eventDateTime).getTime() - new Date(a.eventDateTime).getTime()); @@ -833,7 +872,7 @@ export function createAttendanceClient(apiKey: string, baseId: string) { // Aggregate functions getAllAttendance, getAllEvents, - getApprenticeAttendanceStats, + getApprenticeStats, getCohortAttendanceStats, getAttendanceSummary, getApprenticeAttendanceHistory, diff --git a/src/lib/airtable/config.ts b/src/lib/airtable/config.ts index bd39a01..d54b2ed 100644 --- a/src/lib/airtable/config.ts +++ b/src/lib/airtable/config.ts @@ -10,6 +10,7 @@ export const TABLES = { STAFF: 'tblJjn62ExE1LVjmx', EVENTS: 'tblkbskw4fuTq0E9p', ATTENDANCE: 'tblkDbhJcuT9TTwFc', + TERMS: 'tbl4gkcG92Bc8gFU7', } as const; // Fields - Cohorts @@ -29,6 +30,8 @@ export const APPRENTICE_FIELDS = { // Fields - Staff export const STAFF_FIELDS = { COLLABORATOR: 'fldHEHhQInmSdipn8', // singleCollaborator with { id, email, name } + APPRENTICE_LINK: 'fldAMwe9jOOdwIyBY', // multipleRecordLinks to Apprentices + LEARNER_EMAIL: 'fldPjDZTSySzbefXz', // multipleLookupValues from linked Apprentice } as const; // Fields - Events @@ -51,11 +54,18 @@ export const ATTENDANCE_FIELDS = { APPRENTICE: 'fldOyo3hlj9Ht0rfZ', // multipleRecordLinks to Apprentices EVENT: 'fldiHd75LYtopwyN9', // multipleRecordLinks to Events CHECKIN_TIME: 'fldvXHPmoLlEA8EuN', // dateTime - STATUS: 'fldew45fDGpgl1aRr', // singleSelect (Present/Absent/Late) + 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) } as const; +// Fields - Terms +export const TERM_FIELDS = { + NAME: 'fldrnRBnBuHbscSy7', // singleLineText + STARTING_DATE: 'fldlzwlqYo7rMMSDp', // date + END_DATE: 'fldJKhrzNZNCD6SYY', // date +} as const; + // Defaults export const DEFAULTS = { SURVEY_URL: 'https://airtable.com/app75iT6Or0Rya1KE/tblkbskw4fuTq0E9p/viwMPduDi2Cl0Bwa3/rec3vWwKLpAycDCSL/fld9XBHnCWBtZiZah?copyLinkToCellOrRecordOrigin=gridView', diff --git a/src/lib/airtable/sveltekit-wrapper.ts b/src/lib/airtable/sveltekit-wrapper.ts index a6aa0b2..7ff5b94 100644 --- a/src/lib/airtable/sveltekit-wrapper.ts +++ b/src/lib/airtable/sveltekit-wrapper.ts @@ -15,7 +15,7 @@ import { createAirtableClient } from './airtable.js'; import { createEventsClient } from './events.js'; import { createAttendanceClient } from './attendance.js'; -export type { Apprentice, ApprenticeRecord, Cohort } from './airtable.js'; +export type { Apprentice, ApprenticeRecord, Cohort, Term, StaffRecord } from './airtable.js'; export type { Event, EventFilters, CreateEventInput, UpdateEventInput } from '$lib/types/event.js'; export type { Attendance, @@ -34,9 +34,11 @@ const attendanceClient = createAttendanceClient(AIRTABLE_API_KEY, AIRTABLE_BASE_ export const getApprenticesByFacCohort = client.getApprenticesByFacCohort; export const findStaffByEmail = client.findStaffByEmail; +export const getStaffByEmail = client.getStaffByEmail; export const findApprenticeByEmail = client.findApprenticeByEmail; export const getApprenticeByEmail = client.getApprenticeByEmail; export const listCohorts = client.listCohorts; +export const listTerms = client.listTerms; export const getApprenticesByCohortId = client.getApprenticesByCohortId; export const getApprenticesByIds = client.getApprenticesByIds; @@ -61,7 +63,7 @@ export const getAttendanceForEvent = attendanceClient.getAttendanceForEvent; export const getAttendanceByIds = attendanceClient.getAttendanceByIds; // Attendance statistics -export const getApprenticeAttendanceStats = attendanceClient.getApprenticeAttendanceStats; +export const getApprenticeStats = attendanceClient.getApprenticeStats; 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 index 3597860..d40b9e5 100644 --- a/src/lib/components/ApprenticeAttendanceCard.svelte +++ b/src/lib/components/ApprenticeAttendanceCard.svelte @@ -1,12 +1,12 @@ - + diff --git a/src/lib/components/AttendanceChart.svelte b/src/lib/components/AttendanceChart.svelte new file mode 100644 index 0000000..f6d7813 --- /dev/null +++ b/src/lib/components/AttendanceChart.svelte @@ -0,0 +1,154 @@ + + +
+

{title}

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

No data available for chart

+
+ {:else if data.length === 1} +
+

Not enough data points to show trend

+

Attendance for {data[0].month}: {data[0].percentage.toFixed(1)}%

+
+ {:else} +
+ +
+ {/if} +
diff --git a/src/lib/components/AttendanceFilters.svelte b/src/lib/components/AttendanceFilters.svelte new file mode 100644 index 0000000..f6add07 --- /dev/null +++ b/src/lib/components/AttendanceFilters.svelte @@ -0,0 +1,319 @@ + + +
+ +
+ Filter by: + + + +
+ + + {#if filterMode === 'terms' && terms.length > 0} +
+
+ + {#if termDropdownOpen} +
e.stopPropagation()} + role="listbox" + tabindex="-1" + > +
+ +
+ {#each terms as term (term.id)} + {@const startDate = formatDateShort(term.startingDate)} + {@const endDate = formatDateShort(term.endDate)} + + {/each} +
+ + +
+
+ {/if} +
+
+ {/if} + + + {#if filterMode === 'dateRange'} +
+
+
+ + +
+
+ + +
+
+
+ +
+
+ {/if} +
diff --git a/src/lib/types/attendance.ts b/src/lib/types/attendance.ts index 861fa0c..6331b17 100644 --- a/src/lib/types/attendance.ts +++ b/src/lib/types/attendance.ts @@ -1,7 +1,21 @@ // Attendance status options -export const ATTENDANCE_STATUSES = ['Present', 'Absent', 'Late', 'Excused', 'Not Coming'] as const; +export const ATTENDANCE_STATUSES = ['Present', 'Not Check-in', 'Late', 'Excused', 'Absent'] as const; export type AttendanceStatus = typeof ATTENDANCE_STATUSES[number]; +// Centralized status styling - single source of truth for colors +export const STATUS_STYLES: Record = { + 'Present': { bg: 'bg-green-50', text: 'text-green-600', badge: 'bg-green-100 text-green-700' }, + 'Late': { bg: 'bg-yellow-50', text: 'text-yellow-600', badge: 'bg-yellow-100 text-yellow-700' }, + 'Excused': { bg: 'bg-blue-50', text: 'text-blue-600', badge: 'bg-blue-100 text-blue-700' }, + 'Not Check-in': { bg: 'bg-red-50', text: 'text-red-600', badge: 'bg-red-100 text-red-700' }, + 'Absent': { bg: 'bg-orange-50', text: 'text-orange-600', badge: 'bg-orange-100 text-orange-700' }, +}; + +// Helper to get badge classes for a status +export function getStatusBadgeClass(status: AttendanceStatus): string { + return STATUS_STYLES[status]?.badge ?? 'bg-gray-100 text-gray-700'; +} + export interface Attendance { id: string; eventId: string; @@ -86,6 +100,70 @@ export interface AttendanceHistoryEntry { eventId: string; eventName: string; eventDateTime: string; - status: AttendanceStatus | 'Missed'; // Missed = no attendance record + status: AttendanceStatus; checkinTime: string | null; + attendanceId: string | null; // Null when no attendance record exists (defaults to 'Not Check-in') +} + +/** Monthly attendance data point for charts */ +export interface MonthlyAttendancePoint { + month: string; // Display format: "Jan 2025" + sortKey: string; // Sort format: "2025-01" + percentage: number; // Attendance rate: (Present + Late) / Total + latenessPercentage: number; // Lateness rate: Late / Total + attended: number; + late: number; + total: number; +} + +/** + * Calculate monthly attendance percentages from history entries + * Groups events by month and calculates attendance and lateness rates + */ +export function calculateMonthlyAttendance(history: AttendanceHistoryEntry[]): MonthlyAttendancePoint[] { + if (history.length === 0) return []; + + // Group events by month + const monthlyData = new Map(); + + for (const entry of history) { + const date = new Date(entry.eventDateTime); + const sortKey = `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}`; + + if (!monthlyData.has(sortKey)) { + monthlyData.set(sortKey, { attended: 0, late: 0, total: 0 }); + } + + const data = monthlyData.get(sortKey)!; + data.total++; + + // Present and Late count as attended + if (entry.status === 'Present' || entry.status === 'Late') { + data.attended++; + } + + // Track late separately + if (entry.status === 'Late') { + data.late++; + } + } + + // Convert to array and sort by date + const months = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec']; + + return Array.from(monthlyData.entries()) + .sort(([a], [b]) => a.localeCompare(b)) + .map(([sortKey, data]) => { + const [year, monthNum] = sortKey.split('-'); + const monthName = months[parseInt(monthNum, 10) - 1]; + return { + month: `${monthName} ${year}`, + sortKey, + percentage: data.total > 0 ? (data.attended / data.total) * 100 : 0, + latenessPercentage: data.total > 0 ? (data.late / data.total) * 100 : 0, + attended: data.attended, + late: data.late, + total: data.total, + }; + }); } diff --git a/src/lib/types/filters.ts b/src/lib/types/filters.ts new file mode 100644 index 0000000..e5b3025 --- /dev/null +++ b/src/lib/types/filters.ts @@ -0,0 +1,62 @@ +/** + * Shared filter types for attendance views + */ + +/** Date range filter */ +export interface DateRange { + startDate: Date; + endDate: Date; +} + +/** Attendance filter options - terms and date range are mutually exclusive */ +export interface AttendanceFilters { + termIds?: string[]; + dateRange?: DateRange; +} + +/** URL-serializable version of filters (dates as ISO strings) */ +export interface AttendanceFiltersParams { + terms?: string; // comma-separated term IDs + startDate?: string; // ISO date string + endDate?: string; // ISO date string +} + +/** Parse URL params into AttendanceFilters */ +export function parseFiltersFromParams(params: URLSearchParams): AttendanceFilters { + const termsParam = params.get('terms'); + const startDateParam = params.get('startDate'); + const endDateParam = params.get('endDate'); + + // Date range takes priority (mutually exclusive) + if (startDateParam && endDateParam) { + return { + dateRange: { + startDate: new Date(startDateParam), + endDate: new Date(endDateParam), + }, + }; + } + + if (termsParam) { + return { + termIds: termsParam.split(',').filter(Boolean), + }; + } + + return {}; +} + +/** Serialize AttendanceFilters to URL params */ +export function filtersToParams(filters: AttendanceFilters): URLSearchParams { + const params = new URLSearchParams(); + + if (filters.dateRange) { + params.set('startDate', filters.dateRange.startDate.toISOString().split('T')[0]); + params.set('endDate', filters.dateRange.endDate.toISOString().split('T')[0]); + } + else if (filters.termIds && filters.termIds.length > 0) { + params.set('terms', filters.termIds.join(',')); + } + + return params; +} diff --git a/src/routes/+page.server.ts b/src/routes/+page.server.ts new file mode 100644 index 0000000..d6da00a --- /dev/null +++ b/src/routes/+page.server.ts @@ -0,0 +1,17 @@ +import { redirect } from '@sveltejs/kit'; +import type { PageServerLoad } from './$types'; + +export const load: PageServerLoad = async ({ locals }) => { + if (locals.user) { + // Redirect authenticated users to their landing page + if (locals.user.type === 'staff') { + redirect(303, '/admin'); + } + else { + redirect(303, '/checkin'); + } + } + + // Redirect unauthenticated users to login + redirect(303, '/login'); +}; diff --git a/src/routes/+page.svelte b/src/routes/+page.svelte index 672686e..8284d83 100644 --- a/src/routes/+page.svelte +++ b/src/routes/+page.svelte @@ -1,19 +1,2 @@ - - -

Apprentice Pulse

- -{#if data.user} -

Logged in as: {data.user.email} ({data.user.type})

- -{:else} -

Not logged in

- Login -{/if} + +

Redirecting...

diff --git a/src/routes/admin/+page.svelte b/src/routes/admin/+page.svelte index ec3e8b0..dfc81e3 100644 --- a/src/routes/admin/+page.svelte +++ b/src/routes/admin/+page.svelte @@ -4,26 +4,45 @@
-
- ← Back to Home -

Admin Dashboard

-

Welcome, {data.user?.email}

+
+
+

Admin Dashboard

+

Welcome, {data.user?.email}

+
+
- diff --git a/src/routes/admin/attendance/+page.server.ts b/src/routes/admin/attendance/+page.server.ts new file mode 100644 index 0000000..f436f1f --- /dev/null +++ b/src/routes/admin/attendance/+page.server.ts @@ -0,0 +1,138 @@ +import type { PageServerLoad } from './$types'; +import { + listCohorts, + listTerms, + getApprenticesByCohortId, + getApprenticeStats, + getApprenticeAttendanceHistory, +} from '$lib/airtable/sveltekit-wrapper'; +import type { ApprenticeAttendanceStats, AttendanceHistoryEntry } 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'; + const termsParam = url.searchParams.get('terms'); + const selectedTermIds = termsParam ? termsParam.split(',').filter(Boolean) : []; + const startDateParam = url.searchParams.get('startDate'); + const endDateParam = url.searchParams.get('endDate'); + + try { + // Always fetch cohorts and terms for the selection UI + const [cohorts, terms] = await Promise.all([ + listCohorts(), + listTerms(), + ]); + + // If no cohort selected and not showing all, return early with just cohorts and terms + if (selectedCohortIds.length === 0 && !showAll) { + return { + apprentices: [], + combinedHistory: [], + cohorts, + terms, + selectedCohortIds, + selectedTermIds, + selectedStartDate: startDateParam || '', + selectedEndDate: endDateParam || '', + 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)]; + + // Determine date range for filtering + let filterStartDate: Date | null = null; + let filterEndDate: Date | null = null; + + if (startDateParam && endDateParam) { + // Custom date range takes priority + filterStartDate = new Date(startDateParam); + filterEndDate = new Date(endDateParam); + } + else if (selectedTermIds.length > 0) { + // Fall back to term-based filtering + const selectedTerms = terms.filter(t => selectedTermIds.includes(t.id)); + if (selectedTerms.length > 0) { + // Find earliest start date and latest end date across all selected terms + const startDates = selectedTerms.map(t => new Date(t.startingDate)); + const endDates = selectedTerms.map(t => new Date(t.endDate)); + + filterStartDate = new Date(Math.min(...startDates.map(d => d.getTime()))); + filterEndDate = new Date(Math.max(...endDates.map(d => d.getTime()))); + } + } + + // Fetch attendance stats and history for each apprentice + const apprenticeStats: ApprenticeAttendanceStats[] = []; + const allHistory: AttendanceHistoryEntry[] = []; + const dateOptions = filterStartDate && filterEndDate + ? { startDate: filterStartDate, endDate: filterEndDate } + : undefined; + + for (const apprenticeId of apprenticeIds) { + try { + const [stats, history] = await Promise.all([ + getApprenticeStats(apprenticeId, dateOptions), + getApprenticeAttendanceHistory(apprenticeId, dateOptions), + ]); + if (stats) { + apprenticeStats.push(stats); + } + allHistory.push(...history); + } + catch (err) { + console.error(`[attendance] Error fetching data for ${apprenticeId}:`, err); + } + } + + return { + apprentices: apprenticeStats, + combinedHistory: allHistory, + cohorts, + terms, + selectedCohortIds, + selectedTermIds, + selectedStartDate: startDateParam || '', + selectedEndDate: endDateParam || '', + showAll, + needsSelection: false, + }; + } + catch (err) { + console.error('[attendance] Error loading data:', err); + return { + apprentices: [], + combinedHistory: [], + cohorts: [], + terms: [], + selectedCohortIds, + selectedTermIds, + selectedStartDate: startDateParam || '', + selectedEndDate: endDateParam || '', + showAll: false, + needsSelection: true, + }; + } +}; diff --git a/src/routes/admin/attendance/+page.svelte b/src/routes/admin/attendance/+page.svelte new file mode 100644 index 0000000..f39cc25 --- /dev/null +++ b/src/routes/admin/attendance/+page.svelte @@ -0,0 +1,429 @@ + + +
+
+
+ ← Back to Admin + {#if needsSelection} +

Attendance

+ {:else} +

Cohort Attendance

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

Loading attendance data...

+

This may take a moment

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

Select Cohorts

+
+ + +
+
+ +
+ {#each groupedCohorts as group (group.prefix)} +
+ +
+ {#each group.cohorts as cohort (cohort.id)} + + {/each} +
+
+ {/each} +
+ +
+ {: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')} + AttendedActions
+
{apprentice.apprenticeName}
+
+ {apprentice.cohortName || '—'} + + + {apprentice.attendanceRate.toFixed(0)}% + + {#if isLowAttendance(apprentice.attendanceRate)} + + {/if} + + {apprentice.attended}/{apprentice.totalEvents} + + + View Details + +
+
+ {/if} +
+ + +
+ +
+ {/if} +
diff --git a/src/routes/admin/attendance/[id]/+page.server.ts b/src/routes/admin/attendance/[id]/+page.server.ts new file mode 100644 index 0000000..c648103 --- /dev/null +++ b/src/routes/admin/attendance/[id]/+page.server.ts @@ -0,0 +1,61 @@ +import type { PageServerLoad } from './$types'; +import { error } from '@sveltejs/kit'; +import { + getApprenticeStats, + getApprenticeAttendanceHistory, + listTerms, +} from '$lib/airtable/sveltekit-wrapper'; +import { parseFiltersFromParams } from '$lib/types/filters'; + +export const load: PageServerLoad = async ({ params, url }) => { + const { id } = params; + + // Get cohorts param to preserve for back navigation + const cohortsParam = url.searchParams.get('cohorts') || ''; + + // Parse filters from URL params + const filters = parseFiltersFromParams(url.searchParams); + + // Determine date range for filtering + let dateOptions: { startDate: Date; endDate: Date } | undefined; + + if (filters.dateRange) { + dateOptions = { + startDate: filters.dateRange.startDate, + endDate: filters.dateRange.endDate, + }; + } + else if (filters.termIds && filters.termIds.length > 0) { + // Convert term IDs to date range + const terms = await listTerms(); + const selectedTerms = terms.filter(t => filters.termIds!.includes(t.id)); + if (selectedTerms.length > 0) { + const startDates = selectedTerms.map(t => new Date(t.startingDate)); + const endDates = selectedTerms.map(t => new Date(t.endDate)); + dateOptions = { + startDate: new Date(Math.min(...startDates.map(d => d.getTime()))), + endDate: new Date(Math.max(...endDates.map(d => d.getTime()))), + }; + } + } + + // Fetch apprentice stats with date filter + const stats = await getApprenticeStats(id, dateOptions); + + if (!stats) { + throw error(404, 'Apprentice not found'); + } + + // Fetch attendance history with same date filter + const history = await getApprenticeAttendanceHistory(id, dateOptions); + + // Fetch terms for the filter component + const terms = await listTerms(); + + return { + stats, + history, + terms, + cohortsParam, + }; +}; diff --git a/src/routes/admin/attendance/[id]/+page.svelte b/src/routes/admin/attendance/[id]/+page.svelte new file mode 100644 index 0000000..4bf32c2 --- /dev/null +++ b/src/routes/admin/attendance/[id]/+page.svelte @@ -0,0 +1,347 @@ + + +
+ + {#if isLoading} +
+
+
+

Loading attendance data...

+

This may take a moment

+
+
+ {/if} + +
+
+ + ← Back to Cohort Attendance +

{stats.apprenticeName} - Attendance

+ {#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)} + + {#if editingEntryId === entry.eventId} + + {:else} + + {/if} + + {#if editingEntryId === entry.eventId} +
+ {#if editingStatus === 'Present' || editingStatus === 'Late'} + e.stopPropagation()} + /> + {/if} + + +
+ {:else if entry.checkinTime} + {formatCheckinTime(entry.checkinTime)} + {:else} + + {/if} +
+
+ {/if} +
+ + +
+ +
+
diff --git a/src/routes/admin/attendance/apprentices/+page.server.ts b/src/routes/admin/attendance/apprentices/+page.server.ts deleted file mode 100644 index 2710c01..0000000 --- a/src/routes/admin/attendance/apprentices/+page.server.ts +++ /dev/null @@ -1,83 +0,0 @@ -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 deleted file mode 100644 index a267a02..0000000 --- a/src/routes/admin/attendance/apprentices/+page.svelte +++ /dev/null @@ -1,419 +0,0 @@ - - -
-
- ← 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 deleted file mode 100644 index ffbd7b0..0000000 --- a/src/routes/admin/attendance/apprentices/[id]/+page.server.ts +++ /dev/null @@ -1,25 +0,0 @@ -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 deleted file mode 100644 index ad54e5d..0000000 --- a/src/routes/admin/attendance/apprentices/[id]/+page.svelte +++ /dev/null @@ -1,101 +0,0 @@ - - -
-
- ← 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 49dbb6c..5446853 100644 --- a/src/routes/admin/events/+page.svelte +++ b/src/routes/admin/events/+page.svelte @@ -5,7 +5,7 @@ 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 { ATTENDANCE_STATUSES } from '$lib/types/attendance'; + import { ATTENDANCE_STATUSES, getStatusBadgeClass, type AttendanceStatus } from '$lib/types/attendance'; import { Calendar, DayGrid, Interaction } from '@event-calendar/core'; import '@event-calendar/core/index.css'; import DatePicker from '$lib/components/DatePicker.svelte'; @@ -368,7 +368,6 @@ }); // Expandable row state - 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) @@ -379,13 +378,6 @@ checkinTime?: string; } - 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', - 'Not Coming': 'bg-orange-100 text-orange-700', - }; let expandedEventId = $state(null); let expandedEventDateTime = $state(null); let rosterData = $state([]); @@ -509,10 +501,10 @@ } const hasExistingRecord = !!person.attendanceId; - const needsRecord = editingStatus !== 'Absent'; // Excused still needs a record for tracking + const needsRecord = editingStatus !== 'Not Check-in'; // Excused still needs a record for tracking const countsAsAttendance = editingStatus === 'Present' || editingStatus === 'Late'; - // Case: No record and changing to Absent - just update local UI + // Case: No record and changing to Not Check-in - just update local UI if (!hasExistingRecord && !needsRecord) { rosterData = rosterData.map(p => p.id === person.id ? { ...p, status: editingStatus, checkinTime: undefined } : p, @@ -1002,10 +994,10 @@ {#if events.length === 0}

No events found.

{:else} -
+
- + {person.status} @@ -1980,7 +1972,7 @@ {/if} -
+
diff --git a/src/routes/admin/login/+page.svelte b/src/routes/admin/login/+page.svelte deleted file mode 100644 index efaa7fb..0000000 --- a/src/routes/admin/login/+page.svelte +++ /dev/null @@ -1,152 +0,0 @@ - - - - Staff Login - Apprentice Pulse - - -
-

Staff Login

- - {#if status === 'success'} -
-

{message}

-

The link will expire in 15 minutes.

-
- {:else} -
- - - - - - {#if status === 'error'} -

{message}

- {/if} - - -

- Enter your staff email to access the admin dashboard. -

- {/if} -
- - diff --git a/src/routes/api/attendance/+server.ts b/src/routes/api/attendance/+server.ts index 7b87efe..931810f 100644 --- a/src/routes/api/attendance/+server.ts +++ b/src/routes/api/attendance/+server.ts @@ -38,15 +38,20 @@ export const POST: RequestHandler = async ({ cookies, request }) => { } try { + console.log('POST /api/attendance:', { eventId, apprenticeId, status, checkinTime }); + // First create the attendance record (auto-determines Present/Late) let attendance = await createAttendance({ eventId, apprenticeId }); + console.log('Created attendance:', attendance); // If a specific status was requested and it differs, update it if (status && status !== attendance.status) { + console.log('Updating status from', attendance.status, 'to', status); attendance = await updateAttendance(attendance.id, { status, checkinTime: checkinTime ?? attendance.checkinTime, }); + console.log('Updated attendance:', attendance); } return json({ diff --git a/src/routes/api/auth/login/+server.ts b/src/routes/api/auth/login/+server.ts new file mode 100644 index 0000000..12a8f35 --- /dev/null +++ b/src/routes/api/auth/login/+server.ts @@ -0,0 +1,44 @@ +import { json } from '@sveltejs/kit'; +import type { RequestHandler } from './$types'; +import { findStaffByEmail, findApprenticeByEmail } from '$lib/airtable/sveltekit-wrapper'; +import { generateMagicToken } from '$lib/server/auth'; +import { sendMagicLinkEmail } from '$lib/server/email'; + +export const POST: RequestHandler = async ({ request, url }) => { + const { email } = await request.json(); + + if (!email) { + return json({ error: 'Email is required' }, { status: 400 }); + } + + // Check staff first (higher privilege) + const isStaff = await findStaffByEmail(email); + if (isStaff) { + const token = generateMagicToken(email, 'staff'); + const verifyUrl = new URL('/api/auth/verify', url.origin); + verifyUrl.searchParams.set('token', token); + + const result = await sendMagicLinkEmail(email, verifyUrl.toString(), 'staff'); + if (!result.success) { + return json({ error: 'Failed to send email. Please try again.' }, { status: 500 }); + } + return json({ message: 'Magic link sent! Check your email.' }); + } + + // Check apprentice + const isApprentice = await findApprenticeByEmail(email); + if (isApprentice) { + const token = generateMagicToken(email, 'student'); + const verifyUrl = new URL('/api/auth/verify', url.origin); + verifyUrl.searchParams.set('token', token); + + const result = await sendMagicLinkEmail(email, verifyUrl.toString(), 'student'); + if (!result.success) { + return json({ error: 'Failed to send email. Please try again.' }, { status: 500 }); + } + return json({ message: 'Magic link sent! Check your email.' }); + } + + // Email not found in either table + return json({ error: 'Email not found' }, { status: 404 }); +}; diff --git a/src/routes/api/auth/staff/login/+server.ts b/src/routes/api/auth/staff/login/+server.ts deleted file mode 100644 index ff5289f..0000000 --- a/src/routes/api/auth/staff/login/+server.ts +++ /dev/null @@ -1,33 +0,0 @@ -import { json } from '@sveltejs/kit'; -import type { RequestHandler } from './$types'; -import { findStaffByEmail } from '$lib/airtable/sveltekit-wrapper'; -import { generateMagicToken } from '$lib/server/auth'; -import { sendMagicLinkEmail } from '$lib/server/email'; - -export const POST: RequestHandler = async ({ request, url }) => { - const { email } = await request.json(); - - if (!email) { - return json({ error: 'Email is required' }, { status: 400 }); - } - - const isStaff = await findStaffByEmail(email); - - if (!isStaff) { - return json({ error: 'Email not found in staff directory' }, { status: 401 }); - } - - const token = generateMagicToken(email, 'staff'); - - // Build magic link URL - const verifyUrl = new URL('/api/auth/verify', url.origin); - verifyUrl.searchParams.set('token', token); - - const result = await sendMagicLinkEmail(email, verifyUrl.toString(), 'staff'); - - if (!result.success) { - return json({ error: 'Failed to send email. Please try again.' }, { status: 500 }); - } - - return json({ message: 'Magic link sent! Check your email.' }); -}; diff --git a/src/routes/api/auth/staff/login/server.spec.ts b/src/routes/api/auth/staff/login/server.spec.ts deleted file mode 100644 index de1d04c..0000000 --- a/src/routes/api/auth/staff/login/server.spec.ts +++ /dev/null @@ -1,70 +0,0 @@ -import { describe, it, expect, vi, beforeEach } from 'vitest'; -import { POST } from './+server'; - -// Mock dependencies -vi.mock('$lib/airtable/sveltekit-wrapper', () => ({ - findStaffByEmail: vi.fn(), -})); - -vi.mock('$lib/server/auth', () => ({ - generateMagicToken: vi.fn(() => 'mock-token'), -})); - -vi.mock('$lib/server/email', () => ({ - sendMagicLinkEmail: vi.fn(() => Promise.resolve({ success: true })), -})); - -import { findStaffByEmail } from '$lib/airtable/sveltekit-wrapper'; -import { generateMagicToken } from '$lib/server/auth'; - -const mockFindStaffByEmail = vi.mocked(findStaffByEmail); -const mockGenerateMagicToken = vi.mocked(generateMagicToken); - -function createMockRequest(body: Record) { - return { - json: vi.fn().mockResolvedValue(body), - } as unknown as Request; -} - -function createMockUrl() { - return new URL('http://localhost:5173/api/auth/staff/login'); -} - -describe('/api/auth/staff/login', () => { - beforeEach(() => { - vi.clearAllMocks(); - }); - - it('should return 400 if email is missing', async () => { - const request = createMockRequest({}); - const response = await POST({ request, url: createMockUrl() } as never); - - expect(response.status).toBe(400); - const data = await response.json(); - expect(data.error).toBe('Email is required'); - }); - - it('should return 401 if email is not found in staff directory', async () => { - mockFindStaffByEmail.mockResolvedValue(false); - - const request = createMockRequest({ email: 'unknown@example.com' }); - const response = await POST({ request, url: createMockUrl() } as never); - - expect(response.status).toBe(401); - const data = await response.json(); - expect(data.error).toBe('Email not found in staff directory'); - expect(mockFindStaffByEmail).toHaveBeenCalledWith('unknown@example.com'); - }); - - it('should generate magic token for valid staff email', async () => { - mockFindStaffByEmail.mockResolvedValue(true); - - const request = createMockRequest({ email: 'staff@example.com' }); - const response = await POST({ request, url: createMockUrl() } as never); - - expect(response.status).toBe(200); - const data = await response.json(); - expect(data.message).toBe('Magic link sent! Check your email.'); - expect(mockGenerateMagicToken).toHaveBeenCalledWith('staff@example.com', 'staff'); - }); -}); diff --git a/src/routes/api/auth/student/login/+server.ts b/src/routes/api/auth/student/login/+server.ts deleted file mode 100644 index 46b46d6..0000000 --- a/src/routes/api/auth/student/login/+server.ts +++ /dev/null @@ -1,33 +0,0 @@ -import { json } from '@sveltejs/kit'; -import type { RequestHandler } from './$types'; -import { findApprenticeByEmail } from '$lib/airtable/sveltekit-wrapper'; -import { generateMagicToken } from '$lib/server/auth'; -import { sendMagicLinkEmail } from '$lib/server/email'; - -export const POST: RequestHandler = async ({ request, url }) => { - const { email } = await request.json(); - - if (!email) { - return json({ error: 'Email is required' }, { status: 400 }); - } - - const isApprentice = await findApprenticeByEmail(email); - - if (!isApprentice) { - return json({ error: 'Email not found' }, { status: 401 }); - } - - const token = generateMagicToken(email, 'student'); - - // Build magic link URL - const verifyUrl = new URL('/api/auth/verify', url.origin); - verifyUrl.searchParams.set('token', token); - - const result = await sendMagicLinkEmail(email, verifyUrl.toString(), 'student'); - - if (!result.success) { - return json({ error: 'Failed to send email. Please try again.' }, { status: 500 }); - } - - return json({ message: 'Magic link sent! Check your email.' }); -}; diff --git a/src/routes/api/auth/student/login/server.spec.ts b/src/routes/api/auth/student/login/server.spec.ts deleted file mode 100644 index b6a1603..0000000 --- a/src/routes/api/auth/student/login/server.spec.ts +++ /dev/null @@ -1,70 +0,0 @@ -import { describe, it, expect, vi, beforeEach } from 'vitest'; -import { POST } from './+server'; - -// Mock dependencies -vi.mock('$lib/airtable/sveltekit-wrapper', () => ({ - findApprenticeByEmail: vi.fn(), -})); - -vi.mock('$lib/server/auth', () => ({ - generateMagicToken: vi.fn(() => 'mock-token'), -})); - -vi.mock('$lib/server/email', () => ({ - sendMagicLinkEmail: vi.fn(() => Promise.resolve({ success: true })), -})); - -import { findApprenticeByEmail } from '$lib/airtable/sveltekit-wrapper'; -import { generateMagicToken } from '$lib/server/auth'; - -const mockFindApprenticeByEmail = vi.mocked(findApprenticeByEmail); -const mockGenerateMagicToken = vi.mocked(generateMagicToken); - -function createMockRequest(body: Record) { - return { - json: vi.fn().mockResolvedValue(body), - } as unknown as Request; -} - -function createMockUrl() { - return new URL('http://localhost:5173/api/auth/student/login'); -} - -describe('/api/auth/student/login', () => { - beforeEach(() => { - vi.clearAllMocks(); - }); - - it('should return 400 if email is missing', async () => { - const request = createMockRequest({}); - const response = await POST({ request, url: createMockUrl() } as never); - - expect(response.status).toBe(400); - const data = await response.json(); - expect(data.error).toBe('Email is required'); - }); - - it('should return 401 if email is not found in apprentices', async () => { - mockFindApprenticeByEmail.mockResolvedValue(false); - - const request = createMockRequest({ email: 'unknown@example.com' }); - const response = await POST({ request, url: createMockUrl() } as never); - - expect(response.status).toBe(401); - const data = await response.json(); - expect(data.error).toBe('Email not found'); - expect(mockFindApprenticeByEmail).toHaveBeenCalledWith('unknown@example.com'); - }); - - it('should generate magic token for valid apprentice email', async () => { - mockFindApprenticeByEmail.mockResolvedValue(true); - - const request = createMockRequest({ email: 'student@example.com' }); - const response = await POST({ request, url: createMockUrl() } as never); - - expect(response.status).toBe(200); - const data = await response.json(); - expect(data.message).toBe('Magic link sent! Check your email.'); - expect(mockGenerateMagicToken).toHaveBeenCalledWith('student@example.com', 'student'); - }); -}); diff --git a/src/routes/api/auth/verify/+server.ts b/src/routes/api/auth/verify/+server.ts index 1884e90..ff6e499 100644 --- a/src/routes/api/auth/verify/+server.ts +++ b/src/routes/api/auth/verify/+server.ts @@ -28,5 +28,5 @@ export const GET: RequestHandler = async ({ url, cookies }) => { redirect(303, redirectTo); } - redirect(303, payload.type === 'staff' ? '/admin' : '/'); + redirect(303, payload.type === 'staff' ? '/admin' : '/checkin'); }; diff --git a/src/routes/api/checkin/+server.ts b/src/routes/api/checkin/+server.ts index e54bea0..f58ef38 100644 --- a/src/routes/api/checkin/+server.ts +++ b/src/routes/api/checkin/+server.ts @@ -3,6 +3,7 @@ import type { RequestHandler } from './$types'; import { getSession } from '$lib/server/session'; import { getApprenticeByEmail, + getStaffByEmail, createAttendance, createExternalAttendance, getUserAttendanceForEvent, @@ -46,15 +47,24 @@ export const POST: RequestHandler = async ({ cookies, request }) => { try { // Check if user has an apprentice record - const apprentice = await getApprenticeByEmail(session.email); + // First try direct email lookup + let apprentice = await getApprenticeByEmail(session.email); + + // If not found, check if user is staff with linked learner email + if (!apprentice) { + const staff = await getStaffByEmail(session.email); + if (staff?.learnerEmail) { + apprentice = await getApprenticeByEmail(staff.learnerEmail); + } + } if (apprentice) { // Apprentice flow: check in using apprentice ID const existingAttendance = await getUserAttendanceForEvent(eventId, apprentice.id); if (existingAttendance) { - // Handle "Not Coming" → "Check In" transition - if (existingAttendance.status === 'Not Coming') { + // Handle "Absent" → "Check In" transition + if (existingAttendance.status === 'Absent') { const event = await getEvent(eventId); const status = determineStatus(event?.dateTime ?? null); diff --git a/src/routes/api/checkin/not-coming/+server.ts b/src/routes/api/checkin/absent/+server.ts similarity index 65% rename from src/routes/api/checkin/not-coming/+server.ts rename to src/routes/api/checkin/absent/+server.ts index 9fa57df..a3109e5 100644 --- a/src/routes/api/checkin/not-coming/+server.ts +++ b/src/routes/api/checkin/absent/+server.ts @@ -1,7 +1,7 @@ import { json } from '@sveltejs/kit'; import type { RequestHandler } from './$types'; import { getSession } from '$lib/server/session'; -import { getApprenticeByEmail, markNotComing } from '$lib/airtable/sveltekit-wrapper'; +import { getApprenticeByEmail, getStaffByEmail, markNotComing } from '$lib/airtable/sveltekit-wrapper'; export const POST: RequestHandler = async ({ cookies, request }) => { const session = getSession(cookies); @@ -25,13 +25,22 @@ export const POST: RequestHandler = async ({ cookies, request }) => { } try { - // Only registered apprentices can mark "Not Coming" - const apprentice = await getApprenticeByEmail(session.email); + // Only registered apprentices can mark "Absent" + // First try direct email lookup + let apprentice = await getApprenticeByEmail(session.email); + + // If not found, check if user is staff with linked learner email + if (!apprentice) { + const staff = await getStaffByEmail(session.email); + if (staff?.learnerEmail) { + apprentice = await getApprenticeByEmail(staff.learnerEmail); + } + } if (!apprentice) { return json({ success: false, - error: 'Only registered apprentices can mark as not coming', + error: 'Only registered apprentices can mark as absent', }, { status: 403 }); } @@ -51,8 +60,8 @@ export const POST: RequestHandler = async ({ cookies, request }) => { }); } catch (error) { - console.error('Failed to mark as not coming:', error); - const message = error instanceof Error ? error.message : 'Failed to mark as not coming'; + console.error('Failed to mark as absent:', error); + const message = error instanceof Error ? error.message : 'Failed to mark as absent'; return json({ success: false, error: message }, { status: 500 }); } }; diff --git a/src/routes/api/events/[id]/roster/+server.ts b/src/routes/api/events/[id]/roster/+server.ts index e08e7e4..d142d3a 100644 --- a/src/routes/api/events/[id]/roster/+server.ts +++ b/src/routes/api/events/[id]/roster/+server.ts @@ -54,7 +54,7 @@ export const GET: RequestHandler = async ({ params }) => { name: apprentice.name, email: apprentice.email, type: 'apprentice', - status: attendanceInfo?.status ?? 'Absent', + status: attendanceInfo?.status ?? 'Not Check-in', checkinTime: attendanceInfo?.checkinTime, }); addedApprenticeIds.add(apprentice.id); @@ -101,8 +101,8 @@ export const GET: RequestHandler = async ({ params }) => { } } - // 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 }; + // Sort: Present first, then Late, Excused, Absent, Not Check-in last; then alphabetically + const statusOrder: Record = { 'Present': 0, 'Late': 1, 'Excused': 2, 'Absent': 3, 'Not Check-in': 4 }; roster.sort((a, b) => { const statusDiff = statusOrder[a.status] - statusOrder[b.status]; if (statusDiff !== 0) return statusDiff; @@ -117,7 +117,7 @@ export const GET: RequestHandler = async ({ params }) => { present: roster.filter(r => r.status === 'Present').length, late: roster.filter(r => r.status === 'Late').length, excused: roster.filter(r => r.status === 'Excused').length, - absent: roster.filter(r => r.status === 'Absent').length, + absent: roster.filter(r => r.status === 'Not Check-in').length, }, }); } diff --git a/src/routes/checkin/+page.server.ts b/src/routes/checkin/+page.server.ts index 49e6a71..5265781 100644 --- a/src/routes/checkin/+page.server.ts +++ b/src/routes/checkin/+page.server.ts @@ -1,7 +1,7 @@ import type { PageServerLoad } from './$types'; -import { getApprenticeByEmail, listEvents, listCohorts, getUserAttendanceForEvent, hasExternalCheckedIn } from '$lib/airtable/sveltekit-wrapper'; +import { getApprenticeByEmail, getStaffByEmail, listEvents, listCohorts, getUserAttendanceForEvent, hasExternalCheckedIn } from '$lib/airtable/sveltekit-wrapper'; -export type AttendanceStatusUI = 'none' | 'checked-in' | 'not-coming'; +export type AttendanceStatusUI = 'none' | 'checked-in' | 'absent'; export interface CheckinEvent { id: string; @@ -28,7 +28,16 @@ export const load: PageServerLoad = async ({ locals }) => { } // Authenticated - fetch events based on apprentice record - const apprentice = await getApprenticeByEmail(user.email); + // First try direct email lookup + let apprentice = await getApprenticeByEmail(user.email); + + // If not found and user is staff, check for linked learner email + if (!apprentice && user.type === 'staff') { + const staff = await getStaffByEmail(user.email); + if (staff?.learnerEmail) { + apprentice = await getApprenticeByEmail(staff.learnerEmail); + } + } // Get events and cohorts const now = new Date(); @@ -61,7 +70,7 @@ export const load: PageServerLoad = async ({ locals }) => { if (apprentice) { const attendance = await getUserAttendanceForEvent(event.id, apprentice.id); if (attendance) { - attendanceStatus = attendance.status === 'Not Coming' ? 'not-coming' : 'checked-in'; + attendanceStatus = attendance.status === 'Absent' ? 'absent' : 'checked-in'; } } else { @@ -100,6 +109,7 @@ export const load: PageServerLoad = async ({ locals }) => { user: { name: apprentice?.name || null, email: user.email, + type: user.type, }, }; }; diff --git a/src/routes/checkin/+page.svelte b/src/routes/checkin/+page.svelte index 3f3354b..2192d50 100644 --- a/src/routes/checkin/+page.svelte +++ b/src/routes/checkin/+page.svelte @@ -1,5 +1,6 @@ - Student Login - Apprentice Pulse + Login - Apprentice Pulse
-

Student Login

+

Login

{#if status === 'success'}
@@ -71,7 +71,7 @@

- Enter your apprentice email to check in to sessions. + Enter your email to sign in.

{/if}
diff --git a/src/routes/page.svelte.spec.ts b/src/routes/page.svelte.spec.ts index 61d3c47..c5f14d9 100644 --- a/src/routes/page.svelte.spec.ts +++ b/src/routes/page.svelte.spec.ts @@ -4,10 +4,10 @@ import { render } from 'vitest-browser-svelte'; import Page from './+page.svelte'; describe('/+page.svelte', () => { - it('should render h1', async () => { - render(Page, { data: { user: null } }); + it('should show redirecting message', async () => { + render(Page); - const heading = page.getByRole('heading', { level: 1 }); - await expect.element(heading).toBeInTheDocument(); + const text = page.getByText('Redirecting...'); + await expect.element(text).toBeInTheDocument(); }); });