fixed dashboard functionality

This commit is contained in:
Dmitri 2026-06-12 10:56:13 +02:00
parent 4b45ce30f5
commit 799eba7306
88 changed files with 1905 additions and 237 deletions

View File

@ -65,7 +65,9 @@ all `200`) — see `backend-architecture.md` for the shared contract:
Model columns (`paranoid`, soft-delete via `deletedAt`, `freezeTableName`): 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, `mascot`, `color`, `bgGradient`, `borderColor`, `textColor`, `bgLight`, `description` (all TEXT,
nullable), `isOnline` (BOOLEAN, not null, default `false`), `active` (BOOLEAN, not null, default nullable), `isOnline` (BOOLEAN, not null, default `false`), `active` (BOOLEAN, not null, default
`false`), `importHash` (STRING(255), unique, nullable), `organizationId` (UUID, nullable), `false`), `importHash` (STRING(255), unique, nullable), `organizationId` (UUID, nullable),

View File

@ -1,7 +1,7 @@
# Database Schema # Database Schema
> Generated from the Sequelize models (`backend/src/db/models/*`) — the source of truth. > 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 ## Overview
@ -158,6 +158,7 @@ Authentication identities. `email` is required (login + primary contact). Belong
| `id` | uuid | no | UUIDV4 | PK | | `id` | uuid | no | UUIDV4 | PK |
| `firstName` | text | yes | — | | | `firstName` | text | yes | — | |
| `lastName` | text | yes | — | | | `lastName` | text | yes | — | |
| `name_prefix` | enum | yes | — | honorific (`mr`/`mrs`/`ms`/`mx`/`dr`/`prof`) |
| `phoneNumber` | text | yes | — | | | `phoneNumber` | text | yes | — | |
| `email` | text | no | — | | | `email` | text | no | — | |
| `disabled` | boolean | no | false | | | `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 | | `id` | uuid | no | UUIDV4 | PK |
| `name` | text | yes | — | | | `name` | text | yes | — | |
| `code` | text | yes | — | | | `code` | text | yes | — | |
| `timezone` | text | no | — | validated IANA zone (zone check-in "today") |
| `address` | text | yes | — | | | `address` | text | yes | — | |
| `phone` | text | yes | — | | | `phone` | text | yes | — | |
| `email` | text | yes | — | | | `email` | text | yes | — | |
@ -820,7 +822,8 @@ Product-module "frame" entries.
| Column | Type | Null | Default | Notes | | Column | Type | Null | Default | Notes |
|---|---|---|---|---| |---|---|---|---|---|
| `id` | uuid | no | UUIDV4 | PK | | `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 | — | | | `posted_date` | text | no | — | |
| `formal` | text | no | — | | | `formal` | text | no | — | |
| `recognition` | text | no | — | | | `recognition` | text | no | — | |

View File

@ -49,17 +49,29 @@ Request body for create/update is wrapped as `{ data: <FrameEntryInput> }`.
## Data Contract ## Data Contract
Required request fields (`REQUIRED_FIELDS`): `week_of`, `posted_date`, `formal`, `recognition`, Required request fields (`REQUIRED_FIELDS`): `week_of`, `posted_date`, `formal`, `recognition`,
`application`, `management`, `emotional`, `author`. Optional: `campusId`. Missing/invalid input `application`, `management`, `emotional`, `author`. Optional: `week_label`, `campusId`.
raises `ValidationError`. 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 ## Behavior / Notes
- Create/update run inside `withTransaction`. - Create/update run inside `withTransaction`.
- List is paginated with the shared defaults (`resolvePagination`). - 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 ## 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 ## Related

View File

