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`):
- `id` (UUID PK), `name` (TEXT, not null), `code` (TEXT, not null), `address`, `phone`, `email`,
- `id` (UUID PK), `name` (TEXT, not null), `code` (TEXT, not null), `timezone` (TEXT, **not null**
— a validated IANA zone, e.g. `America/Phoenix`; used by the daily zone check-in to compute the
campus-local "today" server-side, see [`zone-checkin.md`](zone-checkin.md)), `address`, `phone`, `email`,
`mascot`, `color`, `bgGradient`, `borderColor`, `textColor`, `bgLight`, `description` (all TEXT,
nullable), `isOnline` (BOOLEAN, not null, default `false`), `active` (BOOLEAN, not null, default
`false`), `importHash` (STRING(255), unique, nullable), `organizationId` (UUID, nullable),

View File

@ -1,7 +1,7 @@
# Database Schema
> Generated from the Sequelize models (`backend/src/db/models/*`) — the source of truth.
> Regenerate after schema changes. Last generated: 2026-06-11.
> Regenerate after schema changes. Last generated: 2026-06-12.
## Overview
@ -158,6 +158,7 @@ Authentication identities. `email` is required (login + primary contact). Belong
| `id` | uuid | no | UUIDV4 | PK |
| `firstName` | text | yes | — | |
| `lastName` | text | yes | — | |
| `name_prefix` | enum | yes | — | honorific (`mr`/`mrs`/`ms`/`mx`/`dr`/`prof`) |
| `phoneNumber` | text | yes | — | |
| `email` | text | no | — | |
| `disabled` | boolean | no | false | |
@ -238,6 +239,7 @@ A physical or online campus belonging to one organization. Parent of students, s
| `id` | uuid | no | UUIDV4 | PK |
| `name` | text | yes | — | |
| `code` | text | yes | — | |
| `timezone` | text | no | — | validated IANA zone (zone check-in "today") |
| `address` | text | yes | — | |
| `phone` | text | yes | — | |
| `email` | text | yes | — | |
@ -820,7 +822,8 @@ Product-module "frame" entries.
| Column | Type | Null | Default | Notes |
|---|---|---|---|---|
| `id` | uuid | no | UUIDV4 | PK |
| `week_of` | text | no | — | |
| `week_of` | text | no | — | canonical Sunday-start ISO date (American week) |
| `week_label` | text | yes | — | optional free-text label for the week |
| `posted_date` | text | no | — | |
| `formal` | text | no | — | |
| `recognition` | text | no | — | |

View File

@ -49,17 +49,29 @@ Request body for create/update is wrapped as `{ data: <FrameEntryInput> }`.
## Data Contract
Required request fields (`REQUIRED_FIELDS`): `week_of`, `posted_date`, `formal`, `recognition`,
`application`, `management`, `emotional`, `author`. Optional: `campusId`. Missing/invalid input
raises `ValidationError`.
`application`, `management`, `emotional`, `author`. Optional: `week_label`, `campusId`.
Missing/invalid input raises `ValidationError`.
`week_of` is the **week the entry covers**, sent as a `YYYY-MM-DD` date and stored as the
**canonical Sunday-start ISO date** (American week) — the server normalizes it via
`toWeekStartIso` (`shared/constants/week.ts`) and rejects non-dates. `week_label` is an optional
free-text note for that week (e.g. "Spring Break week"), stored trimmed or `null`.
## Behavior / Notes
- Create/update run inside `withTransaction`.
- List is paginated with the shared defaults (`resolvePagination`).
- The same Sunday-start canonicalization is used on the frontend
(`shared/business/week.ts`) for the dashboard hero, the safety-quiz week, and the
F.R.A.M.E. week picker, so the week is consistent everywhere.
## Tests
None yet (no `frame_entries` unit/e2e test in `src/`).
- **Unit** (`npm test`): `shared/constants/week.test.ts` — the Sunday-start
`week_of` normalization (`toWeekStartIso`) + invalid-date rejection.
- **Seeded e2e** (`frontend/tests/e2e/product-workflow.seeded.e2e.ts`,
`npm run test:e2e:content`): a director posts a FRAME entry and reads it back,
asserting the persisted `week_of` is normalized to its Sunday week-start.
## Related

View File

@ -46,6 +46,7 @@ Tenant Scope / Data Contract / Behavior / Tests / Related).
- [`staff-attendance.md`](staff-attendance.md)
- [`user-progress.md`](user-progress.md)
- [`walkthrough-checkins.md`](walkthrough-checkins.md)
- [`zone-checkin.md`](zone-checkin.md): daily staff self-regulation check-in (campus-timezone "today" + nudge).
## Generic CRUD Entity Slices

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
acknowledgments), `20260611010000-audio-files.ts` (the audio library) +
`20260611060000-audio-files-kinds.ts` (the `kind` enum / nullable `url` / `recipe` JSONB), and
`20260611040000-add-user-name-prefix.ts` (the `users.name_prefix` honorific enum).
`20260611040000-add-user-name-prefix.ts` (the `users.name_prefix` honorific enum), and
`20260611070000-campuses-timezone.ts` (the required `campuses.timezone` IANA column — added
nullable, backfilled, then set NOT NULL), and `20260612000000-frame-entries-week-label.ts`
(the optional `frame_entries.week_label`; `week_of` is now the canonical Sunday-start ISO date).
- Seeders: `src/db/seeders/*.ts``admin-user` (the 10 per-role RBAC fixture users),
`user-roles` (the 11 first-class roles, the permission catalog incl. product-feature
permissions, the role->permission matrix, role assignment by user id), `product-campuses`,

View File

@ -103,6 +103,9 @@ const req = createMockRequest({
| File | Description | Tests |
|------|-------------|-------|
| `shared/constants/audio-files.test.ts` | `file`/`url`/`recipe` kinds + `isAudioFileKind` | ~2 |
| `shared/constants/timezone.test.ts` | campus-local date (Phoenix no-DST + DST) + `isValidIanaTimezone` | ~3 |
| `shared/constants/zone-checkin.test.ts` | zone colors + `isZoneCheckinColor` | ~2 |
| `shared/constants/week.test.ts` | Sunday-start week normalization (`toWeekStartIso`) + invalid-date rejection | ~2 |
| `shared/constants/policy-documents.test.ts` | category validation + version-bump re-acknowledgment rule | ~several |
| `shared/constants/users.test.ts` | honorific name-prefix formatting (`formatPersonName`) | ~several |

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

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 { Timetables } from './timetables';
import type { Users } from './users';
import { isValidIanaTimezone } from '@/shared/constants/timezone';
export class Campuses extends Model<
InferAttributes<Campuses>,
@ -28,6 +29,7 @@ export class Campuses extends Model<
declare id: CreationOptional<string>;
declare name: string;
declare code: string;
declare timezone: string;
declare address: string | null;
declare phone: string | null;
declare email: string | null;
@ -118,6 +120,17 @@ export default function (sequelize: Sequelize): typeof Campuses {
},
name: { type: DataTypes.TEXT, allowNull: false },
code: { type: DataTypes.TEXT, allowNull: false },
timezone: {
type: DataTypes.TEXT,
allowNull: false,
validate: {
isValidIana(value: unknown) {
if (!isValidIanaTimezone(value)) {
throw new Error('timezone must be a valid IANA timezone');
}
},
},
},
address: { type: DataTypes.TEXT },
phone: { type: DataTypes.TEXT },
email: { type: DataTypes.TEXT },

View File

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

View File

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

View File

@ -74,6 +74,7 @@ import staffAttendanceRoutes from '@/routes/staff_attendance';
import policyDocumentsRoutes from '@/routes/policy_documents';
import policyAcknowledgmentsRoutes from '@/routes/policy_acknowledgments';
import audioFilesRoutes from '@/routes/audio_files';
import zoneCheckinsRoutes from '@/routes/zone_checkins';
const app = express();
@ -272,6 +273,7 @@ app.use('/api/content-catalog', authenticated, contentCatalogRoutes);
app.use('/api/policy_documents', authenticated, policyDocumentsRoutes);
app.use('/api/policy_acknowledgments', authenticated, policyAcknowledgmentsRoutes);
app.use('/api/audio_files', authenticated, audioFilesRoutes);
app.use('/api/zone_checkins', authenticated, zoneCheckinsRoutes);
app.use('/api/search', authenticated, searchRoutes);
// Unmatched API routes → centralized 404 (the SPA fallback below handles the rest).

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,
} from '@/services/shared/access';
import { FRAME_EDITOR_ROLE_NAMES } from '@/shared/constants/frame';
import { toWeekStartIso } from '@/shared/constants/week';
import type { FrameEntries } from '@/db/models/frame_entries';
import type { CurrentUser } from '@/db/api/types';
interface FrameEntryInput {
week_of: string;
week_label?: string | null;
posted_date: string;
formal: string;
recognition: string;
@ -23,6 +25,20 @@ interface FrameEntryInput {
campusId?: string | null;
}
/** Normalizes the input week to its Sunday-start ISO date, or throws. */
function requireWeekStart(weekOf: string): string {
const weekStart = toWeekStartIso(weekOf);
if (!weekStart) {
throw new ValidationError();
}
return weekStart;
}
/** Optional free-text label (e.g. "Spring Break week"); null when blank. */
function toWeekLabel(value: unknown): string | null {
return typeof value === 'string' && value.trim().length > 0 ? value.trim() : null;
}
const REQUIRED_FIELDS = [
'week_of',
'posted_date',
@ -79,6 +95,7 @@ function toDto(entry: FrameEntries) {
return {
id: plain.id,
week_of: plain.week_of,
week_label: plain.week_label,
posted_date: plain.posted_date,
formal: plain.formal,
recognition: plain.recognition,
@ -125,7 +142,8 @@ class FrameEntriesService {
return withTransaction(async (transaction) => {
const entry = await db.frame_entries.create(
{
week_of: data.week_of.trim(),
week_of: requireWeekStart(data.week_of),
week_label: toWeekLabel(data.week_label),
posted_date: data.posted_date.trim(),
formal: data.formal.trim(),
recognition: data.recognition.trim(),
@ -168,7 +186,8 @@ class FrameEntriesService {
await entry.update(
{
week_of: data.week_of.trim(),
week_of: requireWeekStart(data.week_of),
week_label: toWeekLabel(data.week_label),
posted_date: data.posted_date.trim(),
formal: data.formal.trim(),
recognition: data.recognition.trim(),

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

View File

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

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`
- `DELETE /api/content-catalog/:contentType`
`useContentCatalogPayload(contentType, empty, { enabled })` accepts an optional
`enabled` flag for **lazy** loads — used by the header search to fetch a
catalog only once the user types (see `top-bar-integration.md`).
## Current Consumers
- classroom support strategies

View File

@ -18,7 +18,7 @@ View:
- `frontend/src/components/dashboard/DashboardView.tsx`
- `frontend/src/components/dashboard/DashboardHero.tsx`
- `frontend/src/components/dashboard/DashboardQuotePanel.tsx`
- `frontend/src/components/dashboard/DashboardZoneCheckIn.tsx`
- `frontend/src/components/zone-checkin/ZoneCheckInCard.tsx`
- `frontend/src/components/dashboard/DashboardFramePreview.tsx`
- `frontend/src/components/dashboard/DashboardUpcomingEvents.tsx`
- `frontend/src/components/dashboard/DashboardWeeklyProgress.tsx`
@ -49,11 +49,12 @@ Feature APIs:
- F.R.A.M.E. entries through `useFrameEntries`
- Communication events through `useCommunicationEvents`
- Current-user zone check-in through `useZoneCheckIn`
- Current-user daily Emotional Zone check-in through `useTodayZoneCheckIn` (campus-staff roles only; see `zone-checkin-integration.md`)
## Behavior
- `useDashboardPage` composes all dashboard data sources into one page model.
- The hero's "Week of …" uses the shared American (Sunday-start) week util (`shared/business/week.ts`) — the same canonicalization as the F.R.A.M.E. week picker and the safety-quiz week.
- Selectors handle greeting calculation, day-based quote selection, zone normalization, event limiting, and role-filtered quick actions.
- View components receive prepared props and do not call API/data access modules.
- Loading, empty, and error states remain explicit for each dashboard section.

View File

@ -28,6 +28,7 @@ Business logic layer:
- `frontend/src/business/dashboard/hooks.ts`
- `frontend/src/business/director-dashboard/hooks.ts`
- `frontend/src/business/director-dashboard/selectors.ts`
- `frontend/src/shared/business/week.ts` (shared American/Sunday week canonicalization)
API/data access layer:
@ -42,6 +43,7 @@ Constants:
- FRAME entries load from `GET /api/frame_entries`.
- Create/update workflows use typed API calls and React Query mutations.
- **Week selection**: the create and edit forms use `FrameWeekPicker` (a `Popover` + `Calendar`) — picking any day snaps to that week's **Sunday** (American week) via the shared `shared/business/week.ts` (`toWeekStartIso`), and an optional free-text **week label** (e.g. "Spring Break week") is captured separately. The entry stores the canonical Sunday-start ISO in `week_of` and the label in `week_label`; cards render `Week of <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.
- FRAME view components use shared UI primitives: `Button`, `Input`, `Textarea`, and `StatePanel`.
- 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)
- [`vocational-opportunities.md`](vocational-opportunities.md)
- [`walkthrough-integration.md`](walkthrough-integration.md)
- [`zone-checkin-integration.md`](zone-checkin-integration.md)
- [`zones-of-regulation-integration.md`](zones-of-regulation-integration.md)

View File

@ -48,6 +48,7 @@ Constants:
- Result ownership is derived by the backend from the authenticated session.
- `QBSSafety.tsx` is a thin wrapper that calls `useSafetyQuizPage` and renders focused safety quiz view components.
- Quiz score, progress, result feedback, and compliance summary are derived in business selectors.
- The "current week" key (`getCurrentSafetyQuizWeek`) uses the shared American (Sunday-start) week util (`shared/business/week.ts`) — consistent with the dashboard hero and F.R.A.M.E.
- Weekly focus and key reminders are backend content payload fields, not frontend constants.
- Safety quiz view components use shared `Button`, `StatePanel`, and `ModuleHeader` primitives.
- Director dashboard derives QBS completion metrics and risk rows in business selectors.

View File

@ -17,7 +17,7 @@ UI-facing product types live in `frontend/src/shared/types/app.ts`.
- `ZoneColor`
- cross-module static catalog item types used by the current UI
Backend DTO contracts remain in dedicated files under `frontend/src/shared/types/`, for example `auth.ts`, `frame.ts`, `campusAttendance.ts`, `policyDocuments.ts`, and `audioFiles.ts`.
Backend DTO contracts remain in dedicated files under `frontend/src/shared/types/`, for example `auth.ts`, `frame.ts`, `campusAttendance.ts`, `policyDocuments.ts`, `audioFiles.ts`, and `zoneCheckins.ts`.
## Rules

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/audio-files/selectors.test.ts`
- `frontend/src/business/audio-files/generate.test.ts`
- `frontend/src/business/zone-checkin/selectors.test.ts`
- `frontend/src/business/personality/mappers.test.ts`
- `frontend/src/business/personality/selectors.test.ts`
- `frontend/src/business/policies/mappers.test.ts`
@ -49,6 +50,9 @@ Current coverage includes architecture guardrails, API/data-access behavior, and
- `frontend/src/business/staff-attendance/mappers.test.ts`
- `frontend/src/business/staff-attendance/selectors.test.ts`
- `frontend/src/business/top-bar/selectors.test.ts`
- `frontend/src/business/top-bar/search.test.ts`
- `frontend/src/hooks/useOnClickOutside.test.tsx`
- `frontend/src/shared/business/week.test.ts`
- `frontend/src/business/user-progress/mappers.test.ts`
- `frontend/src/business/vocational/selectors.test.ts`
- `frontend/src/business/walkthrough/mappers.test.ts`
@ -75,7 +79,7 @@ Current coverage includes architecture guardrails, API/data-access behavior, and
- `frontend/src/hooks/usePermissions.test.tsx`
- `frontend/src/components/sign-in-modal/SignInForm.test.tsx`
These tests verify import-boundary enforcement, centralized network access through `shared/api/httpClient`, alias-only source imports, required API contract-test coverage, HTTP client request normalization, cookie-backed auth refresh behavior, JSON body serialization, empty response handling, backend error propagation, API wrapper endpoint/query/body contracts, shared list/query/error helpers, route config, module route metadata, module access correction, sidebar navigation selectors, mobile sidebar overlay visibility, auth display/profile mapping, signup validation, campus mapping, dashboard and director dashboard selectors, classroom support selectors, timer formatting/progress/threshold parsing, DTO mapping, FRAME edit access, campus attendance mapping, calculations, and printable report output, staff attendance mapping and rollups, walkthrough create DTO mapping, walkthrough filtering, walkthrough check-in stats/history mapping, walkthrough scoring, generated summaries, repeated low-rating flags, communication role visibility, community catalog filtering and stats, ESA funding selectors, policy mapping/filtering/validation, safety-protocol mapping and authoring (steps/considerations) validation, audio-library management gating and the local recipe-generation stub, safety quiz compliance mapping and editable payload validation, sign language selectors, top bar display selectors, user progress normalization, vocational zip/category/search/stat calculations, zones selectors, personality DTO mapping, personality distribution grouping, EI thresholds, personality quiz progress, and MBTI-derived communication guidance.
These tests verify import-boundary enforcement, centralized network access through `shared/api/httpClient`, alias-only source imports, required API contract-test coverage, HTTP client request normalization, cookie-backed auth refresh behavior, JSON body serialization, empty response handling, backend error propagation, API wrapper endpoint/query/body contracts, shared list/query/error helpers, route config, module route metadata, module access correction, sidebar navigation selectors, mobile sidebar overlay visibility, auth display/profile mapping, signup validation, campus mapping, dashboard and director dashboard selectors, classroom support selectors, timer formatting/progress/threshold parsing, DTO mapping, FRAME edit access, campus attendance mapping, calculations, and printable report output, staff attendance mapping and rollups, walkthrough create DTO mapping, walkthrough filtering, walkthrough check-in stats/history mapping, walkthrough scoring, generated summaries, repeated low-rating flags, communication role visibility, community catalog filtering and stats, ESA funding selectors, policy mapping/filtering/validation, safety-protocol mapping and authoring (steps/considerations) validation, audio-library management gating and the local recipe-generation stub, daily Zone check-in eligibility/nudge, header search over accessible modules + their content, outside-click dismissal, the shared American (Sunday-start) week canonicalization, F.R.A.M.E. week/label mapping, safety quiz compliance mapping and editable payload validation, sign language selectors, top bar display selectors, user progress normalization, vocational zip/category/search/stat calculations, zones selectors, personality DTO mapping, personality distribution grouping, EI thresholds, personality quiz progress, and MBTI-derived communication guidance.
The component and hook tests verify SignInForm rendering, loading states, user interactions, form submission, password visibility toggling, auth session initialization, sign-in/sign-out flows, AuthExpiredError handling, modal workflow state management, and permissions hook behavior including has(), hasAny(), hasAll() methods with globalAccess support.
@ -103,6 +107,7 @@ Backend-seeded E2E tests live under:
- `frontend/tests/e2e/product-workflow.seeded.e2e.ts`
- `frontend/tests/e2e/policy-acknowledgments.seeded.e2e.ts`
- `frontend/tests/e2e/audio-files.seeded.e2e.ts`
- `frontend/tests/e2e/zone-checkins.seeded.e2e.ts`
The seeded suite is intentionally excluded from default `npm run test:e2e` through `frontend/playwright.config.ts`. Run it with:
@ -126,9 +131,10 @@ The seeded suite verifies:
- RBAC access control for different user roles (teacher, director, superintendent access to appropriate routes)
- **Tenant isolation**: Users from one organization cannot read, list, update, or delete records from another organization
- **Scoped provisioning**: Creating an owner auto-creates and links a new company
- **Product workflows**: Director FRAME entries and staff progress tracking persist correctly
- **Product workflows**: Director FRAME entries and staff progress tracking persist correctly (incl. server-side Sunday normalization of the FRAME `week_of`)
- **Policy acknowledgments**: document create/persist, manage-vs-read RBAC, per-version (idempotent) acknowledgment, and external-role lockout
- **Audio library**: `file`/`url`/`recipe` create/persist, same-campus read, kind/content validation, `support_staff` read-only, and external-role lockout
- **Daily Zone check-in**: campus-staff record/read-back/clear of today's zone (campus-timezone date), invalid-zone rejection, and external-role lockout
## Accessibility E2E Coverage

View File

@ -25,6 +25,7 @@ Business logic:
- `frontend/src/business/top-bar/hooks.ts`
- `frontend/src/business/top-bar/selectors.ts`
- `frontend/src/business/top-bar/search.ts`
- `frontend/src/business/top-bar/types.ts`
Shared config:
@ -36,9 +37,16 @@ Shared config:
- `TopBar.tsx` is a thin wrapper that reads auth session state and passes it into `useTopBarPage`.
- `useTopBarPage` owns profile menu state, notifications menu state, search query state, and sign-out error state.
- Selectors handle initials, campus label fallback, shared role labels, and unread notification count.
- **Header search** (`TopBarSearch`) is a combobox over the user's **accessible modules** (local, role-filtered via `getAccessibleModules`) **plus their product content** from the content catalog (classroom strategies, sign-language signs, regulation zones). Content is fetched **lazily** (only once the user types, and only for accessible modules) via `useContentCatalogPayload({ enabled })`; results are combined by `buildTopBarSearchResults` (modules first, then content, capped). Selecting a result navigates to its module (`setCurrentModule`) and clears the query. Keyboard: ↑/↓ to move, Enter to open, Esc to close; the dropdown closes on outside click (`useOnClickOutside`). The backend `/api/search` is a separate admin SIS-record search and is intentionally **not** used here.
- View components receive a prepared page model and do not call API/data access modules.
- Profile and settings menu items are explicitly disabled until product workflows exist, instead of rendering silent no-op buttons.
## Tests
- `business/top-bar/selectors.test.ts` (notification builder + zones `href`),
`business/top-bar/search.test.ts` (module role-filtering + content matching +
combine/cap), `hooks/useOnClickOutside.test.tsx` (dropdown dismissal).
## Data Ownership Rules
- Do not add notification seed data to frontend constants.

View File

@ -2,7 +2,11 @@
## Purpose
User progress follows the frontend three-layer architecture for sign language progress and dashboard zone check-ins.
User progress follows the frontend three-layer architecture for **sign language**
learned-progress. (The daily Zone check-in also persists in `user_progress`
server-side, but the frontend reads it through the dedicated `/api/zone_checkins`
slice — see [`zone-checkin-integration.md`](zone-checkin-integration.md) — not
through this generic client.)
```text
View -> Business Logic -> API/Data Access -> Backend
@ -15,11 +19,6 @@ View layer:
- `frontend/src/components/frameworks/SignLanguage.tsx`
- `frontend/src/components/sign-language/SignLanguageProgressPanel.tsx`
- `frontend/src/components/sign-language/SignLanguageVideoModal.tsx`
- `frontend/src/components/frameworks/Dashboard.tsx`
- `frontend/src/components/dashboard/DashboardZoneCheckIn.tsx`
- `frontend/src/components/frameworks/ZonesOfRegulation.tsx`
- `frontend/src/components/zones-of-regulation/ZonesOfRegulationView.tsx`
Business logic layer:
- `frontend/src/business/dashboard/hooks.ts`
@ -45,8 +44,6 @@ Constants:
- Marking a sign learned uses `POST /api/user_progress`.
- Unmarking a sign uses `DELETE /api/user_progress/by-item`.
- The sign language page combines user progress with backend content catalog records in `useSignLanguagePage`.
- Dashboard zone check-in uses `item_id=current` and `progress_type=zone_checkin`; dashboard page composition lives in `useDashboardPage`.
- The zones of regulation page currently renders content catalog records only. It does not persist check-ins; adding that interaction requires a dedicated UX task.
- Views render explicit backend errors from React Query state.
- User progress ownership is derived by the backend from the authenticated session.

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.
- View components receive a prepared page model and do not call API/data access modules.
- Loading and error states are explicit through `StatePanel`.
- The module preserves the current behavior: selecting a zone expands details; zone check-in persistence remains owned by the dashboard check-in flow until a dedicated UX task adds it here.
- Selecting a zone expands its details. The page also renders the daily Emotional Zone check-in (`ZoneCheckInSection`: reminder banner + `ZoneCheckInCard`) above the content for eligible campus-staff roles — see [`zone-checkin-integration.md`](zone-checkin-integration.md).
## Data Ownership Rules

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -6,40 +6,46 @@ import {
import type { EditableFrameEntry } from '@/business/frame/types';
import type { FrameEntryDto } from '@/shared/types/frame';
describe('frame mappers', () => {
it('maps backend FRAME DTO fields into the frontend view model shape', () => {
const dto: FrameEntryDto = {
id: 'frame-1',
week_of: '2026-06-08',
posted_date: '2026-06-08',
formal: 'Formal note',
recognition: 'Recognition note',
application: 'Application note',
management: 'Management note',
emotional: 'Emotional note',
author: 'Director',
organizationId: 'org-1',
campusId: 'campus-1',
createdAt: '2026-06-08T10:00:00.000Z',
updatedAt: '2026-06-08T10:00:00.000Z',
};
function dto(overrides: Partial<FrameEntryDto> = {}): FrameEntryDto {
return {
id: 'frame-1',
week_of: '2026-06-07', // a Sunday (canonical week start)
week_label: 'Spring Break week',
posted_date: '2026-06-08',
formal: 'Formal note',
recognition: 'Recognition note',
application: 'Application note',
management: 'Management note',
emotional: 'Emotional note',
author: 'Director',
organizationId: 'org-1',
campusId: 'campus-1',
createdAt: '2026-06-08T10:00:00.000Z',
updatedAt: '2026-06-08T10:00:00.000Z',
...overrides,
};
}
expect(toFrameEntryViewModel(dto)).toEqual({
describe('frame mappers', () => {
it('maps the DTO into the view model (ISO week start + display + label)', () => {
expect(toFrameEntryViewModel(dto())).toMatchObject({
id: 'frame-1',
weekOf: 'June 8, 2026',
postedDate: 'June 8, 2026',
weekStart: '2026-06-07',
weekLabel: 'Spring Break week',
weekOf: 'June 7, 2026',
formal: 'Formal note',
recognition: 'Recognition note',
application: 'Application note',
management: 'Management note',
emotional: 'Emotional note',
author: 'Director',
});
});
it('maps editable FRAME state back into the backend mutation DTO shape', () => {
it('defaults a null label to an empty string', () => {
expect(toFrameEntryViewModel(dto({ week_label: null })).weekLabel).toBe('');
});
it('maps editable state back into the mutation DTO and omits a blank label', () => {
const entry: EditableFrameEntry = {
weekOf: '2026-06-08',
weekStart: '2026-06-07',
weekLabel: ' ',
postedDate: '2026-06-09',
formal: 'Formal',
recognition: 'Recognition',
@ -50,7 +56,8 @@ describe('frame mappers', () => {
};
expect(toFrameEntryMutationDto(entry)).toEqual({
week_of: '2026-06-08',
week_of: '2026-06-07',
week_label: undefined,
posted_date: '2026-06-09',
formal: 'Formal',
recognition: 'Recognition',
@ -60,4 +67,19 @@ describe('frame mappers', () => {
author: 'Office Manager',
});
});
it('trims and keeps a non-blank label', () => {
const entry: EditableFrameEntry = {
weekStart: '2026-06-07',
weekLabel: ' Holiday week ',
postedDate: '2026-06-09',
formal: 'a',
recognition: 'b',
application: 'c',
management: 'd',
emotional: 'e',
author: 'Director',
};
expect(toFrameEntryMutationDto(entry).week_label).toBe('Holiday week');
});
});

View File

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

View File

@ -2,6 +2,11 @@ import { FrameSectionKey } from '@/shared/types/frame';
export interface FrameEntryViewModel {
readonly id: string;
/** Canonical Sunday-start ISO date (drives the week picker). */
readonly weekStart: string;
/** Optional free-text label (e.g. "Spring Break week"); '' when none. */
readonly weekLabel: string;
/** Display string for the week, e.g. "June 7, 2026". */
readonly weekOf: string;
readonly postedDate: string;
readonly formal: string;
@ -12,7 +17,18 @@ export interface FrameEntryViewModel {
readonly author: string;
}
export type EditableFrameEntry = Omit<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;

View File

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

View File

@ -1,24 +1,44 @@
import { useState } from 'react';
import { useMemo, useState } from 'react';
import {
buildTopBarNotifications,
countUnreadTopBarNotifications,
getTopBarCampusLabel,
getTopBarInitials,
getTopBarRoleLabel,
} from '@/business/top-bar/selectors';
import {
buildTopBarSearchResults,
type TopBarContentItem,
type TopBarSearchResult,
} from '@/business/top-bar/search';
import { getAccessibleModules } from '@/business/app-shell/selectors';
import { useContentCatalogPayload } from '@/business/content-catalog/hooks';
import { CONTENT_CATALOG_TYPES } from '@/shared/constants/contentCatalog';
import { MODULES } from '@/shared/constants/appData';
import type {
ModuleId,
SignItem,
Strategy,
ZoneInfo,
} from '@/shared/types/app';
import type {
TopBarNotification,
TopBarPage,
UseTopBarPageOptions,
} from '@/business/top-bar/types';
import { useTodayZoneCheckIn } from '@/business/zone-checkin/hooks';
import { shouldNudgeZoneCheckIn } from '@/business/zone-checkin/selectors';
const EMPTY_TOP_BAR_NOTIFICATIONS: readonly TopBarNotification[] = [];
const EMPTY_STRATEGIES: readonly Strategy[] = [];
const EMPTY_SIGNS: readonly SignItem[] = [];
const EMPTY_ZONES: readonly ZoneInfo[] = [];
export function useTopBarPage({
userRole,
userName,
campusInfo,
toggleSidebar,
setCurrentModule,
profile,
signOut: signOutAction,
}: UseTopBarPageOptions): TopBarPage {
@ -26,7 +46,64 @@ export function useTopBarPage({
const [showNotifications, setShowNotifications] = useState(false);
const [searchQuery, setSearchQuery] = useState('');
const [signOutError, setSignOutError] = useState<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() {
setShowProfileMenu(false);
@ -49,6 +126,7 @@ export function useTopBarPage({
showProfileMenu,
showNotifications,
searchQuery,
searchResults,
signOutError,
notifications,
unreadCount: countUnreadTopBarNotifications(notifications),
@ -58,6 +136,7 @@ export function useTopBarPage({
toggleNotifications: () => setShowNotifications((current) => !current),
closeNotifications: () => setShowNotifications(false),
setSearchQuery,
selectSearchResult,
signOut,
};
}

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 {
buildTopBarNotifications,
countUnreadTopBarNotifications,
getTopBarCampusLabel,
getTopBarInitials,
getTopBarRoleLabel,
} from '@/business/top-bar/selectors';
import { APP_ROUTE_PATHS } from '@/shared/constants/routes';
describe('top bar selectors', () => {
it('surfaces an unread zone check-in nudge (linking to the zones page) only when needed', () => {
expect(buildTopBarNotifications({ needsZoneCheckIn: false })).toEqual([]);
const withNudge = buildTopBarNotifications({ needsZoneCheckIn: true });
expect(withNudge).toHaveLength(1);
expect(withNudge[0]).toMatchObject({ unread: true, href: APP_ROUTE_PATHS.zones });
expect(countUnreadTopBarNotifications(withNudge)).toBe(1);
});
it('builds initials from display names', () => {
expect(getTopBarInitials('Guest')).toBe('G');
expect(getTopBarInitials('Ada Lovelace')).toBe('AL');

View File

@ -1,4 +1,5 @@
import { getAuthRoleLabel } from '@/business/auth/selectors';
import { APP_ROUTE_PATHS } from '@/shared/constants/routes';
import { DEFAULT_CAMPUS_LABEL } from '@/shared/constants/campusDisplay';
import type {
CampusInfo,
@ -30,3 +31,28 @@ export function countUnreadTopBarNotifications(
): number {
return notifications.filter((notification) => notification.unread).length;
}
const ZONE_CHECKIN_NOTIFICATION_ID = 'zone-checkin-today';
/**
* Builds the top-bar notification list from derived app state (there is no
* backend notifications store yet). Currently surfaces a single nudge when an
* eligible user has not logged today's Zone.
*/
export function buildTopBarNotifications(input: {
readonly needsZoneCheckIn: boolean;
}): readonly TopBarNotification[] {
const notifications: TopBarNotification[] = [];
if (input.needsZoneCheckIn) {
notifications.push({
id: ZONE_CHECKIN_NOTIFICATION_ID,
text: "You haven't logged your Emotional Zone today",
time: 'Today',
unread: true,
href: APP_ROUTE_PATHS.zones,
});
}
return notifications;
}

View File

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

View File

@ -7,11 +7,9 @@ import {
import {
USER_PROGRESS_QUERY_KEYS,
USER_PROGRESS_TYPES,
ZONE_CHECKIN_ITEM_ID,
} from '@/shared/constants/userProgress';
import { toLearnedSignIds, toZoneColor } from '@/business/user-progress/mappers';
import { LearnedSignsState, ZoneCheckInState } from '@/business/user-progress/types';
import { ZoneColor } from '@/shared/types/app';
import { toLearnedSignIds } from '@/business/user-progress/mappers';
import { LearnedSignsState } from '@/business/user-progress/types';
import { selectApiListRows } from '@/shared/business/apiListRows';
import { useInvalidatingMutation } from '@/shared/business/queryMutations';
@ -59,50 +57,3 @@ export function useLearnedSignsProgress(): LearnedSignsState {
toggleLearnedSign,
};
}
export function useZoneCheckIn(): ZoneCheckInState {
const progressQuery = useQuery({
queryKey: USER_PROGRESS_QUERY_KEYS.zoneCheckin,
queryFn: () => selectApiListRows(
listUserProgress(
USER_PROGRESS_TYPES.zoneCheckin,
ZONE_CHECKIN_ITEM_ID,
),
(rows) => toZoneColor(rows[0]?.value || null),
),
});
const saveMutation = useInvalidatingMutation({
mutationFn: (zone: ZoneColor) => upsertUserProgress({
progress_type: USER_PROGRESS_TYPES.zoneCheckin,
item_id: ZONE_CHECKIN_ITEM_ID,
value: zone,
}),
invalidateQueryKey: USER_PROGRESS_QUERY_KEYS.zoneCheckin,
});
const deleteMutation = useInvalidatingMutation({
mutationFn: () => deleteUserProgressByItem(
USER_PROGRESS_TYPES.zoneCheckin,
ZONE_CHECKIN_ITEM_ID,
),
invalidateQueryKey: USER_PROGRESS_QUERY_KEYS.zoneCheckin,
});
async function setZone(zone: ZoneColor) {
await saveMutation.mutateAsync(zone);
}
async function resetZone() {
await deleteMutation.mutateAsync();
}
return {
currentZone: progressQuery.data ?? null,
isLoading: progressQuery.isLoading,
isSaving: saveMutation.isPending || deleteMutation.isPending,
error: progressQuery.error || saveMutation.error || deleteMutation.error,
setZone,
resetZone,
};
}

View File

@ -1,8 +1,5 @@
import { describe, expect, it } from 'vitest';
import {
toLearnedSignIds,
toZoneColor,
} from '@/business/user-progress/mappers';
import { toLearnedSignIds } from '@/business/user-progress/mappers';
import type { UserProgressDto } from '@/shared/types/userProgress';
function createProgress(overrides: Partial<UserProgressDto> = {}): UserProgressDto {
@ -32,13 +29,4 @@ describe('user progress mappers', () => {
expect([...learnedSignIds].sort()).toEqual(['hello', 'help']);
});
it('normalizes valid zone colors and rejects invalid values', () => {
expect(toZoneColor('blue')).toBe('blue');
expect(toZoneColor('green')).toBe('green');
expect(toZoneColor('yellow')).toBe('yellow');
expect(toZoneColor('red')).toBe('red');
expect(toZoneColor('purple')).toBeNull();
expect(toZoneColor(null)).toBeNull();
});
});

View File

@ -1,18 +1,5 @@
import { UserProgressDto } from '@/shared/types/userProgress';
import { ZoneColor } from '@/shared/types/app';
export function toLearnedSignIds(progress: readonly UserProgressDto[]): ReadonlySet<string> {
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 {
readonly learnedSignIds: ReadonlySet<string>;
readonly isLoading: boolean;
@ -7,12 +5,3 @@ export interface LearnedSignsState {
readonly error: Error | null;
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 { HERO_IMAGE } from '@/shared/constants/appData';
function getCurrentWeekMonday(): string {
const today = new Date();
const dayOfWeek = today.getDay();
const mondayOffset = dayOfWeek === 0 ? -6 : 1 - dayOfWeek;
const monday = new Date(today);
monday.setDate(today.getDate() + mondayOffset);
return monday.toLocaleDateString('en-US', {
month: 'long',
day: 'numeric',
year: 'numeric',
});
}
import { formatWeekOf, toWeekStartIso } from '@/shared/business/week';
interface DashboardHeroProps {
readonly greeting: string;
@ -27,7 +15,8 @@ export function DashboardHero({
greeting,
userName,
}: DashboardHeroProps) {
const currentWeekMonday = useMemo(() => getCurrentWeekMonday(), []);
// Shared American (Sunday-start) week — same source as F.R.A.M.E. and the quiz.
const currentWeekLabel = useMemo(() => formatWeekOf(toWeekStartIso(new Date())), []);
return (
<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" />
@ -37,7 +26,7 @@ export function DashboardHero({
<div className="flex items-center gap-2 mb-2">
<Sparkles size={18} className="text-amber-400" />
<span className="text-amber-400 text-sm font-medium">
Week of {currentWeekMonday}
Week of {currentWeekLabel}
</span>
</div>
<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 { DashboardUpcomingEvents } from '@/components/dashboard/DashboardUpcomingEvents';
import { DashboardWeeklyProgress } from '@/components/dashboard/DashboardWeeklyProgress';
import { DashboardZoneCheckIn } from '@/components/dashboard/DashboardZoneCheckIn';
import { ZoneCheckInCard } from '@/components/zone-checkin/ZoneCheckInCard';
interface DashboardViewProps {
readonly page: DashboardPage;
@ -23,14 +23,17 @@ export function DashboardView({ page }: DashboardViewProps) {
<DashboardQuotePanel quote={page.todayQuote} state={page.quoteState} />
<DashboardZoneCheckIn
zones={page.zoneOptions}
activeZone={page.activeZone}
isSaving={page.isZoneSaving}
errorMessage={page.zoneErrorMessage}
onCheckIn={page.checkInZone}
onReset={page.resetZone}
/>
{page.showZoneCheckIn && (
<ZoneCheckInCard
zones={page.zoneOptions}
activeZone={page.activeZone}
needsCheckIn={page.needsZoneCheckIn}
isSaving={page.isZoneSaving}
errorMessage={page.zoneErrorMessage}
onCheckIn={page.checkInZone}
onReset={page.resetZone}
/>
)}
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
<DashboardFramePreview

View File

@ -32,7 +32,14 @@ export function FrameEntryCard({ entry, index, workflow }: FrameEntryCardProps)
</span>
)}
<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">
<User size={10} /> {entry.author} - Posted {entry.postedDate}
</p>

View File

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

View File

@ -2,8 +2,8 @@ import { Edit3, Save } from 'lucide-react';
import { FRAME_SECTION_LABELS } from '@/shared/constants/frame';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { FrameSectionField } from '@/components/frame/FrameSectionField';
import { FrameWeekPicker } from '@/components/frame/FrameWeekPicker';
import type { FrameModuleViewProps } from '@/components/frame/types';
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">
<Edit3 size={18} className="text-amber-400" /> Create New F.R.A.M.E. Entry
</h3>
<div>
<label className="text-sm font-medium text-slate-300">Week Of</label>
<Input
type="text"
value={workflow.newEntry.weekOf}
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>
<FrameWeekPicker
weekStart={workflow.newEntry.weekStart}
weekLabel={workflow.newEntry.weekLabel}
onWeekStartChange={(iso) => workflow.updateNewEntryField('weekStart', iso)}
onLabelChange={(label) => workflow.updateNewEntryField('weekLabel', label)}
/>
{FRAME_SECTION_LABELS.map((section) => (
<FrameSectionField
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 { ZonesOfRegulationView } from '@/components/zones-of-regulation/ZonesOfRegulationView';
import { ZoneCheckInSection } from '@/components/zone-checkin/ZoneCheckInSection';
import type { UserRole } from '@/shared/types/app';
const ZonesOfRegulation = () => {
interface ZonesOfRegulationProps {
readonly userRole: UserRole;
}
const ZonesOfRegulation = ({ userRole }: ZonesOfRegulationProps) => {
const page = useZonesOfRegulationPage();
return <ZonesOfRegulationView page={page} />;
return (
<div className="space-y-6">
<ZoneCheckInSection userRole={userRole} />
<ZonesOfRegulationView page={page} />
</div>
);
};
export default ZonesOfRegulation;

View File

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

View File

@ -1,6 +1,8 @@
import { useRef } from 'react';
import { Building2, ChevronDown, LogOut, Settings, User, Wifi } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { useOnClickOutside } from '@/hooks/useOnClickOutside';
import { TOP_BAR_PROFILE_MENU_ITEMS } from '@/shared/constants/topBar';
import type { CampusInfo } from '@/shared/types/app';
import { cn } from '@/lib/utils';
@ -28,12 +30,15 @@ export function TopBarProfileMenu({
onClose,
onSignOut,
}: TopBarProfileMenuProps) {
const containerRef = useRef<HTMLDivElement>(null);
useOnClickOutside(containerRef, onClose, isOpen);
const avatarClassName = campusInfo
? cn('bg-gradient-to-br', campusInfo.bgGradient)
: 'bg-gradient-to-br from-violet-500 to-amber-400 shadow-violet-500/20';
return (
<div className="relative">
<div className="relative" ref={containerRef}>
<Button
type="button"
variant="ghost"
@ -52,9 +57,7 @@ export function TopBarProfileMenu({
</Button>
{isOpen && (
<>
<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="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="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)}>
@ -115,8 +118,7 @@ export function TopBarProfileMenu({
<span className="text-sm font-medium">Sign Out</span>
</Button>
</div>
</div>
</>
</div>
)}
</div>
);

View File

@ -1,23 +1,101 @@
import { useRef, useState } from 'react';
import { Search } from 'lucide-react';
import { Input } from '@/components/ui/input';
import { useOnClickOutside } from '@/hooks/useOnClickOutside';
import type { TopBarSearchResult } from '@/business/top-bar/search';
interface TopBarSearchProps {
readonly value: string;
readonly results: readonly TopBarSearchResult[];
readonly onChange: (value: string) => void;
readonly onSelect: (result: TopBarSearchResult) => void;
}
export function TopBarSearch({ value, onChange }: TopBarSearchProps) {
export function TopBarSearch({ value, results, onChange, onSelect }: TopBarSearchProps) {
const containerRef = useRef<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 (
<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" />
<Input
type="text"
role="combobox"
aria-expanded={showDropdown}
aria-controls="top-bar-search-results"
value={value}
onChange={(event) => onChange(event.target.value)}
onChange={(event) => handleChange(event.target.value)}
onFocus={() => setIsOpen(true)}
onKeyDown={handleKeyDown}
placeholder="Search modules, strategies, signs..."
className="pl-9 pr-4 py-2 w-72 bg-slate-800/80 border-slate-700/50 rounded-xl text-sm text-white placeholder-slate-500 focus-visible:ring-violet-500/50 focus-visible:border-violet-500/50"
/>
{showDropdown && (
<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>
);
}

View File

@ -28,7 +28,12 @@ export function TopBarView({ page }: TopBarViewProps) {
>
<Menu size={20} />
</Button>
<TopBarSearch value={page.searchQuery} onChange={page.setSearchQuery} />
<TopBarSearch
value={page.searchQuery}
results={page.searchResults}
onChange={page.setSearchQuery}
onSelect={page.selectSearchResult}
/>
</div>
<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 { cn } from '@/lib/utils';
interface DashboardZoneCheckInProps {
interface ZoneCheckInCardProps {
readonly zones: readonly DashboardZoneOption[];
readonly activeZone: ZoneColor | null;
readonly isSaving: boolean;
readonly errorMessage: string | null;
/** Highlight that the eligible user has not logged a zone today. */
readonly needsCheckIn?: boolean;
readonly onCheckIn: (zone: ZoneColor) => Promise<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,
activeZone,
isSaving,
errorMessage,
needsCheckIn = false,
onCheckIn,
onReset,
}: DashboardZoneCheckInProps) {
}: ZoneCheckInCardProps) {
return (
<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>
<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>
</div>
{activeZone && (
@ -40,7 +55,7 @@ export function DashboardZoneCheckIn({
</Button>
)}
</div>
<div className="grid grid-cols-4 gap-3">
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3">
{zones.map((zone) => (
<Button
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';
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_INTERNAL_COMM', 'READ_SAFETY', 'READ_HANDBOOK', 'READ_COMMUNITY',
'READ_VOCATIONAL', 'READ_ESA', 'READ_WALKTHROUGH', 'READ_DIRECTOR_DASHBOARD',
'FILL_ATTENDANCE', 'TAKE_QUIZ', 'ACK_READ_RECEIPT',
'FILL_ATTENDANCE', 'TAKE_QUIZ', 'ACK_READ_RECEIPT', 'ZONE_CHECKIN',
] as const;
export type PermissionEntity = (typeof PERMISSION_ENTITIES)[number];

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> = {
signLearned: 'sign_learned',
zoneCheckin: 'zone_checkin',
};
export const ZONE_CHECKIN_ITEM_ID = 'current';
export const USER_PROGRESS_QUERY_KEYS = {
signProgress: ['userProgress', USER_PROGRESS_TYPES.signLearned],
zoneCheckin: ['userProgress', USER_PROGRESS_TYPES.zoneCheckin, ZONE_CHECKIN_ITEM_ID],
} as const;

View File

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

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.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 }) => {

View File

@ -20,6 +20,7 @@ const TEACHER_EMAIL = 'teacher@flatlogic.com';
interface FrameRow {
readonly id: string;
readonly author: string;
readonly week_of: string;
}
interface ProgressRow {
readonly item_id: string;
@ -61,7 +62,11 @@ test.describe('Product-workflow persistence', () => {
const listRes = await page.request.get(`${BACKEND_API_URL}/frame_entries`);
expect(listRes.status()).toBe(200);
const body = (await listRes.json()) as { rows?: FrameRow[] };
expect((body.rows ?? []).some((row) => row.author === author)).toBe(true);
const saved = (body.rows ?? []).find((row) => row.author === author);
expect(saved, 'the FRAME entry persisted').toBeTruthy();
// The posted `week_of` (2026-06-01, a Monday) is normalized server-side to
// its Sunday week-start (American week).
expect(saved?.week_of).toBe('2026-05-31');
});
test('a staff member marks a sign learned and progress persists', async ({

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