From 799eba7306df9adf7e238ad80183003008626b8e Mon Sep 17 00:00:00 2001 From: Dmitri Date: Fri, 12 Jun 2026 10:56:13 +0200 Subject: [PATCH] fixed dashboard functionality --- backend/docs/campuses.md | 4 +- backend/docs/database-schema.md | 7 +- backend/docs/frame-entries.md | 18 ++- backend/docs/index.md | 1 + backend/docs/migrations-and-seeders.md | 5 +- backend/docs/test-coverage.md | 3 + backend/docs/zone-checkin.md | 62 +++++++++ .../controllers/zone_checkins.controller.ts | 22 +++ backend/src/db/api/campuses.ts | 7 +- .../20260611070000-campuses-timezone.ts | 47 +++++++ ...20260612000000-frame-entries-week-label.ts | 34 +++++ backend/src/db/models/campuses.ts | 13 ++ backend/src/db/models/frame_entries.ts | 5 + .../db/seeders/20200430130760-user-roles.ts | 4 +- backend/src/index.ts | 2 + backend/src/routes/zone_checkins.ts | 47 +++++++ backend/src/services/frame_entries.ts | 23 +++- backend/src/services/zone-checkin.ts | 128 ++++++++++++++++++ backend/src/shared/constants/campuses.ts | 6 + .../shared/constants/product-permissions.ts | 2 + backend/src/shared/constants/timezone.test.ts | 35 +++++ backend/src/shared/constants/timezone.ts | 30 ++++ backend/src/shared/constants/week.test.ts | 20 +++ backend/src/shared/constants/week.ts | 38 ++++++ .../src/shared/constants/zone-checkin.test.ts | 20 +++ backend/src/shared/constants/zone-checkin.ts | 19 +++ frontend/docs/content-catalog-integration.md | 4 + frontend/docs/dashboard-integration.md | 5 +- frontend/docs/frame-integration.md | 2 + frontend/docs/index.md | 1 + frontend/docs/safety-quiz-integration.md | 1 + frontend/docs/shared-app-types.md | 2 +- frontend/docs/test-coverage.md | 10 +- frontend/docs/top-bar-integration.md | 8 ++ frontend/docs/user-progress-integration.md | 13 +- frontend/docs/zone-checkin-integration.md | 61 +++++++++ .../docs/zones-of-regulation-integration.md | 2 +- frontend/src/business/app-shell/hooks.ts | 1 + .../src/business/content-catalog/hooks.ts | 2 + frontend/src/business/dashboard/hooks.ts | 16 ++- frontend/src/business/dashboard/types.ts | 2 + .../director-dashboard/selectors.test.ts | 4 +- frontend/src/business/frame/hooks.ts | 12 +- frontend/src/business/frame/mappers.test.ts | 76 +++++++---- frontend/src/business/frame/mappers.ts | 9 +- frontend/src/business/frame/types.ts | 18 ++- .../src/business/safety-quiz/selectors.ts | 9 +- frontend/src/business/top-bar/hooks.ts | 87 +++++++++++- frontend/src/business/top-bar/search.test.ts | 43 ++++++ frontend/src/business/top-bar/search.ts | 94 +++++++++++++ .../src/business/top-bar/selectors.test.ts | 11 ++ frontend/src/business/top-bar/selectors.ts | 26 ++++ frontend/src/business/top-bar/types.ts | 7 + frontend/src/business/user-progress/hooks.ts | 53 +------- .../business/user-progress/mappers.test.ts | 14 +- .../src/business/user-progress/mappers.ts | 13 -- frontend/src/business/user-progress/types.ts | 11 -- frontend/src/business/zone-checkin/hooks.ts | 61 +++++++++ .../business/zone-checkin/selectors.test.ts | 28 ++++ .../src/business/zone-checkin/selectors.ts | 24 ++++ .../components/dashboard/DashboardHero.tsx | 19 +-- .../components/dashboard/DashboardView.tsx | 21 +-- .../src/components/frame/FrameEntryCard.tsx | 9 +- .../components/frame/FrameEntryEditForm.tsx | 7 + .../src/components/frame/FrameEntryForm.tsx | 18 +-- .../src/components/frame/FrameWeekPicker.tsx | 88 ++++++++++++ .../frameworks/ZonesOfRegulation.tsx | 15 +- .../top-bar/TopBarNotifications.tsx | 46 +++++-- .../components/top-bar/TopBarProfileMenu.tsx | 14 +- .../src/components/top-bar/TopBarSearch.tsx | 84 +++++++++++- .../src/components/top-bar/TopBarView.tsx | 7 +- .../ZoneCheckInCard.tsx} | 25 +++- .../zone-checkin/ZoneCheckInReminder.tsx | 25 ++++ .../zone-checkin/ZoneCheckInSection.tsx | 45 ++++++ frontend/src/hooks/useOnClickOutside.test.tsx | 56 ++++++++ frontend/src/hooks/useOnClickOutside.ts | 35 +++++ .../pages/modules/ZonesOfRegulationPage.tsx | 5 +- frontend/src/shared/api/zoneCheckins.test.ts | 77 +++++++++++ frontend/src/shared/api/zoneCheckins.ts | 34 +++++ frontend/src/shared/auth/permissions.ts | 2 +- frontend/src/shared/business/week.test.ts | 27 ++++ frontend/src/shared/business/week.ts | 31 +++++ frontend/src/shared/constants/userProgress.ts | 4 - frontend/src/shared/types/frame.ts | 2 + frontend/src/shared/types/zoneCheckins.ts | 17 +++ .../tests/e2e/content-catalog.seeded.e2e.ts | 2 +- .../tests/e2e/product-workflow.seeded.e2e.ts | 7 +- .../tests/e2e/zone-checkins.seeded.e2e.ts | 88 ++++++++++++ 88 files changed, 1905 insertions(+), 237 deletions(-) create mode 100644 backend/docs/zone-checkin.md create mode 100644 backend/src/api/controllers/zone_checkins.controller.ts create mode 100644 backend/src/db/migrations/20260611070000-campuses-timezone.ts create mode 100644 backend/src/db/migrations/20260612000000-frame-entries-week-label.ts create mode 100644 backend/src/routes/zone_checkins.ts create mode 100644 backend/src/services/zone-checkin.ts create mode 100644 backend/src/shared/constants/timezone.test.ts create mode 100644 backend/src/shared/constants/timezone.ts create mode 100644 backend/src/shared/constants/week.test.ts create mode 100644 backend/src/shared/constants/week.ts create mode 100644 backend/src/shared/constants/zone-checkin.test.ts create mode 100644 backend/src/shared/constants/zone-checkin.ts create mode 100644 frontend/docs/zone-checkin-integration.md create mode 100644 frontend/src/business/top-bar/search.test.ts create mode 100644 frontend/src/business/top-bar/search.ts create mode 100644 frontend/src/business/zone-checkin/hooks.ts create mode 100644 frontend/src/business/zone-checkin/selectors.test.ts create mode 100644 frontend/src/business/zone-checkin/selectors.ts create mode 100644 frontend/src/components/frame/FrameWeekPicker.tsx rename frontend/src/components/{dashboard/DashboardZoneCheckIn.tsx => zone-checkin/ZoneCheckInCard.tsx} (70%) create mode 100644 frontend/src/components/zone-checkin/ZoneCheckInReminder.tsx create mode 100644 frontend/src/components/zone-checkin/ZoneCheckInSection.tsx create mode 100644 frontend/src/hooks/useOnClickOutside.test.tsx create mode 100644 frontend/src/hooks/useOnClickOutside.ts create mode 100644 frontend/src/shared/api/zoneCheckins.test.ts create mode 100644 frontend/src/shared/api/zoneCheckins.ts create mode 100644 frontend/src/shared/business/week.test.ts create mode 100644 frontend/src/shared/business/week.ts create mode 100644 frontend/src/shared/types/zoneCheckins.ts create mode 100644 frontend/tests/e2e/zone-checkins.seeded.e2e.ts diff --git a/backend/docs/campuses.md b/backend/docs/campuses.md index 35831e9..742dde2 100644 --- a/backend/docs/campuses.md +++ b/backend/docs/campuses.md @@ -65,7 +65,9 @@ all `200`) — see `backend-architecture.md` for the shared contract: Model columns (`paranoid`, soft-delete via `deletedAt`, `freezeTableName`): -- `id` (UUID PK), `name` (TEXT, not null), `code` (TEXT, not null), `address`, `phone`, `email`, +- `id` (UUID PK), `name` (TEXT, not null), `code` (TEXT, not null), `timezone` (TEXT, **not null** + — a validated IANA zone, e.g. `America/Phoenix`; used by the daily zone check-in to compute the + campus-local "today" server-side, see [`zone-checkin.md`](zone-checkin.md)), `address`, `phone`, `email`, `mascot`, `color`, `bgGradient`, `borderColor`, `textColor`, `bgLight`, `description` (all TEXT, nullable), `isOnline` (BOOLEAN, not null, default `false`), `active` (BOOLEAN, not null, default `false`), `importHash` (STRING(255), unique, nullable), `organizationId` (UUID, nullable), diff --git a/backend/docs/database-schema.md b/backend/docs/database-schema.md index a9a105b..4273db3 100644 --- a/backend/docs/database-schema.md +++ b/backend/docs/database-schema.md @@ -1,7 +1,7 @@ # Database Schema > Generated from the Sequelize models (`backend/src/db/models/*`) — the source of truth. -> Regenerate after schema changes. Last generated: 2026-06-11. +> Regenerate after schema changes. Last generated: 2026-06-12. ## Overview @@ -158,6 +158,7 @@ Authentication identities. `email` is required (login + primary contact). Belong | `id` | uuid | no | UUIDV4 | PK | | `firstName` | text | yes | — | | | `lastName` | text | yes | — | | +| `name_prefix` | enum | yes | — | honorific (`mr`/`mrs`/`ms`/`mx`/`dr`/`prof`) | | `phoneNumber` | text | yes | — | | | `email` | text | no | — | | | `disabled` | boolean | no | false | | @@ -238,6 +239,7 @@ A physical or online campus belonging to one organization. Parent of students, s | `id` | uuid | no | UUIDV4 | PK | | `name` | text | yes | — | | | `code` | text | yes | — | | +| `timezone` | text | no | — | validated IANA zone (zone check-in "today") | | `address` | text | yes | — | | | `phone` | text | yes | — | | | `email` | text | yes | — | | @@ -820,7 +822,8 @@ Product-module "frame" entries. | Column | Type | Null | Default | Notes | |---|---|---|---|---| | `id` | uuid | no | UUIDV4 | PK | -| `week_of` | text | no | — | | +| `week_of` | text | no | — | canonical Sunday-start ISO date (American week) | +| `week_label` | text | yes | — | optional free-text label for the week | | `posted_date` | text | no | — | | | `formal` | text | no | — | | | `recognition` | text | no | — | | diff --git a/backend/docs/frame-entries.md b/backend/docs/frame-entries.md index 67e74f5..0722844 100644 --- a/backend/docs/frame-entries.md +++ b/backend/docs/frame-entries.md @@ -49,17 +49,29 @@ Request body for create/update is wrapped as `{ data: }`. ## Data Contract Required request fields (`REQUIRED_FIELDS`): `week_of`, `posted_date`, `formal`, `recognition`, -`application`, `management`, `emotional`, `author`. Optional: `campusId`. Missing/invalid input -raises `ValidationError`. +`application`, `management`, `emotional`, `author`. Optional: `week_label`, `campusId`. +Missing/invalid input raises `ValidationError`. + +`week_of` is the **week the entry covers**, sent as a `YYYY-MM-DD` date and stored as the +**canonical Sunday-start ISO date** (American week) — the server normalizes it via +`toWeekStartIso` (`shared/constants/week.ts`) and rejects non-dates. `week_label` is an optional +free-text note for that week (e.g. "Spring Break week"), stored trimmed or `null`. ## Behavior / Notes - Create/update run inside `withTransaction`. - List is paginated with the shared defaults (`resolvePagination`). +- The same Sunday-start canonicalization is used on the frontend + (`shared/business/week.ts`) for the dashboard hero, the safety-quiz week, and the + F.R.A.M.E. week picker, so the week is consistent everywhere. ## Tests -None yet (no `frame_entries` unit/e2e test in `src/`). +- **Unit** (`npm test`): `shared/constants/week.test.ts` — the Sunday-start + `week_of` normalization (`toWeekStartIso`) + invalid-date rejection. +- **Seeded e2e** (`frontend/tests/e2e/product-workflow.seeded.e2e.ts`, + `npm run test:e2e:content`): a director posts a FRAME entry and reads it back, + asserting the persisted `week_of` is normalized to its Sunday week-start. ## Related diff --git a/backend/docs/index.md b/backend/docs/index.md index 4ca9ab0..5dea5e2 100644 --- a/backend/docs/index.md +++ b/backend/docs/index.md @@ -46,6 +46,7 @@ Tenant Scope / Data Contract / Behavior / Tests / Related). - [`staff-attendance.md`](staff-attendance.md) - [`user-progress.md`](user-progress.md) - [`walkthrough-checkins.md`](walkthrough-checkins.md) +- [`zone-checkin.md`](zone-checkin.md): daily staff self-regulation check-in (campus-timezone "today" + nudge). ## Generic CRUD Entity Slices diff --git a/backend/docs/migrations-and-seeders.md b/backend/docs/migrations-and-seeders.md index 783254b..9ffb1e2 100644 --- a/backend/docs/migrations-and-seeders.md +++ b/backend/docs/migrations-and-seeders.md @@ -20,7 +20,10 @@ the runner, file conventions, and how to add a migration or seeder. For VM/PM2 o `20260611000000-policy-documents-and-acknowledgments.ts` (the unified policy store + per-version acknowledgments), `20260611010000-audio-files.ts` (the audio library) + `20260611060000-audio-files-kinds.ts` (the `kind` enum / nullable `url` / `recipe` JSONB), and - `20260611040000-add-user-name-prefix.ts` (the `users.name_prefix` honorific enum). + `20260611040000-add-user-name-prefix.ts` (the `users.name_prefix` honorific enum), and + `20260611070000-campuses-timezone.ts` (the required `campuses.timezone` IANA column — added + nullable, backfilled, then set NOT NULL), and `20260612000000-frame-entries-week-label.ts` + (the optional `frame_entries.week_label`; `week_of` is now the canonical Sunday-start ISO date). - Seeders: `src/db/seeders/*.ts` — `admin-user` (the 10 per-role RBAC fixture users), `user-roles` (the 11 first-class roles, the permission catalog incl. product-feature permissions, the role->permission matrix, role assignment by user id), `product-campuses`, diff --git a/backend/docs/test-coverage.md b/backend/docs/test-coverage.md index e169385..affcce7 100644 --- a/backend/docs/test-coverage.md +++ b/backend/docs/test-coverage.md @@ -103,6 +103,9 @@ const req = createMockRequest({ | File | Description | Tests | |------|-------------|-------| | `shared/constants/audio-files.test.ts` | `file`/`url`/`recipe` kinds + `isAudioFileKind` | ~2 | +| `shared/constants/timezone.test.ts` | campus-local date (Phoenix no-DST + DST) + `isValidIanaTimezone` | ~3 | +| `shared/constants/zone-checkin.test.ts` | zone colors + `isZoneCheckinColor` | ~2 | +| `shared/constants/week.test.ts` | Sunday-start week normalization (`toWeekStartIso`) + invalid-date rejection | ~2 | | `shared/constants/policy-documents.test.ts` | category validation + version-bump re-acknowledgment rule | ~several | | `shared/constants/users.test.ts` | honorific name-prefix formatting (`formatPersonName`) | ~several | diff --git a/backend/docs/zone-checkin.md b/backend/docs/zone-checkin.md new file mode 100644 index 0000000..c9d1890 --- /dev/null +++ b/backend/docs/zone-checkin.md @@ -0,0 +1,62 @@ +# Daily Zone Check-in + +Workstream 16 — campus staff log a daily self-regulation "zone" (Zones of +Regulation: blue/green/yellow/red). History is retained per day, and an eligible +user who has not checked in today is nudged on the dashboard, the +`/zones-of-regulation` page, and in the notification dropdown. + +## "Today" is server-computed in the campus timezone + +The check-in date is **not** decided by the client. Each campus carries a +required IANA `timezone` (`campuses.timezone`); the service computes the +campus-local date (`localDateInTimezone`, native `Intl`, DST-correct) so "today" +is independent of the caller's device clock/zone and correct across +organizations, campuses, and timezones. + +## Storage (reuses `user_progress`) + +No new table. A check-in is a `user_progress` row with +`progress_type = zone_checkin` and `item_id` = the campus-local date +(`YYYY-MM-DD`), `value` = the zone color. Because the per-user upsert key is +`(userId, progress_type, item_id)`, this yields **one row per user per day**, and +the set of rows is the history (`item_id` sorts chronologically). The thin +`services/zone-checkin.ts` wraps `UserProgressService` and owns the timezone/date +logic, keeping the generic `user_progress` endpoint generic. + +## Routes (`/api/zone_checkins`) + +All require `ZONE_CHECKIN` (the four campus staff roles). + +- `GET /today` → `{ date, zone, isCheckedInToday }` (campus-local date). +- `POST /` → record today's zone. Body `{ data: { zone } }` (blue|green|yellow|red). +- `DELETE /today` → clear today's check-in. +- `GET /?from=&to=` → the caller's history (`{ rows: [{ date, zone }], count }`). + +## Authorization + +- `ZONE_CHECKIN` — `director` (full access), `office_manager` (via + `...MODULE_ACTIONS`), `teacher`, `support_staff` (explicit grants). Other roles + (owner/superintendent/student/guardian/system) are not granted it; the frontend + also gates the nudge to the four campus roles (`canZoneCheckIn`). Reads/writes + are scoped to the caller's own `userId` by `UserProgressService`. +- A user with no campus has no campus-local "today" — the service rejects with a + validation error (only campus staff reach these routes). + +## Tests + +- **Unit** (`npm test`): `shared/constants/timezone.test.ts` (campus-local date + incl. Phoenix no-DST + a DST zone; `isValidIanaTimezone`) and + `shared/constants/zone-checkin.test.ts` (`isZoneCheckinColor`). +- **Frontend unit** (`vitest`): `business/zone-checkin/selectors.test.ts` + (eligibility + nudge) and the top-bar notification builder (incl. the zones + `href`). +- **Seeded e2e** (`frontend/tests/e2e/zone-checkins.seeded.e2e.ts`, + `npm run test:e2e:content`): a campus-staff record/read-back/clear of today's + zone, invalid-zone rejection, and external-role lockout. + +## Open / deferred + +- A manager-facing aggregate (campus self-regulation trends across staff) would + need a cross-user report endpoint (`user_progress` is self-scoped) — deferred. +- Editing a campus `timezone` is part of the (design-gated) campus admin UI; + for now it is seeded and validated at the API. diff --git a/backend/src/api/controllers/zone_checkins.controller.ts b/backend/src/api/controllers/zone_checkins.controller.ts new file mode 100644 index 0000000..a109466 --- /dev/null +++ b/backend/src/api/controllers/zone_checkins.controller.ts @@ -0,0 +1,22 @@ +import type { Request, Response } from 'express'; +import ZoneCheckinService from '@/services/zone-checkin'; + +export async function today(req: Request, res: Response): Promise { + const payload = await ZoneCheckinService.today(req.currentUser); + res.status(200).send(payload); +} + +export async function history(req: Request, res: Response): Promise { + const payload = await ZoneCheckinService.history(req.query, req.currentUser); + res.status(200).send(payload); +} + +export async function checkIn(req: Request, res: Response): Promise { + const payload = await ZoneCheckinService.checkIn(req.body.data, req.currentUser); + res.status(200).send(payload); +} + +export async function clearToday(req: Request, res: Response): Promise { + const payload = await ZoneCheckinService.clearToday(req.currentUser); + res.status(200).send(payload); +} diff --git a/backend/src/db/api/campuses.ts b/backend/src/db/api/campuses.ts index 98ee091..1d683b6 100644 --- a/backend/src/db/api/campuses.ts +++ b/backend/src/db/api/campuses.ts @@ -49,7 +49,7 @@ class CampusesDBApi { const currentUser = options?.currentUser ?? NO_USER; const transaction = options?.transaction; - if (data.name == null || data.code == null) { + if (data.name == null || data.code == null || data.timezone == null) { throw new ValidationError(); } @@ -58,6 +58,7 @@ class CampusesDBApi { id: data.id || undefined, name: data.name, code: data.code, + timezone: data.timezone, address: data.address || null, phone: data.phone || null, email: data.email || null, @@ -92,13 +93,14 @@ class CampusesDBApi { const transaction = options?.transaction; const campusesData = data.map((item, index) => { - if (item.name == null || item.code == null) { + if (item.name == null || item.code == null || item.timezone == null) { throw new ValidationError(); } return { id: item.id || undefined, name: item.name, code: item.code, + timezone: item.timezone, address: item.address || null, phone: item.phone || null, email: item.email || null, @@ -140,6 +142,7 @@ class CampusesDBApi { if (data.name !== undefined) updatePayload.name = data.name; if (data.code !== undefined) updatePayload.code = data.code; + if (data.timezone !== undefined) updatePayload.timezone = data.timezone; if (data.address !== undefined) updatePayload.address = data.address; if (data.phone !== undefined) updatePayload.phone = data.phone; if (data.email !== undefined) updatePayload.email = data.email; diff --git a/backend/src/db/migrations/20260611070000-campuses-timezone.ts b/backend/src/db/migrations/20260611070000-campuses-timezone.ts new file mode 100644 index 0000000..3046b02 --- /dev/null +++ b/backend/src/db/migrations/20260611070000-campuses-timezone.ts @@ -0,0 +1,47 @@ +import { DataTypes, type QueryInterface } from 'sequelize'; + +/** + * Zone check-in (Workstream 16) — every campus carries a **required** IANA + * `timezone`. "Today" for a user's zone check-in is computed server-side in this + * timezone, so it is independent of the caller's device clock/zone and correct + * across organizations, campuses, and DST. + * + * Adding a NOT NULL column to a populated table needs a backfill, so this runs + * in three steps: add nullable → backfill existing rows → set NOT NULL. The + * `'UTC'` backfill is a one-time migration value only (there is no app-level + * default); seeders and the campus admin set the real zone. Idempotent: the + * column is only added if missing. + */ +async function columnExists( + queryInterface: QueryInterface, + table: string, + column: string, +): Promise { + const [results] = await queryInterface.sequelize.query(` + SELECT 1 FROM information_schema.columns + WHERE table_name = '${table}' AND column_name = '${column}' + `); + return (results as unknown[]).length > 0; +} + +export default { + up: async (queryInterface: QueryInterface) => { + if (!(await columnExists(queryInterface, 'campuses', 'timezone'))) { + await queryInterface.addColumn('campuses', 'timezone', { + type: DataTypes.TEXT, + allowNull: true, + }); + await queryInterface.sequelize.query( + `UPDATE "campuses" SET "timezone" = 'UTC' WHERE "timezone" IS NULL`, + ); + await queryInterface.changeColumn('campuses', 'timezone', { + type: DataTypes.TEXT, + allowNull: false, + }); + } + }, + + down: async (queryInterface: QueryInterface) => { + await queryInterface.removeColumn('campuses', 'timezone'); + }, +}; diff --git a/backend/src/db/migrations/20260612000000-frame-entries-week-label.ts b/backend/src/db/migrations/20260612000000-frame-entries-week-label.ts new file mode 100644 index 0000000..33441dd --- /dev/null +++ b/backend/src/db/migrations/20260612000000-frame-entries-week-label.ts @@ -0,0 +1,34 @@ +import { DataTypes, type QueryInterface } from 'sequelize'; + +/** + * F.R.A.M.E. weekly entry — split the "Week Of" concept. `week_of` is now the + * canonical Sunday-start ISO date (`YYYY-MM-DD`, normalized server-side); this + * adds an optional free-text `week_label` for the author's extra note + * (e.g. "Spring Break week"). Idempotent: the column is only added if missing. + */ +async function columnExists( + queryInterface: QueryInterface, + table: string, + column: string, +): Promise { + const [results] = await queryInterface.sequelize.query(` + SELECT 1 FROM information_schema.columns + WHERE table_name = '${table}' AND column_name = '${column}' + `); + return (results as unknown[]).length > 0; +} + +export default { + up: async (queryInterface: QueryInterface) => { + if (!(await columnExists(queryInterface, 'frame_entries', 'week_label'))) { + await queryInterface.addColumn('frame_entries', 'week_label', { + type: DataTypes.TEXT, + allowNull: true, + }); + } + }, + + down: async (queryInterface: QueryInterface) => { + await queryInterface.removeColumn('frame_entries', 'week_label'); + }, +}; diff --git a/backend/src/db/models/campuses.ts b/backend/src/db/models/campuses.ts index 67ba0b7..0b84198 100644 --- a/backend/src/db/models/campuses.ts +++ b/backend/src/db/models/campuses.ts @@ -20,6 +20,7 @@ import type { Organizations } from './organizations'; import type { Staff } from './staff'; import type { Timetables } from './timetables'; import type { Users } from './users'; +import { isValidIanaTimezone } from '@/shared/constants/timezone'; export class Campuses extends Model< InferAttributes, @@ -28,6 +29,7 @@ export class Campuses extends Model< declare id: CreationOptional; declare name: string; declare code: string; + declare timezone: string; declare address: string | null; declare phone: string | null; declare email: string | null; @@ -118,6 +120,17 @@ export default function (sequelize: Sequelize): typeof Campuses { }, name: { type: DataTypes.TEXT, allowNull: false }, code: { type: DataTypes.TEXT, allowNull: false }, + timezone: { + type: DataTypes.TEXT, + allowNull: false, + validate: { + isValidIana(value: unknown) { + if (!isValidIanaTimezone(value)) { + throw new Error('timezone must be a valid IANA timezone'); + } + }, + }, + }, address: { type: DataTypes.TEXT }, phone: { type: DataTypes.TEXT }, email: { type: DataTypes.TEXT }, diff --git a/backend/src/db/models/frame_entries.ts b/backend/src/db/models/frame_entries.ts index d0078c7..08fa029 100644 --- a/backend/src/db/models/frame_entries.ts +++ b/backend/src/db/models/frame_entries.ts @@ -21,6 +21,7 @@ export class FrameEntries extends Model< > { declare id: CreationOptional; declare week_of: string; + declare week_label: CreationOptional; declare posted_date: string; declare formal: string; declare recognition: string; @@ -82,6 +83,10 @@ export default function (sequelize: Sequelize): typeof FrameEntries { defaultValue: DataTypes.UUIDV4, primaryKey: true, }, + week_label: { + type: DataTypes.TEXT, + allowNull: true, + }, week_of: { type: DataTypes.TEXT, allowNull: false, diff --git a/backend/src/db/seeders/20200430130760-user-roles.ts b/backend/src/db/seeders/20200430130760-user-roles.ts index 8d182a2..e82e6a6 100644 --- a/backend/src/db/seeders/20200430130760-user-roles.ts +++ b/backend/src/db/seeders/20200430130760-user-roles.ts @@ -88,14 +88,14 @@ const MODULE_PERMISSIONS_BY_ROLE: Partial> = ...MODULE_READ_INSTRUCTIONAL, ...MODULE_READ_PARENT_COMM, ...MODULE_READ_EXTERNAL, - 'TAKE_QUIZ', 'ACK_READ_RECEIPT', 'ACK_POLICY', + 'TAKE_QUIZ', 'ACK_READ_RECEIPT', 'ACK_POLICY', 'ZONE_CHECKIN', 'READ_AUDIO_FILES', 'MANAGE_AUDIO_FILES', ], [ROLE_NAMES.SUPPORT_STAFF]: [ ...MODULE_READ_ALL_STAFF, ...MODULE_READ_INSTRUCTIONAL, ...MODULE_READ_EXTERNAL, - 'TAKE_QUIZ', 'ACK_READ_RECEIPT', 'ACK_POLICY', + 'TAKE_QUIZ', 'ACK_READ_RECEIPT', 'ACK_POLICY', 'ZONE_CHECKIN', 'READ_AUDIO_FILES', ], [ROLE_NAMES.STUDENT]: [...MODULE_READ_EXTERNAL], diff --git a/backend/src/index.ts b/backend/src/index.ts index 8cb552f..2c58521 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -74,6 +74,7 @@ import staffAttendanceRoutes from '@/routes/staff_attendance'; import policyDocumentsRoutes from '@/routes/policy_documents'; import policyAcknowledgmentsRoutes from '@/routes/policy_acknowledgments'; import audioFilesRoutes from '@/routes/audio_files'; +import zoneCheckinsRoutes from '@/routes/zone_checkins'; const app = express(); @@ -272,6 +273,7 @@ app.use('/api/content-catalog', authenticated, contentCatalogRoutes); app.use('/api/policy_documents', authenticated, policyDocumentsRoutes); app.use('/api/policy_acknowledgments', authenticated, policyAcknowledgmentsRoutes); app.use('/api/audio_files', authenticated, audioFilesRoutes); +app.use('/api/zone_checkins', authenticated, zoneCheckinsRoutes); app.use('/api/search', authenticated, searchRoutes); // Unmatched API routes → centralized 404 (the SPA fallback below handles the rest). diff --git a/backend/src/routes/zone_checkins.ts b/backend/src/routes/zone_checkins.ts new file mode 100644 index 0000000..acd0398 --- /dev/null +++ b/backend/src/routes/zone_checkins.ts @@ -0,0 +1,47 @@ +import express from 'express'; +import { wrapAsync } from '@/api/http/request'; +import permissions from '@/middlewares/check-permissions'; +import { FEATURE_PERMISSIONS } from '@/shared/constants/product-permissions'; +import * as zone_checkins from '@/api/controllers/zone_checkins.controller'; + +const router = express.Router(); + +const canCheckIn = permissions.checkPermissions(FEATURE_PERMISSIONS.ZONE_CHECKIN); + +/** + * @openapi + * /api/zone_checkins/today: + * get: + * tags: [Zone Check-in] + * summary: Today's zone check-in for the caller (campus-local date) + * description: > + * Requires ZONE_CHECKIN (the four campus staff roles). "Today" is the + * caller's campus-local date (campus `timezone`), computed server-side. + * responses: + * 200: + * description: '{ date, zone, isCheckedInToday }' + * 401: { $ref: '#/components/responses/UnauthorizedError' } + * 403: { $ref: '#/components/responses/ForbiddenError' } + * /api/zone_checkins: + * get: + * tags: [Zone Check-in] + * summary: The caller's check-in history + * description: Requires ZONE_CHECKIN. Optional `from` / `to` (YYYY-MM-DD) range. + * responses: + * 200: { description: '{ rows: [{ date, zone }], count }' } + * 403: { $ref: '#/components/responses/ForbiddenError' } + * post: + * tags: [Zone Check-in] + * summary: Record today's zone (upsert) + * description: Requires ZONE_CHECKIN. Body `{ data: { zone } }` (blue|green|yellow|red). + * responses: + * 200: { description: '{ date, zone, isCheckedInToday }' } + * 400: { $ref: '#/components/responses/ValidationError' } + * 403: { $ref: '#/components/responses/ForbiddenError' } + */ +router.get('/today', canCheckIn, wrapAsync(zone_checkins.today)); +router.get('/', canCheckIn, wrapAsync(zone_checkins.history)); +router.post('/', canCheckIn, wrapAsync(zone_checkins.checkIn)); +router.delete('/today', canCheckIn, wrapAsync(zone_checkins.clearToday)); + +export default router; diff --git a/backend/src/services/frame_entries.ts b/backend/src/services/frame_entries.ts index d5818d0..68fbe43 100644 --- a/backend/src/services/frame_entries.ts +++ b/backend/src/services/frame_entries.ts @@ -8,11 +8,13 @@ import { hasRoleAccess, } from '@/services/shared/access'; import { FRAME_EDITOR_ROLE_NAMES } from '@/shared/constants/frame'; +import { toWeekStartIso } from '@/shared/constants/week'; import type { FrameEntries } from '@/db/models/frame_entries'; import type { CurrentUser } from '@/db/api/types'; interface FrameEntryInput { week_of: string; + week_label?: string | null; posted_date: string; formal: string; recognition: string; @@ -23,6 +25,20 @@ interface FrameEntryInput { campusId?: string | null; } +/** Normalizes the input week to its Sunday-start ISO date, or throws. */ +function requireWeekStart(weekOf: string): string { + const weekStart = toWeekStartIso(weekOf); + if (!weekStart) { + throw new ValidationError(); + } + return weekStart; +} + +/** Optional free-text label (e.g. "Spring Break week"); null when blank. */ +function toWeekLabel(value: unknown): string | null { + return typeof value === 'string' && value.trim().length > 0 ? value.trim() : null; +} + const REQUIRED_FIELDS = [ 'week_of', 'posted_date', @@ -79,6 +95,7 @@ function toDto(entry: FrameEntries) { return { id: plain.id, week_of: plain.week_of, + week_label: plain.week_label, posted_date: plain.posted_date, formal: plain.formal, recognition: plain.recognition, @@ -125,7 +142,8 @@ class FrameEntriesService { return withTransaction(async (transaction) => { const entry = await db.frame_entries.create( { - week_of: data.week_of.trim(), + week_of: requireWeekStart(data.week_of), + week_label: toWeekLabel(data.week_label), posted_date: data.posted_date.trim(), formal: data.formal.trim(), recognition: data.recognition.trim(), @@ -168,7 +186,8 @@ class FrameEntriesService { await entry.update( { - week_of: data.week_of.trim(), + week_of: requireWeekStart(data.week_of), + week_label: toWeekLabel(data.week_label), posted_date: data.posted_date.trim(), formal: data.formal.trim(), recognition: data.recognition.trim(), diff --git a/backend/src/services/zone-checkin.ts b/backend/src/services/zone-checkin.ts new file mode 100644 index 0000000..e8e9d34 --- /dev/null +++ b/backend/src/services/zone-checkin.ts @@ -0,0 +1,128 @@ +import db from '@/db/models'; +import ValidationError from '@/shared/errors/validation'; +import UserProgressService from '@/services/user_progress'; +import { assertAuthenticatedTenantUser, getCampusId } from '@/services/shared/access'; +import { localDateInTimezone } from '@/shared/constants/timezone'; +import { USER_PROGRESS_TYPES } from '@/shared/constants/user-progress'; +import { + ZONE_CHECKIN_HISTORY_LIMIT, + isZoneCheckinColor, +} from '@/shared/constants/zone-checkin'; +import type { CurrentUser } from '@/db/api/types'; + +/** + * Daily Zone check-in service (Workstream 16). "Today" is computed server-side + * in the user's **campus timezone**, so it is independent of the caller's device + * clock/zone and correct across organizations, campuses, and DST. Storage reuses + * the per-user `user_progress` store via {@link UserProgressService}; the + * campus-local date is the `item_id`, so one row exists per user per day and the + * full set of rows is the history. + */ + +export interface ZoneCheckinTodayPayload { + readonly date: string; + readonly zone: string | null; + readonly isCheckedInToday: boolean; +} + +export interface ZoneCheckinHistoryEntry { + readonly date: string; + readonly zone: string; +} + +async function resolveCampusTimezone(currentUser?: CurrentUser): Promise { + const campusId = getCampusId(currentUser); + if (!campusId) { + // Zone check-in is campus-staff-only; a user without a campus has no + // "today" to compute. + throw new ValidationError('zoneCheckinNoCampus'); + } + const campus = await db.campuses.findByPk(campusId, { + attributes: ['timezone'], + }); + if (!campus) { + throw new ValidationError('zoneCheckinNoCampus'); + } + return campus.timezone; +} + +class ZoneCheckinService { + /** Today's check-in for the caller (campus-local date). */ + static async today(currentUser?: CurrentUser): Promise { + assertAuthenticatedTenantUser(currentUser); + const timezone = await resolveCampusTimezone(currentUser); + const date = localDateInTimezone(timezone); + + const { rows } = await UserProgressService.list( + { progress_type: USER_PROGRESS_TYPES.ZONE_CHECKIN, item_id: date }, + currentUser, + ); + const zone = rows[0]?.value ?? null; + return { date, zone, isCheckedInToday: zone !== null }; + } + + /** Record (upsert) today's zone for the caller. */ + static async checkIn( + data: { zone?: unknown }, + currentUser?: CurrentUser, + ): Promise { + assertAuthenticatedTenantUser(currentUser); + if (!isZoneCheckinColor(data.zone)) { + throw new ValidationError('zoneCheckinInvalidZone'); + } + const timezone = await resolveCampusTimezone(currentUser); + const date = localDateInTimezone(timezone); + + await UserProgressService.upsert( + { + progress_type: USER_PROGRESS_TYPES.ZONE_CHECKIN, + item_id: date, + value: data.zone, + }, + currentUser, + ); + return { date, zone: data.zone, isCheckedInToday: true }; + } + + /** Clears the caller's check-in for today (campus-local date). */ + static async clearToday(currentUser?: CurrentUser): Promise { + assertAuthenticatedTenantUser(currentUser); + const timezone = await resolveCampusTimezone(currentUser); + const date = localDateInTimezone(timezone); + + await UserProgressService.removeByItem( + { progress_type: USER_PROGRESS_TYPES.ZONE_CHECKIN, item_id: date }, + currentUser, + ); + return { date, zone: null, isCheckedInToday: false }; + } + + /** The caller's check-in history (optionally within a [from, to] date range). */ + static async history( + filter: { from?: unknown; to?: unknown }, + currentUser?: CurrentUser, + ): Promise<{ rows: ZoneCheckinHistoryEntry[]; count: number }> { + assertAuthenticatedTenantUser(currentUser); + const { rows } = await UserProgressService.list( + { + progress_type: USER_PROGRESS_TYPES.ZONE_CHECKIN, + limit: ZONE_CHECKIN_HISTORY_LIMIT, + }, + currentUser, + ); + + const from = typeof filter.from === 'string' ? filter.from : null; + const to = typeof filter.to === 'string' ? filter.to : null; + + // `item_id` is the YYYY-MM-DD date, so string comparison is chronological. + const entries = rows + .filter((row): row is typeof row & { value: string } => row.value !== null) + .map((row) => ({ date: row.item_id, zone: row.value })) + .filter((entry) => (!from || entry.date >= from) && (!to || entry.date <= to)) + .sort((a, b) => (a.date < b.date ? 1 : -1)); + + return { rows: entries, count: entries.length }; + } +} + +export default ZoneCheckinService; diff --git a/backend/src/shared/constants/campuses.ts b/backend/src/shared/constants/campuses.ts index 9935bc0..e426760 100644 --- a/backend/src/shared/constants/campuses.ts +++ b/backend/src/shared/constants/campuses.ts @@ -3,6 +3,7 @@ export const PRODUCT_CAMPUS_SEED_ROWS = Object.freeze([ id: '7e15d693-3f7c-4bc6-a399-8345002af8cf', name: 'Tigers Campus', code: 'tigers', + timezone: 'America/Phoenix', mascot: 'Tigers', color: 'bg-orange-500', bgGradient: 'from-orange-500 to-amber-500', @@ -18,6 +19,7 @@ export const PRODUCT_CAMPUS_SEED_ROWS = Object.freeze([ id: '6ac9c04e-729d-41b8-9058-cd3aa26b832c', name: 'Gators Campus', code: 'gators', + timezone: 'America/New_York', mascot: 'Gators', color: 'bg-emerald-500', bgGradient: 'from-emerald-500 to-green-500', @@ -33,6 +35,7 @@ export const PRODUCT_CAMPUS_SEED_ROWS = Object.freeze([ id: '829c4d4b-525e-408a-ae7a-0358c50726f7', name: 'Hawks Campus', code: 'hawks', + timezone: 'America/Chicago', mascot: 'Hawks', color: 'bg-red-500', bgGradient: 'from-red-500 to-rose-500', @@ -48,6 +51,7 @@ export const PRODUCT_CAMPUS_SEED_ROWS = Object.freeze([ id: '848eb809-b2e2-4c0f-ac6b-cb910fd7e26d', name: 'Owls Campus', code: 'owls', + timezone: 'America/Los_Angeles', mascot: 'Owls', color: 'bg-purple-500', bgGradient: 'from-purple-500 to-violet-500', @@ -63,6 +67,7 @@ export const PRODUCT_CAMPUS_SEED_ROWS = Object.freeze([ id: '6670d72a-cf6b-4f92-9e21-378ac81df3d8', name: 'Wildcats Campus', code: 'wildcats', + timezone: 'America/Denver', mascot: 'Wildcats', color: 'bg-blue-500', bgGradient: 'from-blue-500 to-cyan-500', @@ -78,6 +83,7 @@ export const PRODUCT_CAMPUS_SEED_ROWS = Object.freeze([ id: '4a331c45-b463-4748-9e90-23d0e4b41aaf', name: 'Grizzlies Campus', code: 'grizzlies', + timezone: 'America/Anchorage', mascot: 'Grizzlies', color: 'bg-amber-700', bgGradient: 'from-amber-700 to-yellow-600', diff --git a/backend/src/shared/constants/product-permissions.ts b/backend/src/shared/constants/product-permissions.ts index 97fa952..b110389 100644 --- a/backend/src/shared/constants/product-permissions.ts +++ b/backend/src/shared/constants/product-permissions.ts @@ -51,6 +51,7 @@ export const MODULE_ACTIONS = [ 'TAKE_QUIZ', 'ACK_READ_RECEIPT', 'ACK_POLICY', + 'ZONE_CHECKIN', ] as const; /** Audio library (Workstream 13): read = play/select, manage = upload/edit. */ @@ -82,6 +83,7 @@ export const FEATURE_PERMISSIONS = Object.freeze({ TAKE_QUIZ: 'TAKE_QUIZ', ACK_READ_RECEIPT: 'ACK_READ_RECEIPT', ACK_POLICY: 'ACK_POLICY', + ZONE_CHECKIN: 'ZONE_CHECKIN', READ_AUDIO_FILES: 'READ_AUDIO_FILES', MANAGE_AUDIO_FILES: 'MANAGE_AUDIO_FILES', }); diff --git a/backend/src/shared/constants/timezone.test.ts b/backend/src/shared/constants/timezone.test.ts new file mode 100644 index 0000000..eff54e3 --- /dev/null +++ b/backend/src/shared/constants/timezone.test.ts @@ -0,0 +1,35 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { + isValidIanaTimezone, + localDateInTimezone, +} from '@/shared/constants/timezone'; + +test('localDateInTimezone yields the campus-local date (Phoenix, no DST)', () => { + // Phoenix is UTC-7 year-round. 05:30Z = 22:30 previous day in Phoenix. + assert.equal( + localDateInTimezone('America/Phoenix', new Date('2026-06-12T05:30:00Z')), + '2026-06-11', + ); + // 12:00Z = 05:00 same day in Phoenix. + assert.equal( + localDateInTimezone('America/Phoenix', new Date('2026-06-12T12:00:00Z')), + '2026-06-12', + ); +}); + +test('localDateInTimezone is DST-correct (New York, summer = UTC-4)', () => { + // 03:00Z on 2026-06-12 = 23:00 EDT on 2026-06-11. + assert.equal( + localDateInTimezone('America/New_York', new Date('2026-06-12T03:00:00Z')), + '2026-06-11', + ); +}); + +test('isValidIanaTimezone accepts IANA zones and rejects junk', () => { + assert.equal(isValidIanaTimezone('America/Phoenix'), true); + assert.equal(isValidIanaTimezone('Mars/Phobos'), false); + assert.equal(isValidIanaTimezone(''), false); + assert.equal(isValidIanaTimezone(null), false); + assert.equal(isValidIanaTimezone(5), false); +}); diff --git a/backend/src/shared/constants/timezone.ts b/backend/src/shared/constants/timezone.ts new file mode 100644 index 0000000..66c66d2 --- /dev/null +++ b/backend/src/shared/constants/timezone.ts @@ -0,0 +1,30 @@ +/** + * IANA timezone validation + campus-local date helpers (zone check-in, + * Workstream 16). A campus carries a required IANA `timezone`; "today" for a + * user is computed server-side in that timezone so it is independent of the + * caller's device clock/zone. + */ + +// `Intl.supportedValuesOf` is available in Node 18+. Cache the set for O(1) +// validation. +const SUPPORTED_TIMEZONES: ReadonlySet = new Set( + Intl.supportedValuesOf('timeZone'), +); + +export function isValidIanaTimezone(value: unknown): value is string { + return typeof value === 'string' && SUPPORTED_TIMEZONES.has(value); +} + +/** + * The calendar date (YYYY-MM-DD) at `now` in the given IANA timezone. Uses the + * native `Intl` formatter, so it is DST-correct with no extra dependency. The + * `en-CA` locale yields an ISO-style `YYYY-MM-DD`. + */ +export function localDateInTimezone(timezone: string, now: Date = new Date()): string { + return new Intl.DateTimeFormat('en-CA', { + timeZone: timezone, + year: 'numeric', + month: '2-digit', + day: '2-digit', + }).format(now); +} diff --git a/backend/src/shared/constants/week.test.ts b/backend/src/shared/constants/week.test.ts new file mode 100644 index 0000000..b13b71b --- /dev/null +++ b/backend/src/shared/constants/week.test.ts @@ -0,0 +1,20 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { toWeekStartIso } from '@/shared/constants/week'; + +test('normalizes any date to its Sunday week-start (American style)', () => { + // 2026-06-10 is a Wednesday → Sunday 2026-06-07. + assert.equal(toWeekStartIso('2026-06-10'), '2026-06-07'); + // A Sunday maps to itself; a Saturday maps back to that Sunday. + assert.equal(toWeekStartIso('2026-06-07'), '2026-06-07'); + assert.equal(toWeekStartIso('2026-06-13'), '2026-06-07'); +}); + +test('rejects non-date / malformed input', () => { + assert.equal(toWeekStartIso('Week of June 1, 2026'), null); + assert.equal(toWeekStartIso('2026-13-01'), null); + assert.equal(toWeekStartIso('2026-02-31'), null); + assert.equal(toWeekStartIso(''), null); + assert.equal(toWeekStartIso(null), null); + assert.equal(toWeekStartIso(42), null); +}); diff --git a/backend/src/shared/constants/week.ts b/backend/src/shared/constants/week.ts new file mode 100644 index 0000000..868e3b8 --- /dev/null +++ b/backend/src/shared/constants/week.ts @@ -0,0 +1,38 @@ +/** + * Week canonicalization (server side). **American style** — the week starts on + * **Sunday**. Mirrors the frontend `shared/business/week.ts`; the F.R.A.M.E. + * `week_of` is stored as the normalized Sunday-start ISO date. + */ + +/** + * Normalizes a `YYYY-MM-DD` date to its **Sunday** week-start ISO date. + * Computed in UTC (calendar dates are timezone-agnostic). Returns `null` when + * the input is not a valid `YYYY-MM-DD` date. + */ +export function toWeekStartIso(input: unknown): string | null { + if (typeof input !== 'string') { + return null; + } + const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(input.trim()); + if (!match) { + return null; + } + + const year = Number(match[1]); + const month = Number(match[2]); + const day = Number(match[3]); + const date = new Date(Date.UTC(year, month - 1, day)); + + // Reject overflow (e.g. 2026-02-31 rolls over). + if ( + Number.isNaN(date.getTime()) || + date.getUTCFullYear() !== year || + date.getUTCMonth() !== month - 1 || + date.getUTCDate() !== day + ) { + return null; + } + + date.setUTCDate(date.getUTCDate() - date.getUTCDay()); // back to Sunday + return date.toISOString().slice(0, 10); +} diff --git a/backend/src/shared/constants/zone-checkin.test.ts b/backend/src/shared/constants/zone-checkin.test.ts new file mode 100644 index 0000000..7bb5835 --- /dev/null +++ b/backend/src/shared/constants/zone-checkin.test.ts @@ -0,0 +1,20 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { + ZONE_CHECKIN_COLORS, + isZoneCheckinColor, +} from '@/shared/constants/zone-checkin'; + +test('the zone colors are blue/green/yellow/red', () => { + assert.deepEqual([...ZONE_CHECKIN_COLORS], ['blue', 'green', 'yellow', 'red']); +}); + +test('isZoneCheckinColor accepts the four zones and rejects anything else', () => { + for (const color of ZONE_CHECKIN_COLORS) { + assert.equal(isZoneCheckinColor(color), true); + } + assert.equal(isZoneCheckinColor('purple'), false); + assert.equal(isZoneCheckinColor(''), false); + assert.equal(isZoneCheckinColor(null), false); + assert.equal(isZoneCheckinColor(undefined), false); +}); diff --git a/backend/src/shared/constants/zone-checkin.ts b/backend/src/shared/constants/zone-checkin.ts new file mode 100644 index 0000000..d6345b6 --- /dev/null +++ b/backend/src/shared/constants/zone-checkin.ts @@ -0,0 +1,19 @@ +/** + * Daily Zone check-in (Workstream 16). Campus staff log a self-regulation + * "zone" once per day. Stored in `user_progress` with `progress_type = + * zone_checkin` and `item_id` = the campus-local date (YYYY-MM-DD), so each day + * is a distinct row and the history is the set of rows for the user. + */ +export const ZONE_CHECKIN_COLORS = ['blue', 'green', 'yellow', 'red'] as const; + +export type ZoneCheckinColor = (typeof ZONE_CHECKIN_COLORS)[number]; + +export function isZoneCheckinColor(value: unknown): value is ZoneCheckinColor { + return ( + typeof value === 'string' && + (ZONE_CHECKIN_COLORS as readonly string[]).includes(value) + ); +} + +/** Upper bound on history rows returned in one request (~a year of days). */ +export const ZONE_CHECKIN_HISTORY_LIMIT = 366; diff --git a/frontend/docs/content-catalog-integration.md b/frontend/docs/content-catalog-integration.md index de22751..5f520f0 100644 --- a/frontend/docs/content-catalog-integration.md +++ b/frontend/docs/content-catalog-integration.md @@ -28,6 +28,10 @@ Authenticated management: - `PUT /api/content-catalog/:contentType` - `DELETE /api/content-catalog/:contentType` +`useContentCatalogPayload(contentType, empty, { enabled })` accepts an optional +`enabled` flag for **lazy** loads — used by the header search to fetch a +catalog only once the user types (see `top-bar-integration.md`). + ## Current Consumers - classroom support strategies diff --git a/frontend/docs/dashboard-integration.md b/frontend/docs/dashboard-integration.md index 1e0b06a..4a08f40 100644 --- a/frontend/docs/dashboard-integration.md +++ b/frontend/docs/dashboard-integration.md @@ -18,7 +18,7 @@ View: - `frontend/src/components/dashboard/DashboardView.tsx` - `frontend/src/components/dashboard/DashboardHero.tsx` - `frontend/src/components/dashboard/DashboardQuotePanel.tsx` -- `frontend/src/components/dashboard/DashboardZoneCheckIn.tsx` +- `frontend/src/components/zone-checkin/ZoneCheckInCard.tsx` - `frontend/src/components/dashboard/DashboardFramePreview.tsx` - `frontend/src/components/dashboard/DashboardUpcomingEvents.tsx` - `frontend/src/components/dashboard/DashboardWeeklyProgress.tsx` @@ -49,11 +49,12 @@ Feature APIs: - F.R.A.M.E. entries through `useFrameEntries` - Communication events through `useCommunicationEvents` -- Current-user zone check-in through `useZoneCheckIn` +- Current-user daily Emotional Zone check-in through `useTodayZoneCheckIn` (campus-staff roles only; see `zone-checkin-integration.md`) ## Behavior - `useDashboardPage` composes all dashboard data sources into one page model. +- The hero's "Week of …" uses the shared American (Sunday-start) week util (`shared/business/week.ts`) — the same canonicalization as the F.R.A.M.E. week picker and the safety-quiz week. - Selectors handle greeting calculation, day-based quote selection, zone normalization, event limiting, and role-filtered quick actions. - View components receive prepared props and do not call API/data access modules. - Loading, empty, and error states remain explicit for each dashboard section. diff --git a/frontend/docs/frame-integration.md b/frontend/docs/frame-integration.md index 836081e..4eba081 100644 --- a/frontend/docs/frame-integration.md +++ b/frontend/docs/frame-integration.md @@ -28,6 +28,7 @@ Business logic layer: - `frontend/src/business/dashboard/hooks.ts` - `frontend/src/business/director-dashboard/hooks.ts` - `frontend/src/business/director-dashboard/selectors.ts` +- `frontend/src/shared/business/week.ts` (shared American/Sunday week canonicalization) API/data access layer: @@ -42,6 +43,7 @@ Constants: - FRAME entries load from `GET /api/frame_entries`. - Create/update workflows use typed API calls and React Query mutations. +- **Week selection**: the create and edit forms use `FrameWeekPicker` (a `Popover` + `Calendar`) — picking any day snaps to that week's **Sunday** (American week) via the shared `shared/business/week.ts` (`toWeekStartIso`), and an optional free-text **week label** (e.g. "Spring Break week") is captured separately. The entry stores the canonical Sunday-start ISO in `week_of` and the label in `week_label`; cards render `Week of ` + the label badge. The same week util backs the dashboard hero "Week of …" and the safety-quiz week, so the week is consistent across the app. - `FrameModule.tsx` is a thin wrapper that calls `useFrameModule` and renders focused FRAME view components. - FRAME view components use shared UI primitives: `Button`, `Input`, `Textarea`, and `StatePanel`. - Static FRAME sample entries are not used as runtime persisted-data substitutes. diff --git a/frontend/docs/index.md b/frontend/docs/index.md index 7186091..1554718 100644 --- a/frontend/docs/index.md +++ b/frontend/docs/index.md @@ -52,4 +52,5 @@ Read the repository rules first, then use the frontend architecture document as - [`user-progress-integration.md`](user-progress-integration.md) - [`vocational-opportunities.md`](vocational-opportunities.md) - [`walkthrough-integration.md`](walkthrough-integration.md) +- [`zone-checkin-integration.md`](zone-checkin-integration.md) - [`zones-of-regulation-integration.md`](zones-of-regulation-integration.md) diff --git a/frontend/docs/safety-quiz-integration.md b/frontend/docs/safety-quiz-integration.md index 452dab9..3cfbc14 100644 --- a/frontend/docs/safety-quiz-integration.md +++ b/frontend/docs/safety-quiz-integration.md @@ -48,6 +48,7 @@ Constants: - Result ownership is derived by the backend from the authenticated session. - `QBSSafety.tsx` is a thin wrapper that calls `useSafetyQuizPage` and renders focused safety quiz view components. - Quiz score, progress, result feedback, and compliance summary are derived in business selectors. +- The "current week" key (`getCurrentSafetyQuizWeek`) uses the shared American (Sunday-start) week util (`shared/business/week.ts`) — consistent with the dashboard hero and F.R.A.M.E. - Weekly focus and key reminders are backend content payload fields, not frontend constants. - Safety quiz view components use shared `Button`, `StatePanel`, and `ModuleHeader` primitives. - Director dashboard derives QBS completion metrics and risk rows in business selectors. diff --git a/frontend/docs/shared-app-types.md b/frontend/docs/shared-app-types.md index bc979c8..224f228 100644 --- a/frontend/docs/shared-app-types.md +++ b/frontend/docs/shared-app-types.md @@ -17,7 +17,7 @@ UI-facing product types live in `frontend/src/shared/types/app.ts`. - `ZoneColor` - cross-module static catalog item types used by the current UI -Backend DTO contracts remain in dedicated files under `frontend/src/shared/types/`, for example `auth.ts`, `frame.ts`, `campusAttendance.ts`, `policyDocuments.ts`, and `audioFiles.ts`. +Backend DTO contracts remain in dedicated files under `frontend/src/shared/types/`, for example `auth.ts`, `frame.ts`, `campusAttendance.ts`, `policyDocuments.ts`, `audioFiles.ts`, and `zoneCheckins.ts`. ## Rules diff --git a/frontend/docs/test-coverage.md b/frontend/docs/test-coverage.md index 29caf9a..b736db7 100644 --- a/frontend/docs/test-coverage.md +++ b/frontend/docs/test-coverage.md @@ -37,6 +37,7 @@ Current coverage includes architecture guardrails, API/data-access behavior, and - `frontend/src/business/frame/selectors.test.ts` - `frontend/src/business/audio-files/selectors.test.ts` - `frontend/src/business/audio-files/generate.test.ts` +- `frontend/src/business/zone-checkin/selectors.test.ts` - `frontend/src/business/personality/mappers.test.ts` - `frontend/src/business/personality/selectors.test.ts` - `frontend/src/business/policies/mappers.test.ts` @@ -49,6 +50,9 @@ Current coverage includes architecture guardrails, API/data-access behavior, and - `frontend/src/business/staff-attendance/mappers.test.ts` - `frontend/src/business/staff-attendance/selectors.test.ts` - `frontend/src/business/top-bar/selectors.test.ts` +- `frontend/src/business/top-bar/search.test.ts` +- `frontend/src/hooks/useOnClickOutside.test.tsx` +- `frontend/src/shared/business/week.test.ts` - `frontend/src/business/user-progress/mappers.test.ts` - `frontend/src/business/vocational/selectors.test.ts` - `frontend/src/business/walkthrough/mappers.test.ts` @@ -75,7 +79,7 @@ Current coverage includes architecture guardrails, API/data-access behavior, and - `frontend/src/hooks/usePermissions.test.tsx` - `frontend/src/components/sign-in-modal/SignInForm.test.tsx` -These tests verify import-boundary enforcement, centralized network access through `shared/api/httpClient`, alias-only source imports, required API contract-test coverage, HTTP client request normalization, cookie-backed auth refresh behavior, JSON body serialization, empty response handling, backend error propagation, API wrapper endpoint/query/body contracts, shared list/query/error helpers, route config, module route metadata, module access correction, sidebar navigation selectors, mobile sidebar overlay visibility, auth display/profile mapping, signup validation, campus mapping, dashboard and director dashboard selectors, classroom support selectors, timer formatting/progress/threshold parsing, DTO mapping, FRAME edit access, campus attendance mapping, calculations, and printable report output, staff attendance mapping and rollups, walkthrough create DTO mapping, walkthrough filtering, walkthrough check-in stats/history mapping, walkthrough scoring, generated summaries, repeated low-rating flags, communication role visibility, community catalog filtering and stats, ESA funding selectors, policy mapping/filtering/validation, safety-protocol mapping and authoring (steps/considerations) validation, audio-library management gating and the local recipe-generation stub, safety quiz compliance mapping and editable payload validation, sign language selectors, top bar display selectors, user progress normalization, vocational zip/category/search/stat calculations, zones selectors, personality DTO mapping, personality distribution grouping, EI thresholds, personality quiz progress, and MBTI-derived communication guidance. +These tests verify import-boundary enforcement, centralized network access through `shared/api/httpClient`, alias-only source imports, required API contract-test coverage, HTTP client request normalization, cookie-backed auth refresh behavior, JSON body serialization, empty response handling, backend error propagation, API wrapper endpoint/query/body contracts, shared list/query/error helpers, route config, module route metadata, module access correction, sidebar navigation selectors, mobile sidebar overlay visibility, auth display/profile mapping, signup validation, campus mapping, dashboard and director dashboard selectors, classroom support selectors, timer formatting/progress/threshold parsing, DTO mapping, FRAME edit access, campus attendance mapping, calculations, and printable report output, staff attendance mapping and rollups, walkthrough create DTO mapping, walkthrough filtering, walkthrough check-in stats/history mapping, walkthrough scoring, generated summaries, repeated low-rating flags, communication role visibility, community catalog filtering and stats, ESA funding selectors, policy mapping/filtering/validation, safety-protocol mapping and authoring (steps/considerations) validation, audio-library management gating and the local recipe-generation stub, daily Zone check-in eligibility/nudge, header search over accessible modules + their content, outside-click dismissal, the shared American (Sunday-start) week canonicalization, F.R.A.M.E. week/label mapping, safety quiz compliance mapping and editable payload validation, sign language selectors, top bar display selectors, user progress normalization, vocational zip/category/search/stat calculations, zones selectors, personality DTO mapping, personality distribution grouping, EI thresholds, personality quiz progress, and MBTI-derived communication guidance. The component and hook tests verify SignInForm rendering, loading states, user interactions, form submission, password visibility toggling, auth session initialization, sign-in/sign-out flows, AuthExpiredError handling, modal workflow state management, and permissions hook behavior including has(), hasAny(), hasAll() methods with globalAccess support. @@ -103,6 +107,7 @@ Backend-seeded E2E tests live under: - `frontend/tests/e2e/product-workflow.seeded.e2e.ts` - `frontend/tests/e2e/policy-acknowledgments.seeded.e2e.ts` - `frontend/tests/e2e/audio-files.seeded.e2e.ts` +- `frontend/tests/e2e/zone-checkins.seeded.e2e.ts` The seeded suite is intentionally excluded from default `npm run test:e2e` through `frontend/playwright.config.ts`. Run it with: @@ -126,9 +131,10 @@ The seeded suite verifies: - RBAC access control for different user roles (teacher, director, superintendent access to appropriate routes) - **Tenant isolation**: Users from one organization cannot read, list, update, or delete records from another organization - **Scoped provisioning**: Creating an owner auto-creates and links a new company -- **Product workflows**: Director FRAME entries and staff progress tracking persist correctly +- **Product workflows**: Director FRAME entries and staff progress tracking persist correctly (incl. server-side Sunday normalization of the FRAME `week_of`) - **Policy acknowledgments**: document create/persist, manage-vs-read RBAC, per-version (idempotent) acknowledgment, and external-role lockout - **Audio library**: `file`/`url`/`recipe` create/persist, same-campus read, kind/content validation, `support_staff` read-only, and external-role lockout +- **Daily Zone check-in**: campus-staff record/read-back/clear of today's zone (campus-timezone date), invalid-zone rejection, and external-role lockout ## Accessibility E2E Coverage diff --git a/frontend/docs/top-bar-integration.md b/frontend/docs/top-bar-integration.md index 2815311..8ead619 100644 --- a/frontend/docs/top-bar-integration.md +++ b/frontend/docs/top-bar-integration.md @@ -25,6 +25,7 @@ Business logic: - `frontend/src/business/top-bar/hooks.ts` - `frontend/src/business/top-bar/selectors.ts` +- `frontend/src/business/top-bar/search.ts` - `frontend/src/business/top-bar/types.ts` Shared config: @@ -36,9 +37,16 @@ Shared config: - `TopBar.tsx` is a thin wrapper that reads auth session state and passes it into `useTopBarPage`. - `useTopBarPage` owns profile menu state, notifications menu state, search query state, and sign-out error state. - Selectors handle initials, campus label fallback, shared role labels, and unread notification count. +- **Header search** (`TopBarSearch`) is a combobox over the user's **accessible modules** (local, role-filtered via `getAccessibleModules`) **plus their product content** from the content catalog (classroom strategies, sign-language signs, regulation zones). Content is fetched **lazily** (only once the user types, and only for accessible modules) via `useContentCatalogPayload({ enabled })`; results are combined by `buildTopBarSearchResults` (modules first, then content, capped). Selecting a result navigates to its module (`setCurrentModule`) and clears the query. Keyboard: ↑/↓ to move, Enter to open, Esc to close; the dropdown closes on outside click (`useOnClickOutside`). The backend `/api/search` is a separate admin SIS-record search and is intentionally **not** used here. - View components receive a prepared page model and do not call API/data access modules. - Profile and settings menu items are explicitly disabled until product workflows exist, instead of rendering silent no-op buttons. +## Tests + +- `business/top-bar/selectors.test.ts` (notification builder + zones `href`), + `business/top-bar/search.test.ts` (module role-filtering + content matching + + combine/cap), `hooks/useOnClickOutside.test.tsx` (dropdown dismissal). + ## Data Ownership Rules - Do not add notification seed data to frontend constants. diff --git a/frontend/docs/user-progress-integration.md b/frontend/docs/user-progress-integration.md index 8b2a097..20a80a5 100644 --- a/frontend/docs/user-progress-integration.md +++ b/frontend/docs/user-progress-integration.md @@ -2,7 +2,11 @@ ## Purpose -User progress follows the frontend three-layer architecture for sign language progress and dashboard zone check-ins. +User progress follows the frontend three-layer architecture for **sign language** +learned-progress. (The daily Zone check-in also persists in `user_progress` +server-side, but the frontend reads it through the dedicated `/api/zone_checkins` +slice — see [`zone-checkin-integration.md`](zone-checkin-integration.md) — not +through this generic client.) ```text View -> Business Logic -> API/Data Access -> Backend @@ -15,11 +19,6 @@ View layer: - `frontend/src/components/frameworks/SignLanguage.tsx` - `frontend/src/components/sign-language/SignLanguageProgressPanel.tsx` - `frontend/src/components/sign-language/SignLanguageVideoModal.tsx` -- `frontend/src/components/frameworks/Dashboard.tsx` -- `frontend/src/components/dashboard/DashboardZoneCheckIn.tsx` -- `frontend/src/components/frameworks/ZonesOfRegulation.tsx` -- `frontend/src/components/zones-of-regulation/ZonesOfRegulationView.tsx` - Business logic layer: - `frontend/src/business/dashboard/hooks.ts` @@ -45,8 +44,6 @@ Constants: - Marking a sign learned uses `POST /api/user_progress`. - Unmarking a sign uses `DELETE /api/user_progress/by-item`. - The sign language page combines user progress with backend content catalog records in `useSignLanguagePage`. -- Dashboard zone check-in uses `item_id=current` and `progress_type=zone_checkin`; dashboard page composition lives in `useDashboardPage`. -- The zones of regulation page currently renders content catalog records only. It does not persist check-ins; adding that interaction requires a dedicated UX task. - Views render explicit backend errors from React Query state. - User progress ownership is derived by the backend from the authenticated session. diff --git a/frontend/docs/zone-checkin-integration.md b/frontend/docs/zone-checkin-integration.md new file mode 100644 index 0000000..70639f6 --- /dev/null +++ b/frontend/docs/zone-checkin-integration.md @@ -0,0 +1,61 @@ +# Daily Zone Check-in Integration + +## Purpose + +Campus staff log a daily self-regulation "Emotional Zone" (blue/green/yellow/red). +The same state drives three surfaces: the dashboard check-in card, the +`/zones-of-regulation` page (reminder banner + card), and a notification-dropdown +nudge when an eligible user has not checked in today. + +## Backend contract + +`/api/zone_checkins` (requires `ZONE_CHECKIN` — the four campus staff roles). The +client never computes the date; "today" is the campus-local date computed +server-side from `campuses.timezone`. + +- `GET /today` → `{ date, zone, isCheckedInToday }` +- `POST /` `{ data: { zone } }` → record today's zone (upsert) +- `DELETE /today` → clear today's zone +- `GET /?from=&to=` → history `{ rows: [{ date, zone }], count }` + +## Frontend Structure + +- API/types: `shared/api/zoneCheckins.ts`, `shared/types/zoneCheckins.ts` +- Business: `business/zone-checkin/hooks.ts` (`useTodayZoneCheckIn`, + `useZoneCheckInHistory`), `business/zone-checkin/selectors.ts` + (`canZoneCheckIn`, `shouldNudgeZoneCheckIn`) +- Components: `components/zone-checkin/ZoneCheckInCard.tsx` (shared card), + `ZoneCheckInReminder.tsx` (banner), `ZoneCheckInSection.tsx` (page section) + +## Behavior + +- **Eligibility/nudge gating** is role-based (`canZoneCheckIn` — the four campus + staff roles), mirroring the backend grant. The dashboard card and the zones-page + section render only for eligible roles; the nudge (red "Not checked in" badge, + reminder banner, and notification) shows when an eligible user hasn't checked in + today. +- **Dashboard**: the card is wired through `useDashboardPage` (which exposes + `showZoneCheckIn` + `needsZoneCheckIn`) with an optimistic shell value for snappy + selection. +- **Zones page**: `ZoneCheckInSection` is self-contained (`useTodayZoneCheckIn`) + and renders above the regulation content. +- **Notifications**: `business/top-bar` derives a single unread notification from + `shouldNudgeZoneCheckIn` (`buildTopBarNotifications`) — there is no backend + notifications store. The notification carries an `href` + (`APP_ROUTE_PATHS.zones`); clicking it navigates to `/zones-of-regulation` + (a react-router `Link`) and closes the dropdown. +- The `useTodayZoneCheckIn` `error` surfaces **only** save/clear failures; the + today-load query can 403 for an ineligible caller and must not render as an + error in the widget. React Query dedupes the `/today` fetch across all three + surfaces. + +## Tests + +- `business/zone-checkin/selectors.test.ts` (eligibility + nudge), + `business/top-bar/selectors.test.ts` (notification builder + zones `href`). +- Seeded e2e: `frontend/tests/e2e/zone-checkins.seeded.e2e.ts` (record / + read-back / clear today, invalid-zone rejection, external-role lockout). + +## Verification + +- `npm run typecheck`, `npm run lint`, `npm run test` pass. diff --git a/frontend/docs/zones-of-regulation-integration.md b/frontend/docs/zones-of-regulation-integration.md index c84aa56..2f4f7a5 100644 --- a/frontend/docs/zones-of-regulation-integration.md +++ b/frontend/docs/zones-of-regulation-integration.md @@ -55,7 +55,7 @@ Content payloads are seeded in: - Selectors handle expanded-zone toggling, selected-zone lookup, safety connection lookup, and active-tab wording. - View components receive a prepared page model and do not call API/data access modules. - Loading and error states are explicit through `StatePanel`. -- The module preserves the current behavior: selecting a zone expands details; zone check-in persistence remains owned by the dashboard check-in flow until a dedicated UX task adds it here. +- Selecting a zone expands its details. The page also renders the daily Emotional Zone check-in (`ZoneCheckInSection`: reminder banner + `ZoneCheckInCard`) above the content for eligible campus-staff roles — see [`zone-checkin-integration.md`](zone-checkin-integration.md). ## Data Ownership Rules diff --git a/frontend/src/business/app-shell/hooks.ts b/frontend/src/business/app-shell/hooks.ts index f4ce838..d6f996c 100644 --- a/frontend/src/business/app-shell/hooks.ts +++ b/frontend/src/business/app-shell/hooks.ts @@ -86,6 +86,7 @@ export function useAppShell(options: UseAppShellOptions): AppShellState { userName, campusInfo, toggleSidebar, + setCurrentModule, }; const shellOutletContext = { diff --git a/frontend/src/business/content-catalog/hooks.ts b/frontend/src/business/content-catalog/hooks.ts index d2e5cf9..11b3fa7 100644 --- a/frontend/src/business/content-catalog/hooks.ts +++ b/frontend/src/business/content-catalog/hooks.ts @@ -5,6 +5,7 @@ import { CONTENT_CATALOG_QUERY_KEYS } from '@/shared/constants/contentCatalog'; export function useContentCatalogPayload( contentType: string, emptyPayload: TPayload, + options?: { readonly enabled?: boolean }, ) { const query = useQuery({ queryKey: [...CONTENT_CATALOG_QUERY_KEYS.content, contentType], @@ -12,6 +13,7 @@ export function useContentCatalogPayload( const response = await getContentCatalog(contentType); return response.payload; }, + enabled: options?.enabled ?? true, }); return { diff --git a/frontend/src/business/dashboard/hooks.ts b/frontend/src/business/dashboard/hooks.ts index 99823c2..448b80d 100644 --- a/frontend/src/business/dashboard/hooks.ts +++ b/frontend/src/business/dashboard/hooks.ts @@ -15,7 +15,8 @@ import type { DashboardProps, } from '@/business/dashboard/types'; import { useFrameEntries } from '@/business/frame/hooks'; -import { useZoneCheckIn } from '@/business/user-progress/hooks'; +import { useTodayZoneCheckIn } from '@/business/zone-checkin/hooks'; +import { canZoneCheckIn, shouldNudgeZoneCheckIn } from '@/business/zone-checkin/selectors'; import { CONTENT_CATALOG_TYPES } from '@/shared/constants/contentCatalog'; import { DASHBOARD_ZONE_OPTIONS } from '@/shared/constants/dashboard'; import { getOptionalErrorMessage } from '@/shared/errors/errorMessages'; @@ -47,7 +48,7 @@ export function useDashboardPage({ null, ); const frameEntriesQuery = useFrameEntries(); - const zoneCheckInState = useZoneCheckIn(); + const zoneCheckInState = useTodayZoneCheckIn(); const communicationEventsQuery = useCommunicationEvents(); const roleEvents = useMemo( () => filterCommunicationEventsByRole(communicationEventsQuery.data ?? [], userRole), @@ -57,7 +58,12 @@ export function useDashboardPage({ () => selectDashboardUpcomingEvents(roleEvents), [roleEvents], ); - const activeZone = selectDashboardActiveZone(zoneCheckIn, zoneCheckInState.currentZone); + const activeZone = selectDashboardActiveZone(zoneCheckIn, zoneCheckInState.todayZone); + const needsZoneCheckIn = shouldNudgeZoneCheckIn( + userRole, + zoneCheckInState.isLoading, + zoneCheckInState.isCheckedInToday, + ); const todayQuote = useMemo( () => selectDashboardQuote(quotesQuery.payload, dashboardDate), [dashboardDate, quotesQuery.payload], @@ -69,7 +75,7 @@ export function useDashboardPage({ } async function resetZone() { - await zoneCheckInState.resetZone(); + await zoneCheckInState.clearToday(); setZoneCheckIn(null); } @@ -84,7 +90,9 @@ export function useDashboardPage({ isError: Boolean(quotesQuery.error), }, zoneOptions: DASHBOARD_ZONE_OPTIONS, + showZoneCheckIn: canZoneCheckIn(userRole), activeZone, + needsZoneCheckIn, isZoneSaving: zoneCheckInState.isSaving, zoneErrorMessage: getOptionalErrorMessage(zoneCheckInState.error), upcomingEvents, diff --git a/frontend/src/business/dashboard/types.ts b/frontend/src/business/dashboard/types.ts index a0aa0ed..43b940a 100644 --- a/frontend/src/business/dashboard/types.ts +++ b/frontend/src/business/dashboard/types.ts @@ -31,7 +31,9 @@ export interface DashboardPage { readonly todayQuote: DashboardEncouragingQuote | null; readonly quoteState: DashboardContentState; readonly zoneOptions: readonly DashboardZoneOption[]; + readonly showZoneCheckIn: boolean; readonly activeZone: ZoneColor | null; + readonly needsZoneCheckIn: boolean; readonly isZoneSaving: boolean; readonly zoneErrorMessage: string | null; readonly upcomingEvents: readonly CommunicationEventDto[]; diff --git a/frontend/src/business/director-dashboard/selectors.test.ts b/frontend/src/business/director-dashboard/selectors.test.ts index b872533..49f7a51 100644 --- a/frontend/src/business/director-dashboard/selectors.test.ts +++ b/frontend/src/business/director-dashboard/selectors.test.ts @@ -48,7 +48,9 @@ function createQuizResult(overrides: Partial = {}): SafetyQ function createFrameEntry(overrides: Partial = {}): FrameEntryViewModel { return { id: 'frame-1', - weekOf: '2026-06-01', + weekStart: '2026-05-31', + weekLabel: '', + weekOf: 'May 31, 2026', postedDate: 'June 1, 2026', formal: 'Formal learning focus for the week', recognition: 'Recognition focus for the week', diff --git a/frontend/src/business/frame/hooks.ts b/frontend/src/business/frame/hooks.ts index d106cff..f767d91 100644 --- a/frontend/src/business/frame/hooks.ts +++ b/frontend/src/business/frame/hooks.ts @@ -20,13 +20,15 @@ import { import { canEditFrameEntries } from '@/business/frame/selectors'; import { UserRole } from '@/shared/types/app'; import { mapApiListRows } from '@/shared/business/apiListRows'; +import { toWeekStartIso } from '@/shared/business/week'; import { useInvalidatingMutation } from '@/shared/business/queryMutations'; const EMPTY_FRAME_ENTRIES: readonly FrameEntryViewModel[] = []; function createEmptyDraft(author: string): FrameEntryDraft { return { - weekOf: '', + weekStart: toWeekStartIso(new Date()), + weekLabel: '', postedDate: new Date().toLocaleDateString('en-US', { year: 'numeric', month: 'long', @@ -42,8 +44,9 @@ function createEmptyDraft(author: string): FrameEntryDraft { } function isValidDraft(entry: EditableFrameEntry): boolean { + // `weekLabel` is optional; `weekStart` is always set by the picker. return Boolean( - entry.weekOf.trim() + entry.weekStart.trim() && entry.postedDate.trim() && entry.formal.trim() && entry.recognition.trim() @@ -121,6 +124,10 @@ export function useFrameModule(userRole: UserRole, userName: string) { setEditEntry((current) => current ? { ...current, [key]: value } : current); } + function updateEditEntryField(key: 'weekStart' | 'weekLabel', value: string) { + setEditEntry((current) => current ? { ...current, [key]: value } : current); + } + function startEditing(entry: FrameEntryViewModel) { setIsEditing(true); setEditEntry(entry); @@ -178,6 +185,7 @@ export function useFrameModule(userRole: UserRole, userName: string) { updateNewEntryField, updateNewEntrySection, updateEditEntrySection, + updateEditEntryField, startEditing, cancelEditing, saveNewEntry, diff --git a/frontend/src/business/frame/mappers.test.ts b/frontend/src/business/frame/mappers.test.ts index e91c31e..07d68d6 100644 --- a/frontend/src/business/frame/mappers.test.ts +++ b/frontend/src/business/frame/mappers.test.ts @@ -6,40 +6,46 @@ import { import type { EditableFrameEntry } from '@/business/frame/types'; import type { FrameEntryDto } from '@/shared/types/frame'; -describe('frame mappers', () => { - it('maps backend FRAME DTO fields into the frontend view model shape', () => { - const dto: FrameEntryDto = { - id: 'frame-1', - week_of: '2026-06-08', - posted_date: '2026-06-08', - formal: 'Formal note', - recognition: 'Recognition note', - application: 'Application note', - management: 'Management note', - emotional: 'Emotional note', - author: 'Director', - organizationId: 'org-1', - campusId: 'campus-1', - createdAt: '2026-06-08T10:00:00.000Z', - updatedAt: '2026-06-08T10:00:00.000Z', - }; +function dto(overrides: Partial = {}): FrameEntryDto { + return { + id: 'frame-1', + week_of: '2026-06-07', // a Sunday (canonical week start) + week_label: 'Spring Break week', + posted_date: '2026-06-08', + formal: 'Formal note', + recognition: 'Recognition note', + application: 'Application note', + management: 'Management note', + emotional: 'Emotional note', + author: 'Director', + organizationId: 'org-1', + campusId: 'campus-1', + createdAt: '2026-06-08T10:00:00.000Z', + updatedAt: '2026-06-08T10:00:00.000Z', + ...overrides, + }; +} - expect(toFrameEntryViewModel(dto)).toEqual({ +describe('frame mappers', () => { + it('maps the DTO into the view model (ISO week start + display + label)', () => { + expect(toFrameEntryViewModel(dto())).toMatchObject({ id: 'frame-1', - weekOf: 'June 8, 2026', - postedDate: 'June 8, 2026', + weekStart: '2026-06-07', + weekLabel: 'Spring Break week', + weekOf: 'June 7, 2026', formal: 'Formal note', - recognition: 'Recognition note', - application: 'Application note', - management: 'Management note', - emotional: 'Emotional note', author: 'Director', }); }); - it('maps editable FRAME state back into the backend mutation DTO shape', () => { + it('defaults a null label to an empty string', () => { + expect(toFrameEntryViewModel(dto({ week_label: null })).weekLabel).toBe(''); + }); + + it('maps editable state back into the mutation DTO and omits a blank label', () => { const entry: EditableFrameEntry = { - weekOf: '2026-06-08', + weekStart: '2026-06-07', + weekLabel: ' ', postedDate: '2026-06-09', formal: 'Formal', recognition: 'Recognition', @@ -50,7 +56,8 @@ describe('frame mappers', () => { }; expect(toFrameEntryMutationDto(entry)).toEqual({ - week_of: '2026-06-08', + week_of: '2026-06-07', + week_label: undefined, posted_date: '2026-06-09', formal: 'Formal', recognition: 'Recognition', @@ -60,4 +67,19 @@ describe('frame mappers', () => { author: 'Office Manager', }); }); + + it('trims and keeps a non-blank label', () => { + const entry: EditableFrameEntry = { + weekStart: '2026-06-07', + weekLabel: ' Holiday week ', + postedDate: '2026-06-09', + formal: 'a', + recognition: 'b', + application: 'c', + management: 'd', + emotional: 'e', + author: 'Director', + }; + expect(toFrameEntryMutationDto(entry).week_label).toBe('Holiday week'); + }); }); diff --git a/frontend/src/business/frame/mappers.ts b/frontend/src/business/frame/mappers.ts index 0959e1e..018ea5d 100644 --- a/frontend/src/business/frame/mappers.ts +++ b/frontend/src/business/frame/mappers.ts @@ -1,5 +1,6 @@ import { FrameEntryDto, FrameEntryMutationDto } from '@/shared/types/frame'; import { EditableFrameEntry, FrameEntryViewModel } from '@/business/frame/types'; +import { formatWeekOf } from '@/shared/business/week'; function formatDisplayDate(isoDate: string): string { const date = new Date(isoDate); @@ -18,7 +19,9 @@ function formatDisplayDate(isoDate: string): string { export function toFrameEntryViewModel(dto: FrameEntryDto): FrameEntryViewModel { return { id: dto.id, - weekOf: formatDisplayDate(dto.week_of), + weekStart: dto.week_of, + weekLabel: dto.week_label ?? '', + weekOf: formatWeekOf(dto.week_of), postedDate: formatDisplayDate(dto.posted_date), formal: dto.formal, recognition: dto.recognition, @@ -30,8 +33,10 @@ export function toFrameEntryViewModel(dto: FrameEntryDto): FrameEntryViewModel { } export function toFrameEntryMutationDto(entry: EditableFrameEntry): FrameEntryMutationDto { + const weekLabel = entry.weekLabel.trim(); return { - week_of: entry.weekOf, + week_of: entry.weekStart, + week_label: weekLabel ? weekLabel : undefined, posted_date: entry.postedDate, formal: entry.formal, recognition: entry.recognition, diff --git a/frontend/src/business/frame/types.ts b/frontend/src/business/frame/types.ts index 4ae973c..e007850 100644 --- a/frontend/src/business/frame/types.ts +++ b/frontend/src/business/frame/types.ts @@ -2,6 +2,11 @@ import { FrameSectionKey } from '@/shared/types/frame'; export interface FrameEntryViewModel { readonly id: string; + /** Canonical Sunday-start ISO date (drives the week picker). */ + readonly weekStart: string; + /** Optional free-text label (e.g. "Spring Break week"); '' when none. */ + readonly weekLabel: string; + /** Display string for the week, e.g. "June 7, 2026". */ readonly weekOf: string; readonly postedDate: string; readonly formal: string; @@ -12,7 +17,18 @@ export interface FrameEntryViewModel { readonly author: string; } -export type EditableFrameEntry = Omit; +/** The fields an author edits (the display `weekOf` is derived, not edited). */ +export interface EditableFrameEntry { + readonly weekStart: string; + readonly weekLabel: string; + readonly postedDate: string; + readonly formal: string; + readonly recognition: string; + readonly application: string; + readonly management: string; + readonly emotional: string; + readonly author: string; +} export type FrameEntryDraft = EditableFrameEntry; diff --git a/frontend/src/business/safety-quiz/selectors.ts b/frontend/src/business/safety-quiz/selectors.ts index 7a5f0eb..bfef809 100644 --- a/frontend/src/business/safety-quiz/selectors.ts +++ b/frontend/src/business/safety-quiz/selectors.ts @@ -3,13 +3,12 @@ import type { SafetyQuizCompletionSummary, SafetyQuizComplianceRow, } from '@/business/safety-quiz/types'; +import { toWeekStartIso } from '@/shared/business/week'; export function getCurrentSafetyQuizWeek(date: Date): string { - const weekStart = new Date(date); - weekStart.setHours(0, 0, 0, 0); - weekStart.setDate(weekStart.getDate() - weekStart.getDay()); - - return weekStart.toISOString().slice(0, 10); + // Shared American (Sunday-start) canonicalization — same util as the dashboard + // hero and F.R.A.M.E. (behavior unchanged: this was already Sunday-based). + return toWeekStartIso(date); } export function calculateSafetyQuizScore( diff --git a/frontend/src/business/top-bar/hooks.ts b/frontend/src/business/top-bar/hooks.ts index fec3378..2be849e 100644 --- a/frontend/src/business/top-bar/hooks.ts +++ b/frontend/src/business/top-bar/hooks.ts @@ -1,24 +1,44 @@ -import { useState } from 'react'; +import { useMemo, useState } from 'react'; import { + buildTopBarNotifications, countUnreadTopBarNotifications, getTopBarCampusLabel, getTopBarInitials, getTopBarRoleLabel, } from '@/business/top-bar/selectors'; +import { + buildTopBarSearchResults, + type TopBarContentItem, + type TopBarSearchResult, +} from '@/business/top-bar/search'; +import { getAccessibleModules } from '@/business/app-shell/selectors'; +import { useContentCatalogPayload } from '@/business/content-catalog/hooks'; +import { CONTENT_CATALOG_TYPES } from '@/shared/constants/contentCatalog'; +import { MODULES } from '@/shared/constants/appData'; +import type { + ModuleId, + SignItem, + Strategy, + ZoneInfo, +} from '@/shared/types/app'; import type { - TopBarNotification, TopBarPage, UseTopBarPageOptions, } from '@/business/top-bar/types'; +import { useTodayZoneCheckIn } from '@/business/zone-checkin/hooks'; +import { shouldNudgeZoneCheckIn } from '@/business/zone-checkin/selectors'; -const EMPTY_TOP_BAR_NOTIFICATIONS: readonly TopBarNotification[] = []; +const EMPTY_STRATEGIES: readonly Strategy[] = []; +const EMPTY_SIGNS: readonly SignItem[] = []; +const EMPTY_ZONES: readonly ZoneInfo[] = []; export function useTopBarPage({ userRole, userName, campusInfo, toggleSidebar, + setCurrentModule, profile, signOut: signOutAction, }: UseTopBarPageOptions): TopBarPage { @@ -26,7 +46,64 @@ export function useTopBarPage({ const [showNotifications, setShowNotifications] = useState(false); const [searchQuery, setSearchQuery] = useState(''); const [signOutError, setSignOutError] = useState(null); - const notifications = EMPTY_TOP_BAR_NOTIFICATIONS; + + const zoneCheckIn = useTodayZoneCheckIn(); + const needsZoneCheckIn = shouldNudgeZoneCheckIn( + userRole, + zoneCheckIn.isLoading, + zoneCheckIn.isCheckedInToday, + ); + const notifications = buildTopBarNotifications({ needsZoneCheckIn }); + + // Header search = accessible modules (local) + their product content from the + // content catalog. Content is fetched lazily — only once the user types, and + // only for modules the user can access. + const hasQuery = searchQuery.trim().length > 0; + const accessibleModuleIds = useMemo( + () => new Set(getAccessibleModules(MODULES, userRole).map((module) => module.id)), + [userRole], + ); + const moduleNameById = useMemo( + () => new Map(MODULES.map((module) => [module.id, module.name])), + [], + ); + + const strategiesQuery = useContentCatalogPayload( + CONTENT_CATALOG_TYPES.classroomStrategies, + EMPTY_STRATEGIES, + { enabled: hasQuery && accessibleModuleIds.has('classroom') }, + ); + const signsQuery = useContentCatalogPayload( + CONTENT_CATALOG_TYPES.signLanguageItems, + EMPTY_SIGNS, + { enabled: hasQuery && accessibleModuleIds.has('signs') }, + ); + const zonesQuery = useContentCatalogPayload( + CONTENT_CATALOG_TYPES.regulationZones, + EMPTY_ZONES, + { enabled: hasQuery && accessibleModuleIds.has('zones') }, + ); + + const contentItems = useMemo(() => { + const items: TopBarContentItem[] = []; + const add = (moduleId: ModuleId, id: string, label: string) => { + items.push({ id, label, moduleId, moduleName: moduleNameById.get(moduleId) ?? '' }); + }; + strategiesQuery.payload.forEach((s) => add('classroom', `strategy-${s.id}`, s.title)); + signsQuery.payload.forEach((s) => add('signs', `sign-${s.id}`, s.word)); + zonesQuery.payload.forEach((z) => add('zones', `zone-${z.color}`, z.name)); + return items; + }, [strategiesQuery.payload, signsQuery.payload, zonesQuery.payload, moduleNameById]); + + const searchResults = useMemo( + () => buildTopBarSearchResults(MODULES, userRole, searchQuery, contentItems), + [userRole, searchQuery, contentItems], + ); + + function selectSearchResult(result: TopBarSearchResult) { + setSearchQuery(''); + setCurrentModule(result.moduleId); + } async function signOut() { setShowProfileMenu(false); @@ -49,6 +126,7 @@ export function useTopBarPage({ showProfileMenu, showNotifications, searchQuery, + searchResults, signOutError, notifications, unreadCount: countUnreadTopBarNotifications(notifications), @@ -58,6 +136,7 @@ export function useTopBarPage({ toggleNotifications: () => setShowNotifications((current) => !current), closeNotifications: () => setShowNotifications(false), setSearchQuery, + selectSearchResult, signOut, }; } diff --git a/frontend/src/business/top-bar/search.test.ts b/frontend/src/business/top-bar/search.test.ts new file mode 100644 index 0000000..2e8638c --- /dev/null +++ b/frontend/src/business/top-bar/search.test.ts @@ -0,0 +1,43 @@ +import { describe, expect, it } from 'vitest'; +import { + buildTopBarSearchResults, + searchContentItems, + searchModules, + type TopBarContentItem, +} from '@/business/top-bar/search'; +import type { Module } from '@/shared/types/app'; + +const modules: Module[] = [ + { id: 'dashboard', name: 'Home Dashboard', icon: 'home', roles: ['teacher', 'director'], color: '', routePath: '/dashboard' }, + { id: 'zones', name: 'Regulate your Zone', icon: 'layers', roles: ['teacher'], color: '', routePath: '/zones-of-regulation' }, + { id: 'director', name: 'Director Dashboard', icon: 'chart', roles: ['director'], color: '', routePath: '/director-dashboard' }, +]; + +const content: TopBarContentItem[] = [ + { id: 's1', label: 'Visual Schedule Boards', moduleId: 'classroom', moduleName: 'Classroom Support' }, + { id: 'w1', label: 'Help', moduleId: 'signs', moduleName: 'Sign Language' }, +]; + +describe('top bar search', () => { + it('matches only accessible modules by name/id (case-insensitive)', () => { + const results = searchModules(modules, 'teacher', 'dash'); + expect(results.map((result) => result.moduleId)).toEqual(['dashboard']); + // a teacher cannot see the director dashboard even though it matches "dash" + expect(results.some((result) => result.moduleId === 'director')).toBe(false); + // empty query → nothing + expect(searchModules(modules, 'teacher', ' ')).toEqual([]); + }); + + it('matches content items by label and carries the owning module', () => { + const results = searchContentItems(content, 'visual'); + expect(results).toHaveLength(1); + expect(results[0]).toMatchObject({ kind: 'content', moduleId: 'classroom', sublabel: 'Classroom Support' }); + }); + + it('combines modules first, then content, capped', () => { + const results = buildTopBarSearchResults(modules, 'teacher', 'e', content, 2); + expect(results).toHaveLength(2); + expect(results[0]?.kind).toBe('module'); + expect(buildTopBarSearchResults(modules, 'teacher', '', content)).toEqual([]); + }); +}); diff --git a/frontend/src/business/top-bar/search.ts b/frontend/src/business/top-bar/search.ts new file mode 100644 index 0000000..0faf920 --- /dev/null +++ b/frontend/src/business/top-bar/search.ts @@ -0,0 +1,94 @@ +import { getAccessibleModules } from '@/business/app-shell/selectors'; +import type { Module, ModuleId, UserRole } from '@/shared/types/app'; + +export type TopBarSearchResultKind = 'module' | 'content'; + +export interface TopBarSearchResult { + /** Unique key across modules + content. */ + readonly id: string; + readonly kind: TopBarSearchResultKind; + readonly label: string; + /** Secondary line — "Module", or the owning module's name for content. */ + readonly sublabel: string; + /** Navigation target (module to open on select). */ + readonly moduleId: ModuleId; +} + +/** A pre-mapped searchable content item (built from a content-catalog payload). */ +export interface TopBarContentItem { + readonly id: string; + readonly label: string; + readonly moduleId: ModuleId; + readonly moduleName: string; +} + +export const TOP_BAR_SEARCH_RESULT_LIMIT = 8; + +function normalize(value: string): string { + return value.trim().toLowerCase(); +} + +/** Accessible modules whose name (or id) matches the query. */ +export function searchModules( + modules: readonly Module[], + userRole: UserRole, + query: string, +): TopBarSearchResult[] { + const normalized = normalize(query); + if (!normalized) { + return []; + } + + return getAccessibleModules(modules, userRole) + .filter( + (module) => + module.name.toLowerCase().includes(normalized) || + module.id.includes(normalized), + ) + .map((module) => ({ + id: `module:${module.id}`, + kind: 'module' as const, + label: module.name, + sublabel: 'Module', + moduleId: module.id, + })); +} + +/** Pre-mapped content items whose label matches the query. */ +export function searchContentItems( + items: readonly TopBarContentItem[], + query: string, +): TopBarSearchResult[] { + const normalized = normalize(query); + if (!normalized) { + return []; + } + + return items + .filter((item) => item.label.toLowerCase().includes(normalized)) + .map((item) => ({ + id: `content:${item.moduleId}:${item.id}`, + kind: 'content' as const, + label: item.label, + sublabel: item.moduleName, + moduleId: item.moduleId, + })); +} + +/** Combined, capped results: modules first, then content. */ +export function buildTopBarSearchResults( + modules: readonly Module[], + userRole: UserRole, + query: string, + contentItems: readonly TopBarContentItem[], + limit: number = TOP_BAR_SEARCH_RESULT_LIMIT, +): TopBarSearchResult[] { + if (!normalize(query)) { + return []; + } + + return [ + ...searchModules(modules, userRole, query), + ...searchContentItems(contentItems, query), + ].slice(0, limit); +} diff --git a/frontend/src/business/top-bar/selectors.test.ts b/frontend/src/business/top-bar/selectors.test.ts index 0a7e563..65f9091 100644 --- a/frontend/src/business/top-bar/selectors.test.ts +++ b/frontend/src/business/top-bar/selectors.test.ts @@ -1,13 +1,24 @@ import { describe, expect, it } from 'vitest'; import { + buildTopBarNotifications, countUnreadTopBarNotifications, getTopBarCampusLabel, getTopBarInitials, getTopBarRoleLabel, } from '@/business/top-bar/selectors'; +import { APP_ROUTE_PATHS } from '@/shared/constants/routes'; describe('top bar selectors', () => { + it('surfaces an unread zone check-in nudge (linking to the zones page) only when needed', () => { + expect(buildTopBarNotifications({ needsZoneCheckIn: false })).toEqual([]); + + const withNudge = buildTopBarNotifications({ needsZoneCheckIn: true }); + expect(withNudge).toHaveLength(1); + expect(withNudge[0]).toMatchObject({ unread: true, href: APP_ROUTE_PATHS.zones }); + expect(countUnreadTopBarNotifications(withNudge)).toBe(1); + }); + it('builds initials from display names', () => { expect(getTopBarInitials('Guest')).toBe('G'); expect(getTopBarInitials('Ada Lovelace')).toBe('AL'); diff --git a/frontend/src/business/top-bar/selectors.ts b/frontend/src/business/top-bar/selectors.ts index 4bfd657..4eb8d53 100644 --- a/frontend/src/business/top-bar/selectors.ts +++ b/frontend/src/business/top-bar/selectors.ts @@ -1,4 +1,5 @@ import { getAuthRoleLabel } from '@/business/auth/selectors'; +import { APP_ROUTE_PATHS } from '@/shared/constants/routes'; import { DEFAULT_CAMPUS_LABEL } from '@/shared/constants/campusDisplay'; import type { CampusInfo, @@ -30,3 +31,28 @@ export function countUnreadTopBarNotifications( ): number { return notifications.filter((notification) => notification.unread).length; } + +const ZONE_CHECKIN_NOTIFICATION_ID = 'zone-checkin-today'; + +/** + * Builds the top-bar notification list from derived app state (there is no + * backend notifications store yet). Currently surfaces a single nudge when an + * eligible user has not logged today's Zone. + */ +export function buildTopBarNotifications(input: { + readonly needsZoneCheckIn: boolean; +}): readonly TopBarNotification[] { + const notifications: TopBarNotification[] = []; + + if (input.needsZoneCheckIn) { + notifications.push({ + id: ZONE_CHECKIN_NOTIFICATION_ID, + text: "You haven't logged your Emotional Zone today", + time: 'Today', + unread: true, + href: APP_ROUTE_PATHS.zones, + }); + } + + return notifications; +} diff --git a/frontend/src/business/top-bar/types.ts b/frontend/src/business/top-bar/types.ts index c4f2183..1dd8507 100644 --- a/frontend/src/business/top-bar/types.ts +++ b/frontend/src/business/top-bar/types.ts @@ -1,14 +1,17 @@ import type { AuthSessionState } from '@/business/auth/types'; import type { CampusInfo, + ModuleId, UserRole, } from '@/shared/types/app'; +import type { TopBarSearchResult } from '@/business/top-bar/search'; export interface TopBarProps { readonly userRole: UserRole; readonly userName: string; readonly campusInfo?: CampusInfo; readonly toggleSidebar: () => void; + readonly setCurrentModule: (moduleId: ModuleId) => void; } export interface TopBarNotification { @@ -16,6 +19,8 @@ export interface TopBarNotification { readonly text: string; readonly time: string; readonly unread: boolean; + /** Optional in-app route to navigate to when the notification is clicked. */ + readonly href?: string; } export interface UseTopBarPageOptions extends TopBarProps { @@ -34,6 +39,7 @@ export interface TopBarPage { readonly showProfileMenu: boolean; readonly showNotifications: boolean; readonly searchQuery: string; + readonly searchResults: readonly TopBarSearchResult[]; readonly signOutError: string | null; readonly notifications: readonly TopBarNotification[]; readonly unreadCount: number; @@ -43,5 +49,6 @@ export interface TopBarPage { readonly toggleNotifications: () => void; readonly closeNotifications: () => void; readonly setSearchQuery: (value: string) => void; + readonly selectSearchResult: (result: TopBarSearchResult) => void; readonly signOut: () => Promise; } diff --git a/frontend/src/business/user-progress/hooks.ts b/frontend/src/business/user-progress/hooks.ts index db72367..22ebae8 100644 --- a/frontend/src/business/user-progress/hooks.ts +++ b/frontend/src/business/user-progress/hooks.ts @@ -7,11 +7,9 @@ import { import { USER_PROGRESS_QUERY_KEYS, USER_PROGRESS_TYPES, - ZONE_CHECKIN_ITEM_ID, } from '@/shared/constants/userProgress'; -import { toLearnedSignIds, toZoneColor } from '@/business/user-progress/mappers'; -import { LearnedSignsState, ZoneCheckInState } from '@/business/user-progress/types'; -import { ZoneColor } from '@/shared/types/app'; +import { toLearnedSignIds } from '@/business/user-progress/mappers'; +import { LearnedSignsState } from '@/business/user-progress/types'; import { selectApiListRows } from '@/shared/business/apiListRows'; import { useInvalidatingMutation } from '@/shared/business/queryMutations'; @@ -59,50 +57,3 @@ export function useLearnedSignsProgress(): LearnedSignsState { toggleLearnedSign, }; } - -export function useZoneCheckIn(): ZoneCheckInState { - const progressQuery = useQuery({ - queryKey: USER_PROGRESS_QUERY_KEYS.zoneCheckin, - queryFn: () => selectApiListRows( - listUserProgress( - USER_PROGRESS_TYPES.zoneCheckin, - ZONE_CHECKIN_ITEM_ID, - ), - (rows) => toZoneColor(rows[0]?.value || null), - ), - }); - - const saveMutation = useInvalidatingMutation({ - mutationFn: (zone: ZoneColor) => upsertUserProgress({ - progress_type: USER_PROGRESS_TYPES.zoneCheckin, - item_id: ZONE_CHECKIN_ITEM_ID, - value: zone, - }), - invalidateQueryKey: USER_PROGRESS_QUERY_KEYS.zoneCheckin, - }); - - const deleteMutation = useInvalidatingMutation({ - mutationFn: () => deleteUserProgressByItem( - USER_PROGRESS_TYPES.zoneCheckin, - ZONE_CHECKIN_ITEM_ID, - ), - invalidateQueryKey: USER_PROGRESS_QUERY_KEYS.zoneCheckin, - }); - - async function setZone(zone: ZoneColor) { - await saveMutation.mutateAsync(zone); - } - - async function resetZone() { - await deleteMutation.mutateAsync(); - } - - return { - currentZone: progressQuery.data ?? null, - isLoading: progressQuery.isLoading, - isSaving: saveMutation.isPending || deleteMutation.isPending, - error: progressQuery.error || saveMutation.error || deleteMutation.error, - setZone, - resetZone, - }; -} diff --git a/frontend/src/business/user-progress/mappers.test.ts b/frontend/src/business/user-progress/mappers.test.ts index 49c0e1e..ac63eb0 100644 --- a/frontend/src/business/user-progress/mappers.test.ts +++ b/frontend/src/business/user-progress/mappers.test.ts @@ -1,8 +1,5 @@ import { describe, expect, it } from 'vitest'; -import { - toLearnedSignIds, - toZoneColor, -} from '@/business/user-progress/mappers'; +import { toLearnedSignIds } from '@/business/user-progress/mappers'; import type { UserProgressDto } from '@/shared/types/userProgress'; function createProgress(overrides: Partial = {}): UserProgressDto { @@ -32,13 +29,4 @@ describe('user progress mappers', () => { expect([...learnedSignIds].sort()).toEqual(['hello', 'help']); }); - - it('normalizes valid zone colors and rejects invalid values', () => { - expect(toZoneColor('blue')).toBe('blue'); - expect(toZoneColor('green')).toBe('green'); - expect(toZoneColor('yellow')).toBe('yellow'); - expect(toZoneColor('red')).toBe('red'); - expect(toZoneColor('purple')).toBeNull(); - expect(toZoneColor(null)).toBeNull(); - }); }); diff --git a/frontend/src/business/user-progress/mappers.ts b/frontend/src/business/user-progress/mappers.ts index 0f9dda8..54e20f5 100644 --- a/frontend/src/business/user-progress/mappers.ts +++ b/frontend/src/business/user-progress/mappers.ts @@ -1,18 +1,5 @@ import { UserProgressDto } from '@/shared/types/userProgress'; -import { ZoneColor } from '@/shared/types/app'; export function toLearnedSignIds(progress: readonly UserProgressDto[]): ReadonlySet { return new Set(progress.map((item) => item.item_id)); } - -export function toZoneColor(value: string | null): ZoneColor | null { - if (!value) { - return null; - } - - if (value === 'blue' || value === 'green' || value === 'yellow' || value === 'red') { - return value; - } - - return null; -} diff --git a/frontend/src/business/user-progress/types.ts b/frontend/src/business/user-progress/types.ts index b4f3dcb..f90c0d2 100644 --- a/frontend/src/business/user-progress/types.ts +++ b/frontend/src/business/user-progress/types.ts @@ -1,5 +1,3 @@ -import { ZoneColor } from '@/shared/types/app'; - export interface LearnedSignsState { readonly learnedSignIds: ReadonlySet; readonly isLoading: boolean; @@ -7,12 +5,3 @@ export interface LearnedSignsState { readonly error: Error | null; readonly toggleLearnedSign: (id: string, word: string) => Promise; } - -export interface ZoneCheckInState { - readonly currentZone: ZoneColor | null; - readonly isLoading: boolean; - readonly isSaving: boolean; - readonly error: Error | null; - readonly setZone: (zone: ZoneColor) => Promise; - readonly resetZone: () => Promise; -} diff --git a/frontend/src/business/zone-checkin/hooks.ts b/frontend/src/business/zone-checkin/hooks.ts new file mode 100644 index 0000000..6a76c36 --- /dev/null +++ b/frontend/src/business/zone-checkin/hooks.ts @@ -0,0 +1,61 @@ +import { useQuery } from '@tanstack/react-query'; +import { + checkInZone, + clearTodayZoneCheckin, + getTodayZoneCheckin, + listZoneCheckinHistory, +} from '@/shared/api/zoneCheckins'; +import { getApiListRows } from '@/shared/business/apiListRows'; +import { useInvalidatingMutation } from '@/shared/business/queryMutations'; +import type { ZoneColor } from '@/shared/types/app'; + +export const ZONE_CHECKIN_QUERY_KEYS = { + today: ['zoneCheckin', 'today'], + history: ['zoneCheckin', 'history'], +} as const; + +/** + * Today's Zone check-in for the caller. "Today" is resolved server-side in the + * campus timezone, so this hook never computes a date. `retry: false` so a + * caller without `ZONE_CHECKIN` (a non-campus role) silently gets no data + * instead of retrying a 403 — the nudge is role-gated anyway. + */ +export function useTodayZoneCheckIn() { + const todayQuery = useQuery({ + queryKey: ZONE_CHECKIN_QUERY_KEYS.today, + queryFn: getTodayZoneCheckin, + retry: false, + }); + + const saveMutation = useInvalidatingMutation({ + mutationFn: (zone: ZoneColor) => checkInZone(zone), + invalidateQueryKey: ZONE_CHECKIN_QUERY_KEYS.today, + }); + + const clearMutation = useInvalidatingMutation({ + mutationFn: () => clearTodayZoneCheckin(), + invalidateQueryKey: ZONE_CHECKIN_QUERY_KEYS.today, + }); + + return { + todayZone: todayQuery.data?.zone ?? null, + isCheckedInToday: todayQuery.data?.isCheckedInToday ?? false, + isLoading: todayQuery.isLoading, + isSaving: saveMutation.isPending || clearMutation.isPending, + // Only surface actionable mutation (save/clear) errors. The today-load query + // can 403 for a non-eligible role or before seeding; that is non-actionable + // for the user and must not render as a scary "Forbidden" in the widget. + error: saveMutation.error || clearMutation.error, + setZone: (zone: ZoneColor) => saveMutation.mutateAsync(zone), + clearToday: () => clearMutation.mutateAsync(), + }; +} + +/** The caller's daily check-in history (most-recent first). */ +export function useZoneCheckInHistory() { + return useQuery({ + queryKey: ZONE_CHECKIN_QUERY_KEYS.history, + queryFn: () => getApiListRows(listZoneCheckinHistory()), + retry: false, + }); +} diff --git a/frontend/src/business/zone-checkin/selectors.test.ts b/frontend/src/business/zone-checkin/selectors.test.ts new file mode 100644 index 0000000..52a744b --- /dev/null +++ b/frontend/src/business/zone-checkin/selectors.test.ts @@ -0,0 +1,28 @@ +import { describe, expect, it } from 'vitest'; +import { + canZoneCheckIn, + shouldNudgeZoneCheckIn, +} from '@/business/zone-checkin/selectors'; + +describe('zone check-in selectors', () => { + it('limits check-in to the four campus staff roles', () => { + expect(canZoneCheckIn('director')).toBe(true); + expect(canZoneCheckIn('office_manager')).toBe(true); + expect(canZoneCheckIn('teacher')).toBe(true); + expect(canZoneCheckIn('support_staff')).toBe(true); + expect(canZoneCheckIn('owner')).toBe(false); + expect(canZoneCheckIn('superintendent')).toBe(false); + expect(canZoneCheckIn('student')).toBe(false); + expect(canZoneCheckIn('guardian')).toBe(false); + }); + + it('nudges only an eligible role that has loaded and not checked in', () => { + expect(shouldNudgeZoneCheckIn('teacher', false, false)).toBe(true); + // already checked in + expect(shouldNudgeZoneCheckIn('teacher', false, true)).toBe(false); + // still loading + expect(shouldNudgeZoneCheckIn('teacher', true, false)).toBe(false); + // ineligible role + expect(shouldNudgeZoneCheckIn('owner', false, false)).toBe(false); + }); +}); diff --git a/frontend/src/business/zone-checkin/selectors.ts b/frontend/src/business/zone-checkin/selectors.ts new file mode 100644 index 0000000..9cb52c4 --- /dev/null +++ b/frontend/src/business/zone-checkin/selectors.ts @@ -0,0 +1,24 @@ +import type { UserRole } from '@/shared/types/app'; + +/** + * Roles that perform a daily Zone self-regulation check-in — the four campus + * staff roles (mirrors the backend `ZONE_CHECKIN` grant). The nudge/banner and + * notification are shown to these roles only. + */ +export function canZoneCheckIn(userRole: UserRole): boolean { + return ( + userRole === 'director' || + userRole === 'office_manager' || + userRole === 'teacher' || + userRole === 'support_staff' + ); +} + +/** Whether to nudge the user to check in: eligible role + loaded + not yet done today. */ +export function shouldNudgeZoneCheckIn( + userRole: UserRole, + isLoading: boolean, + isCheckedInToday: boolean, +): boolean { + return canZoneCheckIn(userRole) && !isLoading && !isCheckedInToday; +} diff --git a/frontend/src/components/dashboard/DashboardHero.tsx b/frontend/src/components/dashboard/DashboardHero.tsx index d541dcc..07bc893 100644 --- a/frontend/src/components/dashboard/DashboardHero.tsx +++ b/frontend/src/components/dashboard/DashboardHero.tsx @@ -3,19 +3,7 @@ import { Sparkles } from 'lucide-react'; import type { FrameEntryViewModel } from '@/business/frame/types'; import { HERO_IMAGE } from '@/shared/constants/appData'; - -function getCurrentWeekMonday(): string { - const today = new Date(); - const dayOfWeek = today.getDay(); - const mondayOffset = dayOfWeek === 0 ? -6 : 1 - dayOfWeek; - const monday = new Date(today); - monday.setDate(today.getDate() + mondayOffset); - return monday.toLocaleDateString('en-US', { - month: 'long', - day: 'numeric', - year: 'numeric', - }); -} +import { formatWeekOf, toWeekStartIso } from '@/shared/business/week'; interface DashboardHeroProps { readonly greeting: string; @@ -27,7 +15,8 @@ export function DashboardHero({ greeting, userName, }: DashboardHeroProps) { - const currentWeekMonday = useMemo(() => getCurrentWeekMonday(), []); + // Shared American (Sunday-start) week — same source as F.R.A.M.E. and the quiz. + const currentWeekLabel = useMemo(() => formatWeekOf(toWeekStartIso(new Date())), []); return (
Classroom @@ -37,7 +26,7 @@ export function DashboardHero({
- Week of {currentWeekMonday} + Week of {currentWeekLabel}

diff --git a/frontend/src/components/dashboard/DashboardView.tsx b/frontend/src/components/dashboard/DashboardView.tsx index 7ee9157..b204b93 100644 --- a/frontend/src/components/dashboard/DashboardView.tsx +++ b/frontend/src/components/dashboard/DashboardView.tsx @@ -6,7 +6,7 @@ import { DashboardQuotePanel } from '@/components/dashboard/DashboardQuotePanel' import { DashboardSignOfWeek } from '@/components/dashboard/DashboardSignOfWeek'; import { DashboardUpcomingEvents } from '@/components/dashboard/DashboardUpcomingEvents'; import { DashboardWeeklyProgress } from '@/components/dashboard/DashboardWeeklyProgress'; -import { DashboardZoneCheckIn } from '@/components/dashboard/DashboardZoneCheckIn'; +import { ZoneCheckInCard } from '@/components/zone-checkin/ZoneCheckInCard'; interface DashboardViewProps { readonly page: DashboardPage; @@ -23,14 +23,17 @@ export function DashboardView({ page }: DashboardViewProps) { - + {page.showZoneCheckIn && ( + + )}
)}
-

Week of {entry.weekOf}

+

+ Week of {entry.weekOf} + {entry.weekLabel && ( + + {entry.weekLabel} + + )} +

{entry.author} - Posted {entry.postedDate}

diff --git a/frontend/src/components/frame/FrameEntryEditForm.tsx b/frontend/src/components/frame/FrameEntryEditForm.tsx index cb07b01..543a013 100644 --- a/frontend/src/components/frame/FrameEntryEditForm.tsx +++ b/frontend/src/components/frame/FrameEntryEditForm.tsx @@ -4,6 +4,7 @@ import type { FrameEntryViewModel } from '@/business/frame/types'; import { FRAME_SECTION_LABELS } from '@/shared/constants/frame'; import { Button } from '@/components/ui/button'; import { FrameSectionField } from '@/components/frame/FrameSectionField'; +import { FrameWeekPicker } from '@/components/frame/FrameWeekPicker'; import type { FrameModuleWorkflow } from '@/components/frame/types'; interface FrameEntryEditFormProps { @@ -14,6 +15,12 @@ interface FrameEntryEditFormProps { export function FrameEntryEditForm({ editEntry, workflow }: FrameEntryEditFormProps) { return ( <> + workflow.updateEditEntryField('weekStart', iso)} + onLabelChange={(label) => workflow.updateEditEntryField('weekLabel', label)} + /> {FRAME_SECTION_LABELS.map((section) => ( Create New F.R.A.M.E. Entry

-
- - workflow.updateNewEntryField('weekOf', event.target.value)} - placeholder="Week label or date" - className="w-full mt-1 px-4 py-2.5 bg-slate-700/50 border border-slate-600/50 rounded-xl text-sm text-white placeholder-slate-500 focus:ring-2 focus:ring-violet-500 outline-none" - /> -
+ workflow.updateNewEntryField('weekStart', iso)} + onLabelChange={(label) => workflow.updateNewEntryField('weekLabel', label)} + /> {FRAME_SECTION_LABELS.map((section) => ( void; + readonly onLabelChange: (label: string) => void; +} + +/** + * Week selector for a F.R.A.M.E. entry: a calendar where picking any day snaps + * to that week's start (Sunday, American style), plus an optional free-text + * label. Used by both the create and edit forms. + */ +export function FrameWeekPicker({ + weekStart, + weekLabel, + onWeekStartChange, + onLabelChange, +}: FrameWeekPickerProps) { + const [open, setOpen] = useState(false); + const selected = weekStart ? parseISO(weekStart) : undefined; + + return ( +
+
+ + + + + + + { + if (day) { + onWeekStartChange(toWeekStartIso(day)); + setOpen(false); + } + }} + // Shade the whole selected week (Sunday–Saturday) with a clear, + // high-contrast band; the picked day stays a solid violet pill. + modifiers={{ + selectedWeek: (day) => Boolean(weekStart) && toWeekStartIso(day) === weekStart, + }} + modifiersClassNames={{ + selectedWeek: 'bg-violet-500/30 text-white rounded-none', + }} + classNames={{ + selected: + 'bg-violet-600 text-white font-semibold hover:bg-violet-600 hover:text-white focus:bg-violet-600 focus:text-white rounded-md', + }} + /> + + +
+ +
+ + onLabelChange(event.target.value)} + placeholder="e.g. Spring Break week" + className="w-full mt-1 px-4 py-2.5 bg-slate-700/50 border border-slate-600/50 rounded-xl text-sm text-white placeholder-slate-500 focus:ring-2 focus:ring-violet-500 outline-none" + /> +
+
+ ); +} diff --git a/frontend/src/components/frameworks/ZonesOfRegulation.tsx b/frontend/src/components/frameworks/ZonesOfRegulation.tsx index 179092d..c59f49c 100644 --- a/frontend/src/components/frameworks/ZonesOfRegulation.tsx +++ b/frontend/src/components/frameworks/ZonesOfRegulation.tsx @@ -1,10 +1,21 @@ import { useZonesOfRegulationPage } from '@/business/zones/hooks'; import { ZonesOfRegulationView } from '@/components/zones-of-regulation/ZonesOfRegulationView'; +import { ZoneCheckInSection } from '@/components/zone-checkin/ZoneCheckInSection'; +import type { UserRole } from '@/shared/types/app'; -const ZonesOfRegulation = () => { +interface ZonesOfRegulationProps { + readonly userRole: UserRole; +} + +const ZonesOfRegulation = ({ userRole }: ZonesOfRegulationProps) => { const page = useZonesOfRegulationPage(); - return ; + return ( +
+ + +
+ ); }; export default ZonesOfRegulation; diff --git a/frontend/src/components/top-bar/TopBarNotifications.tsx b/frontend/src/components/top-bar/TopBarNotifications.tsx index 30065a9..1a89a12 100644 --- a/frontend/src/components/top-bar/TopBarNotifications.tsx +++ b/frontend/src/components/top-bar/TopBarNotifications.tsx @@ -1,6 +1,9 @@ +import { useRef } from 'react'; import { Bell } from 'lucide-react'; +import { Link } from 'react-router-dom'; import { Button } from '@/components/ui/button'; +import { useOnClickOutside } from '@/hooks/useOnClickOutside'; import type { TopBarNotification } from '@/business/top-bar/types'; interface TopBarNotificationsProps { @@ -18,8 +21,11 @@ export function TopBarNotifications({ onToggle, onClose, }: TopBarNotificationsProps) { + const containerRef = useRef(null); + useOnClickOutside(containerRef, onClose, isOpen); + return ( -
+
); diff --git a/frontend/src/components/top-bar/TopBarProfileMenu.tsx b/frontend/src/components/top-bar/TopBarProfileMenu.tsx index 5314487..04c5c45 100644 --- a/frontend/src/components/top-bar/TopBarProfileMenu.tsx +++ b/frontend/src/components/top-bar/TopBarProfileMenu.tsx @@ -1,6 +1,8 @@ +import { useRef } from 'react'; import { Building2, ChevronDown, LogOut, Settings, User, Wifi } from 'lucide-react'; import { Button } from '@/components/ui/button'; +import { useOnClickOutside } from '@/hooks/useOnClickOutside'; import { TOP_BAR_PROFILE_MENU_ITEMS } from '@/shared/constants/topBar'; import type { CampusInfo } from '@/shared/types/app'; import { cn } from '@/lib/utils'; @@ -28,12 +30,15 @@ export function TopBarProfileMenu({ onClose, onSignOut, }: TopBarProfileMenuProps) { + const containerRef = useRef(null); + useOnClickOutside(containerRef, onClose, isOpen); + const avatarClassName = campusInfo ? cn('bg-gradient-to-br', campusInfo.bgGradient) : 'bg-gradient-to-br from-violet-500 to-amber-400 shadow-violet-500/20'; return ( -
+
-
- +
)} ); diff --git a/frontend/src/components/top-bar/TopBarSearch.tsx b/frontend/src/components/top-bar/TopBarSearch.tsx index 30182d5..05d211a 100644 --- a/frontend/src/components/top-bar/TopBarSearch.tsx +++ b/frontend/src/components/top-bar/TopBarSearch.tsx @@ -1,23 +1,101 @@ +import { useRef, useState } from 'react'; import { Search } from 'lucide-react'; import { Input } from '@/components/ui/input'; +import { useOnClickOutside } from '@/hooks/useOnClickOutside'; +import type { TopBarSearchResult } from '@/business/top-bar/search'; interface TopBarSearchProps { readonly value: string; + readonly results: readonly TopBarSearchResult[]; readonly onChange: (value: string) => void; + readonly onSelect: (result: TopBarSearchResult) => void; } -export function TopBarSearch({ value, onChange }: TopBarSearchProps) { +export function TopBarSearch({ value, results, onChange, onSelect }: TopBarSearchProps) { + const containerRef = useRef(null); + const [isOpen, setIsOpen] = useState(false); + const [highlightIndex, setHighlightIndex] = useState(0); + + useOnClickOutside(containerRef, () => setIsOpen(false), isOpen); + + const showDropdown = isOpen && value.trim().length > 0; + + const handleChange = (next: string) => { + onChange(next); + setIsOpen(true); + setHighlightIndex(0); + }; + + const select = (result: TopBarSearchResult) => { + onSelect(result); + setIsOpen(false); + }; + + const handleKeyDown = (event: React.KeyboardEvent) => { + if (!showDropdown) { + return; + } + if (event.key === 'ArrowDown') { + event.preventDefault(); + setHighlightIndex((index) => Math.min(index + 1, results.length - 1)); + } else if (event.key === 'ArrowUp') { + event.preventDefault(); + setHighlightIndex((index) => Math.max(index - 1, 0)); + } else if (event.key === 'Enter') { + const result = results[highlightIndex]; + if (result) { + event.preventDefault(); + select(result); + } + } else if (event.key === 'Escape') { + setIsOpen(false); + } + }; + return ( -
+
onChange(event.target.value)} + onChange={(event) => handleChange(event.target.value)} + onFocus={() => setIsOpen(true)} + onKeyDown={handleKeyDown} placeholder="Search modules, strategies, signs..." className="pl-9 pr-4 py-2 w-72 bg-slate-800/80 border-slate-700/50 rounded-xl text-sm text-white placeholder-slate-500 focus-visible:ring-violet-500/50 focus-visible:border-violet-500/50" /> + {showDropdown && ( +
+ {results.length === 0 ? ( +
No matches found.
+ ) : ( + results.map((result, index) => ( + + )) + )} +
+ )}
); } diff --git a/frontend/src/components/top-bar/TopBarView.tsx b/frontend/src/components/top-bar/TopBarView.tsx index 78f78b9..dcf97c9 100644 --- a/frontend/src/components/top-bar/TopBarView.tsx +++ b/frontend/src/components/top-bar/TopBarView.tsx @@ -28,7 +28,12 @@ export function TopBarView({ page }: TopBarViewProps) { > - +
diff --git a/frontend/src/components/dashboard/DashboardZoneCheckIn.tsx b/frontend/src/components/zone-checkin/ZoneCheckInCard.tsx similarity index 70% rename from frontend/src/components/dashboard/DashboardZoneCheckIn.tsx rename to frontend/src/components/zone-checkin/ZoneCheckInCard.tsx index 970b11c..9525a58 100644 --- a/frontend/src/components/dashboard/DashboardZoneCheckIn.tsx +++ b/frontend/src/components/zone-checkin/ZoneCheckInCard.tsx @@ -3,28 +3,43 @@ import type { DashboardZoneOption } from '@/shared/constants/dashboard'; import type { ZoneColor } from '@/shared/types/app'; import { cn } from '@/lib/utils'; -interface DashboardZoneCheckInProps { +interface ZoneCheckInCardProps { readonly zones: readonly DashboardZoneOption[]; readonly activeZone: ZoneColor | null; readonly isSaving: boolean; readonly errorMessage: string | null; + /** Highlight that the eligible user has not logged a zone today. */ + readonly needsCheckIn?: boolean; readonly onCheckIn: (zone: ZoneColor) => Promise; readonly onReset: () => Promise; } -export function DashboardZoneCheckIn({ +/** + * The daily Zone check-in card — shared by the dashboard and the + * Zones of Regulation page. Presentational; persistence is owned by + * `useTodayZoneCheckIn`. + */ +export function ZoneCheckInCard({ zones, activeZone, isSaving, errorMessage, + needsCheckIn = false, onCheckIn, onReset, -}: DashboardZoneCheckInProps) { +}: ZoneCheckInCardProps) { return (
-

Today's Zone Check-In

+

+ Today's Emotional Zone Check-In + {needsCheckIn && ( + + Not checked in + + )} +

How are you feeling right now? Saved to your profile.

{activeZone && ( @@ -40,7 +55,7 @@ export function DashboardZoneCheckIn({ )}
-
+
{zones.map((zone) => (
+ ); +} diff --git a/frontend/src/components/zone-checkin/ZoneCheckInSection.tsx b/frontend/src/components/zone-checkin/ZoneCheckInSection.tsx new file mode 100644 index 0000000..3050e11 --- /dev/null +++ b/frontend/src/components/zone-checkin/ZoneCheckInSection.tsx @@ -0,0 +1,45 @@ +import { useTodayZoneCheckIn } from '@/business/zone-checkin/hooks'; +import { canZoneCheckIn, shouldNudgeZoneCheckIn } from '@/business/zone-checkin/selectors'; +import { DASHBOARD_ZONE_OPTIONS } from '@/shared/constants/dashboard'; +import { getOptionalErrorMessage } from '@/shared/errors/errorMessages'; +import { ZoneCheckInCard } from '@/components/zone-checkin/ZoneCheckInCard'; +import { ZoneCheckInReminder } from '@/components/zone-checkin/ZoneCheckInReminder'; +import type { UserRole } from '@/shared/types/app'; + +interface ZoneCheckInSectionProps { + readonly userRole: UserRole; +} + +/** + * Self-contained daily Zone check-in (reminder banner + card) for the Zones of + * Regulation page. Owns its own state via `useTodayZoneCheckIn`; rendered only + * for the eligible campus-staff roles. + */ +export function ZoneCheckInSection({ userRole }: ZoneCheckInSectionProps) { + const zone = useTodayZoneCheckIn(); + + if (!canZoneCheckIn(userRole)) { + return null; + } + + const needsCheckIn = shouldNudgeZoneCheckIn( + userRole, + zone.isLoading, + zone.isCheckedInToday, + ); + + return ( +
+ {needsCheckIn && } + { await zone.setZone(selected); }} + onReset={async () => { await zone.clearToday(); }} + /> +
+ ); +} diff --git a/frontend/src/hooks/useOnClickOutside.test.tsx b/frontend/src/hooks/useOnClickOutside.test.tsx new file mode 100644 index 0000000..6e6a769 --- /dev/null +++ b/frontend/src/hooks/useOnClickOutside.test.tsx @@ -0,0 +1,56 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { createRef } from 'react'; +import { renderHook } from '@testing-library/react'; +import { useOnClickOutside } from '@/hooks/useOnClickOutside'; + +function mount(): { ref: { current: HTMLDivElement }; inside: HTMLElement; outside: HTMLElement } { + const container = document.createElement('div'); + const inside = document.createElement('button'); + container.appendChild(inside); + const outside = document.createElement('div'); + document.body.append(container, outside); + return { ref: { current: container }, inside, outside }; +} + +afterEach(() => { + document.body.innerHTML = ''; +}); + +describe('useOnClickOutside', () => { + it('calls the handler on a pointer press outside the element', () => { + const { ref, outside } = mount(); + const handler = vi.fn(); + renderHook(() => useOnClickOutside(ref, handler, true)); + + outside.dispatchEvent(new MouseEvent('mousedown', { bubbles: true })); + expect(handler).toHaveBeenCalledTimes(1); + }); + + it('ignores presses inside the element', () => { + const { ref, inside } = mount(); + const handler = vi.fn(); + renderHook(() => useOnClickOutside(ref, handler, true)); + + inside.dispatchEvent(new MouseEvent('mousedown', { bubbles: true })); + expect(handler).not.toHaveBeenCalled(); + }); + + it('does nothing when disabled', () => { + const { ref, outside } = mount(); + const handler = vi.fn(); + renderHook(() => useOnClickOutside(ref, handler, false)); + + outside.dispatchEvent(new MouseEvent('mousedown', { bubbles: true })); + expect(handler).not.toHaveBeenCalled(); + }); + + it('is safe when the ref is null', () => { + const ref = createRef(); + const handler = vi.fn(); + renderHook(() => useOnClickOutside(ref, handler, true)); + + // When ref.current is null, the hook returns early without calling handler + document.body.dispatchEvent(new MouseEvent('mousedown', { bubbles: true })); + expect(handler).not.toHaveBeenCalled(); + }); +}); diff --git a/frontend/src/hooks/useOnClickOutside.ts b/frontend/src/hooks/useOnClickOutside.ts new file mode 100644 index 0000000..4f9715f --- /dev/null +++ b/frontend/src/hooks/useOnClickOutside.ts @@ -0,0 +1,35 @@ +import { useEffect } from 'react'; +import type { RefObject } from 'react'; + +/** + * Calls `handler` when a pointer press lands outside the referenced element. + * Uses a document-level `mousedown`/`touchstart` listener (robust against + * z-index/stacking contexts, unlike a fixed overlay). Only active while + * `enabled` is true. + */ +export function useOnClickOutside( + ref: RefObject, + handler: () => void, + enabled = true, +): void { + useEffect(() => { + if (!enabled) { + return; + } + + const onPointerDown = (event: MouseEvent | TouchEvent) => { + const element = ref.current; + if (!element || element.contains(event.target as Node)) { + return; + } + handler(); + }; + + document.addEventListener('mousedown', onPointerDown); + document.addEventListener('touchstart', onPointerDown); + return () => { + document.removeEventListener('mousedown', onPointerDown); + document.removeEventListener('touchstart', onPointerDown); + }; + }, [ref, handler, enabled]); +} diff --git a/frontend/src/pages/modules/ZonesOfRegulationPage.tsx b/frontend/src/pages/modules/ZonesOfRegulationPage.tsx index d69e1f4..169519b 100644 --- a/frontend/src/pages/modules/ZonesOfRegulationPage.tsx +++ b/frontend/src/pages/modules/ZonesOfRegulationPage.tsx @@ -1,5 +1,8 @@ +import { useShellOutletContext } from '@/app/shellOutletContext'; import ZonesOfRegulation from '@/components/frameworks/ZonesOfRegulation'; export default function ZonesOfRegulationPage() { - return ; + const shell = useShellOutletContext(); + + return ; } diff --git a/frontend/src/shared/api/zoneCheckins.test.ts b/frontend/src/shared/api/zoneCheckins.test.ts new file mode 100644 index 0000000..54b801c --- /dev/null +++ b/frontend/src/shared/api/zoneCheckins.test.ts @@ -0,0 +1,77 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { + checkInZone, + clearTodayZoneCheckin, + getTodayZoneCheckin, + listZoneCheckinHistory, +} from '@/shared/api/zoneCheckins'; +import { apiRequest } from '@/shared/api/httpClient'; +import type { ZoneCheckinTodayDto } from '@/shared/types/zoneCheckins'; + +vi.mock('@/shared/api/httpClient', () => ({ + apiRequest: vi.fn(), +})); + +const apiRequestMock = vi.mocked(apiRequest); + +describe('zoneCheckins API', () => { + beforeEach(() => { + apiRequestMock.mockReset(); + }); + + it('fetches today zone check-in status', async () => { + const todayCheckin: ZoneCheckinTodayDto = { + zone: 'green', + checkedInAt: '2026-06-12T10:00:00Z', + }; + apiRequestMock.mockResolvedValueOnce(todayCheckin); + + await expect(getTodayZoneCheckin()).resolves.toEqual(todayCheckin); + + expect(apiRequestMock).toHaveBeenCalledWith('/zone_checkins/today'); + }); + + it('checks in to a zone', async () => { + const todayCheckin: ZoneCheckinTodayDto = { + zone: 'blue', + checkedInAt: '2026-06-12T10:30:00Z', + }; + apiRequestMock.mockResolvedValueOnce(todayCheckin); + + await expect(checkInZone('blue')).resolves.toEqual(todayCheckin); + + expect(apiRequestMock).toHaveBeenCalledWith('/zone_checkins', { + method: 'POST', + body: { data: { zone: 'blue' } }, + }); + }); + + it('clears today zone check-in', async () => { + const clearedCheckin: ZoneCheckinTodayDto = { + zone: null, + checkedInAt: null, + }; + apiRequestMock.mockResolvedValueOnce(clearedCheckin); + + await expect(clearTodayZoneCheckin()).resolves.toEqual(clearedCheckin); + + expect(apiRequestMock).toHaveBeenCalledWith('/zone_checkins/today', { + method: 'DELETE', + }); + }); + + it('lists zone check-in history', async () => { + const historyResponse = { + rows: [ + { id: '1', zone: 'green', date: '2026-06-11' }, + { id: '2', zone: 'yellow', date: '2026-06-10' }, + ], + count: 2, + }; + apiRequestMock.mockResolvedValueOnce(historyResponse); + + await expect(listZoneCheckinHistory()).resolves.toEqual(historyResponse); + + expect(apiRequestMock).toHaveBeenCalledWith('/zone_checkins'); + }); +}); diff --git a/frontend/src/shared/api/zoneCheckins.ts b/frontend/src/shared/api/zoneCheckins.ts new file mode 100644 index 0000000..5725ba6 --- /dev/null +++ b/frontend/src/shared/api/zoneCheckins.ts @@ -0,0 +1,34 @@ +import { apiRequest } from '@/shared/api/httpClient'; +import type { ApiListResponse } from '@/shared/types/api'; +import type { ZoneColor } from '@/shared/types/app'; +import type { + ZoneCheckinHistoryEntryDto, + ZoneCheckinTodayDto, +} from '@/shared/types/zoneCheckins'; + +const ZONE_CHECKINS_PATH = '/zone_checkins'; + +export function getTodayZoneCheckin(): Promise { + return apiRequest(`${ZONE_CHECKINS_PATH}/today`); +} + +export function checkInZone(zone: ZoneColor): Promise { + return apiRequest(ZONE_CHECKINS_PATH, { + method: 'POST', + body: { data: { zone } }, + }); +} + +export function clearTodayZoneCheckin(): Promise { + return apiRequest(`${ZONE_CHECKINS_PATH}/today`, { + method: 'DELETE', + }); +} + +export function listZoneCheckinHistory(): Promise< + ApiListResponse +> { + return apiRequest>( + ZONE_CHECKINS_PATH, + ); +} diff --git a/frontend/src/shared/auth/permissions.ts b/frontend/src/shared/auth/permissions.ts index 5520948..4a16574 100644 --- a/frontend/src/shared/auth/permissions.ts +++ b/frontend/src/shared/auth/permissions.ts @@ -28,7 +28,7 @@ export const MODULE_PERMISSIONS = [ 'READ_EI', 'READ_ZONES', 'READ_SIGNS', 'READ_ATTENDANCE', 'READ_PARENT_COMM', 'READ_INTERNAL_COMM', 'READ_SAFETY', 'READ_HANDBOOK', 'READ_COMMUNITY', 'READ_VOCATIONAL', 'READ_ESA', 'READ_WALKTHROUGH', 'READ_DIRECTOR_DASHBOARD', - 'FILL_ATTENDANCE', 'TAKE_QUIZ', 'ACK_READ_RECEIPT', + 'FILL_ATTENDANCE', 'TAKE_QUIZ', 'ACK_READ_RECEIPT', 'ZONE_CHECKIN', ] as const; export type PermissionEntity = (typeof PERMISSION_ENTITIES)[number]; diff --git a/frontend/src/shared/business/week.test.ts b/frontend/src/shared/business/week.test.ts new file mode 100644 index 0000000..3b5af98 --- /dev/null +++ b/frontend/src/shared/business/week.test.ts @@ -0,0 +1,27 @@ +import { describe, expect, it } from 'vitest'; +import { + formatWeekOf, + isCurrentWeek, + toWeekStartIso, +} from '@/shared/business/week'; + +describe('shared week util (American / Sunday start)', () => { + it('snaps any day to its week-start Sunday', () => { + // 2026-06-10 is a Wednesday → week starts Sunday 2026-06-07. + expect(toWeekStartIso(new Date('2026-06-10T12:00:00'))).toBe('2026-06-07'); + // A Sunday maps to itself. + expect(toWeekStartIso(new Date('2026-06-07T08:00:00'))).toBe('2026-06-07'); + // A Saturday is the last day of the week. + expect(toWeekStartIso(new Date('2026-06-13T23:00:00'))).toBe('2026-06-07'); + }); + + it('formats a week-start ISO as "Month D, YYYY"', () => { + expect(formatWeekOf('2026-06-07')).toBe('June 7, 2026'); + }); + + it('detects the current week relative to a reference date', () => { + const now = new Date('2026-06-10T09:00:00'); + expect(isCurrentWeek('2026-06-07', now)).toBe(true); + expect(isCurrentWeek('2026-06-14', now)).toBe(false); + }); +}); diff --git a/frontend/src/shared/business/week.ts b/frontend/src/shared/business/week.ts new file mode 100644 index 0000000..e26bcd3 --- /dev/null +++ b/frontend/src/shared/business/week.ts @@ -0,0 +1,31 @@ +import { format, parseISO, startOfWeek } from 'date-fns'; + +/** + * Shared week canonicalization. **American style** — the week starts on + * **Sunday** (`weekStartsOn: 0`). One source of truth for the dashboard hero, + * the F.R.A.M.E. weekly entry, and the safety-quiz week, so they never drift. + */ +const WEEK_STARTS_ON = 0; // Sunday + +/** The Date at the start (Sunday, local midnight) of the week containing `date`. */ +export function getWeekStart(date: Date): Date { + return startOfWeek(date, { weekStartsOn: WEEK_STARTS_ON }); +} + +/** The week-start (Sunday) as an ISO `YYYY-MM-DD` string. The canonical key. */ +export function toWeekStartIso(date: Date): string { + return format(getWeekStart(date), 'yyyy-MM-dd'); +} + +/** + * Human display for a week-start ISO date, e.g. `2026-06-07` → `June 7, 2026`. + * Snaps to the week start defensively in case a non-normalized date is passed. + */ +export function formatWeekOf(weekStartIso: string): string { + return format(getWeekStart(parseISO(weekStartIso)), 'MMMM d, yyyy'); +} + +/** Whether `weekStartIso` is the current week (in the runtime's local time). */ +export function isCurrentWeek(weekStartIso: string, now: Date = new Date()): boolean { + return weekStartIso === toWeekStartIso(now); +} diff --git a/frontend/src/shared/constants/userProgress.ts b/frontend/src/shared/constants/userProgress.ts index 1f81db7..9db8876 100644 --- a/frontend/src/shared/constants/userProgress.ts +++ b/frontend/src/shared/constants/userProgress.ts @@ -2,12 +2,8 @@ import { UserProgressType } from '@/shared/types/userProgress'; export const USER_PROGRESS_TYPES: Record = { signLearned: 'sign_learned', - zoneCheckin: 'zone_checkin', }; -export const ZONE_CHECKIN_ITEM_ID = 'current'; - export const USER_PROGRESS_QUERY_KEYS = { signProgress: ['userProgress', USER_PROGRESS_TYPES.signLearned], - zoneCheckin: ['userProgress', USER_PROGRESS_TYPES.zoneCheckin, ZONE_CHECKIN_ITEM_ID], } as const; diff --git a/frontend/src/shared/types/frame.ts b/frontend/src/shared/types/frame.ts index 5083518..b3c532d 100644 --- a/frontend/src/shared/types/frame.ts +++ b/frontend/src/shared/types/frame.ts @@ -8,6 +8,7 @@ export type FrameSectionKey = export interface FrameEntryDto { readonly id: string; readonly week_of: string; + readonly week_label: string | null; readonly posted_date: string; readonly formal: string; readonly recognition: string; @@ -23,6 +24,7 @@ export interface FrameEntryDto { export interface FrameEntryMutationDto { readonly week_of: string; + readonly week_label?: string; readonly posted_date: string; readonly formal: string; readonly recognition: string; diff --git a/frontend/src/shared/types/zoneCheckins.ts b/frontend/src/shared/types/zoneCheckins.ts new file mode 100644 index 0000000..ec4e232 --- /dev/null +++ b/frontend/src/shared/types/zoneCheckins.ts @@ -0,0 +1,17 @@ +import type { ZoneColor } from '@/shared/types/app'; + +/** + * Daily Zone check-in (Workstream 16). "Today" is the caller's campus-local date + * (computed server-side from the campus timezone), so the client never decides + * the date — it reads `isCheckedInToday` from the backend. + */ +export interface ZoneCheckinTodayDto { + readonly date: string; + readonly zone: ZoneColor | null; + readonly isCheckedInToday: boolean; +} + +export interface ZoneCheckinHistoryEntryDto { + readonly date: string; + readonly zone: ZoneColor; +} diff --git a/frontend/tests/e2e/content-catalog.seeded.e2e.ts b/frontend/tests/e2e/content-catalog.seeded.e2e.ts index e0f5fbf..9b8401c 100644 --- a/frontend/tests/e2e/content-catalog.seeded.e2e.ts +++ b/frontend/tests/e2e/content-catalog.seeded.e2e.ts @@ -155,7 +155,7 @@ test.describe('seeded content catalog integration', () => { await expect(page.getByText('Loading classroom strategies...')).toHaveCount(0); await expect(page.getByRole('heading', { name: 'Classroom Support' })).toBeVisible(); - await expect(page.getByText(firstStrategy.title, { exact: true })).toBeVisible(); + await expect(page.getByRole('heading', { name: firstStrategy.title })).toBeVisible(); }); test('renders seeded sign language content', async ({ page, request }) => { diff --git a/frontend/tests/e2e/product-workflow.seeded.e2e.ts b/frontend/tests/e2e/product-workflow.seeded.e2e.ts index c5e7a0b..1eb2b42 100644 --- a/frontend/tests/e2e/product-workflow.seeded.e2e.ts +++ b/frontend/tests/e2e/product-workflow.seeded.e2e.ts @@ -20,6 +20,7 @@ const TEACHER_EMAIL = 'teacher@flatlogic.com'; interface FrameRow { readonly id: string; readonly author: string; + readonly week_of: string; } interface ProgressRow { readonly item_id: string; @@ -61,7 +62,11 @@ test.describe('Product-workflow persistence', () => { const listRes = await page.request.get(`${BACKEND_API_URL}/frame_entries`); expect(listRes.status()).toBe(200); const body = (await listRes.json()) as { rows?: FrameRow[] }; - expect((body.rows ?? []).some((row) => row.author === author)).toBe(true); + const saved = (body.rows ?? []).find((row) => row.author === author); + expect(saved, 'the FRAME entry persisted').toBeTruthy(); + // The posted `week_of` (2026-06-01, a Monday) is normalized server-side to + // its Sunday week-start (American week). + expect(saved?.week_of).toBe('2026-05-31'); }); test('a staff member marks a sign learned and progress persists', async ({ diff --git a/frontend/tests/e2e/zone-checkins.seeded.e2e.ts b/frontend/tests/e2e/zone-checkins.seeded.e2e.ts new file mode 100644 index 0000000..29c2915 --- /dev/null +++ b/frontend/tests/e2e/zone-checkins.seeded.e2e.ts @@ -0,0 +1,88 @@ +import { expect, type Page, test } from '@playwright/test'; + +/** + * Daily Zone check-in e2e (Workstream 16). Proves the contract: campus staff + * (ZONE_CHECKIN) record/clear today's zone and read it back; "today" is + * server-computed from the campus timezone; invalid zones are rejected; and + * external roles are locked out. + * + * Requires the backend running with the database migrated + seeded (the Tigers + * campus is seeded with `America/Phoenix`). + */ +const USER_PASSWORD = 'flatlogicUser123!'; + +const BACKEND_API_URL = + process.env.VITE_BACKEND_API_URL || 'http://localhost:8080/api'; + +const ZONE_CHECKINS = `${BACKEND_API_URL}/zone_checkins`; +const ZONE_CHECKINS_TODAY = `${ZONE_CHECKINS}/today`; + +const TEACHER = 'teacher@flatlogic.com'; +const SUPPORT_STAFF = 'support_staff@flatlogic.com'; +const GUARDIAN = 'guardian@flatlogic.com'; + +const DENIED = [400, 401, 403]; + +interface TodayPayload { + readonly date: string; + readonly zone: string | null; + readonly isCheckedInToday: boolean; +} + +async function logout(page: Page): Promise { + await page.request.post(`${BACKEND_API_URL}/auth/signout`); + await page.context().clearCookies(); +} + +async function login(page: Page, email: string): Promise { + await logout(page); + await page.goto('/login'); + await page.getByPlaceholder('you@school.edu').fill(email); + await page.getByPlaceholder('Enter your password').fill(USER_PASSWORD); + await page.getByRole('button', { name: 'Sign In', exact: true }).click(); + await page.waitForURL((url) => !url.pathname.startsWith('/login'), { + timeout: 10_000, + }); +} + +test.describe('Daily Zone check-in', () => { + test('a teacher records, reads back, and clears today (campus-local)', async ({ page }) => { + await login(page, TEACHER); + + // Clean slate, then check in. + await page.request.delete(ZONE_CHECKINS_TODAY); + const created = await page.request.post(ZONE_CHECKINS, { + data: { data: { zone: 'green' } }, + }); + expect(created.ok()).toBe(true); + + const today = (await (await page.request.get(ZONE_CHECKINS_TODAY)).json()) as TodayPayload; + expect(today.isCheckedInToday).toBe(true); + expect(today.zone).toBe('green'); + // "today" is a YYYY-MM-DD date (server-computed in the campus timezone). + expect(today.date).toMatch(/^\d{4}-\d{2}-\d{2}$/); + + const cleared = await page.request.delete(ZONE_CHECKINS_TODAY); + expect(cleared.ok()).toBe(true); + const after = (await (await page.request.get(ZONE_CHECKINS_TODAY)).json()) as TodayPayload; + expect(after.isCheckedInToday).toBe(false); + expect(after.zone).toBeNull(); + }); + + test('an invalid zone is rejected', async ({ page }) => { + await login(page, SUPPORT_STAFF); + const res = await page.request.post(ZONE_CHECKINS, { + data: { data: { zone: 'purple' } }, + }); + expect(DENIED).toContain(res.status()); + }); + + test('external roles cannot check in', async ({ page }) => { + await login(page, GUARDIAN); + expect(DENIED).toContain((await page.request.get(ZONE_CHECKINS_TODAY)).status()); + const create = await page.request.post(ZONE_CHECKINS, { + data: { data: { zone: 'green' } }, + }); + expect(DENIED).toContain(create.status()); + }); +});