@ -46,6 +46,7 @@ Tenant Scope / Data Contract / Behavior / Tests / Related).
- [`staff-attendance.md`](staff-attendance.md) - [`staff-attendance.md`](staff-attendance.md)
- [`user-progress.md`](user-progress.md) - [`user-progress.md`](user-progress.md)
- [`walkthrough-checkins.md`](walkthrough-checkins.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 ## Generic CRUD Entity Slices

View File

@ -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 `20260611000000-policy-documents-and-acknowledgments.ts` (the unified policy store + per-version
acknowledgments), `20260611010000-audio-files.ts` (the audio library) + acknowledgments), `20260611010000-audio-files.ts` (the audio library) +
`20260611060000-audio-files-kinds.ts` (the `kind` enum / nullable `url` / `recipe` JSONB), and `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), - 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 `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`, permissions, the role->permission matrix, role assignment by user id), `product-campuses`,

View File

@ -103,6 +103,9 @@ const req = createMockRequest({
| File | Description | Tests | | File | Description | Tests |
|------|-------------|-------| |------|-------------|-------|
| `shared/constants/audio-files.test.ts` | `file`/`url`/`recipe` kinds + `isAudioFileKind` | ~2 | | `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/policy-documents.test.ts` | category validation + version-bump re-acknowledgment rule | ~several |
| `shared/constants/users.test.ts` | honorific name-prefix formatting (`formatPersonName`) | ~several | | `shared/constants/users.test.ts` | honorific name-prefix formatting (`formatPersonName`) | ~several |

View File

@ -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.

View File

@ -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<void> {
const payload = await ZoneCheckinService.today(req.currentUser);
res.status(200).send(payload);
}
export async function history(req: Request, res: Response): Promise<void> {
const payload = await ZoneCheckinService.history(req.query, req.currentUser);
res.status(200).send(payload);
}
export async function checkIn(req: Request, res: Response): Promise<void> {
const payload = await ZoneCheckinService.checkIn(req.body.data, req.currentUser);
res.status(200).send(payload);
}
export async function clearToday(req: Request, res: Response): Promise<void> {
const payload = await ZoneCheckinService.clearToday(req.currentUser);
res.status(200).send(payload);
}

View File

@ -49,7 +49,7 @@ class CampusesDBApi {
const currentUser = options?.currentUser ?? NO_USER; const currentUser = options?.currentUser ?? NO_USER;
const transaction = options?.transaction; const transaction = options?.transaction;
if (data.name == null || data.code == null) { if (data.name == null || data.code == null || data.timezone == null) {
throw new ValidationError(); throw new ValidationError();
} }
@ -58,6 +58,7 @@ class CampusesDBApi {
id: data.id || undefined, id: data.id || undefined,
name: data.name, name: data.name,
code: data.code, code: data.code,
timezone: data.timezone,
address: data.address || null, address: data.address || null,
phone: data.phone || null, phone: data.phone || null,
email: data.email || null, email: data.email || null,
@ -92,13 +93,14 @@ class CampusesDBApi {
const transaction = options?.transaction; const transaction = options?.transaction;
const campusesData = data.map((item, index) => { 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(); throw new ValidationError();
} }
return { return {
id: item.id || undefined, id: item.id || undefined,
name: item.name, name: item.name,
code: item.code, code: item.code,
timezone: item.timezone,
address: item.address || null, address: item.address || null,
phone: item.phone || null, phone: item.phone || null,
email: item.email || null, email: item.email || null,
@ -140,6 +142,7 @@ class CampusesDBApi {
if (data.name !== undefined) updatePayload.name = data.name; if (data.name !== undefined) updatePayload.name = data.name;
if (data.code !== undefined) updatePayload.code = data.code; 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.address !== undefined) updatePayload.address = data.address;
if (data.phone !== undefined) updatePayload.phone = data.phone; if (data.phone !== undefined) updatePayload.phone = data.phone;
if (data.email !== undefined) updatePayload.email = data.email; if (data.email !== undefined) updatePayload.email = data.email;

View File

@ -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<boolean> {
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');
},
};

View File

@ -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<boolean> {
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');
},
};

View File

@ -20,6 +20,7 @@ import type { Organizations } from './organizations';
import type { Staff } from './staff'; import type { Staff } from './staff';
import type { Timetables } from './timetables'; import type { Timetables } from './timetables';
import type { Users } from './users'; import type { Users } from './users';
import { isValidIanaTimezone } from '@/shared/constants/timezone';
export class Campuses extends Model< export class Campuses extends Model<
InferAttributes<Campuses>, InferAttributes<Campuses>,
@ -28,6 +29,7 @@ export class Campuses extends Model<
declare id: CreationOptional<string>; declare id: CreationOptional<string>;
declare name: string; declare name: string;
declare code: string; declare code: string;
declare timezone: string;
declare address: string | null; declare address: string | null;
declare phone: string | null; declare phone: string | null;
declare email: string | null; declare email: string | null;
@ -118,6 +120,17 @@ export default function (sequelize: Sequelize): typeof Campuses {
}, },
name: { type: DataTypes.TEXT, allowNull: false }, name: { type: DataTypes.TEXT, allowNull: false },
code: { 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 }, address: { type: DataTypes.TEXT },
phone: { type: DataTypes.TEXT }, phone: { type: DataTypes.TEXT },
email: { type: DataTypes.TEXT }, email: { type: DataTypes.TEXT },

View File

@ -21,6 +21,7 @@ export class FrameEntries extends Model<
> { > {
declare id: CreationOptional<string>; declare id: CreationOptional<string>;
declare week_of: string; declare week_of: string;
declare week_label: CreationOptional<string | null>;
declare posted_date: string; declare posted_date: string;
declare formal: string; declare formal: string;
declare recognition: string; declare recognition: string;
@ -82,6 +83,10 @@ export default function (sequelize: Sequelize): typeof FrameEntries {
defaultValue: DataTypes.UUIDV4, defaultValue: DataTypes.UUIDV4,
primaryKey: true, primaryKey: true,
}, },
week_label: {
type: DataTypes.TEXT,
allowNull: true,
},
week_of: { week_of: {
type: DataTypes.TEXT, type: DataTypes.TEXT,
allowNull: false, allowNull: false,

View File

@ -88,14 +88,14 @@ const MODULE_PERMISSIONS_BY_ROLE: Partial<Record<RoleName, readonly string[]>> =
...MODULE_READ_INSTRUCTIONAL, ...MODULE_READ_INSTRUCTIONAL,
...MODULE_READ_PARENT_COMM, ...MODULE_READ_PARENT_COMM,
...MODULE_READ_EXTERNAL, ...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', 'READ_AUDIO_FILES', 'MANAGE_AUDIO_FILES',
], ],
[ROLE_NAMES.SUPPORT_STAFF]: [ [ROLE_NAMES.SUPPORT_STAFF]: [
...MODULE_READ_ALL_STAFF, ...MODULE_READ_ALL_STAFF,
...MODULE_READ_INSTRUCTIONAL, ...MODULE_READ_INSTRUCTIONAL,
...MODULE_READ_EXTERNAL, ...MODULE_READ_EXTERNAL,
'TAKE_QUIZ', 'ACK_READ_RECEIPT', 'ACK_POLICY', 'TAKE_QUIZ', 'ACK_READ_RECEIPT', 'ACK_POLICY', 'ZONE_CHECKIN',
'READ_AUDIO_FILES', 'READ_AUDIO_FILES',
], ],
[ROLE_NAMES.STUDENT]: [...MODULE_READ_EXTERNAL], [ROLE_NAMES.STUDENT]: [...MODULE_READ_EXTERNAL],

View File

@ -74,6 +74,7 @@ import staffAttendanceRoutes from '@/routes/staff_attendance';
import policyDocumentsRoutes from '@/routes/policy_documents'; import policyDocumentsRoutes from '@/routes/policy_documents';
import policyAcknowledgmentsRoutes from '@/routes/policy_acknowledgments'; import policyAcknowledgmentsRoutes from '@/routes/policy_acknowledgments';
import audioFilesRoutes from '@/routes/audio_files'; import audioFilesRoutes from '@/routes/audio_files';
import zoneCheckinsRoutes from '@/routes/zone_checkins';
const app = express(); 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_documents', authenticated, policyDocumentsRoutes);
app.use('/api/policy_acknowledgments', authenticated, policyAcknowledgmentsRoutes); app.use('/api/policy_acknowledgments', authenticated, policyAcknowledgmentsRoutes);
app.use('/api/audio_files', authenticated, audioFilesRoutes); app.use('/api/audio_files', authenticated, audioFilesRoutes);
app.use('/api/zone_checkins', authenticated, zoneCheckinsRoutes);
app.use('/api/search', authenticated, searchRoutes); app.use('/api/search', authenticated, searchRoutes);
// Unmatched API routes → centralized 404 (the SPA fallback below handles the rest). // Unmatched API routes → centralized 404 (the SPA fallback below handles the rest).

View File

@ -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;

View File

@ -8,11 +8,13 @@ import {
hasRoleAccess, hasRoleAccess,
} from '@/services/shared/access'; } from '@/services/shared/access';
import { FRAME_EDITOR_ROLE_NAMES } from '@/shared/constants/frame'; 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 { FrameEntries } from '@/db/models/frame_entries';
import type { CurrentUser } from '@/db/api/types'; import type { CurrentUser } from '@/db/api/types';
interface FrameEntryInput { interface FrameEntryInput {
week_of: string; week_of: string;
week_label?: string | null;
posted_date: string; posted_date: string;
formal: string; formal: string;
recognition: string; recognition: string;
@ -23,6 +25,20 @@ interface FrameEntryInput {
campusId?: string | null; 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 = [ const REQUIRED_FIELDS = [
'week_of', 'week_of',
'posted_date', 'posted_date',
@ -79,6 +95,7 @@ function toDto(entry: FrameEntries) {
return { return {
id: plain.id, id: plain.id,
week_of: plain.week_of, week_of: plain.week_of,
week_label: plain.week_label,
posted_date: plain.posted_date, posted_date: plain.posted_date,
formal: plain.formal, formal: plain.formal,
recognition: plain.recognition, recognition: plain.recognition,
@ -125,7 +142,8 @@ class FrameEntriesService {
return withTransaction(async (transaction) => { return withTransaction(async (transaction) => {
const entry = await db.frame_entries.create( 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(), posted_date: data.posted_date.trim(),
formal: data.formal.trim(), formal: data.formal.trim(),
recognition: data.recognition.trim(), recognition: data.recognition.trim(),
@ -168,7 +186,8 @@ class FrameEntriesService {
await entry.update( 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(), posted_date: data.posted_date.trim(),
formal: data.formal.trim(), formal: data.formal.trim(),
recognition: data.recognition.trim(), recognition: data.recognition.trim(),

View File

@ -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<string> {
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<ZoneCheckinTodayPayload> {
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<ZoneCheckinTodayPayload> {
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<ZoneCheckinTodayPayload> {
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;

View File

@ -3,6 +3,7 @@ export const PRODUCT_CAMPUS_SEED_ROWS = Object.freeze([
id: '7e15d693-3f7c-4bc6-a399-8345002af8cf', id: '7e15d693-3f7c-4bc6-a399-8345002af8cf',
name: 'Tigers Campus', name: 'Tigers Campus',
code: 'tigers', code: 'tigers',
timezone: 'America/Phoenix',
mascot: 'Tigers', mascot: 'Tigers',
color: 'bg-orange-500', color: 'bg-orange-500',
bgGradient: 'from-orange-500 to-amber-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', id: '6ac9c04e-729d-41b8-9058-cd3aa26b832c',
name: 'Gators Campus', name: 'Gators Campus',
code: 'gators', code: 'gators',
timezone: 'America/New_York',
mascot: 'Gators', mascot: 'Gators',
color: 'bg-emerald-500', color: 'bg-emerald-500',
bgGradient: 'from-emerald-500 to-green-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', id: '829c4d4b-525e-408a-ae7a-0358c50726f7',
name: 'Hawks Campus', name: 'Hawks Campus',
code: 'hawks', code: 'hawks',
timezone: 'America/Chicago',
mascot: 'Hawks', mascot: 'Hawks',
color: 'bg-red-500', color: 'bg-red-500',
bgGradient: 'from-red-500 to-rose-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', id: '848eb809-b2e2-4c0f-ac6b-cb910fd7e26d',
name: 'Owls Campus', name: 'Owls Campus',
code: 'owls', code: 'owls',
timezone: 'America/Los_Angeles',
mascot: 'Owls', mascot: 'Owls',
color: 'bg-purple-500', color: 'bg-purple-500',
bgGradient: 'from-purple-500 to-violet-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', id: '6670d72a-cf6b-4f92-9e21-378ac81df3d8',
name: 'Wildcats Campus', name: 'Wildcats Campus',
code: 'wildcats', code: 'wildcats',
timezone: 'America/Denver',
mascot: 'Wildcats', mascot: 'Wildcats',
color: 'bg-blue-500', color: 'bg-blue-500',
bgGradient: 'from-blue-500 to-cyan-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', id: '4a331c45-b463-4748-9e90-23d0e4b41aaf',
name: 'Grizzlies Campus', name: 'Grizzlies Campus',
code: 'grizzlies', code: 'grizzlies',
timezone: 'America/Anchorage',
mascot: 'Grizzlies', mascot: 'Grizzlies',
color: 'bg-amber-700', color: 'bg-amber-700',
bgGradient: 'from-amber-700 to-yellow-600', bgGradient: 'from-amber-700 to-yellow-600',

View File

@ -51,6 +51,7 @@ export const MODULE_ACTIONS = [
'TAKE_QUIZ', 'TAKE_QUIZ',
'ACK_READ_RECEIPT', 'ACK_READ_RECEIPT',
'ACK_POLICY', 'ACK_POLICY',
'ZONE_CHECKIN',
] as const; ] as const;
/** Audio library (Workstream 13): read = play/select, manage = upload/edit. */ /** Audio library (Workstream 13): read = play/select, manage = upload/edit. */
@ -82,6 +83,7 @@ export const FEATURE_PERMISSIONS = Object.freeze({
TAKE_QUIZ: 'TAKE_QUIZ', TAKE_QUIZ: 'TAKE_QUIZ',
ACK_READ_RECEIPT: 'ACK_READ_RECEIPT', ACK_READ_RECEIPT: 'ACK_READ_RECEIPT',
ACK_POLICY: 'ACK_POLICY', ACK_POLICY: 'ACK_POLICY',
ZONE_CHECKIN: 'ZONE_CHECKIN',
READ_AUDIO_FILES: 'READ_AUDIO_FILES', READ_AUDIO_FILES: 'READ_AUDIO_FILES',
MANAGE_AUDIO_FILES: 'MANAGE_AUDIO_FILES', MANAGE_AUDIO_FILES: 'MANAGE_AUDIO_FILES',
}); });

View File

@ -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);
});

View File

@ -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<string> = 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);
}

View File

@ -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);
});

View File

@ -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);
}

View File

@ -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);
});

View File

@ -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;

View File

@ -28,6 +28,10 @@ Authenticated management:
- `PUT /api/content-catalog/:contentType` - `PUT /api/content-catalog/:contentType`
- `DELETE /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 ## Current Consumers
- classroom support strategies - classroom support strategies

View File

