fixed dashboard functionality
This commit is contained in:
parent
4b45ce30f5
commit
799eba7306
@ -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),
|
||||
|
||||
@ -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 | — | |
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
@ -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`,
|
||||
|
||||
@ -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 |
|
||||
|
||||
|
||||
62
backend/docs/zone-checkin.md
Normal file
62
backend/docs/zone-checkin.md
Normal 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.
|
||||
22
backend/src/api/controllers/zone_checkins.controller.ts
Normal file
22
backend/src/api/controllers/zone_checkins.controller.ts
Normal 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);
|
||||
}
|
||||
@ -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;
|
||||
|
||||
@ -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');
|
||||
},
|
||||
};
|
||||
@ -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');
|
||||
},
|
||||
};
|
||||
@ -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 },
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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],
|
||||
|
||||
@ -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).
|
||||
|
||||
47
backend/src/routes/zone_checkins.ts
Normal file
47
backend/src/routes/zone_checkins.ts
Normal 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;
|
||||
@ -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(),
|
||||
|
||||
128
backend/src/services/zone-checkin.ts
Normal file
128
backend/src/services/zone-checkin.ts
Normal 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;
|
||||
@ -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',
|
||||
|
||||
@ -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',
|
||||
});
|
||||
|
||||
35
backend/src/shared/constants/timezone.test.ts
Normal file
35
backend/src/shared/constants/timezone.test.ts
Normal 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);
|
||||
});
|
||||
30
backend/src/shared/constants/timezone.ts
Normal file
30
backend/src/shared/constants/timezone.ts
Normal 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);
|
||||
}
|
||||
20
backend/src/shared/constants/week.test.ts
Normal file
20
backend/src/shared/constants/week.test.ts
Normal 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);
|
||||
});
|
||||
38
backend/src/shared/constants/week.ts
Normal file
38
backend/src/shared/constants/week.ts
Normal 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);
|
||||
}
|
||||
20
backend/src/shared/constants/zone-checkin.test.ts
Normal file
20
backend/src/shared/constants/zone-checkin.test.ts
Normal 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);
|
||||
});
|
||||
19
backend/src/shared/constants/zone-checkin.ts
Normal file
19
backend/src/shared/constants/zone-checkin.ts
Normal 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;
|
||||
@ -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
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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.
|
||||
|
||||
|
||||
61
frontend/docs/zone-checkin-integration.md
Normal file
61
frontend/docs/zone-checkin-integration.md
Normal 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.
|
||||
@ -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
|
||||
|
||||
|
||||
@ -86,6 +86,7 @@ export function useAppShell(options: UseAppShellOptions): AppShellState {
|
||||
userName,
|
||||
campusInfo,
|
||||
toggleSidebar,
|
||||
setCurrentModule,
|
||||
};
|
||||
|
||||
const shellOutletContext = {
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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[];
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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;
|
||||
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
43
frontend/src/business/top-bar/search.test.ts
Normal file
43
frontend/src/business/top-bar/search.test.ts
Normal 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([]);
|
||||
});
|
||||
});
|
||||
94
frontend/src/business/top-bar/search.ts
Normal file
94
frontend/src/business/top-bar/search.ts
Normal 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);
|
||||
}
|
||||
@ -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');
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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>;
|
||||
}
|
||||
|
||||
@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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>;
|
||||
}
|
||||
|
||||
61
frontend/src/business/zone-checkin/hooks.ts
Normal file
61
frontend/src/business/zone-checkin/hooks.ts
Normal 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,
|
||||
});
|
||||
}
|
||||
28
frontend/src/business/zone-checkin/selectors.test.ts
Normal file
28
frontend/src/business/zone-checkin/selectors.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
24
frontend/src/business/zone-checkin/selectors.ts
Normal file
24
frontend/src/business/zone-checkin/selectors.ts
Normal 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;
|
||||
}
|
||||
@ -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">
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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}
|
||||
|
||||
88
frontend/src/components/frame/FrameWeekPicker.tsx
Normal file
88
frontend/src/components/frame/FrameWeekPicker.tsx
Normal 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 (Sunday–Saturday) with a clear,
|
||||
// high-contrast band; the picked day stays a solid violet pill.
|
||||
modifiers={{
|
||||
selectedWeek: (day) => Boolean(weekStart) && toWeekStartIso(day) === weekStart,
|
||||
}}
|
||||
modifiersClassNames={{
|
||||
selectedWeek: 'bg-violet-500/30 text-white rounded-none',
|
||||
}}
|
||||
classNames={{
|
||||
selected:
|
||||
'bg-violet-600 text-white font-semibold hover:bg-violet-600 hover:text-white focus:bg-violet-600 focus:text-white rounded-md',
|
||||
}}
|
||||
/>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
@ -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;
|
||||
|
||||
@ -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>
|
||||
);
|
||||
|
||||
@ -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>
|
||||
);
|
||||
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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">
|
||||
|
||||
@ -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}
|
||||
25
frontend/src/components/zone-checkin/ZoneCheckInReminder.tsx
Normal file
25
frontend/src/components/zone-checkin/ZoneCheckInReminder.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
45
frontend/src/components/zone-checkin/ZoneCheckInSection.tsx
Normal file
45
frontend/src/components/zone-checkin/ZoneCheckInSection.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
56
frontend/src/hooks/useOnClickOutside.test.tsx
Normal file
56
frontend/src/hooks/useOnClickOutside.test.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
35
frontend/src/hooks/useOnClickOutside.ts
Normal file
35
frontend/src/hooks/useOnClickOutside.ts
Normal 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]);
|
||||
}
|
||||
@ -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} />;
|
||||
}
|
||||
|
||||
77
frontend/src/shared/api/zoneCheckins.test.ts
Normal file
77
frontend/src/shared/api/zoneCheckins.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
34
frontend/src/shared/api/zoneCheckins.ts
Normal file
34
frontend/src/shared/api/zoneCheckins.ts
Normal 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,
|
||||
);
|
||||
}
|
||||
@ -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];
|
||||
|
||||
27
frontend/src/shared/business/week.test.ts
Normal file
27
frontend/src/shared/business/week.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
31
frontend/src/shared/business/week.ts
Normal file
31
frontend/src/shared/business/week.ts
Normal 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);
|
||||
}
|
||||
@ -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;
|
||||
|
||||
@ -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;
|
||||
|
||||
17
frontend/src/shared/types/zoneCheckins.ts
Normal file
17
frontend/src/shared/types/zoneCheckins.ts
Normal 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;
|
||||
}
|
||||
@ -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 }) => {
|
||||
|
||||
@ -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 ({
|
||||
|
||||
88
frontend/tests/e2e/zone-checkins.seeded.e2e.ts
Normal file
88
frontend/tests/e2e/zone-checkins.seeded.e2e.ts
Normal 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());
|
||||
});
|
||||
});
|
||||
Loading…
x
Reference in New Issue
Block a user