Task d96d8925: Presence schedule month grid (#206)
This commit was merged in pull request #206.
This commit is contained in:
+4
-1
@@ -1,6 +1,7 @@
|
|||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import "./App.css";
|
import "./App.css";
|
||||||
import DefinitionsTab from "./definitions/DefinitionsTab";
|
import DefinitionsTab from "./definitions/DefinitionsTab";
|
||||||
|
import ScheduleTab from "./schedule/ScheduleTab";
|
||||||
import TodayTab from "./today/TodayTab";
|
import TodayTab from "./today/TodayTab";
|
||||||
|
|
||||||
const TABS = [
|
const TABS = [
|
||||||
@@ -16,7 +17,7 @@ export default function App() {
|
|||||||
const [active, setActive] = useState<TabId>("today");
|
const [active, setActive] = useState<TabId>("today");
|
||||||
// Built screens own their header (ADMIN-SPEC §1); the generic shell header
|
// Built screens own their header (ADMIN-SPEC §1); the generic shell header
|
||||||
// stays for placeholder tabs and is visually hidden otherwise.
|
// stays for placeholder tabs and is visually hidden otherwise.
|
||||||
const hasScreen = active === "today" || active === "tasks";
|
const hasScreen = active === "today" || active === "tasks" || active === "schedule";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="admin-shell">
|
<div className="admin-shell">
|
||||||
@@ -33,6 +34,8 @@ export default function App() {
|
|||||||
<TodayTab />
|
<TodayTab />
|
||||||
) : active === "tasks" ? (
|
) : active === "tasks" ? (
|
||||||
<DefinitionsTab />
|
<DefinitionsTab />
|
||||||
|
) : active === "schedule" ? (
|
||||||
|
<ScheduleTab />
|
||||||
) : (
|
) : (
|
||||||
<p className="admin-placeholder">
|
<p className="admin-placeholder">
|
||||||
{TABS.find((tab) => tab.id === active)?.label} placeholder — screens
|
{TABS.find((tab) => tab.id === active)?.label} placeholder — screens
|
||||||
|
|||||||
@@ -103,7 +103,7 @@ function readDetail(body: unknown): string | null {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function readJson<T>(response: Response): Promise<T> {
|
export async function readJson<T>(response: Response): Promise<T> {
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
let body: unknown = null;
|
let body: unknown = null;
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -0,0 +1,82 @@
|
|||||||
|
/**
|
||||||
|
* Typed client for the admin presence routes: a child's repeating schedule
|
||||||
|
* (/api/v1/admin/children/{id}/presence-schedule) and the household's
|
||||||
|
* date-range overrides (/api/v1/admin/presence-overrides).
|
||||||
|
*/
|
||||||
|
import { apiFetch } from "./client";
|
||||||
|
import { CHILDREN_PATH, readJson } from "./definitions";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cycle week index ("0".."n-1", JSON object keys) -> present weekdays
|
||||||
|
* (0 = Monday .. 6 = Sunday). An empty list is away that whole week.
|
||||||
|
*/
|
||||||
|
export type PresencePattern = Record<string, number[]>;
|
||||||
|
|
||||||
|
export interface PresenceScheduleBody {
|
||||||
|
cycle_length_weeks: number;
|
||||||
|
/** "YYYY-MM-DD"; pins cycle week 0. */
|
||||||
|
anchor_date: string;
|
||||||
|
pattern: PresencePattern;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PresenceSchedule extends PresenceScheduleBody {
|
||||||
|
child_id: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PresenceOverride {
|
||||||
|
id: number;
|
||||||
|
child_id: number;
|
||||||
|
start_date: string;
|
||||||
|
end_date: string;
|
||||||
|
is_present: boolean;
|
||||||
|
note: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PresenceOverrideFilter {
|
||||||
|
childId?: number;
|
||||||
|
/** "YYYY-MM-DD"; keeps overrides overlapping [start, end]. */
|
||||||
|
start?: string;
|
||||||
|
end?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const PRESENCE_OVERRIDES_PATH = "/api/v1/admin/presence-overrides";
|
||||||
|
|
||||||
|
export function presenceSchedulePath(childId: number): string {
|
||||||
|
return `${CHILDREN_PATH}/${childId}/presence-schedule`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The child's schedule, or null when it has none (present every day). */
|
||||||
|
export async function fetchPresenceSchedule(childId: number): Promise<PresenceSchedule | null> {
|
||||||
|
const body = await readJson<{ schedule: PresenceSchedule | null }>(
|
||||||
|
await apiFetch(presenceSchedulePath(childId)),
|
||||||
|
);
|
||||||
|
return body.schedule;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Set (upsert) the child's schedule; resolves to the schedule as stored. */
|
||||||
|
export async function savePresenceSchedule(
|
||||||
|
childId: number,
|
||||||
|
body: PresenceScheduleBody,
|
||||||
|
): Promise<PresenceSchedule> {
|
||||||
|
return readJson<PresenceSchedule>(
|
||||||
|
await apiFetch(presenceSchedulePath(childId), {
|
||||||
|
method: "PUT",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchPresenceOverrides(
|
||||||
|
filter: PresenceOverrideFilter = {},
|
||||||
|
): Promise<PresenceOverride[]> {
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
if (filter.childId !== undefined) params.set("child_id", String(filter.childId));
|
||||||
|
if (filter.start !== undefined) params.set("start", filter.start);
|
||||||
|
if (filter.end !== undefined) params.set("end", filter.end);
|
||||||
|
const query = params.toString();
|
||||||
|
const body = await readJson<{ overrides: PresenceOverride[] }>(
|
||||||
|
await apiFetch(query ? `${PRESENCE_OVERRIDES_PATH}?${query}` : PRESENCE_OVERRIDES_PATH),
|
||||||
|
);
|
||||||
|
return body.overrides;
|
||||||
|
}
|
||||||
@@ -23,6 +23,9 @@
|
|||||||
--nq-a-danger: #dc2626;
|
--nq-a-danger: #dc2626;
|
||||||
--nq-a-danger-strong: #b91c1c;
|
--nq-a-danger-strong: #b91c1c;
|
||||||
--nq-a-danger-bg: #fee2e2;
|
--nq-a-danger-bg: #fee2e2;
|
||||||
|
--nq-a-warning-ink: #92400e;
|
||||||
|
--nq-a-warning-bg: #fef3c7;
|
||||||
|
--nq-a-warning-border: rgba(217, 119, 6, 0.35);
|
||||||
--nq-a-info-bg: #dbeafe;
|
--nq-a-info-bg: #dbeafe;
|
||||||
--nq-a-info-ink: #1e3a8a;
|
--nq-a-info-ink: #1e3a8a;
|
||||||
--nq-a-info-icon: #1d4ed8;
|
--nq-a-info-icon: #1d4ed8;
|
||||||
|
|||||||
@@ -0,0 +1,379 @@
|
|||||||
|
/* Schedule tab — compact density (design/ADMIN-SPEC.md §4). Mobile-first at
|
||||||
|
the 390x844 reference; on wider viewports the column is capped and centred. */
|
||||||
|
|
||||||
|
.schedule-screen {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 640px;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.schedule-header {
|
||||||
|
background: var(--nq-a-surface);
|
||||||
|
border-bottom: 1px solid var(--nq-a-border);
|
||||||
|
padding: 14px 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.schedule-title {
|
||||||
|
margin: 0;
|
||||||
|
font-size: var(--nq-a-size-screen);
|
||||||
|
font-weight: 800;
|
||||||
|
letter-spacing: -0.02em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.schedule-sub {
|
||||||
|
margin: 2px 0 0;
|
||||||
|
min-height: 1.4em;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--nq-a-ink-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Scroll column: 14px 16px, bottom 92px (set inline, ADMIN-SPEC §1). */
|
||||||
|
.schedule-scroll {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--nq-a-density-gap);
|
||||||
|
padding: 14px 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.schedule-card {
|
||||||
|
background: var(--nq-a-surface);
|
||||||
|
border: 1px solid var(--nq-a-border);
|
||||||
|
border-radius: var(--nq-a-density-radius);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Child selector */
|
||||||
|
.schedule-children {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.schedule-child-chip {
|
||||||
|
min-height: 32px;
|
||||||
|
padding: 0 12px;
|
||||||
|
border: 1px solid var(--nq-a-border);
|
||||||
|
border-radius: var(--nq-a-radius-pill);
|
||||||
|
background: var(--nq-a-surface);
|
||||||
|
color: var(--nq-a-ink-secondary);
|
||||||
|
font-family: inherit;
|
||||||
|
font-size: 12.5px;
|
||||||
|
font-weight: 700;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.schedule-child-chip[aria-pressed="true"] {
|
||||||
|
border-color: var(--nq-brand-blue);
|
||||||
|
background: var(--nq-a-selected);
|
||||||
|
color: var(--nq-brand-blue);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Month grid card */
|
||||||
|
.schedule-month {
|
||||||
|
padding: var(--nq-a-density-card-pad);
|
||||||
|
}
|
||||||
|
|
||||||
|
.schedule-month-head {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.schedule-month-label {
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 700;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.schedule-chevrons {
|
||||||
|
display: flex;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.schedule-chevron {
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
width: 28px;
|
||||||
|
height: 28px;
|
||||||
|
padding: 0;
|
||||||
|
border: 1px solid var(--nq-a-border);
|
||||||
|
border-radius: 6px;
|
||||||
|
background: var(--nq-a-surface);
|
||||||
|
color: var(--nq-a-ink-secondary);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.schedule-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(7, 1fr);
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.schedule-weekday {
|
||||||
|
text-align: center;
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--nq-a-ink-tertiary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.schedule-day {
|
||||||
|
aspect-ratio: 1;
|
||||||
|
padding: 0;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-family: inherit;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 700;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.schedule-day--home {
|
||||||
|
background: var(--nq-a-selected);
|
||||||
|
color: #201f7a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.schedule-day--away {
|
||||||
|
background: var(--nq-a-page);
|
||||||
|
color: var(--nq-a-ink-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.schedule-day--override-away,
|
||||||
|
.schedule-day--override-home {
|
||||||
|
background: var(--nq-a-warning-bg);
|
||||||
|
color: var(--nq-a-warning-ink);
|
||||||
|
border-color: var(--nq-a-warning-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* An override that keeps the child home reads with a dashed border. */
|
||||||
|
.schedule-day--override-home {
|
||||||
|
border-style: dashed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.schedule-day--today {
|
||||||
|
background: var(--nq-brand-gradient);
|
||||||
|
border-color: transparent;
|
||||||
|
color: #fff;
|
||||||
|
font-weight: 800;
|
||||||
|
}
|
||||||
|
|
||||||
|
.schedule-legend {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 6px 12px;
|
||||||
|
margin: 10px 0 0;
|
||||||
|
padding: 0;
|
||||||
|
list-style: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.schedule-legend-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 5px;
|
||||||
|
font-size: 10.5px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--nq-a-ink-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.schedule-swatch {
|
||||||
|
display: inline-block;
|
||||||
|
width: 12px;
|
||||||
|
height: 12px;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
border-radius: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Pattern editing controls */
|
||||||
|
.schedule-controls {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: flex-end;
|
||||||
|
gap: 8px;
|
||||||
|
margin-top: 12px;
|
||||||
|
padding-top: 12px;
|
||||||
|
border-top: 1px solid var(--nq-a-divider);
|
||||||
|
}
|
||||||
|
|
||||||
|
.schedule-field {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 3px;
|
||||||
|
font-size: var(--nq-a-size-label);
|
||||||
|
font-weight: 700;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
color: var(--nq-a-ink-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.schedule-field input,
|
||||||
|
.schedule-field select {
|
||||||
|
min-height: 36px;
|
||||||
|
padding: 0 8px;
|
||||||
|
border: 1px solid var(--nq-a-border);
|
||||||
|
border-radius: 6px;
|
||||||
|
background: var(--nq-a-surface);
|
||||||
|
color: var(--nq-a-ink);
|
||||||
|
font-family: inherit;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
text-transform: none;
|
||||||
|
letter-spacing: normal;
|
||||||
|
}
|
||||||
|
|
||||||
|
.schedule-save {
|
||||||
|
min-height: 36px;
|
||||||
|
margin-left: auto;
|
||||||
|
padding: 0 14px;
|
||||||
|
border: 0;
|
||||||
|
border-radius: var(--nq-a-density-radius);
|
||||||
|
background: var(--nq-brand-gradient);
|
||||||
|
color: #fff;
|
||||||
|
font-family: inherit;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 700;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.schedule-save:disabled {
|
||||||
|
opacity: 0.45;
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
|
||||||
|
.schedule-save-status {
|
||||||
|
margin: 8px 0 0;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--nq-a-success);
|
||||||
|
}
|
||||||
|
|
||||||
|
.schedule-save-status--error {
|
||||||
|
color: var(--nq-a-danger-strong);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Presence pattern + overrides cards */
|
||||||
|
.schedule-section-head {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
|
||||||
|
.schedule-section-label {
|
||||||
|
margin: 6px 0 -2px;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 700;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
color: var(--nq-a-ink-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.schedule-text-action {
|
||||||
|
padding: 4px 0 0;
|
||||||
|
border: 0;
|
||||||
|
background: none;
|
||||||
|
color: var(--nq-brand-blue);
|
||||||
|
font-family: inherit;
|
||||||
|
font-size: 12.5px;
|
||||||
|
font-weight: 700;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.schedule-list {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
list-style: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.schedule-list > li + li {
|
||||||
|
border-top: 1px solid var(--nq-a-divider);
|
||||||
|
}
|
||||||
|
|
||||||
|
.schedule-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
min-height: var(--nq-a-tap-min);
|
||||||
|
padding: var(--nq-a-density-row-pad);
|
||||||
|
}
|
||||||
|
|
||||||
|
.schedule-row-glyph {
|
||||||
|
flex: none;
|
||||||
|
display: flex;
|
||||||
|
color: var(--nq-a-warning-ink);
|
||||||
|
}
|
||||||
|
|
||||||
|
.schedule-row-text {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.schedule-row-title {
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.schedule-row-meta {
|
||||||
|
font-size: var(--nq-a-size-meta);
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--nq-a-ink-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.schedule-rule {
|
||||||
|
flex: none;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--nq-a-ink-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.schedule-icon-action {
|
||||||
|
flex: none;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
width: var(--nq-a-tap-min);
|
||||||
|
height: var(--nq-a-tap-min);
|
||||||
|
margin: -8px -10px -8px 0;
|
||||||
|
padding: 0;
|
||||||
|
border: 0;
|
||||||
|
background: none;
|
||||||
|
color: var(--nq-a-ink-tertiary);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.schedule-empty {
|
||||||
|
padding: var(--nq-a-density-card-pad);
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--nq-a-ink-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Loading / error */
|
||||||
|
.schedule-status {
|
||||||
|
padding: 24px 13px;
|
||||||
|
background: var(--nq-a-surface);
|
||||||
|
border: 1px solid var(--nq-a-border);
|
||||||
|
border-radius: var(--nq-a-density-radius);
|
||||||
|
text-align: center;
|
||||||
|
font-size: 13.5px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--nq-a-ink-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.schedule-status--error {
|
||||||
|
color: var(--nq-a-danger-strong);
|
||||||
|
}
|
||||||
|
|
||||||
|
.schedule-status--error p {
|
||||||
|
margin: 0 0 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.schedule-retry {
|
||||||
|
padding: 8px 16px;
|
||||||
|
border: 1px solid var(--nq-a-border);
|
||||||
|
border-radius: var(--nq-a-density-radius);
|
||||||
|
background: var(--nq-a-surface);
|
||||||
|
color: var(--nq-a-ink);
|
||||||
|
font-family: inherit;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 700;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
@@ -0,0 +1,276 @@
|
|||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import { cleanup, fireEvent, render, screen, within } from "@testing-library/react";
|
||||||
|
import ScheduleTab from "./ScheduleTab";
|
||||||
|
import type { AdminChild } from "../api/definitions";
|
||||||
|
import type { PresenceOverride, PresenceSchedule } from "../api/presence";
|
||||||
|
import { storeTokens } from "../auth/oidc";
|
||||||
|
|
||||||
|
function child(id: number, display_name: string, sort_order: number, is_active = true): AdminChild {
|
||||||
|
return { id, display_name, colour: null, avatar_ref: null, sort_order, is_active };
|
||||||
|
}
|
||||||
|
|
||||||
|
const CHILDREN = [
|
||||||
|
child(3, "Rory", 3),
|
||||||
|
child(1, "Declan", 1),
|
||||||
|
child(2, "Maeve", 2),
|
||||||
|
child(9, "Former", 4, false),
|
||||||
|
];
|
||||||
|
|
||||||
|
// Declan: anchor Monday 2026-09-07; cycle week 0 home every day, week 1 Sat/Sun.
|
||||||
|
// Keys arrive as JSON strings, exactly as the API serializes them.
|
||||||
|
const DECLAN: PresenceSchedule = {
|
||||||
|
child_id: 1,
|
||||||
|
cycle_length_weeks: 2,
|
||||||
|
anchor_date: "2026-09-07",
|
||||||
|
pattern: { "0": [0, 1, 2, 3, 4, 5, 6], "1": [5, 6] },
|
||||||
|
};
|
||||||
|
|
||||||
|
const MAEVE: PresenceSchedule = {
|
||||||
|
child_id: 2,
|
||||||
|
cycle_length_weeks: 4,
|
||||||
|
anchor_date: "2026-09-07",
|
||||||
|
pattern: { "0": [0, 1, 2, 3, 4, 5, 6], "1": [], "2": [0, 1, 2, 3, 4, 5, 6], "3": [] },
|
||||||
|
};
|
||||||
|
|
||||||
|
const OVERRIDES: PresenceOverride[] = [
|
||||||
|
{ id: 41, child_id: 1, start_date: "2026-09-10", end_date: "2026-09-12", is_present: false, note: "Dentist trip" },
|
||||||
|
{ id: 42, child_id: 1, start_date: "2026-09-16", end_date: "2026-09-16", is_present: true, note: null },
|
||||||
|
{ id: 43, child_id: 2, start_date: "2026-09-28", end_date: "2026-10-02", is_present: false, note: "Camp" },
|
||||||
|
];
|
||||||
|
|
||||||
|
const TODAY = "2026-09-23";
|
||||||
|
|
||||||
|
function jsonResponse(body: unknown, status = 200): Response {
|
||||||
|
return new Response(JSON.stringify(body), {
|
||||||
|
status,
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
let fetchMock: ReturnType<typeof vi.fn>;
|
||||||
|
let putResponse: (body: unknown) => Response;
|
||||||
|
|
||||||
|
function routeFetch(url: string, init: RequestInit = {}): Response {
|
||||||
|
const path = new URL(url, "http://x").pathname;
|
||||||
|
if (init.method === "PUT") return putResponse(JSON.parse(String(init.body)));
|
||||||
|
if (path.endsWith("/admin/children")) return jsonResponse({ children: CHILDREN });
|
||||||
|
const match = /\/children\/(\d+)\/presence-schedule$/.exec(path);
|
||||||
|
if (match) {
|
||||||
|
const id = Number(match[1]);
|
||||||
|
return jsonResponse({ schedule: id === 1 ? DECLAN : id === 2 ? MAEVE : null });
|
||||||
|
}
|
||||||
|
if (path.endsWith("/presence-overrides")) return jsonResponse({ overrides: OVERRIDES });
|
||||||
|
return jsonResponse({ detail: "unexpected" }, 500);
|
||||||
|
}
|
||||||
|
|
||||||
|
function day(date: string): HTMLElement {
|
||||||
|
const cell = document.querySelector<HTMLElement>(`[data-date="${date}"]`);
|
||||||
|
if (!cell) throw new Error(`no cell for ${date}`);
|
||||||
|
return cell;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function renderReady() {
|
||||||
|
render(<ScheduleTab today={TODAY} />);
|
||||||
|
await screen.findByTestId("pattern-1");
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("ScheduleTab", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
sessionStorage.clear();
|
||||||
|
storeTokens({ access_token: "at-123", expires_in: 600 });
|
||||||
|
putResponse = (body) => jsonResponse({ child_id: 1, ...(body as object) });
|
||||||
|
fetchMock = vi.fn(async (url: string, init?: RequestInit) => routeFetch(url, init));
|
||||||
|
vi.stubGlobal("fetch", fetchMock);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
cleanup();
|
||||||
|
vi.unstubAllGlobals();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("loads active children, their schedules and the overrides", async () => {
|
||||||
|
await renderReady();
|
||||||
|
const urls = fetchMock.mock.calls.map(([url]) => new URL(String(url), "http://x").pathname);
|
||||||
|
expect(urls).toContain("/api/v1/admin/children");
|
||||||
|
expect(urls).toContain("/api/v1/admin/children/1/presence-schedule");
|
||||||
|
expect(urls).toContain("/api/v1/admin/children/2/presence-schedule");
|
||||||
|
expect(urls).toContain("/api/v1/admin/children/3/presence-schedule");
|
||||||
|
expect(urls).not.toContain("/api/v1/admin/children/9/presence-schedule");
|
||||||
|
expect(urls).toContain("/api/v1/admin/presence-overrides");
|
||||||
|
const chips = within(screen.getByRole("group", { name: "Child" })).getAllByRole("button");
|
||||||
|
expect(chips.map((chip) => chip.textContent)).toEqual(["Declan", "Maeve", "Rory"]);
|
||||||
|
expect(chips[0].getAttribute("aria-pressed")).toBe("true");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("header shows the cycle length and anchor", async () => {
|
||||||
|
await renderReady();
|
||||||
|
expect(screen.getByRole("heading", { name: "Schedule" })).toBeTruthy();
|
||||||
|
expect(screen.getByText("2-week cycle · anchor Monday, Sep 7")).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("month grid renders home, away, override and today states from the anchor", async () => {
|
||||||
|
await renderReady();
|
||||||
|
expect(screen.getByTestId("month-label").textContent).toBe("September 2026");
|
||||||
|
const states: Record<string, string> = {
|
||||||
|
"2026-09-01": "away", // before the anchor: cycle week 1, Tuesday
|
||||||
|
"2026-09-05": "home", // before the anchor: cycle week 1, Saturday
|
||||||
|
"2026-09-07": "home", // anchor: week 0
|
||||||
|
"2026-09-11": "override-away",
|
||||||
|
"2026-09-15": "away", // week 1, Tuesday
|
||||||
|
"2026-09-16": "override-home",
|
||||||
|
"2026-09-19": "home", // week 1, Saturday
|
||||||
|
"2026-09-22": "home", // wrap back to week 0
|
||||||
|
"2026-09-29": "away", // week 1 again
|
||||||
|
};
|
||||||
|
for (const [date, state] of Object.entries(states)) {
|
||||||
|
expect(day(date).dataset.state, date).toBe(state);
|
||||||
|
expect(day(date).className).toContain(`schedule-day--${state}`);
|
||||||
|
expect(day(date).className).not.toContain("schedule-day--today");
|
||||||
|
}
|
||||||
|
const today = day(TODAY);
|
||||||
|
expect(today.className).toContain("schedule-day--today");
|
||||||
|
expect(today.dataset.state).toBe("home");
|
||||||
|
expect(today.getAttribute("aria-label")).toBe("Sep 23 (today): Home");
|
||||||
|
// September 2026 starts on a Tuesday: one leading pad after the initials.
|
||||||
|
const grid = screen.getByRole("group", { name: "Month grid" });
|
||||||
|
expect(grid.children[7].tagName).toBe("SPAN");
|
||||||
|
expect(grid.children[8].getAttribute("data-date")).toBe("2026-09-01");
|
||||||
|
|
||||||
|
const legend = screen.getByRole("list", { name: "Legend" });
|
||||||
|
expect(within(legend).getAllByRole("listitem").map((li) => li.textContent)).toEqual([
|
||||||
|
"Home",
|
||||||
|
"Away",
|
||||||
|
"Override · away",
|
||||||
|
"Override · home",
|
||||||
|
"Today",
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("navigates months with the chevrons", async () => {
|
||||||
|
await renderReady();
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "Next month" }));
|
||||||
|
expect(screen.getByTestId("month-label").textContent).toBe("October 2026");
|
||||||
|
// Oct 1 is day 24 from the anchor: cycle week 1, a Thursday — away.
|
||||||
|
expect(day("2026-10-01").dataset.state).toBe("away");
|
||||||
|
expect(day("2026-10-03").dataset.state).toBe("home"); // week 1, Saturday
|
||||||
|
expect(day("2026-10-05").dataset.state).toBe("home"); // week 0 again
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "Previous month" }));
|
||||||
|
expect(screen.getByTestId("month-label").textContent).toBe("September 2026");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("presence pattern card shows a rule per child", async () => {
|
||||||
|
await renderReady();
|
||||||
|
expect(within(screen.getByTestId("pattern-1")).getByText("Weeks 1 & 2 of cycle")).toBeTruthy();
|
||||||
|
expect(within(screen.getByTestId("pattern-2")).getByText("Weeks 1 & 3 of cycle")).toBeTruthy();
|
||||||
|
expect(within(screen.getByTestId("pattern-3")).getByText("Every day")).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("overrides card lists the selected child's overrides with inert actions", async () => {
|
||||||
|
await renderReady();
|
||||||
|
const list = screen.getByRole("list", { name: "Overrides" });
|
||||||
|
expect(within(list).getAllByRole("listitem")).toHaveLength(2);
|
||||||
|
const away = within(list).getByTestId("override-41");
|
||||||
|
expect(within(away).getByText("Declan away · Sep 10 – 12")).toBeTruthy();
|
||||||
|
expect(within(away).getByText("Dentist trip")).toBeTruthy();
|
||||||
|
expect(within(away).getByRole("button", { name: "Delete override" })).toBeTruthy();
|
||||||
|
expect(within(list).getByText("Declan home · Sep 16")).toBeTruthy();
|
||||||
|
expect(screen.getByRole("button", { name: "Add" })).toBeTruthy();
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "Maeve" }));
|
||||||
|
expect(screen.getByText("Maeve away · Sep 28 – Oct 2")).toBeTruthy();
|
||||||
|
expect(screen.getByText("4-week cycle · anchor Monday, Sep 7")).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("a child with no schedule is home every day and defaults to a 2-week cycle", async () => {
|
||||||
|
await renderReady();
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "Rory" }));
|
||||||
|
expect(screen.getByText("2-week cycle · anchor Tuesday, Sep 1")).toBeTruthy();
|
||||||
|
expect(day("2026-09-15").dataset.state).toBe("home");
|
||||||
|
expect(screen.getByText("No overrides for Rory.")).toBeTruthy();
|
||||||
|
expect((screen.getByRole("button", { name: "Save pattern" }) as HTMLButtonElement).disabled).toBe(
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("editing a day and saving PUTs the pattern; the grid reflects the saved schedule", async () => {
|
||||||
|
await renderReady();
|
||||||
|
expect(day("2026-09-01").dataset.state).toBe("away");
|
||||||
|
fireEvent.click(day("2026-09-01")); // cycle week 1 (pre-anchor), Tuesday
|
||||||
|
|
||||||
|
// The draft already shows the edit, and every other week-1 Tuesday follows.
|
||||||
|
expect(day("2026-09-01").dataset.state).toBe("home");
|
||||||
|
expect(day("2026-09-15").dataset.state).toBe("home");
|
||||||
|
expect(day("2026-09-08").dataset.state).toBe("home");
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "Save pattern" }));
|
||||||
|
expect(await screen.findByText("Pattern saved.")).toBeTruthy();
|
||||||
|
|
||||||
|
const put = fetchMock.mock.calls.find(([, init]) => init?.method === "PUT");
|
||||||
|
expect(put).toBeTruthy();
|
||||||
|
const [url, init] = put!;
|
||||||
|
expect(new URL(String(url), "http://x").pathname).toBe("/api/v1/admin/children/1/presence-schedule");
|
||||||
|
expect(init.headers.Authorization).toBe("Bearer at-123");
|
||||||
|
expect(JSON.parse(init.body)).toEqual({
|
||||||
|
cycle_length_weeks: 2,
|
||||||
|
anchor_date: "2026-09-07",
|
||||||
|
pattern: { "0": [0, 1, 2, 3, 4, 5, 6], "1": [1, 5, 6] },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(day("2026-09-15").dataset.state).toBe("home");
|
||||||
|
expect(day("2026-09-29").dataset.state).toBe("home");
|
||||||
|
expect(day("2026-09-30").dataset.state).toBe("away");
|
||||||
|
expect((screen.getByRole("button", { name: "Save pattern" }) as HTMLButtonElement).disabled).toBe(
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("saves an edited anchor date and cycle length", async () => {
|
||||||
|
await renderReady();
|
||||||
|
fireEvent.change(screen.getByLabelText("Anchor date"), { target: { value: "2026-09-14" } });
|
||||||
|
fireEvent.change(screen.getByLabelText("Cycle length"), { target: { value: "3" } });
|
||||||
|
expect(screen.getByText("3-week cycle · anchor Monday, Sep 14")).toBeTruthy();
|
||||||
|
// Anchor moved a week: Sep 7-13 is now the last week of the cycle (full, added).
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "Save pattern" }));
|
||||||
|
await screen.findByText("Pattern saved.");
|
||||||
|
const [, init] = fetchMock.mock.calls.find(([, i]) => i?.method === "PUT")!;
|
||||||
|
expect(JSON.parse(init.body)).toEqual({
|
||||||
|
cycle_length_weeks: 3,
|
||||||
|
anchor_date: "2026-09-14",
|
||||||
|
pattern: {
|
||||||
|
"0": [0, 1, 2, 3, 4, 5, 6],
|
||||||
|
"1": [5, 6],
|
||||||
|
"2": [0, 1, 2, 3, 4, 5, 6],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("a 422 shows the API detail and keeps the draft", async () => {
|
||||||
|
putResponse = () => jsonResponse({ detail: "anchor_date must be a Monday" }, 422);
|
||||||
|
await renderReady();
|
||||||
|
fireEvent.click(day("2026-09-01"));
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "Save pattern" }));
|
||||||
|
|
||||||
|
const alert = await screen.findByRole("alert");
|
||||||
|
expect(alert.textContent).toBe("anchor_date must be a Monday");
|
||||||
|
expect(day("2026-09-01").dataset.state).toBe("home");
|
||||||
|
expect((screen.getByRole("button", { name: "Save pattern" }) as HTMLButtonElement).disabled).toBe(
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
// The saved rule is unchanged.
|
||||||
|
expect(within(screen.getByTestId("pattern-1")).getByText("Weeks 1 & 2 of cycle")).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("scroll column ends with 92px bottom padding", async () => {
|
||||||
|
await renderReady();
|
||||||
|
expect(screen.getByTestId("schedule-scroll").style.paddingBottom).toBe("92px");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows an error state on a failed load and retries", async () => {
|
||||||
|
fetchMock.mockResolvedValueOnce(new Response("boom", { status: 500 }));
|
||||||
|
render(<ScheduleTab today={TODAY} />);
|
||||||
|
const error = await screen.findByTestId("schedule-error");
|
||||||
|
expect(error.textContent).toContain("could not be loaded");
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "Try again" }));
|
||||||
|
expect(await screen.findByTestId("pattern-1")).toBeTruthy();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,440 @@
|
|||||||
|
/**
|
||||||
|
* Schedule tab — the presence month grid (design/ADMIN-SPEC.md §4).
|
||||||
|
*
|
||||||
|
* Every day's cycle position comes from the anchor date (./cycle.ts), never
|
||||||
|
* from ISO week numbers or week parity (D-004).
|
||||||
|
*/
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { ApiForbiddenError } from "../api/client";
|
||||||
|
import { ApiRequestError, fetchChildren, type AdminChild } from "../api/definitions";
|
||||||
|
import {
|
||||||
|
fetchPresenceOverrides,
|
||||||
|
fetchPresenceSchedule,
|
||||||
|
savePresenceSchedule,
|
||||||
|
type PresenceOverride,
|
||||||
|
type PresenceSchedule,
|
||||||
|
type PresenceScheduleBody,
|
||||||
|
} from "../api/presence";
|
||||||
|
import {
|
||||||
|
cycleWeekIndex,
|
||||||
|
dayState,
|
||||||
|
fullPattern,
|
||||||
|
localTodayIso,
|
||||||
|
monthDates,
|
||||||
|
monthStart,
|
||||||
|
patternIncludes,
|
||||||
|
presenceRule,
|
||||||
|
resizePattern,
|
||||||
|
togglePatternDay,
|
||||||
|
weekdayOf,
|
||||||
|
type DayState,
|
||||||
|
} from "./cycle";
|
||||||
|
import { anchorLabel, dateRange, monthLabel, shortDate } from "./format";
|
||||||
|
import {
|
||||||
|
CalendarCheckGlyph,
|
||||||
|
CalendarXGlyph,
|
||||||
|
ChevronLeftGlyph,
|
||||||
|
ChevronRightGlyph,
|
||||||
|
Trash2Glyph,
|
||||||
|
} from "./glyphs";
|
||||||
|
import "./ScheduleTab.css";
|
||||||
|
|
||||||
|
/** ADMIN-SPEC §1: every scroll column ends with 92px so content clears the tab bar. */
|
||||||
|
export const SCROLL_BOTTOM_PADDING = "92px";
|
||||||
|
|
||||||
|
export const DEFAULT_CYCLE_LENGTH_WEEKS = 2;
|
||||||
|
const CYCLE_LENGTH_CHOICES = [1, 2, 3, 4];
|
||||||
|
const WEEKDAY_INITIALS = ["M", "T", "W", "T", "F", "S", "S"];
|
||||||
|
|
||||||
|
export const STATE_LABELS: Record<DayState, string> = {
|
||||||
|
home: "Home",
|
||||||
|
away: "Away",
|
||||||
|
"override-away": "Override · away",
|
||||||
|
"override-home": "Override · home",
|
||||||
|
};
|
||||||
|
|
||||||
|
interface Loaded {
|
||||||
|
children: AdminChild[];
|
||||||
|
schedules: Record<number, PresenceSchedule | null>;
|
||||||
|
overrides: PresenceOverride[];
|
||||||
|
}
|
||||||
|
|
||||||
|
type LoadState =
|
||||||
|
| { kind: "loading" }
|
||||||
|
| { kind: "error"; message: string }
|
||||||
|
| { kind: "ready"; data: Loaded };
|
||||||
|
|
||||||
|
type SaveState =
|
||||||
|
| { kind: "idle" }
|
||||||
|
| { kind: "saving" }
|
||||||
|
| { kind: "saved" }
|
||||||
|
| { kind: "error"; message: string };
|
||||||
|
|
||||||
|
async function loadSchedule(): Promise<Loaded> {
|
||||||
|
const children = (await fetchChildren())
|
||||||
|
.filter((child) => child.is_active)
|
||||||
|
.sort((a, b) => a.sort_order - b.sort_order);
|
||||||
|
const [schedules, overrides] = await Promise.all([
|
||||||
|
Promise.all(children.map((child) => fetchPresenceSchedule(child.id))),
|
||||||
|
fetchPresenceOverrides(),
|
||||||
|
]);
|
||||||
|
return {
|
||||||
|
children,
|
||||||
|
schedules: Object.fromEntries(children.map((child, i) => [child.id, schedules[i]])),
|
||||||
|
overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function saveErrorMessage(error: unknown): string {
|
||||||
|
if (error instanceof ApiForbiddenError) return error.message;
|
||||||
|
if (error instanceof ApiRequestError && error.detail) return error.detail;
|
||||||
|
return "The pattern could not be saved. Check your connection and try again.";
|
||||||
|
}
|
||||||
|
|
||||||
|
function ScreenHeader({ sub }: { sub: string }) {
|
||||||
|
return (
|
||||||
|
<header className="schedule-header">
|
||||||
|
<h2 className="schedule-title">Schedule</h2>
|
||||||
|
<p className="schedule-sub">{sub}</p>
|
||||||
|
</header>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface MonthGridProps {
|
||||||
|
month: string;
|
||||||
|
today: string;
|
||||||
|
schedule: PresenceScheduleBody;
|
||||||
|
/** Null when the child has no saved schedule and no edits: home every day. */
|
||||||
|
effective: PresenceScheduleBody | null;
|
||||||
|
overrides: PresenceOverride[];
|
||||||
|
onMonth: (delta: number) => void;
|
||||||
|
onToggle: (date: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function MonthGrid({ month, today, schedule, effective, overrides, onMonth, onToggle }: MonthGridProps) {
|
||||||
|
const dates = monthDates(month);
|
||||||
|
const leading = weekdayOf(month);
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="schedule-month-head">
|
||||||
|
<span className="schedule-month-label" data-testid="month-label">
|
||||||
|
{monthLabel(month)}
|
||||||
|
</span>
|
||||||
|
<div className="schedule-chevrons">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="schedule-chevron"
|
||||||
|
aria-label="Previous month"
|
||||||
|
onClick={() => onMonth(-1)}
|
||||||
|
>
|
||||||
|
<ChevronLeftGlyph />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="schedule-chevron"
|
||||||
|
aria-label="Next month"
|
||||||
|
onClick={() => onMonth(1)}
|
||||||
|
>
|
||||||
|
<ChevronRightGlyph />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="schedule-grid" role="group" aria-label="Month grid">
|
||||||
|
{WEEKDAY_INITIALS.map((initial, i) => (
|
||||||
|
<span key={`wd-${i}`} className="schedule-weekday" aria-hidden="true">
|
||||||
|
{initial}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
{Array.from({ length: leading }, (_, i) => (
|
||||||
|
<span key={`pad-${i}`} aria-hidden="true" />
|
||||||
|
))}
|
||||||
|
{dates.map((date) => {
|
||||||
|
const state = dayState(effective, overrides, date);
|
||||||
|
const isToday = date === today;
|
||||||
|
let className = `schedule-day schedule-day--${state}`;
|
||||||
|
if (isToday) className += " schedule-day--today";
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={date}
|
||||||
|
type="button"
|
||||||
|
className={className}
|
||||||
|
data-date={date}
|
||||||
|
data-state={state}
|
||||||
|
aria-pressed={patternIncludes(schedule, date)}
|
||||||
|
aria-label={`${shortDate(date)}${isToday ? " (today)" : ""}: ${STATE_LABELS[state]}`}
|
||||||
|
onClick={() => onToggle(date)}
|
||||||
|
>
|
||||||
|
{Number(date.slice(8))}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
<ul className="schedule-legend" aria-label="Legend">
|
||||||
|
{(["home", "away", "override-away", "override-home"] as DayState[]).map((state) => (
|
||||||
|
<li key={state} className="schedule-legend-item">
|
||||||
|
<span className={`schedule-swatch schedule-day--${state}`} aria-hidden="true" />
|
||||||
|
{STATE_LABELS[state]}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
<li className="schedule-legend-item">
|
||||||
|
<span className="schedule-swatch schedule-day--today" aria-hidden="true" />
|
||||||
|
Today
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function OverrideRow({ override, childName }: { override: PresenceOverride; childName: string }) {
|
||||||
|
return (
|
||||||
|
<li className="schedule-row" data-testid={`override-${override.id}`}>
|
||||||
|
<span className="schedule-row-glyph">
|
||||||
|
{override.is_present ? <CalendarCheckGlyph /> : <CalendarXGlyph />}
|
||||||
|
</span>
|
||||||
|
<div className="schedule-row-text">
|
||||||
|
<div className="schedule-row-title">
|
||||||
|
{childName} {override.is_present ? "home" : "away"} ·{" "}
|
||||||
|
{dateRange(override.start_date, override.end_date)}
|
||||||
|
</div>
|
||||||
|
<div className="schedule-row-meta">{override.note || "No reason given"}</div>
|
||||||
|
</div>
|
||||||
|
{/* TODO(4ae0c879): the override editor wires delete; inert until then. */}
|
||||||
|
<button type="button" className="schedule-icon-action" aria-label="Delete override">
|
||||||
|
<Trash2Glyph />
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ScheduleTabProps {
|
||||||
|
/** "YYYY-MM-DD"; defaults to the viewer's local date. */
|
||||||
|
today?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ScheduleTab({ today = localTodayIso() }: ScheduleTabProps) {
|
||||||
|
const [state, setState] = useState<LoadState>({ kind: "loading" });
|
||||||
|
const [attempt, setAttempt] = useState(0);
|
||||||
|
const [selectedId, setSelectedId] = useState<number | null>(null);
|
||||||
|
const [month, setMonth] = useState(() => monthStart(today));
|
||||||
|
const [drafts, setDrafts] = useState<Record<number, PresenceScheduleBody>>({});
|
||||||
|
const [save, setSave] = useState<SaveState>({ kind: "idle" });
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
setState({ kind: "loading" });
|
||||||
|
loadSchedule()
|
||||||
|
.then((data) => {
|
||||||
|
if (cancelled) return;
|
||||||
|
setState({ kind: "ready", data });
|
||||||
|
setSelectedId((current) => current ?? data.children[0]?.id ?? null);
|
||||||
|
})
|
||||||
|
.catch((error: unknown) => {
|
||||||
|
if (cancelled) return;
|
||||||
|
const message =
|
||||||
|
error instanceof ApiForbiddenError
|
||||||
|
? error.message
|
||||||
|
: "The schedule could not be loaded. Check your connection and try again.";
|
||||||
|
setState({ kind: "error", message });
|
||||||
|
});
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, [attempt]);
|
||||||
|
|
||||||
|
let sub = "";
|
||||||
|
let body;
|
||||||
|
if (state.kind === "loading") {
|
||||||
|
body = (
|
||||||
|
<div className="schedule-status" role="status" data-testid="schedule-loading">
|
||||||
|
Loading schedule…
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
} else if (state.kind === "error") {
|
||||||
|
body = (
|
||||||
|
<div className="schedule-status schedule-status--error" role="alert" data-testid="schedule-error">
|
||||||
|
<p>{state.message}</p>
|
||||||
|
<button type="button" className="schedule-retry" onClick={() => setAttempt((n) => n + 1)}>
|
||||||
|
Try again
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
} else if (state.data.children.length === 0) {
|
||||||
|
body = <div className="schedule-card schedule-empty">No children yet.</div>;
|
||||||
|
} else {
|
||||||
|
const { data } = state;
|
||||||
|
const child = data.children.find((c) => c.id === selectedId) ?? data.children[0];
|
||||||
|
const saved = data.schedules[child.id] ?? null;
|
||||||
|
const draft = drafts[child.id];
|
||||||
|
// Edits start from the saved schedule, or — with none — from "home every
|
||||||
|
// day" on a 2-week cycle anchored at the displayed month's 1st.
|
||||||
|
const schedule: PresenceScheduleBody = draft ??
|
||||||
|
saved ?? {
|
||||||
|
cycle_length_weeks: DEFAULT_CYCLE_LENGTH_WEEKS,
|
||||||
|
anchor_date: month,
|
||||||
|
pattern: fullPattern(DEFAULT_CYCLE_LENGTH_WEEKS),
|
||||||
|
};
|
||||||
|
const effective = draft ?? saved;
|
||||||
|
const childOverrides = data.overrides.filter((o) => o.child_id === child.id);
|
||||||
|
sub = `${schedule.cycle_length_weeks}-week cycle · anchor ${anchorLabel(schedule.anchor_date)}`;
|
||||||
|
|
||||||
|
const edit = (next: PresenceScheduleBody) => {
|
||||||
|
setDrafts((all) => ({ ...all, [child.id]: next }));
|
||||||
|
setSave({ kind: "idle" });
|
||||||
|
};
|
||||||
|
const toggle = (date: string) => {
|
||||||
|
const week = cycleWeekIndex(schedule.anchor_date, date, schedule.cycle_length_weeks);
|
||||||
|
edit({ ...schedule, pattern: togglePatternDay(schedule.pattern, week, weekdayOf(date)) });
|
||||||
|
};
|
||||||
|
const submit = () => {
|
||||||
|
if (!draft) return;
|
||||||
|
const childId = child.id;
|
||||||
|
setSave({ kind: "saving" });
|
||||||
|
const { cycle_length_weeks, anchor_date, pattern } = draft;
|
||||||
|
savePresenceSchedule(childId, { cycle_length_weeks, anchor_date, pattern })
|
||||||
|
.then((stored) => {
|
||||||
|
setState((current) =>
|
||||||
|
current.kind === "ready"
|
||||||
|
? {
|
||||||
|
kind: "ready",
|
||||||
|
data: {
|
||||||
|
...current.data,
|
||||||
|
schedules: { ...current.data.schedules, [childId]: stored },
|
||||||
|
},
|
||||||
|
}
|
||||||
|
: current,
|
||||||
|
);
|
||||||
|
setDrafts((all) => {
|
||||||
|
const rest = { ...all };
|
||||||
|
delete rest[childId];
|
||||||
|
return rest;
|
||||||
|
});
|
||||||
|
setSave({ kind: "saved" });
|
||||||
|
})
|
||||||
|
.catch((error: unknown) => setSave({ kind: "error", message: saveErrorMessage(error) }));
|
||||||
|
};
|
||||||
|
|
||||||
|
body = (
|
||||||
|
<>
|
||||||
|
<div className="schedule-children" role="group" aria-label="Child">
|
||||||
|
{data.children.map((c) => (
|
||||||
|
<button
|
||||||
|
key={c.id}
|
||||||
|
type="button"
|
||||||
|
className="schedule-child-chip"
|
||||||
|
aria-pressed={c.id === child.id}
|
||||||
|
onClick={() => {
|
||||||
|
setSelectedId(c.id);
|
||||||
|
setSave({ kind: "idle" });
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{c.display_name}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<section className="schedule-card schedule-month" aria-label="Month grid card">
|
||||||
|
<MonthGrid
|
||||||
|
month={month}
|
||||||
|
today={today}
|
||||||
|
schedule={schedule}
|
||||||
|
effective={effective}
|
||||||
|
overrides={childOverrides}
|
||||||
|
onMonth={(delta) => setMonth((m) => monthStart(m, delta))}
|
||||||
|
onToggle={toggle}
|
||||||
|
/>
|
||||||
|
<div className="schedule-controls">
|
||||||
|
<label className="schedule-field">
|
||||||
|
<span>Anchor date</span>
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
value={schedule.anchor_date}
|
||||||
|
onChange={(event) => {
|
||||||
|
if (event.target.value) edit({ ...schedule, anchor_date: event.target.value });
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="schedule-field">
|
||||||
|
<span>Cycle length</span>
|
||||||
|
<select
|
||||||
|
value={schedule.cycle_length_weeks}
|
||||||
|
onChange={(event) => {
|
||||||
|
const weeks = Number(event.target.value);
|
||||||
|
edit({
|
||||||
|
...schedule,
|
||||||
|
cycle_length_weeks: weeks,
|
||||||
|
pattern: resizePattern(schedule.pattern, weeks),
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{CYCLE_LENGTH_CHOICES.map((weeks) => (
|
||||||
|
<option key={weeks} value={weeks}>
|
||||||
|
{weeks} {weeks === 1 ? "week" : "weeks"}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="schedule-save"
|
||||||
|
disabled={!draft || save.kind === "saving"}
|
||||||
|
onClick={submit}
|
||||||
|
>
|
||||||
|
{save.kind === "saving" ? "Saving…" : "Save pattern"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{save.kind === "error" ? (
|
||||||
|
<p className="schedule-save-status schedule-save-status--error" role="alert">
|
||||||
|
{save.message}
|
||||||
|
</p>
|
||||||
|
) : save.kind === "saved" ? (
|
||||||
|
<p className="schedule-save-status" role="status">
|
||||||
|
Pattern saved.
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<h3 className="schedule-section-label">Presence pattern</h3>
|
||||||
|
<ul className="schedule-card schedule-list" aria-label="Presence pattern">
|
||||||
|
{data.children.map((c) => (
|
||||||
|
<li key={c.id} className="schedule-row" data-testid={`pattern-${c.id}`}>
|
||||||
|
<div className="schedule-row-text">
|
||||||
|
<div className="schedule-row-title">{c.display_name}</div>
|
||||||
|
</div>
|
||||||
|
<span className="schedule-rule">{presenceRule(data.schedules[c.id] ?? null)}</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<div className="schedule-section-head">
|
||||||
|
<h3 className="schedule-section-label">Overrides</h3>
|
||||||
|
{/* TODO(4ae0c879): the override editor wires Add; inert until then. */}
|
||||||
|
<button type="button" className="schedule-text-action">
|
||||||
|
Add
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{childOverrides.length > 0 ? (
|
||||||
|
<ul className="schedule-card schedule-list" aria-label="Overrides">
|
||||||
|
{childOverrides.map((override) => (
|
||||||
|
<OverrideRow key={override.id} override={override} childName={child.display_name} />
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
) : (
|
||||||
|
<div className="schedule-card schedule-empty">No overrides for {child.display_name}.</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="schedule-screen">
|
||||||
|
<ScreenHeader sub={sub} />
|
||||||
|
<div
|
||||||
|
className="schedule-scroll"
|
||||||
|
data-testid="schedule-scroll"
|
||||||
|
style={{ paddingBottom: SCROLL_BOTTOM_PADDING }}
|
||||||
|
>
|
||||||
|
{body}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,115 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import {
|
||||||
|
cycleWeekIndex,
|
||||||
|
dayState,
|
||||||
|
monthDates,
|
||||||
|
monthStart,
|
||||||
|
presenceRule,
|
||||||
|
resizePattern,
|
||||||
|
togglePatternDay,
|
||||||
|
weekdayOf,
|
||||||
|
} from "./cycle";
|
||||||
|
import type { PresenceOverride, PresenceScheduleBody } from "../api/presence";
|
||||||
|
|
||||||
|
// Anchor Monday 2026-09-07; week 0 home every day, week 1 home Sat/Sun only.
|
||||||
|
const SCHEDULE: PresenceScheduleBody = {
|
||||||
|
cycle_length_weeks: 2,
|
||||||
|
anchor_date: "2026-09-07",
|
||||||
|
pattern: { 0: [0, 1, 2, 3, 4, 5, 6], 1: [5, 6] },
|
||||||
|
};
|
||||||
|
|
||||||
|
describe("cycleWeekIndex", () => {
|
||||||
|
it("follows the anchor across a month, wrapping every 2 weeks", () => {
|
||||||
|
const expected: Record<string, number> = {};
|
||||||
|
for (const date of monthDates("2026-09-01")) {
|
||||||
|
const day = Number(date.slice(8));
|
||||||
|
// Sep 7-13 week 0, 14-20 week 1, 21-27 week 0 (wrap), 28-30 week 1;
|
||||||
|
// Sep 1-6 lie before the anchor and walk back into week 1.
|
||||||
|
expected[date] = day < 7 ? 1 : Math.floor((day - 7) / 7) % 2;
|
||||||
|
}
|
||||||
|
for (const [date, week] of Object.entries(expected)) {
|
||||||
|
expect(cycleWeekIndex(SCHEDULE.anchor_date, date, 2), date).toBe(week);
|
||||||
|
}
|
||||||
|
expect(cycleWeekIndex("2026-09-07", "2026-09-21", 2)).toBe(0);
|
||||||
|
expect(cycleWeekIndex("2026-09-07", "2026-09-28", 2)).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("walks the cycle backwards before the anchor (floor division)", () => {
|
||||||
|
expect(cycleWeekIndex("2026-09-07", "2026-09-06", 2)).toBe(1); // day -1
|
||||||
|
expect(cycleWeekIndex("2026-09-07", "2026-08-31", 2)).toBe(1); // day -7
|
||||||
|
expect(cycleWeekIndex("2026-09-07", "2026-08-30", 2)).toBe(0); // day -8
|
||||||
|
expect(cycleWeekIndex("2026-09-07", "2026-08-24", 2)).toBe(0); // day -14
|
||||||
|
expect(cycleWeekIndex("2026-09-07", "2026-08-23", 2)).toBe(1); // day -15
|
||||||
|
expect(cycleWeekIndex("2026-09-07", "2026-09-06", 3)).toBe(2); // last week of a 3-week cycle
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ignores ISO week parity across a 53-week year", () => {
|
||||||
|
// 2026-12-28 is ISO week 53 and 2027-01-04 ISO week 1 — both odd, so
|
||||||
|
// parity would call them the same week. From the anchor they alternate.
|
||||||
|
expect(cycleWeekIndex("2026-09-07", "2026-12-28", 2)).toBe(0);
|
||||||
|
expect(cycleWeekIndex("2026-09-07", "2027-01-04", 2)).toBe(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("calendar helpers", () => {
|
||||||
|
it("weekdayOf is Monday=0", () => {
|
||||||
|
expect(weekdayOf("2026-09-07")).toBe(0);
|
||||||
|
expect(weekdayOf("2026-09-01")).toBe(1);
|
||||||
|
expect(weekdayOf("2026-09-06")).toBe(6);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("monthStart shifts across year ends", () => {
|
||||||
|
expect(monthStart("2026-12-15", 1)).toBe("2027-01-01");
|
||||||
|
expect(monthStart("2026-01-31", -1)).toBe("2025-12-01");
|
||||||
|
expect(monthDates("2026-02-01")).toHaveLength(28);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("dayState", () => {
|
||||||
|
const overrides: PresenceOverride[] = [
|
||||||
|
{ id: 1, child_id: 1, start_date: "2026-09-10", end_date: "2026-09-12", is_present: false, note: null },
|
||||||
|
{ id: 2, child_id: 1, start_date: "2026-09-16", end_date: "2026-09-16", is_present: true, note: null },
|
||||||
|
];
|
||||||
|
|
||||||
|
it("applies overrides first, then the anchor-based pattern", () => {
|
||||||
|
expect(dayState(SCHEDULE, overrides, "2026-09-08")).toBe("home");
|
||||||
|
expect(dayState(SCHEDULE, overrides, "2026-09-11")).toBe("override-away");
|
||||||
|
expect(dayState(SCHEDULE, overrides, "2026-09-15")).toBe("away");
|
||||||
|
expect(dayState(SCHEDULE, overrides, "2026-09-16")).toBe("override-home");
|
||||||
|
expect(dayState(SCHEDULE, overrides, "2026-09-19")).toBe("home");
|
||||||
|
expect(dayState(SCHEDULE, overrides, "2026-09-01")).toBe("away");
|
||||||
|
expect(dayState(SCHEDULE, overrides, "2026-09-05")).toBe("home");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("treats a child with no schedule as home every day", () => {
|
||||||
|
expect(dayState(null, [], "2026-09-15")).toBe("home");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("pattern editing and rule text", () => {
|
||||||
|
it("toggles one weekday of one cycle week", () => {
|
||||||
|
expect(togglePatternDay(SCHEDULE.pattern, 1, 1)).toEqual({
|
||||||
|
0: [0, 1, 2, 3, 4, 5, 6],
|
||||||
|
1: [1, 5, 6],
|
||||||
|
});
|
||||||
|
expect(togglePatternDay(SCHEDULE.pattern, 1, 5)[1]).toEqual([6]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("resizes the pattern to the cycle length", () => {
|
||||||
|
expect(resizePattern(SCHEDULE.pattern, 1)).toEqual({ 0: [0, 1, 2, 3, 4, 5, 6] });
|
||||||
|
expect(resizePattern(SCHEDULE.pattern, 3)[2]).toEqual([0, 1, 2, 3, 4, 5, 6]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("describes the presence rule in plain language", () => {
|
||||||
|
expect(presenceRule(null)).toBe("Every day");
|
||||||
|
expect(presenceRule({ ...SCHEDULE, pattern: { 0: [0, 1, 2, 3, 4, 5, 6], 1: [0, 1, 2, 3, 4, 5, 6] } })).toBe(
|
||||||
|
"Every day",
|
||||||
|
);
|
||||||
|
expect(presenceRule(SCHEDULE)).toBe("Weeks 1 & 2 of cycle");
|
||||||
|
expect(
|
||||||
|
presenceRule({ cycle_length_weeks: 4, anchor_date: "2026-09-07", pattern: { 0: [0], 1: [], 2: [3], 3: [] } }),
|
||||||
|
).toBe("Weeks 1 & 3 of cycle");
|
||||||
|
expect(presenceRule({ ...SCHEDULE, pattern: { 0: [1], 1: [] } })).toBe("Week 1 of cycle");
|
||||||
|
expect(presenceRule({ ...SCHEDULE, pattern: { 0: [], 1: [] } })).toBe("Never home");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,142 @@
|
|||||||
|
/**
|
||||||
|
* Pure presence-cycle math for the Schedule tab.
|
||||||
|
*
|
||||||
|
* Cycle position comes from the anchor date ONLY, mirroring
|
||||||
|
* core/presence.py `cycle_week_index`:
|
||||||
|
* `((date - anchor).days // 7) % cycle_length_weeks` with floor division,
|
||||||
|
* so dates before the anchor walk the cycle backwards. Never ISO week
|
||||||
|
* numbers or week parity (D-004).
|
||||||
|
*
|
||||||
|
* Calendar dates are "YYYY-MM-DD" strings handled as whole UTC days, so no
|
||||||
|
* viewer offset or DST change can shift a day.
|
||||||
|
*/
|
||||||
|
import type { PresenceOverride, PresencePattern, PresenceScheduleBody } from "../api/presence";
|
||||||
|
|
||||||
|
const MS_PER_DAY = 86_400_000;
|
||||||
|
|
||||||
|
export const ALL_WEEKDAYS = [0, 1, 2, 3, 4, 5, 6];
|
||||||
|
|
||||||
|
/** Days since 1970-01-01 for a "YYYY-MM-DD" calendar date. */
|
||||||
|
export function dayNumber(iso: string): number {
|
||||||
|
const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(iso);
|
||||||
|
if (!match) throw new Error(`Invalid calendar date: ${iso}`);
|
||||||
|
const [, year, month, day] = match;
|
||||||
|
return Date.UTC(Number(year), Number(month) - 1, Number(day)) / MS_PER_DAY;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isoFromDayNumber(days: number): string {
|
||||||
|
return new Date(days * MS_PER_DAY).toISOString().slice(0, 10);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Floor modulo: always in 0..divisor-1, like Python's `%`. */
|
||||||
|
function mod(value: number, divisor: number): number {
|
||||||
|
return ((value % divisor) + divisor) % divisor;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 0 = Monday .. 6 = Sunday (1970-01-01 was a Thursday). */
|
||||||
|
export function weekdayOf(iso: string): number {
|
||||||
|
return mod(dayNumber(iso) + 3, 7);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** `((date - anchor).days // 7) % cycleLengthWeeks`, Python semantics. */
|
||||||
|
export function cycleWeekIndex(anchorDate: string, date: string, cycleLengthWeeks: number): number {
|
||||||
|
const days = dayNumber(date) - dayNumber(anchorDate);
|
||||||
|
return mod(Math.floor(days / 7), cycleLengthWeeks);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The viewer's local calendar date. */
|
||||||
|
export function localTodayIso(now: Date = new Date()): string {
|
||||||
|
const month = String(now.getMonth() + 1).padStart(2, "0");
|
||||||
|
const day = String(now.getDate()).padStart(2, "0");
|
||||||
|
return `${now.getFullYear()}-${month}-${day}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** "YYYY-MM-01" of the month containing `iso`, shifted by `delta` months. */
|
||||||
|
export function monthStart(iso: string, delta = 0): string {
|
||||||
|
const [year, month] = iso.split("-").map(Number);
|
||||||
|
const date = new Date(Date.UTC(year, month - 1 + delta, 1));
|
||||||
|
return date.toISOString().slice(0, 10);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Every date of the month starting at `firstIso`. */
|
||||||
|
export function monthDates(firstIso: string): string[] {
|
||||||
|
const start = dayNumber(firstIso);
|
||||||
|
const end = dayNumber(monthStart(firstIso, 1));
|
||||||
|
const dates: string[] = [];
|
||||||
|
for (let day = start; day < end; day += 1) dates.push(isoFromDayNumber(day));
|
||||||
|
return dates;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Present every day for every week of the cycle (the no-schedule case). */
|
||||||
|
export function fullPattern(cycleLengthWeeks: number): PresencePattern {
|
||||||
|
const pattern: PresencePattern = {};
|
||||||
|
for (let week = 0; week < cycleLengthWeeks; week += 1) pattern[week] = [...ALL_WEEKDAYS];
|
||||||
|
return pattern;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type DayState = "home" | "away" | "override-home" | "override-away";
|
||||||
|
|
||||||
|
export function patternIncludes(
|
||||||
|
schedule: PresenceScheduleBody,
|
||||||
|
date: string,
|
||||||
|
): boolean {
|
||||||
|
const week = cycleWeekIndex(schedule.anchor_date, date, schedule.cycle_length_weeks);
|
||||||
|
return (schedule.pattern[week] ?? []).includes(weekdayOf(date));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A date's state for one child: an override covering the date wins;
|
||||||
|
* otherwise the schedule's pattern decides (no schedule = home every day).
|
||||||
|
*/
|
||||||
|
export function dayState(
|
||||||
|
schedule: PresenceScheduleBody | null,
|
||||||
|
overrides: PresenceOverride[],
|
||||||
|
date: string,
|
||||||
|
): DayState {
|
||||||
|
const override = overrides.find((o) => o.start_date <= date && date <= o.end_date);
|
||||||
|
if (override) return override.is_present ? "override-home" : "override-away";
|
||||||
|
if (!schedule) return "home";
|
||||||
|
return patternIncludes(schedule, date) ? "home" : "away";
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Flip `weekday` in `week` of the pattern; returns a new pattern. */
|
||||||
|
export function togglePatternDay(
|
||||||
|
pattern: PresencePattern,
|
||||||
|
week: number,
|
||||||
|
weekday: number,
|
||||||
|
): PresencePattern {
|
||||||
|
const days = pattern[week] ?? [];
|
||||||
|
const next = days.includes(weekday)
|
||||||
|
? days.filter((d) => d !== weekday)
|
||||||
|
: [...days, weekday].sort((a, b) => a - b);
|
||||||
|
return { ...pattern, [week]: next };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Fit the pattern to a new cycle length; added weeks start present every day. */
|
||||||
|
export function resizePattern(pattern: PresencePattern, cycleLengthWeeks: number): PresencePattern {
|
||||||
|
const next: PresencePattern = {};
|
||||||
|
for (let week = 0; week < cycleLengthWeeks; week += 1) {
|
||||||
|
next[week] = pattern[week] ? [...pattern[week]] : [...ALL_WEEKDAYS];
|
||||||
|
}
|
||||||
|
return next;
|
||||||
|
}
|
||||||
|
|
||||||
|
function joinWeeks(weeks: number[]): string {
|
||||||
|
if (weeks.length === 1) return String(weeks[0]);
|
||||||
|
return `${weeks.slice(0, -1).join(", ")} & ${weeks[weeks.length - 1]}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Plain-language rule: `Every day`, `Weeks 1 & 3 of cycle`, `Never home`. */
|
||||||
|
export function presenceRule(schedule: PresenceScheduleBody | null): string {
|
||||||
|
if (!schedule) return "Every day";
|
||||||
|
const weeks: number[] = [];
|
||||||
|
let full = true;
|
||||||
|
for (let week = 0; week < schedule.cycle_length_weeks; week += 1) {
|
||||||
|
const days = schedule.pattern[week] ?? [];
|
||||||
|
if (days.length > 0) weeks.push(week + 1);
|
||||||
|
if (days.length < 7) full = false;
|
||||||
|
}
|
||||||
|
if (full) return "Every day";
|
||||||
|
if (weeks.length === 0) return "Never home";
|
||||||
|
return `${weeks.length === 1 ? "Week" : "Weeks"} ${joinWeeks(weeks)} of cycle`;
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
/**
|
||||||
|
* Date labels for the Schedule tab. Calendar dates are formatted in UTC from
|
||||||
|
* their components, so the printed day never shifts with the viewer's offset.
|
||||||
|
*/
|
||||||
|
import { dayNumber } from "./cycle";
|
||||||
|
|
||||||
|
const LOCALE = "en-US";
|
||||||
|
|
||||||
|
function utcDate(iso: string): Date {
|
||||||
|
return new Date(dayNumber(iso) * 86_400_000);
|
||||||
|
}
|
||||||
|
|
||||||
|
function format(iso: string, options: Intl.DateTimeFormatOptions): string {
|
||||||
|
return new Intl.DateTimeFormat(LOCALE, { ...options, timeZone: "UTC" }).format(utcDate(iso));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** "Tuesday, Sep 1" */
|
||||||
|
export function anchorLabel(iso: string): string {
|
||||||
|
return `${format(iso, { weekday: "long" })}, ${format(iso, { month: "short", day: "numeric" })}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** "September 2026" */
|
||||||
|
export function monthLabel(iso: string): string {
|
||||||
|
return format(iso, { month: "long", year: "numeric" });
|
||||||
|
}
|
||||||
|
|
||||||
|
/** "Sep 4" */
|
||||||
|
export function shortDate(iso: string): string {
|
||||||
|
return format(iso, { month: "short", day: "numeric" });
|
||||||
|
}
|
||||||
|
|
||||||
|
/** "Sep 4 – 7", "Sep 28 – Oct 2", or "Sep 4" for a single day. */
|
||||||
|
export function dateRange(start: string, end: string): string {
|
||||||
|
if (start === end) return shortDate(start);
|
||||||
|
const sameMonth = start.slice(0, 7) === end.slice(0, 7);
|
||||||
|
return `${shortDate(start)} – ${sameMonth ? Number(end.slice(8)) : shortDate(end)}`;
|
||||||
|
}
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
/**
|
||||||
|
* Inline stroke glyphs for the Schedule tab (Lucide-style 24px grid),
|
||||||
|
* CDN-free like the Today and Tasks tabs' glyphs.
|
||||||
|
*/
|
||||||
|
import type { ReactNode } from "react";
|
||||||
|
|
||||||
|
function Glyph({ size, children }: { size: number; children: ReactNode }) {
|
||||||
|
return (
|
||||||
|
<svg
|
||||||
|
width={size}
|
||||||
|
height={size}
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth={2}
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
aria-hidden="true"
|
||||||
|
focusable="false"
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ChevronLeftGlyph({ size = 16 }: { size?: number }) {
|
||||||
|
return (
|
||||||
|
<Glyph size={size}>
|
||||||
|
<path d="m15 18-6-6 6-6" />
|
||||||
|
</Glyph>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ChevronRightGlyph({ size = 16 }: { size?: number }) {
|
||||||
|
return (
|
||||||
|
<Glyph size={size}>
|
||||||
|
<path d="m9 18 6-6-6-6" />
|
||||||
|
</Glyph>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function CalendarFrame() {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<path d="M8 2v4M16 2v4" />
|
||||||
|
<rect x="3" y="4" width="18" height="18" rx="2" />
|
||||||
|
<path d="M3 10h18" />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CalendarXGlyph({ size = 18 }: { size?: number }) {
|
||||||
|
return (
|
||||||
|
<Glyph size={size}>
|
||||||
|
<CalendarFrame />
|
||||||
|
<path d="m14 14-4 4M10 14l4 4" />
|
||||||
|
</Glyph>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CalendarCheckGlyph({ size = 18 }: { size?: number }) {
|
||||||
|
return (
|
||||||
|
<Glyph size={size}>
|
||||||
|
<CalendarFrame />
|
||||||
|
<path d="m9 16 2 2 4-4" />
|
||||||
|
</Glyph>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Trash2Glyph({ size = 16 }: { size?: number }) {
|
||||||
|
return (
|
||||||
|
<Glyph size={size}>
|
||||||
|
<path d="M3 6h18M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2M10 11v6M14 11v6" />
|
||||||
|
</Glyph>
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user