@ -18,7 +18,7 @@ View:
- `frontend/src/components/dashboard/DashboardView.tsx` - `frontend/src/components/dashboard/DashboardView.tsx`
- `frontend/src/components/dashboard/DashboardHero.tsx` - `frontend/src/components/dashboard/DashboardHero.tsx`
- `frontend/src/components/dashboard/DashboardQuotePanel.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/DashboardFramePreview.tsx`
- `frontend/src/components/dashboard/DashboardUpcomingEvents.tsx` - `frontend/src/components/dashboard/DashboardUpcomingEvents.tsx`
- `frontend/src/components/dashboard/DashboardWeeklyProgress.tsx` - `frontend/src/components/dashboard/DashboardWeeklyProgress.tsx`
@ -49,11 +49,12 @@ Feature APIs:
- F.R.A.M.E. entries through `useFrameEntries` - F.R.A.M.E. entries through `useFrameEntries`
- Communication events through `useCommunicationEvents` - 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 ## Behavior
- `useDashboardPage` composes all dashboard data sources into one page model. - `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. - 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. - View components receive prepared props and do not call API/data access modules.
- Loading, empty, and error states remain explicit for each dashboard section. - Loading, empty, and error states remain explicit for each dashboard section.

View File

@ -28,6 +28,7 @@ Business logic layer:
- `frontend/src/business/dashboard/hooks.ts` - `frontend/src/business/dashboard/hooks.ts`
- `frontend/src/business/director-dashboard/hooks.ts` - `frontend/src/business/director-dashboard/hooks.ts`
- `frontend/src/business/director-dashboard/selectors.ts` - `frontend/src/business/director-dashboard/selectors.ts`
- `frontend/src/shared/business/week.ts` (shared American/Sunday week canonicalization)
API/data access layer: API/data access layer:
@ -42,6 +43,7 @@ Constants:
- FRAME entries load from `GET /api/frame_entries`. - FRAME entries load from `GET /api/frame_entries`.
- Create/update workflows use typed API calls and React Query mutations. - 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 <formatWeekOf(weekStart)>` + 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. - `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`. - FRAME view components use shared UI primitives: `Button`, `Input`, `Textarea`, and `StatePanel`.
- Static FRAME sample entries are not used as runtime persisted-data substitutes. - Static FRAME sample entries are not used as runtime persisted-data substitutes.

View File

@ -52,4 +52,5 @@ Read the repository rules first, then use the frontend architecture document as
- [`user-progress-integration.md`](user-progress-integration.md) - [`user-progress-integration.md`](user-progress-integration.md)
- [`vocational-opportunities.md`](vocational-opportunities.md) - [`vocational-opportunities.md`](vocational-opportunities.md)
- [`walkthrough-integration.md`](walkthrough-integration.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) - [`zones-of-regulation-integration.md`](zones-of-regulation-integration.md)

View File

@ -48,6 +48,7 @@ Constants:
- Result ownership is derived by the backend from the authenticated session. - 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. - `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. - 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. - Weekly focus and key reminders are backend content payload fields, not frontend constants.
- Safety quiz view components use shared `Button`, `StatePanel`, and `ModuleHeader` primitives. - Safety quiz view components use shared `Button`, `StatePanel`, and `ModuleHeader` primitives.
- Director dashboard derives QBS completion metrics and risk rows in business selectors. - Director dashboard derives QBS completion metrics and risk rows in business selectors.

View File

@ -17,7 +17,7 @@ UI-facing product types live in `frontend/src/shared/types/app.ts`.
- `ZoneColor` - `ZoneColor`
- cross-module static catalog item types used by the current UI - 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 ## Rules

View File

@ -37,6 +37,7 @@ Current coverage includes architecture guardrails, API/data-access behavior, and
- `frontend/src/business/frame/selectors.test.ts` - `frontend/src/business/frame/selectors.test.ts`
- `frontend/src/business/audio-files/selectors.test.ts` - `frontend/src/business/audio-files/selectors.test.ts`
- `frontend/src/business/audio-files/generate.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/mappers.test.ts`
- `frontend/src/business/personality/selectors.test.ts` - `frontend/src/business/personality/selectors.test.ts`
- `frontend/src/business/policies/mappers.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/mappers.test.ts`
- `frontend/src/business/staff-attendance/selectors.test.ts` - `frontend/src/business/staff-attendance/selectors.test.ts`
- `frontend/src/business/top-bar/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/user-progress/mappers.test.ts`
- `frontend/src/business/vocational/selectors.test.ts` - `frontend/src/business/vocational/selectors.test.ts`
- `frontend/src/business/walkthrough/mappers.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/hooks/usePermissions.test.tsx`
- `frontend/src/components/sign-in-modal/SignInForm.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. 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/product-workflow.seeded.e2e.ts`
- `frontend/tests/e2e/policy-acknowledgments.seeded.e2e.ts` - `frontend/tests/e2e/policy-acknowledgments.seeded.e2e.ts`
- `frontend/tests/e2e/audio-files.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: 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) - 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 - **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 - **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 - **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 - **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 ## Accessibility E2E Coverage

View File

@ -25,6 +25,7 @@ Business logic:
- `frontend/src/business/top-bar/hooks.ts` - `frontend/src/business/top-bar/hooks.ts`
- `frontend/src/business/top-bar/selectors.ts` - `frontend/src/business/top-bar/selectors.ts`
- `frontend/src/business/top-bar/search.ts`
- `frontend/src/business/top-bar/types.ts` - `frontend/src/business/top-bar/types.ts`
Shared config: Shared config:
@ -36,9 +37,16 @@ Shared config:
- `TopBar.tsx` is a thin wrapper that reads auth session state and passes it into `useTopBarPage`. - `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. - `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. - 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. - 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. - 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 ## Data Ownership Rules
- Do not add notification seed data to frontend constants. - Do not add notification seed data to frontend constants.

View File

@ -2,7 +2,11 @@
## Purpose ## 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 ```text
View -> Business Logic -> API/Data Access -> Backend View -> Business Logic -> API/Data Access -> Backend
@ -15,11 +19,6 @@ View layer:
- `frontend/src/components/frameworks/SignLanguage.tsx` - `frontend/src/components/frameworks/SignLanguage.tsx`
- `frontend/src/components/sign-language/SignLanguageProgressPanel.tsx` - `frontend/src/components/sign-language/SignLanguageProgressPanel.tsx`
- `frontend/src/components/sign-language/SignLanguageVideoModal.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: Business logic layer:
- `frontend/src/business/dashboard/hooks.ts` - `frontend/src/business/dashboard/hooks.ts`
@ -45,8 +44,6 @@ Constants:
- Marking a sign learned uses `POST /api/user_progress`. - Marking a sign learned uses `POST /api/user_progress`.
- Unmarking a sign uses `DELETE /api/user_progress/by-item`. - Unmarking a sign uses `DELETE /api/user_progress/by-item`.
- The sign language page combines user progress with backend content catalog records in `useSignLanguagePage`. - 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. - Views render explicit backend errors from React Query state.
- User progress ownership is derived by the backend from the authenticated session. - User progress ownership is derived by the backend from the authenticated session.

View File

@ -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.

View File

@ -55,7 +55,7 @@ Content payloads are seeded in:
- Selectors handle expanded-zone toggling, selected-zone lookup, safety connection lookup, and active-tab wording. - 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. - View components receive a prepared page model and do not call API/data access modules.
- Loading and error states are explicit through `StatePanel`. - 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 ## Data Ownership Rules

View File

@ -86,6 +86,7 @@ export function useAppShell(options: UseAppShellOptions): AppShellState {
userName, userName,
campusInfo, campusInfo,
toggleSidebar, toggleSidebar,
setCurrentModule,
}; };
const shellOutletContext = { const shellOutletContext = {

View File

@ -5,6 +5,7 @@ import { CONTENT_CATALOG_QUERY_KEYS } from '@/shared/constants/contentCatalog';
export function useContentCatalogPayload<TPayload>( export function useContentCatalogPayload<TPayload>(
contentType: string, contentType: string,
emptyPayload: TPayload, emptyPayload: TPayload,
options?: { readonly enabled?: boolean },
) { ) {
const query = useQuery({ const query = useQuery({
queryKey: [...CONTENT_CATALOG_QUERY_KEYS.content, contentType], queryKey: [...CONTENT_CATALOG_QUERY_KEYS.content, contentType],
@ -12,6 +13,7 @@ export function useContentCatalogPayload<TPayload>(
const response = await getContentCatalog<TPayload>(contentType); const response = await getContentCatalog<TPayload>(contentType);
return response.payload; return response.payload;
}, },
enabled: options?.enabled ?? true,
}); });
return { return {

View File

@ -15,7 +15,8 @@ import type {
DashboardProps, DashboardProps,
} from '@/business/dashboard/types'; } from '@/business/dashboard/types';
import { useFrameEntries } from '@/business/frame/hooks'; 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 { CONTENT_CATALOG_TYPES } from '@/shared/constants/contentCatalog';
import { DASHBOARD_ZONE_OPTIONS } from '@/shared/constants/dashboard'; import { DASHBOARD_ZONE_OPTIONS } from '@/shared/constants/dashboard';
import { getOptionalErrorMessage } from '@/shared/errors/errorMessages'; import { getOptionalErrorMessage } from '@/shared/errors/errorMessages';
@ -47,7 +48,7 @@ export function useDashboardPage({
null, null,
); );
const frameEntriesQuery = useFrameEntries(); const frameEntriesQuery = useFrameEntries();
const zoneCheckInState = useZoneCheckIn(); const zoneCheckInState = useTodayZoneCheckIn();
const communicationEventsQuery = useCommunicationEvents(); const communicationEventsQuery = useCommunicationEvents();
const roleEvents = useMemo( const roleEvents = useMemo(
() => filterCommunicationEventsByRole(communicationEventsQuery.data ?? [], userRole), () => filterCommunicationEventsByRole(communicationEventsQuery.data ?? [], userRole),
@ -57,7 +58,12 @@ export function useDashboardPage({
() => selectDashboardUpcomingEvents(roleEvents), () => selectDashboardUpcomingEvents(roleEvents),
[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( const todayQuote = useMemo(
() => selectDashboardQuote(quotesQuery.payload, dashboardDate), () => selectDashboardQuote(quotesQuery.payload, dashboardDate),
[dashboardDate, quotesQuery.payload], [dashboardDate, quotesQuery.payload],
@ -69,7 +75,7 @@ export function useDashboardPage({
} }
async function resetZone() { async function resetZone() {
await zoneCheckInState.resetZone(); await zoneCheckInState.clearToday();
setZoneCheckIn(null); setZoneCheckIn(null);
} }
@ -84,7 +90,9 @@ export function useDashboardPage({
isError: Boolean(quotesQuery.error), isError: Boolean(quotesQuery.error),
}, },
zoneOptions: DASHBOARD_ZONE_OPTIONS, zoneOptions: DASHBOARD_ZONE_OPTIONS,
showZoneCheckIn: canZoneCheckIn(userRole),
activeZone, activeZone,
needsZoneCheckIn,
isZoneSaving: zoneCheckInState.isSaving, isZoneSaving: zoneCheckInState.isSaving,
zoneErrorMessage: getOptionalErrorMessage(zoneCheckInState.error), zoneErrorMessage: getOptionalErrorMessage(zoneCheckInState.error),
upcomingEvents, upcomingEvents,

View File

@ -31,7 +31,9 @@ export interface DashboardPage {
readonly todayQuote: DashboardEncouragingQuote | null; readonly todayQuote: DashboardEncouragingQuote | null;
readonly quoteState: DashboardContentState; readonly quoteState: DashboardContentState;
readonly zoneOptions: readonly DashboardZoneOption[]; readonly zoneOptions: readonly DashboardZoneOption[];
readonly showZoneCheckIn: boolean;
readonly activeZone: ZoneColor | null; readonly activeZone: ZoneColor | null;
readonly needsZoneCheckIn: boolean;
readonly isZoneSaving: boolean; readonly isZoneSaving: boolean;
readonly zoneErrorMessage: string | null; readonly zoneErrorMessage: string | null;
readonly upcomingEvents: readonly CommunicationEventDto[]; readonly upcomingEvents: readonly CommunicationEventDto[];

View File

@ -48,7 +48,9 @@ function createQuizResult(overrides: Partial<SafetyQuizResultDto> = {}): SafetyQ
function createFrameEntry(overrides: Partial<FrameEntryViewModel> = {}): FrameEntryViewModel { function createFrameEntry(overrides: Partial<FrameEntryViewModel> = {}): FrameEntryViewModel {
return { return {
id: 'frame-1', id: 'frame-1',
weekOf: '2026-06-01', weekStart: '2026-05-31',
weekLabel: '',
weekOf: 'May 31, 2026',
postedDate: 'June 1, 2026', postedDate: 'June 1, 2026',
formal: 'Formal learning focus for the week', formal: 'Formal learning focus for the week',
recognition: 'Recognition focus for the week', recognition: 'Recognition focus for the week',

View File

@ -20,13 +20,15 @@ import {
import { canEditFrameEntries } from '@/business/frame/selectors'; import { canEditFrameEntries } from '@/business/frame/selectors';
import { UserRole } from '@/shared/types/app'; import { UserRole } from '@/shared/types/app';
import { mapApiListRows } from '@/shared/business/apiListRows'; import { mapApiListRows } from '@/shared/business/apiListRows';
import { toWeekStartIso } from '@/shared/business/week';
import { useInvalidatingMutation } from '@/shared/business/queryMutations'; import { useInvalidatingMutation } from '@/shared/business/queryMutations';
const EMPTY_FRAME_ENTRIES: readonly FrameEntryViewModel[] = []; const EMPTY_FRAME_ENTRIES: readonly FrameEntryViewModel[] = [];
function createEmptyDraft(author: string): FrameEntryDraft { function createEmptyDraft(author: string): FrameEntryDraft {
return { return {
weekOf: '', weekStart: toWeekStartIso(new Date()),
weekLabel: '',
postedDate: new Date().toLocaleDateString('en-US', { postedDate: new Date().toLocaleDateString('en-US', {
year: 'numeric', year: 'numeric',
month: 'long', month: 'long',
@ -42,8 +44,9 @@ function createEmptyDraft(author: string): FrameEntryDraft {
} }
function isValidDraft(entry: EditableFrameEntry): boolean { function isValidDraft(entry: EditableFrameEntry): boolean {
// `weekLabel` is optional; `weekStart` is always set by the picker.
return Boolean( return Boolean(
entry.weekOf.trim() entry.weekStart.trim()
&& entry.postedDate.trim() && entry.postedDate.trim()
&& entry.formal.trim() && entry.formal.trim()
&& entry.recognition.trim() && entry.recognition.trim()
@ -121,6 +124,10 @@ export function useFrameModule(userRole: UserRole, userName: string) {
setEditEntry((current) => current ? { ...current, [key]: value } : current); 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) { function startEditing(entry: FrameEntryViewModel) {
setIsEditing(true); setIsEditing(true);
setEditEntry(entry); setEditEntry(entry);
@ -178,6 +185,7 @@ export function useFrameModule(userRole: UserRole, userName: string) {
updateNewEntryField, updateNewEntryField,
updateNewEntrySection, updateNewEntrySection,
updateEditEntrySection, updateEditEntrySection,
updateEditEntryField,
startEditing, startEditing,
cancelEditing, cancelEditing,
saveNewEntry, saveNewEntry,

View File

@ -6,40 +6,46 @@ import {
import type { EditableFrameEntry } from '@/business/frame/types'; import type { EditableFrameEntry } from '@/business/frame/types';
import type { FrameEntryDto } from '@/shared/types/frame'; import type { FrameEntryDto } from '@/shared/types/frame';
describe('frame mappers', () => { function dto(overrides: Partial<FrameEntryDto> = {}): FrameEntryDto {
it('maps backend FRAME DTO fields into the frontend view model shape', () => { return {
const dto: FrameEntryDto = { id: 'frame-1',
id: 'frame-1', week_of: '2026-06-07', // a Sunday (canonical week start)
week_of: '2026-06-08', week_label: 'Spring Break week',
posted_date: '2026-06-08', posted_date: '2026-06-08',
formal: 'Formal note', formal: 'Formal note',
recognition: 'Recognition note', recognition: 'Recognition note',
application: 'Application note', application: 'Application note',
management: 'Management note', management: 'Management note',
emotional: 'Emotional note', emotional: 'Emotional note',
author: 'Director', author: 'Director',
organizationId: 'org-1', organizationId: 'org-1',
campusId: 'campus-1', campusId: 'campus-1',
createdAt: '2026-06-08T10:00:00.000Z', createdAt: '2026-06-08T10:00:00.000Z',
updatedAt: '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', id: 'frame-1',
weekOf: 'June 8, 2026', weekStart: '2026-06-07',
postedDate: 'June 8, 2026', weekLabel: 'Spring Break week',
weekOf: 'June 7, 2026',
formal: 'Formal note', formal: 'Formal note',
recognition: 'Recognition note',
application: 'Application note',
management: 'Management note',
emotional: 'Emotional note',
author: 'Director', 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 = { const entry: EditableFrameEntry = {
weekOf: '2026-06-08', weekStart: '2026-06-07',
weekLabel: ' ',
postedDate: '2026-06-09', postedDate: '2026-06-09',
formal: 'Formal', formal: 'Formal',
recognition: 'Recognition', recognition: 'Recognition',
@ -50,7 +56,8 @@ describe('frame mappers', () => {
}; };
expect(toFrameEntryMutationDto(entry)).toEqual({ expect(toFrameEntryMutationDto(entry)).toEqual({
week_of: '2026-06-08', week_of: '2026-06-07',
week_label: undefined,
posted_date: '2026-06-09', posted_date: '2026-06-09',
formal: 'Formal', formal: 'Formal',
recognition: 'Recognition', recognition: 'Recognition',
@ -60,4 +67,19 @@ describe('frame mappers', () => {
author: 'Office Manager', 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');
});
}); });

View File

@ -1,5 +1,6 @@
import { FrameEntryDto, FrameEntryMutationDto } from '@/shared/types/frame'; import { FrameEntryDto, FrameEntryMutationDto } from '@/shared/types/frame';
import { EditableFrameEntry, FrameEntryViewModel } from '@/business/frame/types'; import { EditableFrameEntry, FrameEntryViewModel } from '@/business/frame/types';
import { formatWeekOf } from '@/shared/business/week';
function formatDisplayDate(isoDate: string): string { function formatDisplayDate(isoDate: string): string {
const date = new Date(isoDate); const date = new Date(isoDate);
@ -18,7 +19,9 @@ function formatDisplayDate(isoDate: string): string {
export function toFrameEntryViewModel(dto: FrameEntryDto): FrameEntryViewModel { export function toFrameEntryViewModel(dto: FrameEntryDto): FrameEntryViewModel {
return { return {
id: dto.id, 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), postedDate: formatDisplayDate(dto.posted_date),
formal: dto.formal, formal: dto.formal,
recognition: dto.recognition, recognition: dto.recognition,
@ -30,8 +33,10 @@ export function toFrameEntryViewModel(dto: FrameEntryDto): FrameEntryViewModel {
} }
export function toFrameEntryMutationDto(entry: EditableFrameEntry): FrameEntryMutationDto { export function toFrameEntryMutationDto(entry: EditableFrameEntry): FrameEntryMutationDto {
const weekLabel = entry.weekLabel.trim();
return { return {
week_of: entry.weekOf, week_of: entry.weekStart,
week_label: weekLabel ? weekLabel : undefined,
posted_date: entry.postedDate, posted_date: entry.postedDate,
formal: entry.formal, formal: entry.formal,
recognition: entry.recognition, recognition: entry.recognition,

View File

@ -2,6 +2,11 @@ import { FrameSectionKey } from '@/shared/types/frame';
export interface FrameEntryViewModel { export interface FrameEntryViewModel {
readonly id: string; 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 weekOf: string;
readonly postedDate: string; readonly postedDate: string;
readonly formal: string; readonly formal: string;
@ -12,7 +17,18 @@ export interface FrameEntryViewModel {
readonly author: string; readonly author: string;
} }
export type EditableFrameEntry = Omit<FrameEntryViewModel, 'id'>; /** 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; export type FrameEntryDraft = EditableFrameEntry;

View File

@ -3,13 +3,12 @@ import type {
SafetyQuizCompletionSummary, SafetyQuizCompletionSummary,
SafetyQuizComplianceRow, SafetyQuizComplianceRow,
} from '@/business/safety-quiz/types'; } from '@/business/safety-quiz/types';
import { toWeekStartIso } from '@/shared/business/week';
export function getCurrentSafetyQuizWeek(date: Date): string { export function getCurrentSafetyQuizWeek(date: Date): string {
const weekStart = new Date(date); // Shared American (Sunday-start) canonicalization — same util as the dashboard
weekStart.setHours(0, 0, 0, 0); // hero and F.R.A.M.E. (behavior unchanged: this was already Sunday-based).
weekStart.setDate(weekStart.getDate() - weekStart.getDay()); return toWeekStartIso(date);
return weekStart.toISOString().slice(0, 10);
} }
export function calculateSafetyQuizScore( export function calculateSafetyQuizScore(

View File

@ -1,24 +1,44 @@
import { useState } from 'react'; import { useMemo, useState } from 'react';
import { import {
buildTopBarNotifications,
countUnreadTopBarNotifications, countUnreadTopBarNotifications,
getTopBarCampusLabel, getTopBarCampusLabel,
getTopBarInitials, getTopBarInitials,
getTopBarRoleLabel, getTopBarRoleLabel,
} from '@/business/top-bar/selectors'; } 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 { import type {
TopBarNotification,
TopBarPage, TopBarPage,
UseTopBarPageOptions, UseTopBarPageOptions,
} from '@/business/top-bar/types'; } 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({ export function useTopBarPage({
userRole, userRole,
userName, userName,
campusInfo, campusInfo,
toggleSidebar, toggleSidebar,
setCurrentModule,
profile, profile,
signOut: signOutAction, signOut: signOutAction,
}: UseTopBarPageOptions): TopBarPage { }: UseTopBarPageOptions): TopBarPage {
@ -26,7 +46,64 @@ export function useTopBarPage({
const [showNotifications, setShowNotifications] = useState(false); const [showNotifications, setShowNotifications] = useState(false);
const [searchQuery, setSearchQuery] = useState(''); const [searchQuery, setSearchQuery] = useState('');
const [signOutError, setSignOutError] = useState<string | null>(null); const [signOutError, setSignOutError] = useState<string | null>(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<ModuleId, string>(MODULES.map((module) => [module.id, module.name])),
[],
);
const strategiesQuery = useContentCatalogPayload<readonly Strategy[]>(
CONTENT_CATALOG_TYPES.classroomStrategies,
EMPTY_STRATEGIES,
{ enabled: hasQuery && accessibleModuleIds.has('classroom') },
);
const signsQuery = useContentCatalogPayload<readonly SignItem[]>(
CONTENT_CATALOG_TYPES.signLanguageItems,
EMPTY_SIGNS,
{ enabled: hasQuery && accessibleModuleIds.has('signs') },
);
const zonesQuery = useContentCatalogPayload<readonly ZoneInfo[]>(
CONTENT_CATALOG_TYPES.regulationZones,
EMPTY_ZONES,
{ enabled: hasQuery && accessibleModuleIds.has('zones') },
);
const contentItems = useMemo<readonly TopBarContentItem[]>(() => {
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() { async function signOut() {
setShowProfileMenu(false); setShowProfileMenu(false);
@ -49,6 +126,7 @@ export function useTopBarPage({
showProfileMenu, showProfileMenu,
showNotifications, showNotifications,
searchQuery, searchQuery,
searchResults,
signOutError, signOutError,
notifications, notifications,
unreadCount: countUnreadTopBarNotifications(notifications), unreadCount: countUnreadTopBarNotifications(notifications),
@ -58,6 +136,7 @@ export function useTopBarPage({
toggleNotifications: () => setShowNotifications((current) => !current), toggleNotifications: () => setShowNotifications((current) => !current),
closeNotifications: () => setShowNotifications(false), closeNotifications: () => setShowNotifications(false),
setSearchQuery, setSearchQuery,
selectSearchResult,
signOut, signOut,
}; };
} }

View File

@ -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([]);
});
});

View File

@ -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);
}

View File

@ -1,13 +1,24 @@
import { describe, expect, it } from 'vitest'; import { describe, expect, it } from 'vitest';
import { import {
buildTopBarNotifications,
countUnreadTopBarNotifications, countUnreadTopBarNotifications,
getTopBarCampusLabel, getTopBarCampusLabel,
getTopBarInitials, getTopBarInitials,
getTopBarRoleLabel, getTopBarRoleLabel,
} from '@/business/top-bar/selectors'; } from '@/business/top-bar/selectors';
import { APP_ROUTE_PATHS } from '@/shared/constants/routes';
describe('top bar selectors', () => { 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', () => { it('builds initials from display names', () => {
expect(getTopBarInitials('Guest')).toBe('G'); expect(getTopBarInitials('Guest')).toBe('G');
expect(getTopBarInitials('Ada Lovelace')).toBe('AL'); expect(getTopBarInitials('Ada Lovelace')).toBe('AL');

View File

@ -1,4 +1,5 @@
import { getAuthRoleLabel } from '@/business/auth/selectors'; import { getAuthRoleLabel } from '@/business/auth/selectors';
import { APP_ROUTE_PATHS } from '@/shared/constants/routes';
import { DEFAULT_CAMPUS_LABEL } from '@/shared/constants/campusDisplay'; import { DEFAULT_CAMPUS_LABEL } from '@/shared/constants/campusDisplay';
import type { import type {
CampusInfo, CampusInfo,
@ -30,3 +31,28 @@ export function countUnreadTopBarNotifications(
): number { ): number {
return notifications.filter((notification) => notification.unread).length; 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;
}

View File

@ -1,14 +1,17 @@
import type { AuthSessionState } from '@/business/auth/types'; import type { AuthSessionState } from '@/business/auth/types';
import type { import type {
CampusInfo, CampusInfo,
ModuleId,
UserRole, UserRole,
} from '@/shared/types/app'; } from '@/shared/types/app';
import type { TopBarSearchResult } from '@/business/top-bar/search';
export interface TopBarProps { export interface TopBarProps {
readonly userRole: UserRole; readonly userRole: UserRole;
readonly userName: string; readonly userName: string;
readonly campusInfo?: CampusInfo; readonly campusInfo?: CampusInfo;
readonly toggleSidebar: () => void; readonly toggleSidebar: () => void;
readonly setCurrentModule: (moduleId: ModuleId) => void;
} }
export interface TopBarNotification { export interface TopBarNotification {
@ -16,6 +19,8 @@ export interface TopBarNotification {
readonly text: string; readonly text: string;
readonly time: string; readonly time: string;
readonly unread: boolean; readonly unread: boolean;
/** Optional in-app route to navigate to when the notification is clicked. */
readonly href?: string;
} }
export interface UseTopBarPageOptions extends TopBarProps { export interface UseTopBarPageOptions extends TopBarProps {
@ -34,6 +39,7 @@ export interface TopBarPage {
readonly showProfileMenu: boolean; readonly showProfileMenu: boolean;
readonly showNotifications: boolean; readonly showNotifications: boolean;
readonly searchQuery: string; readonly searchQuery: string;
readonly searchResults: readonly TopBarSearchResult[];
readonly signOutError: string | null; readonly signOutError: string | null;
readonly notifications: readonly TopBarNotification[]; readonly notifications: readonly TopBarNotification[];
readonly unreadCount: number; readonly unreadCount: number;
@ -43,5 +49,6 @@ export interface TopBarPage {
readonly toggleNotifications: () => void; readonly toggleNotifications: () => void;
readonly closeNotifications: () => void; readonly closeNotifications: () => void;
readonly setSearchQuery: (value: string) => void; readonly setSearchQuery: (value: string) => void;
readonly selectSearchResult: (result: TopBarSearchResult) => void;
readonly signOut: () => Promise<void>; readonly signOut: () => Promise<void>;
} }

View File

@ -7,11 +7,9 @@ import {
import { import {
USER_PROGRESS_QUERY_KEYS, USER_PROGRESS_QUERY_KEYS,
USER_PROGRESS_TYPES, USER_PROGRESS_TYPES,
ZONE_CHECKIN_ITEM_ID,
} from '@/shared/constants/userProgress'; } from '@/shared/constants/userProgress';
import { toLearnedSignIds, toZoneColor } from '@/business/user-progress/mappers'; import { toLearnedSignIds } from '@/business/user-progress/mappers';
import { LearnedSignsState, ZoneCheckInState } from '@/business/user-progress/types'; import { LearnedSignsState } from '@/business/user-progress/types';
import { ZoneColor } from '@/shared/types/app';
import { selectApiListRows } from '@/shared/business/apiListRows'; import { selectApiListRows } from '@/shared/business/apiListRows';
import { useInvalidatingMutation } from '@/shared/business/queryMutations'; import { useInvalidatingMutation } from '@/shared/business/queryMutations';
@ -59,50 +57,3 @@ export function useLearnedSignsProgress(): LearnedSignsState {
toggleLearnedSign, 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,
};
}

View File

@ -1,8 +1,5 @@
import { describe, expect, it } from 'vitest'; import { describe, expect, it } from 'vitest';
import { import { toLearnedSignIds } from '@/business/user-progress/mappers';
toLearnedSignIds,
toZoneColor,
} from '@/business/user-progress/mappers';
import type { UserProgressDto } from '@/shared/types/userProgress'; import type { UserProgressDto } from '@/shared/types/userProgress';
function createProgress(overrides: Partial<UserProgressDto> = {}): UserProgressDto { function createProgress(overrides: Partial<UserProgressDto> = {}): UserProgressDto {
@ -32,13 +29,4 @@ describe('user progress mappers', () => {
expect([...learnedSignIds].sort()).toEqual(['hello', 'help']); 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();
});
}); });

View File

@ -1,18 +1,5 @@
import { UserProgressDto } from '@/shared/types/userProgress'; import { UserProgressDto } from '@/shared/types/userProgress';
import { ZoneColor } from '@/shared/types/app';
export function toLearnedSignIds(progress: readonly UserProgressDto[]): ReadonlySet<string> { export function toLearnedSignIds(progress: readonly UserProgressDto[]): ReadonlySet<string> {
return new Set(progress.map((item) => item.item_id)); 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;
}

View File

@ -1,5 +1,3 @@
import { ZoneColor } from '@/shared/types/app';
export interface LearnedSignsState { export interface LearnedSignsState {
readonly learnedSignIds: ReadonlySet<string>; readonly learnedSignIds: ReadonlySet<string>;
readonly isLoading: boolean; readonly isLoading: boolean;
@ -7,12 +5,3 @@ export interface LearnedSignsState {
readonly error: Error | null; readonly error: Error | null;
readonly toggleLearnedSign: (id: string, word: string) => Promise<void>; readonly toggleLearnedSign: (id: string, word: string) => Promise<void>;
} }
export interface ZoneCheckInState {
readonly currentZone: ZoneColor | null;
readonly isLoading: boolean;
readonly isSaving: boolean;
readonly error: Error | null;
readonly setZone: (zone: ZoneColor) => Promise<void>;
readonly resetZone: () => Promise<void>;
}

View File

@ -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,
});
}

View File

@ -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);
});
});

View File

@ -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;
}

View File

@ -3,19 +3,7 @@ import { Sparkles } from 'lucide-react';
import type { FrameEntryViewModel } from '@/business/frame/types'; import type { FrameEntryViewModel } from '@/business/frame/types';
import { HERO_IMAGE } from '@/shared/constants/appData'; import { HERO_IMAGE } from '@/shared/constants/appData';
import { formatWeekOf, toWeekStartIso } from '@/shared/business/week';
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',
});
}
interface DashboardHeroProps { interface DashboardHeroProps {
readonly greeting: string; readonly greeting: string;
@ -27,7 +15,8 @@ export function DashboardHero({
greeting, greeting,
userName, userName,
}: DashboardHeroProps) { }: 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 ( return (
<section className="relative overflow-hidden rounded-2xl h-52 md:h-60"> <section className="relative overflow-hidden rounded-2xl h-52 md:h-60">
<img src={HERO_IMAGE} alt="Classroom" className="w-full h-full object-cover" /> <img src={HERO_IMAGE} alt="Classroom" className="w-full h-full object-cover" />
@ -37,7 +26,7 @@ export function DashboardHero({
<div className="flex items-center gap-2 mb-2"> <div className="flex items-center gap-2 mb-2">
<Sparkles size={18} className="text-amber-400" /> <Sparkles size={18} className="text-amber-400" />
<span className="text-amber-400 text-sm font-medium"> <span className="text-amber-400 text-sm font-medium">
Week of {currentWeekMonday} Week of {currentWeekLabel}
</span> </span>
</div> </div>
<h1 className="text-2xl md:text-4xl font-bold text-white mb-1"> <h1 className="text-2xl md:text-4xl font-bold text-white mb-1">

View File

@ -6,7 +6,7 @@ import { DashboardQuotePanel } from '@/components/dashboard/DashboardQuotePanel'
import { DashboardSignOfWeek } from '@/components/dashboard/DashboardSignOfWeek'; import { DashboardSignOfWeek } from '@/components/dashboard/DashboardSignOfWeek';
import { DashboardUpcomingEvents } from '@/components/dashboard/DashboardUpcomingEvents'; import { DashboardUpcomingEvents } from '@/components/dashboard/DashboardUpcomingEvents';
import { DashboardWeeklyProgress } from '@/components/dashboard/DashboardWeeklyProgress'; import { DashboardWeeklyProgress } from '@/components/dashboard/DashboardWeeklyProgress';
import { DashboardZoneCheckIn } from '@/components/dashboard/DashboardZoneCheckIn'; import { ZoneCheckInCard } from '@/components/zone-checkin/ZoneCheckInCard';
interface DashboardViewProps { interface DashboardViewProps {
readonly page: DashboardPage; readonly page: DashboardPage;
@ -23,14 +23,17 @@ export function DashboardView({ page }: DashboardViewProps) {
<DashboardQuotePanel quote={page.todayQuote} state={page.quoteState} /> <DashboardQuotePanel quote={page.todayQuote} state={page.quoteState} />
<DashboardZoneCheckIn {page.showZoneCheckIn && (
zones={page.zoneOptions} <ZoneCheckInCard
activeZone={page.activeZone} zones={page.zoneOptions}
isSaving={page.isZoneSaving} activeZone={page.activeZone}
errorMessage={page.zoneErrorMessage} needsCheckIn={page.needsZoneCheckIn}
onCheckIn={page.checkInZone} isSaving={page.isZoneSaving}
onReset={page.resetZone} errorMessage={page.zoneErrorMessage}
/> onCheckIn={page.checkInZone}
onReset={page.resetZone}
/>
)}
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6"> <div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
<DashboardFramePreview <DashboardFramePreview

View File

@ -32,7 +32,14 @@ export function FrameEntryCard({ entry, index, workflow }: FrameEntryCardProps)
</span> </span>
)} )}
<div className="text-left"> <div className="text-left">
<h4 className="font-semibold text-white">Week of {entry.weekOf}</h4> <h4 className="font-semibold text-white flex items-center gap-2">
Week of {entry.weekOf}
{entry.weekLabel && (
<span className="px-2 py-0.5 rounded-full bg-violet-500/15 border border-violet-500/30 text-violet-300 text-[10px] font-medium">
{entry.weekLabel}
</span>
)}
</h4>
<p className="text-xs text-slate-500 flex items-center gap-1 mt-0.5"> <p className="text-xs text-slate-500 flex items-center gap-1 mt-0.5">
<User size={10} /> {entry.author} - Posted {entry.postedDate} <User size={10} /> {entry.author} - Posted {entry.postedDate}
</p> </p>

View File

@ -4,6 +4,7 @@ import type { FrameEntryViewModel } from '@/business/frame/types';
import { FRAME_SECTION_LABELS } from '@/shared/constants/frame'; import { FRAME_SECTION_LABELS } from '@/shared/constants/frame';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { FrameSectionField } from '@/components/frame/FrameSectionField'; import { FrameSectionField } from '@/components/frame/FrameSectionField';
import { FrameWeekPicker } from '@/components/frame/FrameWeekPicker';
import type { FrameModuleWorkflow } from '@/components/frame/types'; import type { FrameModuleWorkflow } from '@/components/frame/types';
interface FrameEntryEditFormProps { interface FrameEntryEditFormProps {
@ -14,6 +15,12 @@ interface FrameEntryEditFormProps {
export function FrameEntryEditForm({ editEntry, workflow }: FrameEntryEditFormProps) { export function FrameEntryEditForm({ editEntry, workflow }: FrameEntryEditFormProps) {
return ( return (
<> <>
<FrameWeekPicker
weekStart={editEntry.weekStart}
weekLabel={editEntry.weekLabel}
onWeekStartChange={(iso) => workflow.updateEditEntryField('weekStart', iso)}
onLabelChange={(label) => workflow.updateEditEntryField('weekLabel', label)}
/>
{FRAME_SECTION_LABELS.map((section) => ( {FRAME_SECTION_LABELS.map((section) => (
<FrameSectionField <FrameSectionField
key={section.key} key={section.key}

View File

@ -2,8 +2,8 @@ import { Edit3, Save } from 'lucide-react';
import { FRAME_SECTION_LABELS } from '@/shared/constants/frame'; import { FRAME_SECTION_LABELS } from '@/shared/constants/frame';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { FrameSectionField } from '@/components/frame/FrameSectionField'; import { FrameSectionField } from '@/components/frame/FrameSectionField';
import { FrameWeekPicker } from '@/components/frame/FrameWeekPicker';
import type { FrameModuleViewProps } from '@/components/frame/types'; import type { FrameModuleViewProps } from '@/components/frame/types';
export function FrameEntryForm({ workflow }: FrameModuleViewProps) { export function FrameEntryForm({ workflow }: FrameModuleViewProps) {
@ -16,16 +16,12 @@ export function FrameEntryForm({ workflow }: FrameModuleViewProps) {
<h3 className="font-bold text-lg text-white flex items-center gap-2"> <h3 className="font-bold text-lg text-white flex items-center gap-2">
<Edit3 size={18} className="text-amber-400" /> Create New F.R.A.M.E. Entry <Edit3 size={18} className="text-amber-400" /> Create New F.R.A.M.E. Entry
</h3> </h3>
<div> <FrameWeekPicker
<label className="text-sm font-medium text-slate-300">Week Of</label> weekStart={workflow.newEntry.weekStart}
<Input weekLabel={workflow.newEntry.weekLabel}
type="text" onWeekStartChange={(iso) => workflow.updateNewEntryField('weekStart', iso)}
value={workflow.newEntry.weekOf} onLabelChange={(label) => workflow.updateNewEntryField('weekLabel', label)}
onChange={(event) => 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"
/>
</div>
{FRAME_SECTION_LABELS.map((section) => ( {FRAME_SECTION_LABELS.map((section) => (
<FrameSectionField <FrameSectionField
key={section.key} key={section.key}

View File

@ -0,0 +1,88 @@
import { useState } from 'react';
import { parseISO } from 'date-fns';
import { CalendarDays } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Calendar } from '@/components/ui/calendar';
import { Input } from '@/components/ui/input';
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
import { formatWeekOf, toWeekStartIso } from '@/shared/business/week';
interface FrameWeekPickerProps {
/** Sunday-start ISO date. */
readonly weekStart: string;
readonly weekLabel: string;
readonly onWeekStartChange: (weekStartIso: string) => 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 (
<div className="space-y-3">
<div>
<label className="text-sm font-medium text-slate-300">Week Of</label>
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button
type="button"
variant="ghost"
className="w-full mt-1 px-4 py-2.5 h-auto justify-start gap-2 bg-slate-700/50 border border-slate-600/50 rounded-xl text-sm text-white hover:bg-slate-700/70"
>
<CalendarDays size={16} className="text-violet-400" />
{weekStart ? `Week of ${formatWeekOf(weekStart)}` : 'Select a week'}
</Button>
</PopoverTrigger>
<PopoverContent className="w-auto p-0" align="start">
<Calendar
mode="single"
selected={selected}
defaultMonth={selected}
onSelect={(day) => {
if (day) {
onWeekStartChange(toWeekStartIso(day));
setOpen(false);
}
}}
// Shade the whole selected week (SundaySaturday) 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',
}}
/>
</PopoverContent>
</Popover>
</div>
<div>
<label className="text-sm font-medium text-slate-300">Week label (optional)</label>
<Input
type="text"
value={weekLabel}
onChange={(event) => 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"
/>
</div>
</div>
);
}

View File

@ -1,10 +1,21 @@
import { useZonesOfRegulationPage } from '@/business/zones/hooks'; import { useZonesOfRegulationPage } from '@/business/zones/hooks';
import { ZonesOfRegulationView } from '@/components/zones-of-regulation/ZonesOfRegulationView'; 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(); const page = useZonesOfRegulationPage();
return <ZonesOfRegulationView page={page} />; return (
<div className="space-y-6">
<ZoneCheckInSection userRole={userRole} />
<ZonesOfRegulationView page={page} />
</div>
);
}; };
export default ZonesOfRegulation; export default ZonesOfRegulation;

View File

@ -1,6 +1,9 @@
import { useRef } from 'react';
import { Bell } from 'lucide-react'; import { Bell } from 'lucide-react';
import { Link } from 'react-router-dom';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { useOnClickOutside } from '@/hooks/useOnClickOutside';
import type { TopBarNotification } from '@/business/top-bar/types'; import type { TopBarNotification } from '@/business/top-bar/types';
interface TopBarNotificationsProps { interface TopBarNotificationsProps {
@ -18,8 +21,11 @@ export function TopBarNotifications({
onToggle, onToggle,
onClose, onClose,
}: TopBarNotificationsProps) { }: TopBarNotificationsProps) {
const containerRef = useRef<HTMLDivElement>(null);
useOnClickOutside(containerRef, onClose, isOpen);
return ( return (
<div className="relative"> <div className="relative" ref={containerRef}>
<Button <Button
type="button" type="button"
variant="ghost" variant="ghost"
@ -37,9 +43,7 @@ export function TopBarNotifications({
)} )}
</Button> </Button>
{isOpen && ( {isOpen && (
<> <div className="absolute right-0 top-full mt-2 w-80 bg-slate-800 rounded-xl shadow-2xl shadow-black/40 border border-slate-700/50 z-40 overflow-hidden">
<div className="fixed inset-0 z-30" onClick={onClose} role="presentation" />
<div className="absolute right-0 top-full mt-2 w-80 bg-slate-800 rounded-xl shadow-2xl shadow-black/40 border border-slate-700/50 z-40 overflow-hidden">
<div className="px-4 py-3 border-b border-slate-700/50 flex items-center justify-between"> <div className="px-4 py-3 border-b border-slate-700/50 flex items-center justify-between">
<h3 className="font-semibold text-sm text-white">Notifications</h3> <h3 className="font-semibold text-sm text-white">Notifications</h3>
<span className="text-xs text-violet-400 font-medium">{unreadCount} new</span> <span className="text-xs text-violet-400 font-medium">{unreadCount} new</span>
@ -48,13 +52,11 @@ export function TopBarNotifications({
{notifications.length === 0 ? ( {notifications.length === 0 ? (
<div className="px-4 py-5 text-sm text-slate-400">No notifications yet.</div> <div className="px-4 py-5 text-sm text-slate-400">No notifications yet.</div>
) : ( ) : (
notifications.map((notification) => ( notifications.map((notification) => {
<div const itemClassName = `block px-4 py-3 border-b border-slate-700/30 hover:bg-slate-700/30 transition-colors ${
key={notification.id} notification.unread ? 'bg-violet-500/5' : ''
className={`px-4 py-3 border-b border-slate-700/30 hover:bg-slate-700/30 transition-colors ${ } ${notification.href ? 'cursor-pointer' : ''}`;
notification.unread ? 'bg-violet-500/5' : '' const content = (
}`}
>
<div className="flex items-start gap-2"> <div className="flex items-start gap-2">
{notification.unread && ( {notification.unread && (
<span className="w-2 h-2 rounded-full bg-violet-500 mt-1.5 flex-shrink-0 shadow-sm shadow-violet-500/50" /> <span className="w-2 h-2 rounded-full bg-violet-500 mt-1.5 flex-shrink-0 shadow-sm shadow-violet-500/50" />
@ -64,12 +66,26 @@ export function TopBarNotifications({
<p className="text-[10px] text-slate-500 mt-0.5">{notification.time}</p> <p className="text-[10px] text-slate-500 mt-0.5">{notification.time}</p>
</div> </div>
</div> </div>
</div> );
))
return notification.href ? (
<Link
key={notification.id}
to={notification.href}
onClick={onClose}
className={itemClassName}
>
{content}
</Link>
) : (
<div key={notification.id} className={itemClassName}>
{content}
</div>
);
})
)} )}
</div> </div>
</div> </div>
</>
)} )}
</div> </div>
); );

View File

@ -1,6 +1,8 @@
import { useRef } from 'react';
import { Building2, ChevronDown, LogOut, Settings, User, Wifi } from 'lucide-react'; import { Building2, ChevronDown, LogOut, Settings, User, Wifi } from 'lucide-react';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { useOnClickOutside } from '@/hooks/useOnClickOutside';
import { TOP_BAR_PROFILE_MENU_ITEMS } from '@/shared/constants/topBar'; import { TOP_BAR_PROFILE_MENU_ITEMS } from '@/shared/constants/topBar';
import type { CampusInfo } from '@/shared/types/app'; import type { CampusInfo } from '@/shared/types/app';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
@ -28,12 +30,15 @@ export function TopBarProfileMenu({
onClose, onClose,
onSignOut, onSignOut,
}: TopBarProfileMenuProps) { }: TopBarProfileMenuProps) {
const containerRef = useRef<HTMLDivElement>(null);
useOnClickOutside(containerRef, onClose, isOpen);
const avatarClassName = campusInfo const avatarClassName = campusInfo
? cn('bg-gradient-to-br', campusInfo.bgGradient) ? cn('bg-gradient-to-br', campusInfo.bgGradient)
: 'bg-gradient-to-br from-violet-500 to-amber-400 shadow-violet-500/20'; : 'bg-gradient-to-br from-violet-500 to-amber-400 shadow-violet-500/20';
return ( return (
<div className="relative"> <div className="relative" ref={containerRef}>
<Button <Button
type="button" type="button"
variant="ghost" variant="ghost"
@ -52,9 +57,7 @@ export function TopBarProfileMenu({
</Button> </Button>
{isOpen && ( {isOpen && (
<> <div className="absolute right-0 top-full mt-2 w-64 bg-slate-800 rounded-xl shadow-2xl shadow-black/40 border border-slate-700/50 py-2 z-40">
<div className="fixed inset-0 z-30" onClick={onClose} role="presentation" />
<div className="absolute right-0 top-full mt-2 w-64 bg-slate-800 rounded-xl shadow-2xl shadow-black/40 border border-slate-700/50 py-2 z-40">
<div className="px-4 py-3 border-b border-slate-700/50"> <div className="px-4 py-3 border-b border-slate-700/50">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<div className={cn('w-10 h-10 rounded-xl flex items-center justify-center text-white text-sm font-bold shadow-lg', avatarClassName)}> <div className={cn('w-10 h-10 rounded-xl flex items-center justify-center text-white text-sm font-bold shadow-lg', avatarClassName)}>
@ -115,8 +118,7 @@ export function TopBarProfileMenu({
<span className="text-sm font-medium">Sign Out</span> <span className="text-sm font-medium">Sign Out</span>
</Button> </Button>
</div> </div>
</div> </div>
</>
)} )}
</div> </div>
); );

View File

@ -1,23 +1,101 @@
import { useRef, useState } from 'react';
import { Search } from 'lucide-react'; import { Search } from 'lucide-react';
import { Input } from '@/components/ui/input'; import { Input } from '@/components/ui/input';
import { useOnClickOutside } from '@/hooks/useOnClickOutside';
import type { TopBarSearchResult } from '@/business/top-bar/search';
interface TopBarSearchProps { interface TopBarSearchProps {
readonly value: string; readonly value: string;
readonly results: readonly TopBarSearchResult[];
readonly onChange: (value: string) => void; 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<HTMLDivElement>(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<HTMLInputElement>) => {
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 ( return (
<div className="hidden md:flex relative"> <div ref={containerRef} className="hidden md:flex relative">
<Search size={16} className="absolute left-3 top-1/2 -translate-y-1/2 text-slate-500" /> <Search size={16} className="absolute left-3 top-1/2 -translate-y-1/2 text-slate-500" />
<Input <Input
type="text" type="text"
role="combobox"
aria-expanded={showDropdown}
aria-controls="top-bar-search-results"
value={value} value={value}
onChange={(event) => onChange(event.target.value)} onChange={(event) => handleChange(event.target.value)}
onFocus={() => setIsOpen(true)}
onKeyDown={handleKeyDown}
placeholder="Search modules, strategies, signs..." 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" 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 && (
<div
id="top-bar-search-results"
role="listbox"
className="absolute left-0 top-full mt-2 w-72 bg-slate-800 rounded-xl shadow-2xl shadow-black/40 border border-slate-700/50 z-40 overflow-hidden max-h-80 overflow-y-auto"
>
{results.length === 0 ? (
<div className="px-4 py-3 text-sm text-slate-400">No matches found.</div>
) : (
results.map((result, index) => (
<button
key={result.id}
type="button"
role="option"
aria-selected={index === highlightIndex}
onMouseEnter={() => setHighlightIndex(index)}
onClick={() => select(result)}
className={`w-full text-left px-4 py-2.5 border-b border-slate-700/30 last:border-b-0 transition-colors ${
index === highlightIndex ? 'bg-slate-700/40' : 'hover:bg-slate-700/30'
}`}
>
<span className="block text-sm text-slate-200">{result.label}</span>
<span className="block text-[10px] text-slate-500 mt-0.5">{result.sublabel}</span>
</button>
))
)}
</div>
)}
</div> </div>
); );
} }

View File

@ -28,7 +28,12 @@ export function TopBarView({ page }: TopBarViewProps) {
> >
<Menu size={20} /> <Menu size={20} />
</Button> </Button>
<TopBarSearch value={page.searchQuery} onChange={page.setSearchQuery} /> <TopBarSearch
value={page.searchQuery}
results={page.searchResults}
onChange={page.setSearchQuery}
onSelect={page.selectSearchResult}
/>
</div> </div>
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">

View File

@ -3,28 +3,43 @@ import type { DashboardZoneOption } from '@/shared/constants/dashboard';
import type { ZoneColor } from '@/shared/types/app'; import type { ZoneColor } from '@/shared/types/app';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
interface DashboardZoneCheckInProps { interface ZoneCheckInCardProps {
readonly zones: readonly DashboardZoneOption[]; readonly zones: readonly DashboardZoneOption[];
readonly activeZone: ZoneColor | null; readonly activeZone: ZoneColor | null;
readonly isSaving: boolean; readonly isSaving: boolean;
readonly errorMessage: string | null; readonly errorMessage: string | null;
/** Highlight that the eligible user has not logged a zone today. */
readonly needsCheckIn?: boolean;
readonly onCheckIn: (zone: ZoneColor) => Promise<void>; readonly onCheckIn: (zone: ZoneColor) => Promise<void>;
readonly onReset: () => Promise<void>; readonly onReset: () => Promise<void>;
} }
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, zones,
activeZone, activeZone,
isSaving, isSaving,
errorMessage, errorMessage,
needsCheckIn = false,
onCheckIn, onCheckIn,
onReset, onReset,
}: DashboardZoneCheckInProps) { }: ZoneCheckInCardProps) {
return ( return (
<section className="bg-slate-800/40 backdrop-blur-sm rounded-2xl border border-slate-700/40 p-5"> <section className="bg-slate-800/40 backdrop-blur-sm rounded-2xl border border-slate-700/40 p-5">
<div className="flex items-center justify-between mb-3"> <div className="flex items-center justify-between mb-3">
<div> <div>
<h3 className="font-semibold text-white">Today's Zone Check-In</h3> <h3 className="font-semibold text-white flex items-center gap-2">
Today's Emotional Zone Check-In
{needsCheckIn && (
<span className="px-2 py-0.5 rounded-full bg-red-500/15 border border-red-500/40 text-red-400 text-[10px] font-semibold uppercase tracking-wide">
Not checked in
</span>
)}
</h3>
<p className="text-xs text-slate-400">How are you feeling right now? Saved to your profile.</p> <p className="text-xs text-slate-400">How are you feeling right now? Saved to your profile.</p>
</div> </div>
{activeZone && ( {activeZone && (
@ -40,7 +55,7 @@ export function DashboardZoneCheckIn({
</Button> </Button>
)} )}
</div> </div>
<div className="grid grid-cols-4 gap-3"> <div className="grid grid-cols-2 sm:grid-cols-4 gap-3">
{zones.map((zone) => ( {zones.map((zone) => (
<Button <Button
key={zone.color} key={zone.color}

View File

@ -0,0 +1,25 @@
import { HeartPulse } from 'lucide-react';
/**
* Inline banner nudging an eligible user who has not logged their Zone today.
* Rendered above the page content (e.g. Zones of Regulation); the actual
* check-in happens in the {@link ZoneCheckInCard} below it.
*/
export function ZoneCheckInReminder() {
return (
<div
role="status"
className="flex items-start gap-3 rounded-2xl border border-amber-500/30 bg-amber-500/10 px-5 py-4"
>
<div className="w-9 h-9 rounded-xl bg-amber-500/20 flex items-center justify-center flex-shrink-0">
<HeartPulse size={18} className="text-amber-300" />
</div>
<div>
<p className="font-semibold text-amber-200 text-sm">You haven't logged your Emotional Zone today</p>
<p className="text-xs text-amber-200/70 mt-0.5">
Take a moment to check in on how you're feeling — it's saved to your profile.
</p>
</div>
</div>
);
}

View File

@ -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 (
<div className="space-y-4">
{needsCheckIn && <ZoneCheckInReminder />}
<ZoneCheckInCard
zones={DASHBOARD_ZONE_OPTIONS}
activeZone={zone.todayZone}
needsCheckIn={needsCheckIn}
isSaving={zone.isSaving}
errorMessage={getOptionalErrorMessage(zone.error)}
onCheckIn={async (selected) => { await zone.setZone(selected); }}
onReset={async () => { await zone.clearToday(); }}
/>
</div>
);
}

View File

@ -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<HTMLDivElement>();
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();
});
});

View File

@ -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<HTMLElement | null>,
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]);
}

View File

@ -1,5 +1,8 @@
import { useShellOutletContext } from '@/app/shellOutletContext';
import ZonesOfRegulation from '@/components/frameworks/ZonesOfRegulation'; import ZonesOfRegulation from '@/components/frameworks/ZonesOfRegulation';
export default function ZonesOfRegulationPage() { export default function ZonesOfRegulationPage() {
return <ZonesOfRegulation />; const shell = useShellOutletContext();
return <ZonesOfRegulation userRole={shell.userRole} />;
} }

View File

@ -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');
});
});

View File

@ -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<ZoneCheckinTodayDto> {
return apiRequest<ZoneCheckinTodayDto>(`${ZONE_CHECKINS_PATH}/today`);
}
export function checkInZone(zone: ZoneColor): Promise<ZoneCheckinTodayDto> {
return apiRequest<ZoneCheckinTodayDto>(ZONE_CHECKINS_PATH, {
method: 'POST',
body: { data: { zone } },
});
}
export function clearTodayZoneCheckin(): Promise<ZoneCheckinTodayDto> {
return apiRequest<ZoneCheckinTodayDto>(`${ZONE_CHECKINS_PATH}/today`, {
method: 'DELETE',
});
}
export function listZoneCheckinHistory(): Promise<
ApiListResponse<ZoneCheckinHistoryEntryDto>
> {
return apiRequest<ApiListResponse<ZoneCheckinHistoryEntryDto>>(
ZONE_CHECKINS_PATH,
);
}

View File

@ -28,7 +28,7 @@ export const MODULE_PERMISSIONS = [
'READ_EI', 'READ_ZONES', 'READ_SIGNS', 'READ_ATTENDANCE', 'READ_PARENT_COMM', 'READ_EI', 'READ_ZONES', 'READ_SIGNS', 'READ_ATTENDANCE', 'READ_PARENT_COMM',
'READ_INTERNAL_COMM', 'READ_SAFETY', 'READ_HANDBOOK', 'READ_COMMUNITY', 'READ_INTERNAL_COMM', 'READ_SAFETY', 'READ_HANDBOOK', 'READ_COMMUNITY',
'READ_VOCATIONAL', 'READ_ESA', 'READ_WALKTHROUGH', 'READ_DIRECTOR_DASHBOARD', '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; ] as const;
export type PermissionEntity = (typeof PERMISSION_ENTITIES)[number]; export type PermissionEntity = (typeof PERMISSION_ENTITIES)[number];

View File

@ -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);
});
});

View File

@ -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);
}

View File

@ -2,12 +2,8 @@ import { UserProgressType } from '@/shared/types/userProgress';
export const USER_PROGRESS_TYPES: Record<string, UserProgressType> = { export const USER_PROGRESS_TYPES: Record<string, UserProgressType> = {
signLearned: 'sign_learned', signLearned: 'sign_learned',
zoneCheckin: 'zone_checkin',
}; };
export const ZONE_CHECKIN_ITEM_ID = 'current';
export const USER_PROGRESS_QUERY_KEYS = { export const USER_PROGRESS_QUERY_KEYS = {
signProgress: ['userProgress', USER_PROGRESS_TYPES.signLearned], signProgress: ['userProgress', USER_PROGRESS_TYPES.signLearned],
zoneCheckin: ['userProgress', USER_PROGRESS_TYPES.zoneCheckin, ZONE_CHECKIN_ITEM_ID],
} as const; } as const;

View File

@ -8,6 +8,7 @@ export type FrameSectionKey =
export interface FrameEntryDto { export interface FrameEntryDto {
readonly id: string; readonly id: string;
readonly week_of: string; readonly week_of: string;
readonly week_label: string | null;
readonly posted_date: string; readonly posted_date: string;
readonly formal: string; readonly formal: string;
readonly recognition: string; readonly recognition: string;
@ -23,6 +24,7 @@ export interface FrameEntryDto {
export interface FrameEntryMutationDto { export interface FrameEntryMutationDto {
readonly week_of: string; readonly week_of: string;
readonly week_label?: string;
readonly posted_date: string; readonly posted_date: string;
readonly formal: string; readonly formal: string;
readonly recognition: string; readonly recognition: string;

View File

@ -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;
}

View File

@ -155,7 +155,7 @@ test.describe('seeded content catalog integration', () => {
await expect(page.getByText('Loading classroom strategies...')).toHaveCount(0); await expect(page.getByText('Loading classroom strategies...')).toHaveCount(0);
await expect(page.getByRole('heading', { name: 'Classroom Support' })).toBeVisible(); 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 }) => { test('renders seeded sign language content', async ({ page, request }) => {

View File

@ -20,6 +20,7 @@ const TEACHER_EMAIL = 'teacher@flatlogic.com';
interface FrameRow { interface FrameRow {
readonly id: string; readonly id: string;
readonly author: string; readonly author: string;
readonly week_of: string;
} }
interface ProgressRow { interface ProgressRow {
readonly item_id: string; readonly item_id: string;
@ -61,7 +62,11 @@ test.describe('Product-workflow persistence', () => {
const listRes = await page.request.get(`${BACKEND_API_URL}/frame_entries`); const listRes = await page.request.get(`${BACKEND_API_URL}/frame_entries`);
expect(listRes.status()).toBe(200); expect(listRes.status()).toBe(200);
const body = (await listRes.json()) as { rows?: FrameRow[] }; 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 ({ test('a staff member marks a sign learned and progress persists', async ({

View File

@ -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<void> {
await page.request.post(`${BACKEND_API_URL}/auth/signout`);
await page.context().clearCookies();
}
async function login(page: Page, email: string): Promise<void> {
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());
});
});