Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bf00b63215 | ||
|
|
5a28fddfe2 | ||
|
|
dbe873eb2a | ||
|
|
4b39af4b67 | ||
|
|
706a7954d8 | ||
|
|
42ea5710d7 | ||
|
|
be4fd04754 |
@@ -31,6 +31,23 @@
|
||||
padding: 14px 16px 92px; /* bottom padding clears the 70px tab bar */
|
||||
}
|
||||
|
||||
/* A built screen supplies its own header and 92px-padded scroll column. */
|
||||
.admin-panel--screen {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.visually-hidden {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
margin: -1px;
|
||||
padding: 0;
|
||||
overflow: hidden;
|
||||
clip: rect(0 0 0 0);
|
||||
white-space: nowrap;
|
||||
border: 0;
|
||||
}
|
||||
|
||||
.admin-placeholder {
|
||||
margin: 0;
|
||||
padding: 13px;
|
||||
|
||||
+21
-6
@@ -1,9 +1,17 @@
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
|
||||
import App from "./App";
|
||||
|
||||
describe("NestQuest Admin shell", () => {
|
||||
afterEach(cleanup);
|
||||
beforeEach(() => {
|
||||
// The Today tab fetches the admin snapshot; keep the shell tests offline.
|
||||
vi.stubGlobal("fetch", vi.fn(() => new Promise<Response>(() => {})));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it("renders the title and all four tab labels", () => {
|
||||
render(<App />);
|
||||
@@ -14,12 +22,10 @@ describe("NestQuest Admin shell", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("starts on the Today placeholder and switches on tab click", () => {
|
||||
it("starts on the Today screen and switches on tab click", () => {
|
||||
render(<App />);
|
||||
|
||||
expect(screen.getByRole("tabpanel").textContent).toContain(
|
||||
"Today placeholder",
|
||||
);
|
||||
expect(screen.getByRole("heading", { name: "Today" })).toBeTruthy();
|
||||
|
||||
fireEvent.click(screen.getByRole("tab", { name: "History" }));
|
||||
|
||||
@@ -30,4 +36,13 @@ describe("NestQuest Admin shell", () => {
|
||||
"true",
|
||||
);
|
||||
});
|
||||
|
||||
it("the Tasks tab renders the definitions screen", () => {
|
||||
render(<App />);
|
||||
|
||||
fireEvent.click(screen.getByRole("tab", { name: "Tasks" }));
|
||||
|
||||
expect(screen.getByRole("heading", { name: "Tasks" })).toBeTruthy();
|
||||
expect(screen.getByRole("tabpanel").textContent).not.toContain("placeholder");
|
||||
});
|
||||
});
|
||||
+17
-6
@@ -1,5 +1,7 @@
|
||||
import { useState } from "react";
|
||||
import "./App.css";
|
||||
import DefinitionsTab from "./definitions/DefinitionsTab";
|
||||
import TodayTab from "./today/TodayTab";
|
||||
|
||||
const TABS = [
|
||||
{ id: "today", label: "Today" },
|
||||
@@ -12,22 +14,31 @@ type TabId = (typeof TABS)[number]["id"];
|
||||
|
||||
export default function App() {
|
||||
const [active, setActive] = useState<TabId>("today");
|
||||
// Built screens own their header (ADMIN-SPEC §1); the generic shell header
|
||||
// stays for placeholder tabs and is visually hidden otherwise.
|
||||
const hasScreen = active === "today" || active === "tasks";
|
||||
|
||||
return (
|
||||
<div className="admin-shell">
|
||||
<header className="admin-header">
|
||||
<header className={hasScreen ? "admin-header visually-hidden" : "admin-header"}>
|
||||
<h1 className="admin-title">NestQuest Admin</h1>
|
||||
<p className="admin-subline">Admin console</p>
|
||||
</header>
|
||||
<main
|
||||
className="admin-panel"
|
||||
className={hasScreen ? "admin-panel admin-panel--screen" : "admin-panel"}
|
||||
role="tabpanel"
|
||||
aria-labelledby={`tab-${active}`}
|
||||
>
|
||||
<p className="admin-placeholder">
|
||||
{TABS.find((tab) => tab.id === active)?.label} placeholder — screens
|
||||
arrive in later tasks.
|
||||
</p>
|
||||
{active === "today" ? (
|
||||
<TodayTab />
|
||||
) : active === "tasks" ? (
|
||||
<DefinitionsTab />
|
||||
) : (
|
||||
<p className="admin-placeholder">
|
||||
{TABS.find((tab) => tab.id === active)?.label} placeholder — screens
|
||||
arrive in later tasks.
|
||||
</p>
|
||||
)}
|
||||
</main>
|
||||
<nav className="admin-tabbar" aria-label="Admin sections">
|
||||
{TABS.map((tab) => (
|
||||
|
||||
@@ -0,0 +1,148 @@
|
||||
/**
|
||||
* Typed client for the admin quest-definition routes and the children list
|
||||
* the assignee picker reads (/api/v1/admin/quest-definitions, /children).
|
||||
*/
|
||||
import { apiFetch } from "./client";
|
||||
|
||||
export type WindowName = "morning" | "afternoon" | "evening";
|
||||
|
||||
/** Model rule names, as the admin plane serializes ScheduleRule.to_dict. */
|
||||
export type RuleType =
|
||||
| "daily"
|
||||
| "weekly"
|
||||
| "monthly_day"
|
||||
| "monthly_weekday"
|
||||
| "yearly"
|
||||
| "custom_days";
|
||||
|
||||
export interface DefinitionRule {
|
||||
rule_type: RuleType;
|
||||
interval: number;
|
||||
/** 0 = Monday .. 6 = Sunday. */
|
||||
weekday_set: number[] | null;
|
||||
day_of_month: number | null;
|
||||
nth_weekday: number | null;
|
||||
nth_weekday_weekday: number | null;
|
||||
month: number | null;
|
||||
start_date: string;
|
||||
end_date: string | null;
|
||||
}
|
||||
|
||||
export interface DefinitionAssignee {
|
||||
id: number;
|
||||
display_name: string;
|
||||
}
|
||||
|
||||
export interface DefinitionWindow {
|
||||
window: WindowName;
|
||||
/** Local wall-clock "HH:MM", or null. */
|
||||
due_time: string | null;
|
||||
}
|
||||
|
||||
export interface QuestDefinition {
|
||||
id: number;
|
||||
title: string;
|
||||
description: string | null;
|
||||
icon: string | null;
|
||||
is_active: boolean;
|
||||
skip_on_away: boolean;
|
||||
rule: DefinitionRule;
|
||||
assignees: DefinitionAssignee[];
|
||||
windows: DefinitionWindow[];
|
||||
}
|
||||
|
||||
export interface AdminChild {
|
||||
id: number;
|
||||
display_name: string;
|
||||
colour: string | null;
|
||||
avatar_ref: string | null;
|
||||
sort_order: number;
|
||||
is_active: boolean;
|
||||
}
|
||||
|
||||
/** A window declaration as the create/edit bodies accept it. */
|
||||
export type WindowEntry = WindowName | [WindowName, string | null];
|
||||
|
||||
export interface DefinitionCreateBody {
|
||||
title: string;
|
||||
rule: DefinitionRule;
|
||||
assignee_child_ids: number[];
|
||||
windows: WindowEntry[];
|
||||
description?: string | null;
|
||||
icon?: string | null;
|
||||
skip_on_away?: boolean;
|
||||
}
|
||||
|
||||
export type DefinitionEditBody = Partial<DefinitionCreateBody>;
|
||||
|
||||
export const DEFINITIONS_PATH = "/api/v1/admin/quest-definitions";
|
||||
export const CHILDREN_PATH = "/api/v1/admin/children";
|
||||
|
||||
/** A non-2xx save response; `detail` is the API's message when it sent one. */
|
||||
export class ApiRequestError extends Error {
|
||||
constructor(
|
||||
readonly status: number,
|
||||
readonly detail: string | null,
|
||||
) {
|
||||
super(detail ?? `Request failed (HTTP ${status}).`);
|
||||
this.name = "ApiRequestError";
|
||||
}
|
||||
}
|
||||
|
||||
/** FastAPI sends `detail` as a string (core errors) or a list of {msg} (body validation). */
|
||||
function readDetail(body: unknown): string | null {
|
||||
if (!body || typeof body !== "object" || !("detail" in body)) return null;
|
||||
const { detail } = body as { detail: unknown };
|
||||
if (typeof detail === "string") return detail;
|
||||
if (Array.isArray(detail)) {
|
||||
const messages = detail
|
||||
.map((item) => (item && typeof item === "object" ? (item as { msg?: unknown }).msg : null))
|
||||
.filter((msg): msg is string => typeof msg === "string");
|
||||
return messages.length > 0 ? messages.join("; ") : null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
async function readJson<T>(response: Response): Promise<T> {
|
||||
if (!response.ok) {
|
||||
let body: unknown = null;
|
||||
try {
|
||||
body = await response.json();
|
||||
} catch {
|
||||
// Non-JSON error body: report the status alone.
|
||||
}
|
||||
throw new ApiRequestError(response.status, readDetail(body));
|
||||
}
|
||||
return (await response.json()) as T;
|
||||
}
|
||||
|
||||
export async function fetchDefinitions(): Promise<QuestDefinition[]> {
|
||||
const body = await readJson<{ definitions: QuestDefinition[] }>(
|
||||
await apiFetch(DEFINITIONS_PATH),
|
||||
);
|
||||
return body.definitions;
|
||||
}
|
||||
|
||||
export async function fetchChildren(): Promise<AdminChild[]> {
|
||||
const body = await readJson<{ children: AdminChild[] }>(await apiFetch(CHILDREN_PATH));
|
||||
return body.children;
|
||||
}
|
||||
|
||||
function sendJson(path: string, method: "POST" | "PATCH", body: unknown): Promise<Response> {
|
||||
return apiFetch(path, {
|
||||
method,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
}
|
||||
|
||||
export async function createDefinition(body: DefinitionCreateBody): Promise<QuestDefinition> {
|
||||
return readJson<QuestDefinition>(await sendJson(DEFINITIONS_PATH, "POST", body));
|
||||
}
|
||||
|
||||
export async function updateDefinition(
|
||||
id: number,
|
||||
body: DefinitionEditBody,
|
||||
): Promise<QuestDefinition> {
|
||||
return readJson<QuestDefinition>(await sendJson(`${DEFINITIONS_PATH}/${id}`, "PATCH", body));
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
/**
|
||||
* Typed client for GET /api/v1/admin/snapshot — today's household snapshot
|
||||
* that drives the admin Today tab.
|
||||
*/
|
||||
import { apiFetch } from "./client";
|
||||
|
||||
export type InstanceWindow = "morning" | "afternoon" | "evening";
|
||||
export type InstanceState = "open" | "completed" | "missed";
|
||||
|
||||
export interface SnapshotInstance {
|
||||
id: number;
|
||||
definition_id: number;
|
||||
child_id: number;
|
||||
title: string;
|
||||
icon: string | null;
|
||||
window: InstanceWindow;
|
||||
/** Local wall-clock "HH:MM", or null when the definition has no due time. */
|
||||
due_time: string | null;
|
||||
state: InstanceState;
|
||||
overdue: boolean;
|
||||
/** UTC ISO timestamp of the completion event, when completed. */
|
||||
completed_at: string | null;
|
||||
on_time: boolean | null;
|
||||
}
|
||||
|
||||
export interface SnapshotChild {
|
||||
child_id: number;
|
||||
child_name: string;
|
||||
present: boolean;
|
||||
/** "YYYY-MM-DD" of the next present day, or null. */
|
||||
next_present: string | null;
|
||||
due_today: number;
|
||||
completed_today: number;
|
||||
remaining_today: number;
|
||||
completion_pct: number;
|
||||
instances: SnapshotInstance[];
|
||||
}
|
||||
|
||||
export interface AdminSnapshot {
|
||||
/** "YYYY-MM-DD" in the household's local zone. */
|
||||
today_iso: string;
|
||||
/** 1-based day in the custody cycle. */
|
||||
cycle_day: number;
|
||||
children: SnapshotChild[];
|
||||
}
|
||||
|
||||
export const SNAPSHOT_PATH = "/api/v1/admin/snapshot";
|
||||
|
||||
export async function fetchSnapshot(): Promise<AdminSnapshot> {
|
||||
const response = await apiFetch(SNAPSHOT_PATH);
|
||||
if (!response.ok) {
|
||||
throw new Error(`Snapshot request failed (HTTP ${response.status}).`);
|
||||
}
|
||||
return (await response.json()) as AdminSnapshot;
|
||||
}
|
||||
@@ -0,0 +1,552 @@
|
||||
/* Definitions ("Tasks") tab — list + edit sheet (design/ADMIN-SPEC.md §3.1–3.2).
|
||||
Mobile-first at the 390x844 reference; the column is capped and centred wider. */
|
||||
|
||||
.defs-screen {
|
||||
width: 100%;
|
||||
max-width: 640px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
/* §3.1 header */
|
||||
.defs-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
background: var(--nq-a-surface);
|
||||
border-bottom: 1px solid var(--nq-a-border);
|
||||
padding: 14px 16px;
|
||||
}
|
||||
|
||||
.defs-title {
|
||||
margin: 0;
|
||||
font-size: var(--nq-a-size-screen);
|
||||
font-weight: 800;
|
||||
letter-spacing: -0.02em;
|
||||
}
|
||||
|
||||
.defs-sub {
|
||||
margin: 2px 0 0;
|
||||
min-height: 1.4em;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: var(--nq-a-ink-secondary);
|
||||
}
|
||||
|
||||
.defs-new {
|
||||
flex: none;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
min-height: 36px;
|
||||
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;
|
||||
}
|
||||
|
||||
.defs-new:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
/* Scroll column: 14px 16px, bottom 92px (set inline, ADMIN-SPEC §1). */
|
||||
.defs-scroll {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--nq-a-density-gap);
|
||||
padding: 14px 16px;
|
||||
}
|
||||
|
||||
/* Filter chips */
|
||||
.defs-chips {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.defs-chip {
|
||||
flex: none;
|
||||
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);
|
||||
font-family: inherit;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.defs-chip--active {
|
||||
border-color: #18181d;
|
||||
background: #18181d;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
/* Rows */
|
||||
.defs-card {
|
||||
background: var(--nq-a-surface);
|
||||
border: 1px solid var(--nq-a-border);
|
||||
border-radius: var(--nq-a-density-radius);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.defs-list {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.defs-list > li + li {
|
||||
border-top: 1px solid var(--nq-a-divider);
|
||||
}
|
||||
|
||||
.defs-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
width: 100%;
|
||||
min-height: var(--nq-a-tap-min);
|
||||
padding: var(--nq-a-density-row-pad);
|
||||
border: 0;
|
||||
background: none;
|
||||
color: var(--nq-a-ink);
|
||||
font-family: inherit;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.defs-row-glyph {
|
||||
flex: none;
|
||||
display: flex;
|
||||
color: var(--nq-a-ink-secondary);
|
||||
}
|
||||
|
||||
.defs-row-text {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.defs-row-title {
|
||||
font-size: var(--nq-a-size-row);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.defs-row-meta {
|
||||
font-size: var(--nq-a-size-meta);
|
||||
font-weight: 600;
|
||||
color: var(--nq-a-ink-secondary);
|
||||
}
|
||||
|
||||
.defs-row-chevron {
|
||||
flex: none;
|
||||
display: flex;
|
||||
color: var(--nq-a-ink-tertiary);
|
||||
}
|
||||
|
||||
/* An inactive definition renders the whole row tertiary (never hard-deleted). */
|
||||
.defs-row--inactive,
|
||||
.defs-row--inactive .defs-row-glyph,
|
||||
.defs-row--inactive .defs-row-meta {
|
||||
color: var(--nq-a-ink-tertiary);
|
||||
}
|
||||
|
||||
.defs-row--editing {
|
||||
background: var(--nq-a-selected);
|
||||
}
|
||||
|
||||
.defs-row--editing .defs-row-glyph {
|
||||
color: var(--nq-brand-blue);
|
||||
}
|
||||
|
||||
.defs-empty {
|
||||
padding: var(--nq-a-density-card-pad);
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--nq-a-ink-secondary);
|
||||
}
|
||||
|
||||
/* Loading / error */
|
||||
.defs-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);
|
||||
}
|
||||
|
||||
.defs-status--error {
|
||||
color: var(--nq-a-danger-strong);
|
||||
}
|
||||
|
||||
.defs-status--error p {
|
||||
margin: 0 0 10px;
|
||||
}
|
||||
|
||||
.defs-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;
|
||||
}
|
||||
|
||||
/* §3.2 edit sheet */
|
||||
.defs-sheet-layer {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 20;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.defs-scrim {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: rgba(9, 9, 11, 0.4);
|
||||
animation: defs-fade var(--nq-dur-modal) var(--nq-ease-out);
|
||||
}
|
||||
|
||||
.defs-sheet {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
max-width: 640px;
|
||||
max-height: 92%;
|
||||
margin: 0 auto;
|
||||
overflow-y: auto;
|
||||
padding: 14px 18px 24px;
|
||||
border-radius: var(--nq-a-radius-sheet);
|
||||
background: var(--nq-a-surface);
|
||||
box-shadow: var(--nq-a-sheet-shadow);
|
||||
animation: defs-rise var(--nq-dur-modal) var(--nq-ease-out);
|
||||
}
|
||||
|
||||
@keyframes defs-fade {
|
||||
from {
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes defs-rise {
|
||||
from {
|
||||
transform: translateY(100%);
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.defs-scrim,
|
||||
.defs-sheet {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
|
||||
.defs-grabber {
|
||||
width: 38px;
|
||||
height: 4px;
|
||||
margin: 0 auto 12px;
|
||||
border-radius: var(--nq-a-radius-pill);
|
||||
background: var(--nq-a-border);
|
||||
}
|
||||
|
||||
.defs-sheet-title {
|
||||
margin: 0 0 12px;
|
||||
font-size: 19px;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.defs-fields {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 11px;
|
||||
}
|
||||
|
||||
.defs-field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 5px;
|
||||
}
|
||||
|
||||
.defs-label {
|
||||
font-size: var(--nq-a-size-label);
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: var(--nq-a-track-label);
|
||||
color: var(--nq-a-ink-secondary);
|
||||
}
|
||||
|
||||
.defs-input {
|
||||
width: 100%;
|
||||
min-height: var(--nq-a-tap-min);
|
||||
padding: 0 11px;
|
||||
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: 14px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.defs-select-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
min-height: var(--nq-a-tap-min);
|
||||
padding: 0 11px;
|
||||
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: 14px;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.defs-picker {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
border: 1px solid var(--nq-a-border);
|
||||
border-radius: var(--nq-a-density-radius);
|
||||
}
|
||||
|
||||
.defs-picker > li + li {
|
||||
border-top: 1px solid var(--nq-a-divider);
|
||||
}
|
||||
|
||||
.defs-picker-option {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
width: 100%;
|
||||
min-height: var(--nq-a-tap-min);
|
||||
padding: 0 11px;
|
||||
border: 0;
|
||||
background: none;
|
||||
color: var(--nq-a-ink);
|
||||
font-family: inherit;
|
||||
font-size: 13.5px;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.defs-picker-check {
|
||||
display: flex;
|
||||
color: var(--nq-brand-blue);
|
||||
}
|
||||
|
||||
.defs-picker-option--locked {
|
||||
color: var(--nq-a-ink-secondary);
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.defs-picker-note {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.defs-repeats {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.defs-interval {
|
||||
flex: none;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
color: var(--nq-a-ink-secondary);
|
||||
}
|
||||
|
||||
.defs-interval .defs-input {
|
||||
width: 64px;
|
||||
}
|
||||
|
||||
.defs-weekdays {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(7, 1fr);
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.defs-day,
|
||||
.defs-segment {
|
||||
min-height: var(--nq-a-tap-min);
|
||||
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;
|
||||
}
|
||||
|
||||
.defs-day--on,
|
||||
.defs-segment--on {
|
||||
border-color: transparent;
|
||||
background: var(--nq-brand-gradient);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.defs-segmented {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.defs-due-times {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.defs-due {
|
||||
display: grid;
|
||||
grid-template-columns: 84px 1fr;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 12.5px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.defs-hint {
|
||||
margin: 0;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: var(--nq-a-ink-secondary);
|
||||
}
|
||||
|
||||
.defs-toggle-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.defs-toggle-title {
|
||||
font-size: 13.5px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.defs-toggle-sub {
|
||||
font-size: var(--nq-a-size-meta);
|
||||
font-weight: 600;
|
||||
color: var(--nq-a-ink-secondary);
|
||||
}
|
||||
|
||||
.defs-switch {
|
||||
flex: none;
|
||||
position: relative;
|
||||
width: 44px;
|
||||
height: 26px;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
border-radius: var(--nq-a-radius-pill);
|
||||
background: var(--nq-a-border);
|
||||
cursor: pointer;
|
||||
transition: background var(--nq-dur-base) var(--nq-ease-out);
|
||||
}
|
||||
|
||||
.defs-switch--on {
|
||||
background: var(--nq-brand-gradient);
|
||||
}
|
||||
|
||||
.defs-switch-knob {
|
||||
position: absolute;
|
||||
top: 3px;
|
||||
left: 3px;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border-radius: 50%;
|
||||
background: #fff;
|
||||
box-shadow: 0 1px 2px rgba(9, 9, 11, 0.25);
|
||||
transition: transform var(--nq-dur-base) var(--nq-ease-out);
|
||||
}
|
||||
|
||||
.defs-switch--on .defs-switch-knob {
|
||||
transform: translateX(18px);
|
||||
}
|
||||
|
||||
.defs-sheet-error {
|
||||
margin: 12px 0 0;
|
||||
padding: 10px 12px;
|
||||
border-radius: var(--nq-a-density-radius);
|
||||
background: var(--nq-a-danger-bg);
|
||||
color: var(--nq-a-danger-strong);
|
||||
font-size: 12.5px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.defs-sheet-footer {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
.defs-confirm {
|
||||
margin-top: 12px;
|
||||
padding: 10px 12px;
|
||||
border-radius: var(--nq-a-density-radius);
|
||||
background: var(--nq-a-danger-bg);
|
||||
color: var(--nq-a-danger-strong);
|
||||
font-size: 12.5px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.defs-confirm p {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.defs-confirm .defs-sheet-footer {
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.defs-cancel,
|
||||
.defs-save {
|
||||
padding: 13px 0;
|
||||
border-radius: var(--nq-a-density-radius);
|
||||
font-family: inherit;
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.defs-cancel {
|
||||
flex: 1;
|
||||
border: 1px solid var(--nq-a-border);
|
||||
background: var(--nq-a-surface);
|
||||
color: var(--nq-a-ink);
|
||||
}
|
||||
|
||||
.defs-save {
|
||||
flex: 2;
|
||||
border: 0;
|
||||
background: var(--nq-brand-gradient);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.defs-cancel:disabled,
|
||||
.defs-save:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: default;
|
||||
}
|
||||
@@ -0,0 +1,644 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { act, cleanup, fireEvent, render, screen, within } from "@testing-library/react";
|
||||
import DefinitionsTab from "./DefinitionsTab";
|
||||
import type { AdminChild, DefinitionRule, QuestDefinition } from "../api/definitions";
|
||||
import { storeTokens } from "../auth/oidc";
|
||||
|
||||
function child(id: number, name: string, overrides: Partial<AdminChild> = {}): AdminChild {
|
||||
return {
|
||||
id,
|
||||
display_name: name,
|
||||
colour: null,
|
||||
avatar_ref: null,
|
||||
sort_order: id,
|
||||
is_active: true,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
const CHILDREN: AdminChild[] = [
|
||||
child(1, "Declan"),
|
||||
child(2, "Maeve"),
|
||||
child(3, "Rory"),
|
||||
child(4, "Old", { is_active: false }),
|
||||
];
|
||||
|
||||
function rule(overrides: Partial<DefinitionRule> = {}): DefinitionRule {
|
||||
return {
|
||||
rule_type: "daily",
|
||||
interval: 1,
|
||||
weekday_set: null,
|
||||
day_of_month: null,
|
||||
nth_weekday: null,
|
||||
nth_weekday_weekday: null,
|
||||
month: null,
|
||||
start_date: "2026-01-01",
|
||||
end_date: null,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
const BRUSH: QuestDefinition = {
|
||||
id: 10,
|
||||
title: "Brush teeth",
|
||||
description: null,
|
||||
icon: "smile",
|
||||
is_active: true,
|
||||
skip_on_away: true,
|
||||
rule: rule(),
|
||||
assignees: [
|
||||
{ id: 1, display_name: "Declan" },
|
||||
{ id: 2, display_name: "Maeve" },
|
||||
{ id: 3, display_name: "Rory" },
|
||||
],
|
||||
windows: [
|
||||
{ window: "morning", due_time: "07:30" },
|
||||
{ window: "evening", due_time: null },
|
||||
],
|
||||
};
|
||||
|
||||
const BINS: QuestDefinition = {
|
||||
id: 11,
|
||||
title: "Take out bins",
|
||||
description: "Green bin",
|
||||
icon: null,
|
||||
is_active: true,
|
||||
skip_on_away: false,
|
||||
rule: rule({ rule_type: "weekly", interval: 2, weekday_set: [0, 3] }),
|
||||
assignees: [
|
||||
{ id: 1, display_name: "Declan" },
|
||||
{ id: 4, display_name: "Old" },
|
||||
],
|
||||
windows: [{ window: "afternoon", due_time: "16:00" }],
|
||||
};
|
||||
|
||||
const PIANO: QuestDefinition = {
|
||||
id: 12,
|
||||
title: "Piano practice",
|
||||
description: null,
|
||||
icon: null,
|
||||
is_active: false,
|
||||
skip_on_away: true,
|
||||
rule: rule({ rule_type: "monthly_day", day_of_month: 15 }),
|
||||
assignees: [{ id: 2, display_name: "Maeve" }],
|
||||
windows: [{ window: "afternoon", due_time: null }],
|
||||
};
|
||||
|
||||
function jsonResponse(body: unknown, status = 200): Response {
|
||||
return new Response(JSON.stringify(body), {
|
||||
status,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
}
|
||||
|
||||
interface Call {
|
||||
method: string;
|
||||
path: string;
|
||||
body: unknown;
|
||||
}
|
||||
|
||||
/**
|
||||
* A stub of the admin API: serves the list and children, records writes, and
|
||||
* lets a test queue a response for the next write.
|
||||
*/
|
||||
function fakeApi(initial: QuestDefinition[]) {
|
||||
let definitions = [...initial];
|
||||
const calls: Call[] = [];
|
||||
let nextWrite: Response | null = null;
|
||||
const fetchMock = vi.fn(async (input: RequestInfo | URL, init: RequestInit = {}) => {
|
||||
const path = new URL(String(input), "http://localhost").pathname;
|
||||
const method = init.method ?? "GET";
|
||||
const body = typeof init.body === "string" ? JSON.parse(init.body) : null;
|
||||
calls.push({ method, path, body });
|
||||
if (method === "GET" && path === "/api/v1/admin/quest-definitions") {
|
||||
return jsonResponse({ definitions });
|
||||
}
|
||||
if (method === "GET" && path === "/api/v1/admin/children") {
|
||||
return jsonResponse({ children: CHILDREN });
|
||||
}
|
||||
if (nextWrite) {
|
||||
const response = nextWrite;
|
||||
nextWrite = null;
|
||||
return response;
|
||||
}
|
||||
if (method === "POST" && path === "/api/v1/admin/quest-definitions") {
|
||||
const created: QuestDefinition = {
|
||||
id: 99,
|
||||
title: body.title,
|
||||
description: null,
|
||||
icon: null,
|
||||
is_active: true,
|
||||
skip_on_away: body.skip_on_away,
|
||||
rule: body.rule,
|
||||
assignees: CHILDREN.filter((c) => body.assignee_child_ids.includes(c.id)).map((c) => ({
|
||||
id: c.id,
|
||||
display_name: c.display_name,
|
||||
})),
|
||||
windows: body.windows.map(([window, due_time]: [string, string | null]) => ({
|
||||
window,
|
||||
due_time,
|
||||
})),
|
||||
};
|
||||
definitions = [...definitions, created];
|
||||
return jsonResponse(created, 201);
|
||||
}
|
||||
const match = /^\/api\/v1\/admin\/quest-definitions\/(\d+)$/.exec(path);
|
||||
if (method === "PATCH" && match) {
|
||||
const id = Number(match[1]);
|
||||
definitions = definitions.map((d) =>
|
||||
d.id === id
|
||||
? {
|
||||
...d,
|
||||
title: body.title ?? d.title,
|
||||
skip_on_away: body.skip_on_away ?? d.skip_on_away,
|
||||
rule: body.rule ?? d.rule,
|
||||
// Like the API: a sent list replaces the whole set; an absent one keeps it.
|
||||
assignees:
|
||||
"assignee_child_ids" in body
|
||||
? CHILDREN.filter((c) => body.assignee_child_ids.includes(c.id)).map((c) => ({
|
||||
id: c.id,
|
||||
display_name: c.display_name,
|
||||
}))
|
||||
: d.assignees,
|
||||
}
|
||||
: d,
|
||||
);
|
||||
return jsonResponse(definitions.find((d) => d.id === id));
|
||||
}
|
||||
return jsonResponse({ detail: "unexpected" }, 500);
|
||||
});
|
||||
return {
|
||||
fetchMock,
|
||||
calls,
|
||||
writes: () => calls.filter((c) => c.method !== "GET"),
|
||||
listFetches: () =>
|
||||
calls.filter((c) => c.method === "GET" && c.path === "/api/v1/admin/quest-definitions")
|
||||
.length,
|
||||
failNextWrite: (response: Response) => {
|
||||
nextWrite = response;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
let api: ReturnType<typeof fakeApi>;
|
||||
|
||||
async function renderReady(initial: QuestDefinition[] = [BRUSH, BINS, PIANO]) {
|
||||
api = fakeApi(initial);
|
||||
vi.stubGlobal("fetch", api.fetchMock);
|
||||
render(<DefinitionsTab />);
|
||||
await screen.findByText("Brush teeth");
|
||||
}
|
||||
|
||||
function meta(row: HTMLElement): string {
|
||||
return (row.querySelector(".defs-row-meta") as HTMLElement).textContent ?? "";
|
||||
}
|
||||
|
||||
function sheet(): HTMLElement {
|
||||
return screen.getByRole("dialog");
|
||||
}
|
||||
|
||||
describe("DefinitionsTab", () => {
|
||||
beforeEach(() => {
|
||||
sessionStorage.clear();
|
||||
storeTokens({ access_token: "at-123", expires_in: 600 });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.unstubAllGlobals();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("header counts definitions and active children", async () => {
|
||||
await renderReady();
|
||||
expect(screen.getByRole("heading", { name: "Tasks" })).toBeTruthy();
|
||||
expect(screen.getByText("3 definitions · 3 children")).toBeTruthy();
|
||||
const [url, init] = api.fetchMock.mock.calls[0];
|
||||
expect(String(url)).toMatch(/\/api\/v1\/admin\/quest-definitions$/);
|
||||
expect((init as RequestInit & { headers: Record<string, string> }).headers.Authorization).toBe(
|
||||
"Bearer at-123",
|
||||
);
|
||||
});
|
||||
|
||||
it("rows show `<assignees> · <recurrence> · <windows>` meta", async () => {
|
||||
await renderReady();
|
||||
const brush = screen.getByTestId("definition-10");
|
||||
expect(within(brush).getByText("Brush teeth")).toBeTruthy();
|
||||
expect(meta(brush)).toBe("All three · Daily · Morning, Evening");
|
||||
expect(meta(screen.getByTestId("definition-11"))).toBe(
|
||||
"Declan, Old · Every 2 weeks on Mon, Thu · Afternoon",
|
||||
);
|
||||
expect(brush.className).not.toContain("defs-row--inactive");
|
||||
});
|
||||
|
||||
it("an inactive definition is styled inactive with `· inactive`", async () => {
|
||||
await renderReady();
|
||||
const piano = screen.getByTestId("definition-12");
|
||||
expect(meta(piano)).toBe("Maeve · Monthly on day 15 · Afternoon · inactive");
|
||||
expect(piano.className).toContain("defs-row--inactive");
|
||||
});
|
||||
|
||||
it("filter chips filter by child", async () => {
|
||||
await renderReady();
|
||||
const chips = screen.getByRole("group", { name: "Filter by child" });
|
||||
expect(
|
||||
within(chips)
|
||||
.getAllByRole("button")
|
||||
.map((b) => b.textContent),
|
||||
).toEqual(["All", "Declan", "Maeve", "Rory"]);
|
||||
expect(within(chips).getByRole("button", { name: "All" }).className).toContain(
|
||||
"defs-chip--active",
|
||||
);
|
||||
|
||||
fireEvent.click(within(chips).getByRole("button", { name: "Maeve" }));
|
||||
expect(within(chips).getByRole("button", { name: "Maeve" }).getAttribute("aria-pressed")).toBe(
|
||||
"true",
|
||||
);
|
||||
expect(screen.queryByTestId("definition-10")).toBeTruthy();
|
||||
expect(screen.queryByTestId("definition-11")).toBeNull();
|
||||
expect(screen.queryByTestId("definition-12")).toBeTruthy();
|
||||
|
||||
fireEvent.click(within(chips).getByRole("button", { name: "Rory" }));
|
||||
expect(screen.queryByTestId("definition-10")).toBeTruthy();
|
||||
expect(screen.queryByTestId("definition-12")).toBeNull();
|
||||
|
||||
fireEvent.click(within(chips).getByRole("button", { name: "All" }));
|
||||
expect(screen.getAllByRole("listitem")).toHaveLength(3);
|
||||
});
|
||||
|
||||
it("New opens the sheet and saving POSTs the exact body, then re-fetches", async () => {
|
||||
vi.useFakeTimers({ toFake: ["Date"] });
|
||||
vi.setSystemTime(new Date(2026, 8, 23, 9, 0)); // Wed 23 Sep 2026, local
|
||||
await renderReady();
|
||||
expect(api.listFetches()).toBe(1);
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "New" }));
|
||||
const s = sheet();
|
||||
expect(within(s).getByRole("heading", { name: "New task" })).toBeTruthy();
|
||||
|
||||
fireEvent.change(within(s).getByRole("textbox", { name: "Title" }), {
|
||||
target: { value: " Feed the cat " },
|
||||
});
|
||||
fireEvent.click(within(s).getByRole("button", { name: /Assigned children/ }));
|
||||
fireEvent.click(within(s).getByRole("checkbox", { name: "Declan" }));
|
||||
fireEvent.click(within(s).getByRole("checkbox", { name: "Rory" }));
|
||||
expect(within(s).getByRole("button", { name: /Assigned children/ }).textContent).toContain(
|
||||
"Declan, Rory",
|
||||
);
|
||||
|
||||
fireEvent.change(within(s).getByRole("combobox", { name: "Repeats" }), {
|
||||
target: { value: "weekly" },
|
||||
});
|
||||
fireEvent.change(within(s).getByRole("spinbutton", { name: "Interval" }), {
|
||||
target: { value: "2" },
|
||||
});
|
||||
// Today (Wednesday) is preselected; add Saturday.
|
||||
expect(within(s).getByRole("button", { name: "Wednesday" }).getAttribute("aria-pressed")).toBe(
|
||||
"true",
|
||||
);
|
||||
fireEvent.click(within(s).getByRole("button", { name: "Saturday" }));
|
||||
|
||||
fireEvent.click(within(s).getByRole("button", { name: "Evening" }));
|
||||
fireEvent.click(within(s).getByRole("button", { name: "Morning" }));
|
||||
fireEvent.change(within(s).getByLabelText("Morning due time"), {
|
||||
target: { value: "07:15" },
|
||||
});
|
||||
|
||||
const toggle = within(s).getByRole("switch", { name: "Skip on away days" });
|
||||
expect(toggle.getAttribute("aria-checked")).toBe("true");
|
||||
fireEvent.click(toggle);
|
||||
expect(toggle.getAttribute("aria-checked")).toBe("false");
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.click(within(s).getByRole("button", { name: "Save changes" }));
|
||||
});
|
||||
|
||||
expect(api.writes()).toEqual([
|
||||
{
|
||||
method: "POST",
|
||||
path: "/api/v1/admin/quest-definitions",
|
||||
body: {
|
||||
title: "Feed the cat",
|
||||
rule: {
|
||||
rule_type: "weekly",
|
||||
interval: 2,
|
||||
weekday_set: [2, 5],
|
||||
day_of_month: null,
|
||||
nth_weekday: null,
|
||||
nth_weekday_weekday: null,
|
||||
month: null,
|
||||
start_date: "2026-09-23",
|
||||
end_date: null,
|
||||
},
|
||||
assignee_child_ids: [1, 3],
|
||||
windows: [
|
||||
["morning", "07:15"],
|
||||
["evening", null],
|
||||
],
|
||||
skip_on_away: false,
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
const created = await screen.findByTestId("definition-99");
|
||||
expect(meta(created)).toBe("Declan, Rory · Every 2 weeks on Wed, Sat · Morning, Evening");
|
||||
expect(api.listFetches()).toBe(2);
|
||||
expect(screen.queryByRole("dialog")).toBeNull();
|
||||
expect(screen.getByText("4 definitions · 3 children")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("tapping a row opens the sheet in editing state and saving PATCHes", async () => {
|
||||
await renderReady();
|
||||
const row = screen.getByTestId("definition-11");
|
||||
fireEvent.click(row);
|
||||
|
||||
// §3.1 editing state on the row.
|
||||
expect(row.className).toContain("defs-row--editing");
|
||||
expect(meta(row)).toBe("Editing");
|
||||
expect(row.getAttribute("aria-expanded")).toBe("true");
|
||||
|
||||
const s = sheet();
|
||||
expect(within(s).getByRole("heading", { name: "Edit task" })).toBeTruthy();
|
||||
expect((within(s).getByRole("textbox", { name: "Title" }) as HTMLInputElement).value).toBe(
|
||||
"Take out bins",
|
||||
);
|
||||
expect((within(s).getByRole("combobox", { name: "Repeats" }) as HTMLSelectElement).value).toBe(
|
||||
"weekly",
|
||||
);
|
||||
expect((within(s).getByLabelText("Afternoon due time") as HTMLInputElement).value).toBe(
|
||||
"16:00",
|
||||
);
|
||||
// The inactive child "Old" is shown locked, not offered as a checkbox.
|
||||
fireEvent.click(within(s).getByRole("button", { name: /Assigned children/ }));
|
||||
expect(within(s).queryByRole("checkbox", { name: "Old" })).toBeNull();
|
||||
const locked = within(s).getByTestId("inactive-assignee-4");
|
||||
expect(locked.textContent).toContain("Old");
|
||||
expect(locked.textContent).toContain("Inactive");
|
||||
expect(locked.getAttribute("aria-disabled")).toBe("true");
|
||||
fireEvent.click(within(s).getByRole("checkbox", { name: "Maeve" }));
|
||||
|
||||
fireEvent.change(within(s).getByRole("textbox", { name: "Title" }), {
|
||||
target: { value: "Take out the bins" },
|
||||
});
|
||||
fireEvent.change(within(s).getByRole("combobox", { name: "Repeats" }), {
|
||||
target: { value: "monthly" },
|
||||
});
|
||||
fireEvent.change(within(s).getByRole("spinbutton", { name: "Day of month" }), {
|
||||
target: { value: "3" },
|
||||
});
|
||||
fireEvent.change(within(s).getByRole("spinbutton", { name: "Interval" }), {
|
||||
target: { value: "1" },
|
||||
});
|
||||
fireEvent.change(within(s).getByLabelText("Afternoon due time"), {
|
||||
target: { value: "" },
|
||||
});
|
||||
fireEvent.click(within(s).getByRole("switch", { name: "Skip on away days" }));
|
||||
|
||||
// Changing the selection would drop "Old": confirm before sending.
|
||||
fireEvent.click(within(s).getByRole("button", { name: "Save changes" }));
|
||||
expect(api.writes()).toHaveLength(0);
|
||||
await act(async () => {
|
||||
fireEvent.click(within(s).getByRole("button", { name: "Unassign and save" }));
|
||||
});
|
||||
|
||||
expect(api.writes()).toEqual([
|
||||
{
|
||||
method: "PATCH",
|
||||
path: "/api/v1/admin/quest-definitions/11",
|
||||
body: {
|
||||
title: "Take out the bins",
|
||||
rule: {
|
||||
rule_type: "monthly_day",
|
||||
interval: 1,
|
||||
weekday_set: null,
|
||||
day_of_month: 3,
|
||||
nth_weekday: null,
|
||||
nth_weekday_weekday: null,
|
||||
month: null,
|
||||
start_date: "2026-01-01",
|
||||
end_date: null,
|
||||
},
|
||||
assignee_child_ids: [1, 2],
|
||||
windows: [["afternoon", null]],
|
||||
skip_on_away: true,
|
||||
},
|
||||
},
|
||||
]);
|
||||
// The icon is never sent, so it is carried through unchanged.
|
||||
expect(api.writes()[0].body).not.toHaveProperty("icon");
|
||||
|
||||
await screen.findByText("Take out the bins");
|
||||
expect(api.listFetches()).toBe(2);
|
||||
expect(screen.queryByRole("dialog")).toBeNull();
|
||||
});
|
||||
|
||||
it("editing only the title omits assignee_child_ids and keeps an inactive assignee", async () => {
|
||||
await renderReady();
|
||||
fireEvent.click(screen.getByTestId("definition-11"));
|
||||
const s = sheet();
|
||||
expect(within(s).getByText(/Also assigned \(inactive\): Old\./)).toBeTruthy();
|
||||
fireEvent.change(within(s).getByRole("textbox", { name: "Title" }), {
|
||||
target: { value: "Bins out" },
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.click(within(s).getByRole("button", { name: "Save changes" }));
|
||||
});
|
||||
|
||||
expect(screen.queryByTestId("unassign-confirm")).toBeNull();
|
||||
expect(api.writes()).toHaveLength(1);
|
||||
const body = api.writes()[0].body as Record<string, unknown>;
|
||||
expect(api.writes()[0].method).toBe("PATCH");
|
||||
expect(body.title).toBe("Bins out");
|
||||
expect(body).not.toHaveProperty("assignee_child_ids");
|
||||
|
||||
await screen.findByText("Bins out");
|
||||
expect(meta(screen.getByTestId("definition-11"))).toBe(
|
||||
"Declan, Old · Every 2 weeks on Mon, Thu · Afternoon",
|
||||
);
|
||||
});
|
||||
|
||||
it("changing the active selection with an inactive assignee asks first", async () => {
|
||||
await renderReady();
|
||||
fireEvent.click(screen.getByTestId("definition-11"));
|
||||
const s = sheet();
|
||||
fireEvent.click(within(s).getByRole("button", { name: /Assigned children/ }));
|
||||
fireEvent.click(within(s).getByRole("checkbox", { name: "Rory" }));
|
||||
|
||||
fireEvent.click(within(s).getByRole("button", { name: "Save changes" }));
|
||||
const confirm = within(s).getByTestId("unassign-confirm");
|
||||
expect(confirm.textContent).toContain("Saving will unassign Old.");
|
||||
expect(document.activeElement).toBe(confirm);
|
||||
expect(api.writes()).toHaveLength(0);
|
||||
|
||||
// Going back sends nothing and restores the normal footer.
|
||||
fireEvent.click(within(confirm).getByRole("button", { name: "Go back" }));
|
||||
expect(within(s).queryByTestId("unassign-confirm")).toBeNull();
|
||||
expect(api.writes()).toHaveLength(0);
|
||||
|
||||
fireEvent.click(within(s).getByRole("button", { name: "Save changes" }));
|
||||
await act(async () => {
|
||||
fireEvent.click(within(s).getByRole("button", { name: "Unassign and save" }));
|
||||
});
|
||||
expect(api.writes()).toHaveLength(1);
|
||||
expect((api.writes()[0].body as { assignee_child_ids: number[] }).assignee_child_ids).toEqual([
|
||||
1, 3,
|
||||
]);
|
||||
});
|
||||
|
||||
it("reverting the selection to the original omits assignee_child_ids without asking", async () => {
|
||||
await renderReady();
|
||||
fireEvent.click(screen.getByTestId("definition-11"));
|
||||
const s = sheet();
|
||||
fireEvent.click(within(s).getByRole("button", { name: /Assigned children/ }));
|
||||
fireEvent.click(within(s).getByRole("checkbox", { name: "Rory" }));
|
||||
fireEvent.click(within(s).getByRole("checkbox", { name: "Rory" }));
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.click(within(s).getByRole("button", { name: "Save changes" }));
|
||||
});
|
||||
expect(screen.queryByTestId("unassign-confirm")).toBeNull();
|
||||
expect(api.writes()).toHaveLength(1);
|
||||
expect(api.writes()[0].body).not.toHaveProperty("assignee_child_ids");
|
||||
});
|
||||
|
||||
it("focus moves into the sheet on open and returns to the row on Escape", async () => {
|
||||
await renderReady();
|
||||
const row = screen.getByTestId("definition-10");
|
||||
row.focus();
|
||||
fireEvent.click(row);
|
||||
const s = sheet();
|
||||
expect(document.activeElement).toBe(within(s).getByRole("heading", { name: "Edit task" }));
|
||||
|
||||
fireEvent.keyDown(s, { key: "Escape" });
|
||||
expect(screen.queryByRole("dialog")).toBeNull();
|
||||
expect(document.activeElement).toBe(row);
|
||||
expect(api.writes()).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("Tab and Shift+Tab wrap within the sheet", async () => {
|
||||
await renderReady();
|
||||
fireEvent.click(screen.getByTestId("definition-10"));
|
||||
const s = sheet();
|
||||
const title = within(s).getByRole("textbox", { name: "Title" });
|
||||
const save = within(s).getByRole("button", { name: "Save changes" });
|
||||
|
||||
save.focus();
|
||||
fireEvent.keyDown(s, { key: "Tab" });
|
||||
expect(document.activeElement).toBe(title);
|
||||
|
||||
fireEvent.keyDown(s, { key: "Tab", shiftKey: true });
|
||||
expect(document.activeElement).toBe(save);
|
||||
});
|
||||
|
||||
it("Escape does not close the sheet while saving", async () => {
|
||||
await renderReady();
|
||||
fireEvent.click(screen.getByTestId("definition-10"));
|
||||
let release: (response: Response) => void = () => {};
|
||||
api.fetchMock.mockImplementationOnce(
|
||||
() => new Promise<Response>((resolve) => (release = resolve)),
|
||||
);
|
||||
const s = sheet();
|
||||
await act(async () => {
|
||||
fireEvent.click(within(s).getByRole("button", { name: "Save changes" }));
|
||||
});
|
||||
fireEvent.keyDown(s, { key: "Escape" });
|
||||
expect(screen.getByRole("dialog")).toBeTruthy();
|
||||
|
||||
await act(async () => {
|
||||
release(jsonResponse(BRUSH));
|
||||
});
|
||||
await vi.waitFor(() => expect(screen.queryByRole("dialog")).toBeNull());
|
||||
});
|
||||
|
||||
it("the skip-on-away toggle reflects and persists a false value", async () => {
|
||||
await renderReady();
|
||||
fireEvent.click(screen.getByTestId("definition-11"));
|
||||
const s = sheet();
|
||||
const toggle = within(s).getByRole("switch", { name: "Skip on away days" });
|
||||
expect(toggle.getAttribute("aria-checked")).toBe("false");
|
||||
expect(toggle.className).not.toContain("defs-switch--on");
|
||||
expect(within(s).getByText("No instance when the child is absent")).toBeTruthy();
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.click(within(s).getByRole("button", { name: "Save changes" }));
|
||||
});
|
||||
expect(api.writes()).toHaveLength(1);
|
||||
expect((api.writes()[0].body as { skip_on_away: boolean }).skip_on_away).toBe(false);
|
||||
});
|
||||
|
||||
it("a 422 from save shows the API's detail and keeps the sheet open", async () => {
|
||||
await renderReady();
|
||||
fireEvent.click(screen.getByTestId("definition-10"));
|
||||
api.failNextWrite(jsonResponse({ detail: "interval must be >= 1, got 0" }, 422));
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.click(within(sheet()).getByRole("button", { name: "Save changes" }));
|
||||
});
|
||||
|
||||
expect(screen.getByTestId("sheet-error").textContent).toBe("interval must be >= 1, got 0");
|
||||
expect(screen.getByRole("dialog")).toBeTruthy();
|
||||
expect(api.listFetches()).toBe(1);
|
||||
// Save is usable again after the failure.
|
||||
expect(
|
||||
(within(sheet()).getByRole("button", { name: "Save changes" }) as HTMLButtonElement).disabled,
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("a 403 from save shows the admin-group refusal", async () => {
|
||||
await renderReady();
|
||||
fireEvent.click(screen.getByTestId("definition-10"));
|
||||
api.failNextWrite(jsonResponse({ detail: "Forbidden" }, 403));
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.click(within(sheet()).getByRole("button", { name: "Save changes" }));
|
||||
});
|
||||
expect(screen.getByTestId("sheet-error").textContent).toBe(
|
||||
"Your account is not in the nestquest-admins group.",
|
||||
);
|
||||
});
|
||||
|
||||
it("guards against a double submit", async () => {
|
||||
await renderReady();
|
||||
fireEvent.click(screen.getByTestId("definition-10"));
|
||||
const save = within(sheet()).getByRole("button", { name: "Save changes" });
|
||||
await act(async () => {
|
||||
fireEvent.click(save);
|
||||
fireEvent.click(save);
|
||||
});
|
||||
expect(api.writes()).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("an incomplete new task is not submitted", async () => {
|
||||
await renderReady();
|
||||
fireEvent.click(screen.getByRole("button", { name: "New" }));
|
||||
fireEvent.click(within(sheet()).getByRole("button", { name: "Save changes" }));
|
||||
expect(screen.getByTestId("sheet-error").textContent).toBe("Give the task a title.");
|
||||
expect(api.writes()).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("Cancel closes the sheet without writing", async () => {
|
||||
await renderReady();
|
||||
fireEvent.click(screen.getByTestId("definition-10"));
|
||||
fireEvent.click(within(sheet()).getByRole("button", { name: "Cancel" }));
|
||||
expect(screen.queryByRole("dialog")).toBeNull();
|
||||
expect(meta(screen.getByTestId("definition-10"))).toBe("All three · Daily · Morning, Evening");
|
||||
expect(api.writes()).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("a 403 on load renders the refusal", async () => {
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn(async () => jsonResponse({ detail: "Forbidden" }, 403)),
|
||||
);
|
||||
render(<DefinitionsTab />);
|
||||
expect((await screen.findByTestId("definitions-error")).textContent).toContain(
|
||||
"Your account is not in the nestquest-admins group.",
|
||||
);
|
||||
});
|
||||
|
||||
it("scroll column carries the 92px bottom padding", async () => {
|
||||
await renderReady();
|
||||
expect(screen.getByTestId("definitions-scroll").style.paddingBottom).toBe("92px");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,853 @@
|
||||
/**
|
||||
* Definitions ("Tasks") tab — list + edit sheet (design/ADMIN-SPEC.md §3.1–3.2).
|
||||
*
|
||||
* The icon picker (§3.3) and the occurrence preview are separate tasks: an
|
||||
* existing definition's icon is carried through unchanged by never sending it.
|
||||
*/
|
||||
import {
|
||||
useEffect,
|
||||
useRef,
|
||||
useState,
|
||||
type KeyboardEvent as ReactKeyboardEvent,
|
||||
type ReactNode,
|
||||
} from "react";
|
||||
import { ApiForbiddenError } from "../api/client";
|
||||
import {
|
||||
ApiRequestError,
|
||||
createDefinition,
|
||||
fetchChildren,
|
||||
fetchDefinitions,
|
||||
updateDefinition,
|
||||
type AdminChild,
|
||||
type DefinitionCreateBody,
|
||||
type DefinitionEditBody,
|
||||
type DefinitionRule,
|
||||
type QuestDefinition,
|
||||
type WindowEntry,
|
||||
type WindowName,
|
||||
} from "../api/definitions";
|
||||
import {
|
||||
MONTH_SHORT,
|
||||
WEEKDAY_SHORT,
|
||||
WINDOW_LABELS,
|
||||
WINDOW_ORDER,
|
||||
assigneeSummary,
|
||||
recurrenceSummary,
|
||||
windowSummary,
|
||||
} from "./format";
|
||||
import {
|
||||
CheckGlyph,
|
||||
ChevronDownGlyph,
|
||||
ChevronRightGlyph,
|
||||
PlusGlyph,
|
||||
TaskGlyph,
|
||||
} from "./glyphs";
|
||||
import "./DefinitionsTab.css";
|
||||
|
||||
/** ADMIN-SPEC §1: every scroll column ends with 92px so content clears the tab bar. */
|
||||
export const SCROLL_BOTTOM_PADDING = "92px";
|
||||
|
||||
type Repeats = "daily" | "weekly" | "monthly" | "yearly" | "custom";
|
||||
|
||||
const REPEATS_OPTIONS: { value: Repeats; label: string }[] = [
|
||||
{ value: "daily", label: "Daily" },
|
||||
{ value: "weekly", label: "Weekly" },
|
||||
{ value: "monthly", label: "Monthly" },
|
||||
{ value: "yearly", label: "Yearly" },
|
||||
{ value: "custom", label: "Custom" },
|
||||
];
|
||||
|
||||
const REPEATS_BY_RULE: Record<DefinitionRule["rule_type"], Repeats> = {
|
||||
daily: "daily",
|
||||
weekly: "weekly",
|
||||
monthly_day: "monthly",
|
||||
monthly_weekday: "monthly",
|
||||
yearly: "yearly",
|
||||
custom_days: "custom",
|
||||
};
|
||||
|
||||
const WEEKDAY_LONG = ["Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday"];
|
||||
|
||||
interface Draft {
|
||||
title: string;
|
||||
assigneeIds: number[];
|
||||
repeats: Repeats;
|
||||
interval: string;
|
||||
weekdays: number[];
|
||||
dayOfMonth: string;
|
||||
month: string;
|
||||
/** An existing "nth weekday" monthly rule, kept while Repeats stays Monthly. */
|
||||
monthlyWeekday: { nth: number; weekday: number } | null;
|
||||
windows: WindowName[];
|
||||
dueTimes: Record<WindowName, string>;
|
||||
skipOnAway: boolean;
|
||||
startDate: string;
|
||||
endDate: string | null;
|
||||
}
|
||||
|
||||
type LoadState =
|
||||
| { kind: "loading" }
|
||||
| { kind: "error"; message: string }
|
||||
| { kind: "ready"; definitions: QuestDefinition[]; children: AdminChild[] };
|
||||
|
||||
/** `null` = closed, `"new"` = creating, a definition = editing it. */
|
||||
type SheetTarget = null | "new" | QuestDefinition;
|
||||
|
||||
function pad2(n: number): string {
|
||||
return String(n).padStart(2, "0");
|
||||
}
|
||||
|
||||
function localIsoDate(date: Date): string {
|
||||
return `${date.getFullYear()}-${pad2(date.getMonth() + 1)}-${pad2(date.getDate())}`;
|
||||
}
|
||||
|
||||
function emptyDueTimes(): Record<WindowName, string> {
|
||||
return { morning: "", afternoon: "", evening: "" };
|
||||
}
|
||||
|
||||
function newDraft(activeChildren: AdminChild[]): Draft {
|
||||
const today = new Date();
|
||||
return {
|
||||
title: "",
|
||||
assigneeIds: activeChildren.length === 1 ? [activeChildren[0].id] : [],
|
||||
repeats: "daily",
|
||||
interval: "1",
|
||||
weekdays: [(today.getDay() + 6) % 7],
|
||||
dayOfMonth: String(today.getDate()),
|
||||
month: String(today.getMonth() + 1),
|
||||
monthlyWeekday: null,
|
||||
windows: [],
|
||||
dueTimes: emptyDueTimes(),
|
||||
// Mirrors the API's create default: no instance on an away day.
|
||||
skipOnAway: true,
|
||||
startDate: localIsoDate(today),
|
||||
endDate: null,
|
||||
};
|
||||
}
|
||||
|
||||
function draftFrom(definition: QuestDefinition, activeChildren: AdminChild[]): Draft {
|
||||
const { rule } = definition;
|
||||
const today = new Date();
|
||||
const active = new Set(activeChildren.map((child) => child.id));
|
||||
const dueTimes = emptyDueTimes();
|
||||
for (const w of definition.windows) dueTimes[w.window] = w.due_time ?? "";
|
||||
return {
|
||||
title: definition.title,
|
||||
// Inactive children cannot be submitted (the API rejects them with 422).
|
||||
assigneeIds: definition.assignees.map((a) => a.id).filter((id) => active.has(id)),
|
||||
repeats: REPEATS_BY_RULE[rule.rule_type],
|
||||
interval: String(rule.interval),
|
||||
weekdays:
|
||||
rule.rule_type === "weekly" || rule.rule_type === "custom_days"
|
||||
? [...(rule.weekday_set ?? [])]
|
||||
: [(today.getDay() + 6) % 7],
|
||||
dayOfMonth: String(rule.day_of_month ?? today.getDate()),
|
||||
month: String(rule.month ?? today.getMonth() + 1),
|
||||
monthlyWeekday:
|
||||
rule.rule_type === "monthly_weekday"
|
||||
? { nth: rule.nth_weekday ?? 1, weekday: rule.nth_weekday_weekday ?? 0 }
|
||||
: null,
|
||||
windows: WINDOW_ORDER.filter((w) => definition.windows.some((d) => d.window === w)),
|
||||
dueTimes,
|
||||
skipOnAway: definition.skip_on_away,
|
||||
startDate: rule.start_date,
|
||||
endDate: rule.end_date,
|
||||
};
|
||||
}
|
||||
|
||||
function parseWhole(value: string, min: number, max: number): number | null {
|
||||
if (!/^\d+$/.test(value.trim())) return null;
|
||||
const n = Number(value);
|
||||
return n >= min && n <= max ? n : null;
|
||||
}
|
||||
|
||||
/** Build the request rule from the draft, or return a message naming what to fix. */
|
||||
function buildRule(draft: Draft): DefinitionRule | string {
|
||||
const interval = parseWhole(draft.interval, 1, 999);
|
||||
if (interval === null) return "Interval must be a whole number of at least 1.";
|
||||
const rule: DefinitionRule = {
|
||||
rule_type: "daily",
|
||||
interval,
|
||||
weekday_set: null,
|
||||
day_of_month: null,
|
||||
nth_weekday: null,
|
||||
nth_weekday_weekday: null,
|
||||
month: null,
|
||||
start_date: draft.startDate,
|
||||
end_date: draft.endDate,
|
||||
};
|
||||
switch (draft.repeats) {
|
||||
case "daily":
|
||||
return rule;
|
||||
case "weekly":
|
||||
case "custom":
|
||||
if (draft.weekdays.length === 0) return "Pick at least one day.";
|
||||
return {
|
||||
...rule,
|
||||
rule_type: draft.repeats === "weekly" ? "weekly" : "custom_days",
|
||||
weekday_set: [...draft.weekdays].sort((a, b) => a - b),
|
||||
};
|
||||
case "monthly": {
|
||||
if (draft.monthlyWeekday) {
|
||||
return {
|
||||
...rule,
|
||||
rule_type: "monthly_weekday",
|
||||
nth_weekday: draft.monthlyWeekday.nth,
|
||||
nth_weekday_weekday: draft.monthlyWeekday.weekday,
|
||||
};
|
||||
}
|
||||
const day = parseWhole(draft.dayOfMonth, 1, 31);
|
||||
if (day === null) return "Day of month must be 1 to 31.";
|
||||
return { ...rule, rule_type: "monthly_day", day_of_month: day };
|
||||
}
|
||||
case "yearly": {
|
||||
const month = parseWhole(draft.month, 1, 12);
|
||||
const day = parseWhole(draft.dayOfMonth, 1, 31);
|
||||
if (month === null || day === null) return "Pick a month and a day (1 to 31).";
|
||||
return { ...rule, rule_type: "yearly", month, day_of_month: day };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* `keepsAssignees`: an edit whose active selection is unchanged sends no
|
||||
* assignee list, so an empty selection is fine when inactive children remain.
|
||||
*/
|
||||
function buildBody(draft: Draft, keepsAssignees = false): DefinitionCreateBody | string {
|
||||
const title = draft.title.trim();
|
||||
if (!title) return "Give the task a title.";
|
||||
if (draft.assigneeIds.length === 0 && !keepsAssignees) return "Assign at least one child.";
|
||||
if (draft.windows.length === 0) return "Pick at least one window.";
|
||||
const rule = buildRule(draft);
|
||||
if (typeof rule === "string") return rule;
|
||||
const windows: WindowEntry[] = WINDOW_ORDER.filter((w) => draft.windows.includes(w)).map(
|
||||
(w) => [w, draft.dueTimes[w] || null],
|
||||
);
|
||||
return {
|
||||
title,
|
||||
rule,
|
||||
assignee_child_ids: [...draft.assigneeIds],
|
||||
windows,
|
||||
skip_on_away: draft.skipOnAway,
|
||||
};
|
||||
}
|
||||
|
||||
function saveErrorMessage(error: unknown): string {
|
||||
if (error instanceof ApiForbiddenError) return error.message;
|
||||
if (error instanceof ApiRequestError && error.detail) return error.detail;
|
||||
return "The task could not be saved. Check your connection and try again.";
|
||||
}
|
||||
|
||||
function toggle<T>(list: T[], value: T): T[] {
|
||||
return list.includes(value) ? list.filter((item) => item !== value) : [...list, value];
|
||||
}
|
||||
|
||||
function sameIds(a: number[], b: number[]): boolean {
|
||||
return a.length === b.length && a.every((id) => b.includes(id));
|
||||
}
|
||||
|
||||
const FOCUSABLE =
|
||||
'button:not([disabled]), input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [href], [tabindex]:not([tabindex="-1"])';
|
||||
|
||||
/* ── List ───────────────────────────────────────────────────────────── */
|
||||
|
||||
function FilterChips({
|
||||
options,
|
||||
filter,
|
||||
onChange,
|
||||
}: {
|
||||
options: AdminChild[];
|
||||
filter: number | "all";
|
||||
onChange: (filter: number | "all") => void;
|
||||
}) {
|
||||
const chips: { key: number | "all"; label: string }[] = [
|
||||
{ key: "all", label: "All" },
|
||||
...options.map((child) => ({ key: child.id, label: child.display_name })),
|
||||
];
|
||||
return (
|
||||
<div className="defs-chips" role="group" aria-label="Filter by child">
|
||||
{chips.map((chip) => (
|
||||
<button
|
||||
key={chip.key}
|
||||
type="button"
|
||||
className={chip.key === filter ? "defs-chip defs-chip--active" : "defs-chip"}
|
||||
aria-pressed={chip.key === filter}
|
||||
onClick={() => onChange(chip.key)}
|
||||
>
|
||||
{chip.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function DefinitionRow({
|
||||
definition,
|
||||
activeChildren,
|
||||
editing,
|
||||
onOpen,
|
||||
}: {
|
||||
definition: QuestDefinition;
|
||||
activeChildren: AdminChild[];
|
||||
editing: boolean;
|
||||
onOpen: () => void;
|
||||
}) {
|
||||
let meta = [
|
||||
assigneeSummary(definition.assignees, activeChildren),
|
||||
recurrenceSummary(definition.rule),
|
||||
windowSummary(definition.windows),
|
||||
].join(" · ");
|
||||
if (!definition.is_active) meta += " · inactive";
|
||||
let className = "defs-row";
|
||||
if (!definition.is_active) className += " defs-row--inactive";
|
||||
if (editing) className += " defs-row--editing";
|
||||
return (
|
||||
<li>
|
||||
<button
|
||||
type="button"
|
||||
className={className}
|
||||
data-testid={`definition-${definition.id}`}
|
||||
aria-expanded={editing}
|
||||
onClick={onOpen}
|
||||
>
|
||||
<span className="defs-row-glyph">
|
||||
<TaskGlyph />
|
||||
</span>
|
||||
<span className="defs-row-text">
|
||||
<span className="defs-row-title">{definition.title}</span>
|
||||
<span className="defs-row-meta">{editing ? "Editing" : meta}</span>
|
||||
</span>
|
||||
<span className="defs-row-chevron">
|
||||
{editing ? <ChevronDownGlyph /> : <ChevronRightGlyph />}
|
||||
</span>
|
||||
</button>
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
/* ── Edit sheet ─────────────────────────────────────────────────────── */
|
||||
|
||||
function Field({ label, id, children }: { label: string; id: string; children: ReactNode }) {
|
||||
return (
|
||||
<div className="defs-field" role="group" aria-labelledby={id}>
|
||||
<span className="defs-label" id={id}>
|
||||
{label}
|
||||
</span>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function EditSheet({
|
||||
target,
|
||||
activeChildren,
|
||||
onCancel,
|
||||
onSaved,
|
||||
}: {
|
||||
target: "new" | QuestDefinition;
|
||||
activeChildren: AdminChild[];
|
||||
onCancel: () => void;
|
||||
onSaved: () => void;
|
||||
}) {
|
||||
const [draft, setDraft] = useState<Draft>(() =>
|
||||
target === "new" ? newDraft(activeChildren) : draftFrom(target, activeChildren),
|
||||
);
|
||||
// The active selection as loaded: while it is unchanged, the PATCH omits
|
||||
// assignee_child_ids, because the API replaces the whole set and inactive
|
||||
// children cannot be sent back.
|
||||
const [originalActiveIds] = useState(() => draft.assigneeIds);
|
||||
const inactiveAssignees =
|
||||
target === "new"
|
||||
? []
|
||||
: target.assignees.filter((a) => !activeChildren.some((child) => child.id === a.id));
|
||||
const [pickerOpen, setPickerOpen] = useState(false);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [confirming, setConfirming] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
// A ref, not state: two taps in one frame must not both see `saving === false`.
|
||||
const inFlight = useRef(false);
|
||||
const sheetRef = useRef<HTMLDivElement>(null);
|
||||
const titleRef = useRef<HTMLHeadingElement>(null);
|
||||
const confirmRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Modal focus: move in on open, hand back to the trigger on close.
|
||||
useEffect(() => {
|
||||
const trigger = document.activeElement as HTMLElement | null;
|
||||
titleRef.current?.focus();
|
||||
return () => {
|
||||
if (trigger && trigger.isConnected) trigger.focus();
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (confirming) confirmRef.current?.focus();
|
||||
}, [confirming]);
|
||||
|
||||
function onKeyDown(event: ReactKeyboardEvent<HTMLDivElement>) {
|
||||
if (event.key === "Escape") {
|
||||
event.preventDefault();
|
||||
if (!inFlight.current) onCancel();
|
||||
return;
|
||||
}
|
||||
if (event.key !== "Tab" || !sheetRef.current) return;
|
||||
const focusable = Array.from(sheetRef.current.querySelectorAll<HTMLElement>(FOCUSABLE));
|
||||
if (focusable.length === 0) return;
|
||||
const first = focusable[0];
|
||||
const last = focusable[focusable.length - 1];
|
||||
const current = document.activeElement;
|
||||
const outside = !focusable.includes(current as HTMLElement);
|
||||
if (event.shiftKey && (current === first || outside)) {
|
||||
event.preventDefault();
|
||||
last.focus();
|
||||
} else if (!event.shiftKey && (current === last || outside)) {
|
||||
event.preventDefault();
|
||||
first.focus();
|
||||
}
|
||||
}
|
||||
|
||||
const update = (patch: Partial<Draft>) => setDraft((d) => ({ ...d, ...patch }));
|
||||
|
||||
const selectedAssignees = activeChildren
|
||||
.filter((child) => draft.assigneeIds.includes(child.id))
|
||||
.map((child) => ({ id: child.id, display_name: child.display_name }));
|
||||
const orderedWindows = WINDOW_ORDER.filter((w) => draft.windows.includes(w));
|
||||
|
||||
async function save(confirmed = false) {
|
||||
if (inFlight.current) return;
|
||||
const keepsAssignees = target !== "new" && sameIds(draft.assigneeIds, originalActiveIds);
|
||||
const body = buildBody(draft, keepsAssignees);
|
||||
if (typeof body === "string") {
|
||||
setError(body);
|
||||
setConfirming(false);
|
||||
return;
|
||||
}
|
||||
if (target !== "new" && !keepsAssignees && inactiveAssignees.length > 0 && !confirmed) {
|
||||
setError(null);
|
||||
setConfirming(true);
|
||||
return;
|
||||
}
|
||||
setConfirming(false);
|
||||
inFlight.current = true;
|
||||
setSaving(true);
|
||||
setError(null);
|
||||
try {
|
||||
if (target === "new") {
|
||||
await createDefinition(body);
|
||||
} else {
|
||||
const edit: DefinitionEditBody = { ...body };
|
||||
if (keepsAssignees) delete edit.assignee_child_ids;
|
||||
await updateDefinition(target.id, edit);
|
||||
}
|
||||
onSaved();
|
||||
} catch (err) {
|
||||
setError(saveErrorMessage(err));
|
||||
inFlight.current = false;
|
||||
setSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="defs-sheet-layer">
|
||||
<div
|
||||
className="defs-scrim"
|
||||
data-testid="sheet-scrim"
|
||||
onClick={() => {
|
||||
if (!inFlight.current) onCancel();
|
||||
}}
|
||||
/>
|
||||
<div
|
||||
ref={sheetRef}
|
||||
className="defs-sheet"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="defs-sheet-title"
|
||||
onKeyDown={onKeyDown}
|
||||
>
|
||||
<div className="defs-grabber" aria-hidden="true" />
|
||||
<h3 className="defs-sheet-title" id="defs-sheet-title" ref={titleRef} tabIndex={-1}>
|
||||
{target === "new" ? "New task" : "Edit task"}
|
||||
</h3>
|
||||
<div className="defs-fields">
|
||||
<Field label="Title" id="defs-label-title">
|
||||
<input
|
||||
className="defs-input"
|
||||
aria-labelledby="defs-label-title"
|
||||
value={draft.title}
|
||||
onChange={(e) => update({ title: e.target.value })}
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<Field label="Assigned children" id="defs-label-children">
|
||||
<button
|
||||
type="button"
|
||||
className="defs-select-row"
|
||||
aria-expanded={pickerOpen}
|
||||
aria-labelledby="defs-label-children defs-children-summary"
|
||||
onClick={() => setPickerOpen((open) => !open)}
|
||||
>
|
||||
<span id="defs-children-summary">
|
||||
{selectedAssignees.length > 0
|
||||
? assigneeSummary(selectedAssignees, activeChildren)
|
||||
: "Choose children"}
|
||||
</span>
|
||||
{pickerOpen ? <ChevronDownGlyph /> : <ChevronRightGlyph />}
|
||||
</button>
|
||||
{pickerOpen ? (
|
||||
<ul className="defs-picker" aria-label="Children">
|
||||
{activeChildren.map((child) => {
|
||||
const checked = draft.assigneeIds.includes(child.id);
|
||||
return (
|
||||
<li key={child.id}>
|
||||
<button
|
||||
type="button"
|
||||
role="checkbox"
|
||||
aria-checked={checked}
|
||||
className="defs-picker-option"
|
||||
onClick={() => update({ assigneeIds: toggle(draft.assigneeIds, child.id) })}
|
||||
>
|
||||
<span>{child.display_name}</span>
|
||||
<span className="defs-picker-check">{checked ? <CheckGlyph /> : null}</span>
|
||||
</button>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
{inactiveAssignees.map((assignee) => (
|
||||
<li
|
||||
key={assignee.id}
|
||||
className="defs-picker-option defs-picker-option--locked"
|
||||
aria-disabled="true"
|
||||
data-testid={`inactive-assignee-${assignee.id}`}
|
||||
>
|
||||
<span>{assignee.display_name}</span>
|
||||
<span className="defs-picker-note">Inactive</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
) : null}
|
||||
{inactiveAssignees.length > 0 ? (
|
||||
<p className="defs-hint">
|
||||
Also assigned (inactive): {inactiveAssignees.map((a) => a.display_name).join(", ")}.
|
||||
Kept unless you change the selection.
|
||||
</p>
|
||||
) : null}
|
||||
</Field>
|
||||
|
||||
<Field label="Repeats" id="defs-label-repeats">
|
||||
<div className="defs-repeats">
|
||||
<select
|
||||
className="defs-input"
|
||||
aria-label="Repeats"
|
||||
value={draft.repeats}
|
||||
onChange={(e) =>
|
||||
update({
|
||||
repeats: e.target.value as Repeats,
|
||||
// Leaving Monthly drops a carried nth-weekday rule for good.
|
||||
monthlyWeekday: e.target.value === "monthly" ? draft.monthlyWeekday : null,
|
||||
})
|
||||
}
|
||||
>
|
||||
{REPEATS_OPTIONS.map((option) => (
|
||||
<option key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<label className="defs-interval">
|
||||
<span>Every</span>
|
||||
<input
|
||||
className="defs-input"
|
||||
type="number"
|
||||
min={1}
|
||||
aria-label="Interval"
|
||||
value={draft.interval}
|
||||
onChange={(e) => update({ interval: e.target.value })}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
{draft.repeats === "weekly" || draft.repeats === "custom" ? (
|
||||
<div className="defs-weekdays" role="group" aria-label="Days">
|
||||
{WEEKDAY_SHORT.map((label, day) => (
|
||||
<button
|
||||
key={label}
|
||||
type="button"
|
||||
aria-pressed={draft.weekdays.includes(day)}
|
||||
aria-label={WEEKDAY_LONG[day]}
|
||||
className={
|
||||
draft.weekdays.includes(day) ? "defs-day defs-day--on" : "defs-day"
|
||||
}
|
||||
onClick={() => update({ weekdays: toggle(draft.weekdays, day) })}
|
||||
>
|
||||
{label.charAt(0)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
{draft.repeats === "monthly" && draft.monthlyWeekday ? (
|
||||
<p className="defs-hint">
|
||||
{recurrenceSummary({
|
||||
rule_type: "monthly_weekday",
|
||||
interval: parseWhole(draft.interval, 1, 999) ?? 1,
|
||||
weekday_set: null,
|
||||
day_of_month: null,
|
||||
nth_weekday: draft.monthlyWeekday.nth,
|
||||
nth_weekday_weekday: draft.monthlyWeekday.weekday,
|
||||
month: null,
|
||||
start_date: draft.startDate,
|
||||
end_date: draft.endDate,
|
||||
})}
|
||||
</p>
|
||||
) : null}
|
||||
{draft.repeats === "yearly" ? (
|
||||
<select
|
||||
className="defs-input"
|
||||
aria-label="Month"
|
||||
value={draft.month}
|
||||
onChange={(e) => update({ month: e.target.value })}
|
||||
>
|
||||
{MONTH_SHORT.map((label, index) => (
|
||||
<option key={label} value={String(index + 1)}>
|
||||
{label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
) : null}
|
||||
{(draft.repeats === "monthly" && !draft.monthlyWeekday) || draft.repeats === "yearly" ? (
|
||||
<input
|
||||
className="defs-input"
|
||||
type="number"
|
||||
min={1}
|
||||
max={31}
|
||||
aria-label="Day of month"
|
||||
value={draft.dayOfMonth}
|
||||
onChange={(e) => update({ dayOfMonth: e.target.value })}
|
||||
/>
|
||||
) : null}
|
||||
</Field>
|
||||
|
||||
<Field label="Due time" id="defs-label-due">
|
||||
{orderedWindows.length === 0 ? (
|
||||
<p className="defs-hint">Pick a window to set its due time.</p>
|
||||
) : (
|
||||
<div className="defs-due-times">
|
||||
{orderedWindows.map((w) => (
|
||||
<label key={w} className="defs-due">
|
||||
<span>{WINDOW_LABELS[w]}</span>
|
||||
<input
|
||||
className="defs-input"
|
||||
type="time"
|
||||
aria-label={`${WINDOW_LABELS[w]} due time`}
|
||||
value={draft.dueTimes[w]}
|
||||
onChange={(e) =>
|
||||
update({ dueTimes: { ...draft.dueTimes, [w]: e.target.value } })
|
||||
}
|
||||
/>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</Field>
|
||||
|
||||
<Field label="Window" id="defs-label-window">
|
||||
<div className="defs-segmented">
|
||||
{WINDOW_ORDER.map((w) => (
|
||||
<button
|
||||
key={w}
|
||||
type="button"
|
||||
aria-pressed={draft.windows.includes(w)}
|
||||
className={
|
||||
draft.windows.includes(w) ? "defs-segment defs-segment--on" : "defs-segment"
|
||||
}
|
||||
onClick={() => update({ windows: toggle(draft.windows, w) })}
|
||||
>
|
||||
{WINDOW_LABELS[w]}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</Field>
|
||||
|
||||
<div className="defs-toggle-row">
|
||||
<div>
|
||||
<div className="defs-toggle-title" id="defs-label-skip">
|
||||
Skip on away days
|
||||
</div>
|
||||
<div className="defs-toggle-sub">No instance when the child is absent</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
role="switch"
|
||||
aria-checked={draft.skipOnAway}
|
||||
aria-labelledby="defs-label-skip"
|
||||
className={draft.skipOnAway ? "defs-switch defs-switch--on" : "defs-switch"}
|
||||
onClick={() => update({ skipOnAway: !draft.skipOnAway })}
|
||||
>
|
||||
<span className="defs-switch-knob" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error ? (
|
||||
<p className="defs-sheet-error" role="alert" data-testid="sheet-error">
|
||||
{error}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
{confirming ? (
|
||||
<div
|
||||
className="defs-confirm"
|
||||
role="group"
|
||||
aria-labelledby="defs-confirm-text"
|
||||
data-testid="unassign-confirm"
|
||||
ref={confirmRef}
|
||||
tabIndex={-1}
|
||||
>
|
||||
<p id="defs-confirm-text">
|
||||
Saving will unassign{" "}
|
||||
{inactiveAssignees.map((a) => a.display_name).join(", ")}. Inactive children can't be
|
||||
assigned again until they are reactivated.
|
||||
</p>
|
||||
<div className="defs-sheet-footer">
|
||||
<button type="button" className="defs-cancel" onClick={() => setConfirming(false)}>
|
||||
Go back
|
||||
</button>
|
||||
<button type="button" className="defs-save" onClick={() => save(true)}>
|
||||
Unassign and save
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="defs-sheet-footer">
|
||||
<button type="button" className="defs-cancel" onClick={onCancel} disabled={saving}>
|
||||
Cancel
|
||||
</button>
|
||||
<button type="button" className="defs-save" onClick={() => save()} disabled={saving}>
|
||||
{saving ? "Saving…" : "Save changes"}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ── Screen ─────────────────────────────────────────────────────────── */
|
||||
|
||||
export default function DefinitionsTab() {
|
||||
const [state, setState] = useState<LoadState>({ kind: "loading" });
|
||||
const [attempt, setAttempt] = useState(0);
|
||||
const [filter, setFilter] = useState<number | "all">("all");
|
||||
const [sheet, setSheet] = useState<SheetTarget>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
// Keep the list on screen during a post-save re-fetch; spin only on first load.
|
||||
setState((current) => (current.kind === "ready" ? current : { kind: "loading" }));
|
||||
Promise.all([fetchDefinitions(), fetchChildren()])
|
||||
.then(([definitions, children]) => {
|
||||
if (!cancelled) setState({ kind: "ready", definitions, children });
|
||||
})
|
||||
.catch((error: unknown) => {
|
||||
if (cancelled) return;
|
||||
const message =
|
||||
error instanceof ApiForbiddenError
|
||||
? error.message
|
||||
: "Tasks could not be loaded. Check your connection and try again.";
|
||||
setState({ kind: "error", message });
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [attempt]);
|
||||
|
||||
const activeChildren =
|
||||
state.kind === "ready"
|
||||
? [...state.children]
|
||||
.filter((child) => child.is_active)
|
||||
.sort((a, b) => a.sort_order - b.sort_order)
|
||||
: [];
|
||||
|
||||
let sub = "";
|
||||
let body;
|
||||
if (state.kind === "loading") {
|
||||
body = (
|
||||
<div className="defs-status" role="status" data-testid="definitions-loading">
|
||||
Loading tasks…
|
||||
</div>
|
||||
);
|
||||
} else if (state.kind === "error") {
|
||||
body = (
|
||||
<div className="defs-status defs-status--error" role="alert" data-testid="definitions-error">
|
||||
<p>{state.message}</p>
|
||||
<button type="button" className="defs-retry" onClick={() => setAttempt((n) => n + 1)}>
|
||||
Try again
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
} else {
|
||||
const { definitions } = state;
|
||||
sub = `${definitions.length} definitions · ${activeChildren.length} children`;
|
||||
const shown =
|
||||
filter === "all"
|
||||
? definitions
|
||||
: definitions.filter((d) => d.assignees.some((a) => a.id === filter));
|
||||
const editingId = sheet !== null && sheet !== "new" ? sheet.id : null;
|
||||
body = (
|
||||
<>
|
||||
<FilterChips options={activeChildren} filter={filter} onChange={setFilter} />
|
||||
{shown.length > 0 ? (
|
||||
<ul className="defs-card defs-list" aria-label="Task definitions">
|
||||
{shown.map((definition) => (
|
||||
<DefinitionRow
|
||||
key={definition.id}
|
||||
definition={definition}
|
||||
activeChildren={activeChildren}
|
||||
editing={definition.id === editingId}
|
||||
onOpen={() => setSheet(definition)}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
) : (
|
||||
<div className="defs-card defs-empty">No tasks yet.</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="defs-screen">
|
||||
<header className="defs-header">
|
||||
<div>
|
||||
<h2 className="defs-title">Tasks</h2>
|
||||
<p className="defs-sub">{sub}</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="defs-new"
|
||||
disabled={state.kind !== "ready"}
|
||||
onClick={() => setSheet("new")}
|
||||
>
|
||||
<PlusGlyph />
|
||||
New
|
||||
</button>
|
||||
</header>
|
||||
<div
|
||||
className="defs-scroll"
|
||||
data-testid="definitions-scroll"
|
||||
style={{ paddingBottom: SCROLL_BOTTOM_PADDING }}
|
||||
>
|
||||
{body}
|
||||
</div>
|
||||
{sheet !== null && state.kind === "ready" ? (
|
||||
<EditSheet
|
||||
key={sheet === "new" ? "new" : sheet.id}
|
||||
target={sheet}
|
||||
activeChildren={activeChildren}
|
||||
onCancel={() => setSheet(null)}
|
||||
onSaved={() => {
|
||||
setSheet(null);
|
||||
setAttempt((n) => n + 1);
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
/**
|
||||
* List-row summaries for the Definitions tab (ADMIN-SPEC §3.1):
|
||||
* `<assignees> · <recurrence> · <windows>`.
|
||||
*/
|
||||
import type {
|
||||
AdminChild,
|
||||
DefinitionAssignee,
|
||||
DefinitionRule,
|
||||
DefinitionWindow,
|
||||
WindowName,
|
||||
} from "../api/definitions";
|
||||
|
||||
export const WINDOW_ORDER: WindowName[] = ["morning", "afternoon", "evening"];
|
||||
|
||||
export const WINDOW_LABELS: Record<WindowName, string> = {
|
||||
morning: "Morning",
|
||||
afternoon: "Afternoon",
|
||||
evening: "Evening",
|
||||
};
|
||||
|
||||
/** Index 0 = Monday .. 6 = Sunday, the core's weekday numbering. */
|
||||
export const WEEKDAY_SHORT = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"];
|
||||
|
||||
export const MONTH_SHORT = [
|
||||
"Jan", "Feb", "Mar", "Apr", "May", "Jun",
|
||||
"Jul", "Aug", "Sep", "Oct", "Nov", "Dec",
|
||||
];
|
||||
|
||||
const COUNT_WORDS = ["zero", "one", "two", "three", "four", "five", "six", "seven", "eight", "nine", "ten"];
|
||||
|
||||
/**
|
||||
* `Declan`, `Declan, Maeve`, or `All three` when every active child (two or
|
||||
* more) is assigned.
|
||||
*/
|
||||
export function assigneeSummary(
|
||||
assignees: Pick<DefinitionAssignee, "id" | "display_name">[],
|
||||
activeChildren: AdminChild[],
|
||||
): string {
|
||||
if (assignees.length === 0) return "Unassigned";
|
||||
const assigned = new Set(assignees.map((a) => a.id));
|
||||
const everyone =
|
||||
activeChildren.length > 1 &&
|
||||
assigned.size === activeChildren.length &&
|
||||
activeChildren.every((child) => assigned.has(child.id));
|
||||
if (everyone) {
|
||||
const word = COUNT_WORDS[activeChildren.length] ?? String(activeChildren.length);
|
||||
return `All ${word}`;
|
||||
}
|
||||
return assignees.map((a) => a.display_name).join(", ");
|
||||
}
|
||||
|
||||
function weekdayList(days: number[] | null): string {
|
||||
return [...(days ?? [])]
|
||||
.sort((a, b) => a - b)
|
||||
.map((day) => WEEKDAY_SHORT[day])
|
||||
.join(", ");
|
||||
}
|
||||
|
||||
function ordinal(n: number): string {
|
||||
if (n === -1) return "last";
|
||||
const suffix = n === 1 ? "st" : n === 2 ? "nd" : n === 3 ? "rd" : "th";
|
||||
return `${n}${suffix}`;
|
||||
}
|
||||
|
||||
function every(interval: number, one: string, unit: string): string {
|
||||
return interval === 1 ? one : `Every ${interval} ${unit}`;
|
||||
}
|
||||
|
||||
export function recurrenceSummary(rule: DefinitionRule): string {
|
||||
const n = rule.interval;
|
||||
switch (rule.rule_type) {
|
||||
case "daily":
|
||||
return every(n, "Daily", "days");
|
||||
case "weekly":
|
||||
return `${every(n, "Weekly", "weeks")} on ${weekdayList(rule.weekday_set)}`;
|
||||
case "monthly_day":
|
||||
return `${every(n, "Monthly", "months")} on day ${rule.day_of_month}`;
|
||||
case "monthly_weekday":
|
||||
return `${every(n, "Monthly", "months")} on the ${ordinal(rule.nth_weekday ?? 1)} ${
|
||||
WEEKDAY_SHORT[rule.nth_weekday_weekday ?? 0]
|
||||
}`;
|
||||
case "yearly": {
|
||||
const month = MONTH_SHORT[(rule.month ?? 1) - 1];
|
||||
const when = rule.day_of_month ? `on ${month} ${rule.day_of_month}` : `in ${month}`;
|
||||
return `${every(n, "Yearly", "years")} ${when}`;
|
||||
}
|
||||
case "custom_days":
|
||||
return `Custom${n === 1 ? "" : ` every ${n} weeks`} (${weekdayList(rule.weekday_set)})`;
|
||||
}
|
||||
}
|
||||
|
||||
export function windowSummary(windows: Pick<DefinitionWindow, "window">[]): string {
|
||||
const names = new Set(windows.map((w) => w.window));
|
||||
return WINDOW_ORDER.filter((w) => names.has(w))
|
||||
.map((w) => WINDOW_LABELS[w])
|
||||
.join(", ");
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
/**
|
||||
* Inline stroke glyphs for the Definitions tab (Lucide-style 24px grid),
|
||||
* CDN-free like the Today tab's. Definition icons arrive with the icon
|
||||
* picker task; until then every row carries the generic task glyph.
|
||||
*/
|
||||
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 TaskGlyph({ size = 17 }: { size?: number }) {
|
||||
return (
|
||||
<Glyph size={size}>
|
||||
<rect x="8" y="2" width="8" height="4" rx="1" />
|
||||
<path d="M16 4h2a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2h2" />
|
||||
<path d="m9 14 2 2 4-4" />
|
||||
</Glyph>
|
||||
);
|
||||
}
|
||||
|
||||
export function PlusGlyph({ size = 16 }: { size?: number }) {
|
||||
return (
|
||||
<Glyph size={size}>
|
||||
<path d="M12 5v14M5 12h14" />
|
||||
</Glyph>
|
||||
);
|
||||
}
|
||||
|
||||
export function ChevronRightGlyph({ size = 16 }: { size?: number }) {
|
||||
return (
|
||||
<Glyph size={size}>
|
||||
<path d="m9 18 6-6-6-6" />
|
||||
</Glyph>
|
||||
);
|
||||
}
|
||||
|
||||
export function ChevronDownGlyph({ size = 16 }: { size?: number }) {
|
||||
return (
|
||||
<Glyph size={size}>
|
||||
<path d="m6 9 6 6 6-6" />
|
||||
</Glyph>
|
||||
);
|
||||
}
|
||||
|
||||
export function CheckGlyph({ size = 16 }: { size?: number }) {
|
||||
return (
|
||||
<Glyph size={size}>
|
||||
<path d="M20 6 9 17l-5-5" />
|
||||
</Glyph>
|
||||
);
|
||||
}
|
||||
@@ -6,9 +6,12 @@
|
||||
:root {
|
||||
--nq-brand-blue: #3b3ab8;
|
||||
--nq-brand-purple: #a035cc;
|
||||
--nq-brand-gradient: linear-gradient(135deg, var(--nq-brand-blue), var(--nq-brand-purple));
|
||||
--nq-brand-gradient-h: linear-gradient(90deg, var(--nq-brand-blue), var(--nq-brand-purple));
|
||||
|
||||
--nq-a-page: #f4f4f6;
|
||||
--nq-a-surface: #ffffff;
|
||||
--nq-a-selected: #ebebf8;
|
||||
--nq-a-border: #e4e4ea;
|
||||
--nq-a-divider: #ededf1;
|
||||
|
||||
@@ -16,8 +19,37 @@
|
||||
--nq-a-ink-secondary: #52525e;
|
||||
--nq-a-ink-tertiary: #70707e;
|
||||
|
||||
--nq-a-success: #15803d;
|
||||
--nq-a-danger: #dc2626;
|
||||
--nq-a-danger-strong: #b91c1c;
|
||||
--nq-a-danger-bg: #fee2e2;
|
||||
--nq-a-info-bg: #dbeafe;
|
||||
--nq-a-info-ink: #1e3a8a;
|
||||
--nq-a-info-icon: #1d4ed8;
|
||||
|
||||
--nq-a-font: Nunito, system-ui, sans-serif;
|
||||
--nq-a-size-screen: 24px;
|
||||
--nq-a-size-stat: 22px;
|
||||
--nq-a-size-row: 13.5px;
|
||||
--nq-a-size-meta: 11px;
|
||||
--nq-a-size-label: 10px;
|
||||
--nq-a-track-label: 0.1em;
|
||||
|
||||
--nq-a-density-gap: 10px;
|
||||
--nq-a-density-card-pad: 13px;
|
||||
--nq-a-density-row-pad: 11px 13px;
|
||||
--nq-a-density-radius: 8px;
|
||||
|
||||
--nq-a-tabbar-height: 70px;
|
||||
--nq-a-tap-min: 44px;
|
||||
--nq-a-radius-pill: 9999px;
|
||||
--nq-a-radius-sheet: 20px 20px 0 0;
|
||||
--nq-a-sheet-shadow: 0 -8px 28px rgba(9, 9, 11, 0.18);
|
||||
--nq-a-progress-height: 6px;
|
||||
|
||||
--nq-ease-out: cubic-bezier(0, 0, 0.2, 1);
|
||||
--nq-dur-base: 200ms;
|
||||
--nq-dur-modal: 350ms;
|
||||
}
|
||||
|
||||
* {
|
||||
|
||||
@@ -0,0 +1,300 @@
|
||||
/* Today tab — compact density (design/ADMIN-SPEC.md §2). Mobile-first at the
|
||||
390x844 reference; on wider viewports the column is capped and centred. */
|
||||
|
||||
.today-screen {
|
||||
width: 100%;
|
||||
max-width: 640px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
/* §2.1 header */
|
||||
.today-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
background: var(--nq-a-surface);
|
||||
border-bottom: 1px solid var(--nq-a-border);
|
||||
padding: 14px 16px;
|
||||
}
|
||||
|
||||
.today-title {
|
||||
margin: 0;
|
||||
font-size: var(--nq-a-size-screen);
|
||||
font-weight: 800;
|
||||
letter-spacing: -0.02em;
|
||||
}
|
||||
|
||||
.today-sub {
|
||||
margin: 2px 0 0;
|
||||
min-height: 1.4em;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: var(--nq-a-ink-secondary);
|
||||
}
|
||||
|
||||
.today-icon-button {
|
||||
flex: none;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
padding: 0;
|
||||
border: 1px solid var(--nq-a-border);
|
||||
border-radius: var(--nq-a-density-radius);
|
||||
background: var(--nq-a-surface);
|
||||
color: var(--nq-a-ink-secondary);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
/* Scroll column: 14px 16px, bottom 92px (set inline, ADMIN-SPEC §1). */
|
||||
.today-scroll {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--nq-a-density-gap);
|
||||
padding: 14px 16px;
|
||||
}
|
||||
|
||||
.today-card {
|
||||
background: var(--nq-a-surface);
|
||||
border: 1px solid var(--nq-a-border);
|
||||
border-radius: var(--nq-a-density-radius);
|
||||
}
|
||||
|
||||
.today-list {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.today-list > li + li {
|
||||
border-top: 1px solid var(--nq-a-divider);
|
||||
}
|
||||
|
||||
/* §2.2 stat row */
|
||||
.today-stats {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.today-stat {
|
||||
padding: 12px;
|
||||
background: var(--nq-a-surface);
|
||||
border: 1px solid var(--nq-a-border);
|
||||
border-radius: var(--nq-a-density-radius);
|
||||
}
|
||||
|
||||
.today-stat-value {
|
||||
font-size: var(--nq-a-size-stat);
|
||||
font-weight: 800;
|
||||
line-height: 1.1;
|
||||
}
|
||||
|
||||
.today-stat-value--done {
|
||||
color: var(--nq-brand-blue);
|
||||
}
|
||||
|
||||
.today-stat-value--remaining {
|
||||
color: var(--nq-a-ink);
|
||||
}
|
||||
|
||||
.today-stat-value--overdue {
|
||||
color: var(--nq-a-danger);
|
||||
}
|
||||
|
||||
.today-stat-label {
|
||||
margin-top: 2px;
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
color: var(--nq-a-ink-secondary);
|
||||
}
|
||||
|
||||
/* §2.3 child rows */
|
||||
.today-child {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
min-height: var(--nq-a-tap-min);
|
||||
padding: var(--nq-a-density-row-pad);
|
||||
}
|
||||
|
||||
.today-avatar {
|
||||
flex: none;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
border-radius: 7px;
|
||||
background: var(--nq-brand-gradient);
|
||||
color: #fff;
|
||||
font-size: 13px;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.today-child--away .today-avatar {
|
||||
background: #9898a8;
|
||||
}
|
||||
|
||||
.today-child-text {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.today-child-name {
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.today-child-meta {
|
||||
font-size: var(--nq-a-size-meta);
|
||||
font-weight: 600;
|
||||
color: var(--nq-a-ink-secondary);
|
||||
}
|
||||
|
||||
.today-child-meta--done {
|
||||
color: var(--nq-a-success);
|
||||
}
|
||||
|
||||
.today-progress {
|
||||
flex: none;
|
||||
width: 82px;
|
||||
height: var(--nq-a-progress-height);
|
||||
border-radius: var(--nq-a-radius-pill);
|
||||
background: var(--nq-a-divider);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.today-progress-fill {
|
||||
height: 100%;
|
||||
border-radius: inherit;
|
||||
background: var(--nq-brand-gradient-h);
|
||||
}
|
||||
|
||||
/* §2.5 needs attention */
|
||||
.today-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);
|
||||
}
|
||||
|
||||
.today-event {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
min-height: var(--nq-a-tap-min);
|
||||
padding: var(--nq-a-density-row-pad);
|
||||
}
|
||||
|
||||
.today-event-glyph {
|
||||
flex: none;
|
||||
display: flex;
|
||||
color: var(--nq-a-success);
|
||||
}
|
||||
|
||||
.today-event-glyph--overdue {
|
||||
color: var(--nq-a-danger);
|
||||
}
|
||||
|
||||
.today-event-text {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.today-event-title {
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.today-event-meta {
|
||||
font-size: var(--nq-a-size-meta);
|
||||
font-weight: 600;
|
||||
color: var(--nq-a-ink-secondary);
|
||||
}
|
||||
|
||||
.today-event-meta--overdue {
|
||||
color: var(--nq-a-danger);
|
||||
}
|
||||
|
||||
.today-action {
|
||||
flex: none;
|
||||
min-height: var(--nq-a-tap-min);
|
||||
padding: 0 4px;
|
||||
border: 0;
|
||||
background: none;
|
||||
font-family: inherit;
|
||||
font-size: 12.5px;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.today-action--done {
|
||||
color: var(--nq-brand-blue);
|
||||
}
|
||||
|
||||
.today-action--undo {
|
||||
color: var(--nq-brand-purple);
|
||||
}
|
||||
|
||||
.today-empty {
|
||||
padding: var(--nq-a-density-card-pad);
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--nq-a-ink-secondary);
|
||||
}
|
||||
|
||||
/* §2.6 permission note */
|
||||
.today-note {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 8px;
|
||||
padding: 10px 12px;
|
||||
border-radius: var(--nq-a-density-radius);
|
||||
background: var(--nq-a-info-bg);
|
||||
color: var(--nq-a-info-ink);
|
||||
font-size: 11.5px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.today-note-glyph {
|
||||
flex: none;
|
||||
display: flex;
|
||||
color: var(--nq-a-info-icon);
|
||||
}
|
||||
|
||||
/* Loading / error */
|
||||
.today-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);
|
||||
}
|
||||
|
||||
.today-status--error {
|
||||
color: var(--nq-a-danger-strong);
|
||||
}
|
||||
|
||||
.today-status--error p {
|
||||
margin: 0 0 10px;
|
||||
}
|
||||
|
||||
.today-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,223 @@
|
||||
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { cleanup, fireEvent, render, screen, within } from "@testing-library/react";
|
||||
import TodayTab, { PERMISSION_NOTE } from "./TodayTab";
|
||||
import type { AdminSnapshot, SnapshotInstance } from "../api/snapshot";
|
||||
import { storeTokens } from "../auth/oidc";
|
||||
|
||||
function instance(overrides: Partial<SnapshotInstance>): SnapshotInstance {
|
||||
return {
|
||||
id: 0,
|
||||
definition_id: 1,
|
||||
child_id: 1,
|
||||
title: "Quest",
|
||||
icon: null,
|
||||
window: "morning",
|
||||
due_time: null,
|
||||
state: "open",
|
||||
overdue: false,
|
||||
completed_at: null,
|
||||
on_time: null,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
const SNAPSHOT: AdminSnapshot = {
|
||||
today_iso: "2026-09-23",
|
||||
cycle_day: 5,
|
||||
children: [
|
||||
{
|
||||
child_id: 1,
|
||||
child_name: "Declan",
|
||||
present: true,
|
||||
next_present: null,
|
||||
due_today: 7,
|
||||
completed_today: 3,
|
||||
remaining_today: 4,
|
||||
completion_pct: 43,
|
||||
instances: [
|
||||
instance({ id: 11, title: "Brush teeth", due_time: "07:30", overdue: true }),
|
||||
instance({
|
||||
id: 12,
|
||||
title: "Make bed",
|
||||
state: "completed",
|
||||
completed_at: "2026-09-23T11:04:00Z",
|
||||
on_time: true,
|
||||
}),
|
||||
instance({ id: 13, title: "Homework", window: "afternoon", due_time: "16:00" }),
|
||||
// Overdue but already completed: not counted as overdue.
|
||||
instance({
|
||||
id: 14,
|
||||
title: "Pack bag",
|
||||
state: "completed",
|
||||
overdue: true,
|
||||
completed_at: "2026-09-23T10:00:00Z",
|
||||
on_time: false,
|
||||
}),
|
||||
],
|
||||
},
|
||||
{
|
||||
child_id: 2,
|
||||
child_name: "Maeve",
|
||||
present: true,
|
||||
next_present: null,
|
||||
due_today: 4,
|
||||
completed_today: 4,
|
||||
remaining_today: 0,
|
||||
completion_pct: 100,
|
||||
instances: [],
|
||||
},
|
||||
{
|
||||
child_id: 3,
|
||||
child_name: "Rory",
|
||||
present: false,
|
||||
next_present: "2026-10-01",
|
||||
due_today: 0,
|
||||
completed_today: 0,
|
||||
remaining_today: 0,
|
||||
completion_pct: 0,
|
||||
instances: [],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
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>;
|
||||
|
||||
async function renderReady() {
|
||||
fetchMock.mockResolvedValue(jsonResponse(SNAPSHOT));
|
||||
render(<TodayTab />);
|
||||
await screen.findByText("Declan");
|
||||
}
|
||||
|
||||
describe("TodayTab", () => {
|
||||
beforeAll(() => {
|
||||
// A negative-offset zone: `new Date("2026-09-23")` would read as Sep 22 here.
|
||||
vi.stubEnv("TZ", "America/Los_Angeles");
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
vi.unstubAllEnvs();
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
sessionStorage.clear();
|
||||
storeTokens({ access_token: "at-123", expires_in: 600 });
|
||||
fetchMock = vi.fn();
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it("fetches the admin snapshot with the Bearer token", async () => {
|
||||
await renderReady();
|
||||
const [url, init] = fetchMock.mock.calls[0];
|
||||
expect(String(url)).toMatch(/\/api\/v1\/admin\/snapshot$/);
|
||||
expect(init.headers.Authorization).toBe("Bearer at-123");
|
||||
});
|
||||
|
||||
it("header shows the weekday, date and cycle day", async () => {
|
||||
await renderReady();
|
||||
expect(screen.getByRole("heading", { name: "Today" })).toBeTruthy();
|
||||
expect(screen.getByText("Wednesday, September 23 · cycle day 5 of 14")).toBeTruthy();
|
||||
expect(screen.getByRole("button", { name: "Settings" })).toBeTruthy();
|
||||
});
|
||||
|
||||
it("stat row shows done, remaining and overdue totals", async () => {
|
||||
await renderReady();
|
||||
expect(screen.getByTestId("stat-done").textContent).toBe("7Done");
|
||||
expect(screen.getByTestId("stat-remaining").textContent).toBe("4Remaining");
|
||||
expect(screen.getByTestId("stat-overdue").textContent).toBe("1Overdue");
|
||||
});
|
||||
|
||||
it("present child row shows progress meta and fill", async () => {
|
||||
await renderReady();
|
||||
const row = screen.getByTestId("child-1");
|
||||
expect(within(row).getByText("3 of 7 · 1 overdue")).toBeTruthy();
|
||||
const bar = within(row).getByRole("progressbar");
|
||||
expect(bar.getAttribute("aria-valuenow")).toBe("43");
|
||||
expect((bar.firstElementChild as HTMLElement).style.width).toBe("43%");
|
||||
expect(row.className).not.toContain("today-child--away");
|
||||
});
|
||||
|
||||
it("all-done child renders the success state", async () => {
|
||||
await renderReady();
|
||||
const meta = within(screen.getByTestId("child-2")).getByText("4 of 4 · all done");
|
||||
expect(meta.className).toContain("today-child-meta--done");
|
||||
});
|
||||
|
||||
it("away child shows the return date and the flat avatar", async () => {
|
||||
await renderReady();
|
||||
const row = screen.getByTestId("child-3");
|
||||
expect(within(row).getByText("Away · returns Oct 1")).toBeTruthy();
|
||||
expect(row.className).toContain("today-child--away");
|
||||
expect(within(row).getByTestId("child-avatar").textContent).toBe("R");
|
||||
});
|
||||
|
||||
it("needs-attention lists overdue and completed rows with their actions", async () => {
|
||||
await renderReady();
|
||||
const list = screen.getByRole("list", { name: "Needs attention" });
|
||||
const overdue = within(list).getByTestId("attention-11");
|
||||
expect(within(overdue).getByText("Brush teeth · Declan")).toBeTruthy();
|
||||
expect(within(overdue).getByText("Overdue since 7:30 AM")).toBeTruthy();
|
||||
expect(within(overdue).getByRole("button", { name: "Mark done" })).toBeTruthy();
|
||||
|
||||
const completed = within(list).getByTestId("attention-12");
|
||||
expect(within(completed).getByText("Make bed · Declan")).toBeTruthy();
|
||||
// 11:04Z is 4:04 AM in Los Angeles (PDT).
|
||||
expect(within(completed).getByText("Completed 4:04 AM")).toBeTruthy();
|
||||
expect(within(completed).getByRole("button", { name: "Undo" })).toBeTruthy();
|
||||
|
||||
// Open, not-overdue instances do not need attention.
|
||||
expect(within(list).queryByTestId("attention-13")).toBeNull();
|
||||
// No actor in the payload, so no "from panel".
|
||||
expect(list.textContent).not.toContain("from panel");
|
||||
});
|
||||
|
||||
it("permission note carries the exact copy", async () => {
|
||||
await renderReady();
|
||||
expect(PERMISSION_NOTE).toBe(
|
||||
"Undo is admin-only. A panel tap can never reverse a completion.",
|
||||
);
|
||||
expect(screen.getByTestId("permission-note").textContent).toBe(PERMISSION_NOTE);
|
||||
});
|
||||
|
||||
it("scroll column ends with 92px bottom padding", async () => {
|
||||
await renderReady();
|
||||
expect(screen.getByTestId("today-scroll").style.paddingBottom).toBe("92px");
|
||||
});
|
||||
|
||||
it("shows a loading state while the snapshot is in flight", () => {
|
||||
fetchMock.mockReturnValue(new Promise<Response>(() => {}));
|
||||
render(<TodayTab />);
|
||||
expect(screen.getByTestId("today-loading").textContent).toContain("Loading");
|
||||
expect(screen.getByTestId("today-scroll").style.paddingBottom).toBe("92px");
|
||||
});
|
||||
|
||||
it("shows an error state on a failed fetch and retries", async () => {
|
||||
fetchMock.mockResolvedValueOnce(new Response("boom", { status: 500 }));
|
||||
render(<TodayTab />);
|
||||
const error = await screen.findByTestId("today-error");
|
||||
expect(error.getAttribute("role")).toBe("alert");
|
||||
expect(error.textContent).toContain("could not be loaded");
|
||||
|
||||
fetchMock.mockResolvedValueOnce(jsonResponse(SNAPSHOT));
|
||||
fireEvent.click(screen.getByRole("button", { name: "Try again" }));
|
||||
expect(await screen.findByText("Declan")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("shows the refusal message when the API answers 403", async () => {
|
||||
fetchMock.mockResolvedValueOnce(new Response("", { status: 403 }));
|
||||
render(<TodayTab />);
|
||||
const error = await screen.findByTestId("today-error");
|
||||
expect(error.textContent).toContain("nestquest-admins");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,277 @@
|
||||
/**
|
||||
* Today tab — compact density (design/ADMIN-SPEC.md §2.1–2.3, §2.5–2.6).
|
||||
*/
|
||||
import { useEffect, useState } from "react";
|
||||
import {
|
||||
fetchSnapshot,
|
||||
type AdminSnapshot,
|
||||
type SnapshotChild,
|
||||
type SnapshotInstance,
|
||||
} from "../api/snapshot";
|
||||
import { ApiForbiddenError } from "../api/client";
|
||||
import {
|
||||
formatClockTime,
|
||||
formatDueTime,
|
||||
formatLongDate,
|
||||
formatShortDate,
|
||||
} from "./format";
|
||||
import {
|
||||
AlertCircleGlyph,
|
||||
CheckCircleGlyph,
|
||||
InfoGlyph,
|
||||
SettingsGlyph,
|
||||
} from "./glyphs";
|
||||
import "./TodayTab.css";
|
||||
|
||||
const CYCLE_LENGTH_DAYS = 14;
|
||||
|
||||
/** ADMIN-SPEC §1: every scroll column ends with 92px so content clears the tab bar. */
|
||||
export const SCROLL_BOTTOM_PADDING = "92px";
|
||||
|
||||
export const PERMISSION_NOTE =
|
||||
"Undo is admin-only. A panel tap can never reverse a completion.";
|
||||
|
||||
type LoadState =
|
||||
| { kind: "loading" }
|
||||
| { kind: "error"; message: string }
|
||||
| { kind: "ready"; snapshot: AdminSnapshot };
|
||||
|
||||
function isOverdueOpen(instance: SnapshotInstance): boolean {
|
||||
return instance.overdue && instance.state === "open";
|
||||
}
|
||||
|
||||
function ScreenHeader({ sub }: { sub: string }) {
|
||||
return (
|
||||
<header className="today-header">
|
||||
<div>
|
||||
<h2 className="today-title">Today</h2>
|
||||
<p className="today-sub">{sub}</p>
|
||||
</div>
|
||||
{/* Settings screen is a later task; the control is not wired yet. */}
|
||||
<button type="button" className="today-icon-button" aria-label="Settings">
|
||||
<SettingsGlyph />
|
||||
</button>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
|
||||
function StatRow({ snapshot }: { snapshot: AdminSnapshot }) {
|
||||
const done = snapshot.children.reduce((sum, c) => sum + c.completed_today, 0);
|
||||
const remaining = snapshot.children.reduce((sum, c) => sum + c.remaining_today, 0);
|
||||
const overdue = snapshot.children.reduce(
|
||||
(sum, c) => sum + c.instances.filter(isOverdueOpen).length,
|
||||
0,
|
||||
);
|
||||
const stats = [
|
||||
{ key: "done", label: "Done", value: done },
|
||||
{ key: "remaining", label: "Remaining", value: remaining },
|
||||
{ key: "overdue", label: "Overdue", value: overdue },
|
||||
];
|
||||
return (
|
||||
<div className="today-stats">
|
||||
{stats.map((stat) => (
|
||||
<div key={stat.key} className="today-stat" data-testid={`stat-${stat.key}`}>
|
||||
<div className={`today-stat-value today-stat-value--${stat.key}`}>
|
||||
{stat.value}
|
||||
</div>
|
||||
<div className="today-stat-label">{stat.label}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ChildRow({ child }: { child: SnapshotChild }) {
|
||||
const overdue = child.instances.filter(isOverdueOpen).length;
|
||||
const allDone = child.due_today > 0 && child.completed_today === child.due_today;
|
||||
let meta: string;
|
||||
let metaClass = "today-child-meta";
|
||||
if (!child.present) {
|
||||
meta = child.next_present
|
||||
? `Away · returns ${formatShortDate(child.next_present)}`
|
||||
: "Away";
|
||||
} else if (allDone) {
|
||||
meta = `${child.completed_today} of ${child.due_today} · all done`;
|
||||
metaClass += " today-child-meta--done";
|
||||
} else {
|
||||
meta = `${child.completed_today} of ${child.due_today} · ${overdue} overdue`;
|
||||
}
|
||||
const pct = Math.max(0, Math.min(100, child.completion_pct));
|
||||
// ADMIN-SPEC §2.3 also asks for a lowercase `override` pill when an absence
|
||||
// comes from a presence override. The snapshot carries no field saying
|
||||
// where an absence came from, so the pill is omitted rather than guessed.
|
||||
return (
|
||||
<li
|
||||
className={child.present ? "today-child" : "today-child today-child--away"}
|
||||
data-testid={`child-${child.child_id}`}
|
||||
>
|
||||
<span className="today-avatar" data-testid="child-avatar" aria-hidden="true">
|
||||
{child.child_name.charAt(0).toUpperCase()}
|
||||
</span>
|
||||
<div className="today-child-text">
|
||||
<div className="today-child-name">{child.child_name}</div>
|
||||
<div className={metaClass}>{meta}</div>
|
||||
</div>
|
||||
<div
|
||||
className="today-progress"
|
||||
role="progressbar"
|
||||
aria-label={`${child.child_name} progress`}
|
||||
aria-valuemin={0}
|
||||
aria-valuemax={100}
|
||||
aria-valuenow={pct}
|
||||
>
|
||||
<div className="today-progress-fill" style={{ width: `${pct}%` }} />
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
interface AttentionItem {
|
||||
instance: SnapshotInstance;
|
||||
childName: string;
|
||||
}
|
||||
|
||||
function attentionItems(snapshot: AdminSnapshot): AttentionItem[] {
|
||||
const all = snapshot.children.flatMap((child) =>
|
||||
child.instances.map((instance) => ({ instance, childName: child.child_name })),
|
||||
);
|
||||
const overdue = all.filter((item) => isOverdueOpen(item.instance));
|
||||
const completed = all
|
||||
.filter((item) => item.instance.state === "completed" && item.instance.completed_at)
|
||||
.sort((a, b) =>
|
||||
(b.instance.completed_at ?? "").localeCompare(a.instance.completed_at ?? ""),
|
||||
);
|
||||
return [...overdue, ...completed];
|
||||
}
|
||||
|
||||
function AttentionRow({ item }: { item: AttentionItem }) {
|
||||
const { instance, childName } = item;
|
||||
const overdue = isOverdueOpen(instance);
|
||||
// TODO(e57facc2): wire "Mark done" / "Undo" mutations and live updates.
|
||||
// The admin plane has no complete route yet, so both actions are inert here.
|
||||
return (
|
||||
<li className="today-event" data-testid={`attention-${instance.id}`}>
|
||||
<span
|
||||
className={overdue ? "today-event-glyph today-event-glyph--overdue" : "today-event-glyph"}
|
||||
>
|
||||
{overdue ? <AlertCircleGlyph /> : <CheckCircleGlyph />}
|
||||
</span>
|
||||
<div className="today-event-text">
|
||||
<div className="today-event-title">
|
||||
{instance.title} · {childName}
|
||||
</div>
|
||||
{overdue ? (
|
||||
<div className="today-event-meta today-event-meta--overdue">
|
||||
{instance.due_time
|
||||
? `Overdue since ${formatDueTime(instance.due_time)}`
|
||||
: "Overdue"}
|
||||
</div>
|
||||
) : (
|
||||
// The snapshot carries no completion actor, so "from panel" is not printed.
|
||||
<div className="today-event-meta">
|
||||
Completed {formatClockTime(instance.completed_at as string)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className={overdue ? "today-action today-action--done" : "today-action today-action--undo"}
|
||||
>
|
||||
{overdue ? "Mark done" : "Undo"}
|
||||
</button>
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
function PermissionNote() {
|
||||
return (
|
||||
<div className="today-note" data-testid="permission-note">
|
||||
<span className="today-note-glyph">
|
||||
<InfoGlyph />
|
||||
</span>
|
||||
<span>{PERMISSION_NOTE}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function TodayTab() {
|
||||
const [state, setState] = useState<LoadState>({ kind: "loading" });
|
||||
const [attempt, setAttempt] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
setState({ kind: "loading" });
|
||||
fetchSnapshot()
|
||||
.then((snapshot) => {
|
||||
if (!cancelled) setState({ kind: "ready", snapshot });
|
||||
})
|
||||
.catch((error: unknown) => {
|
||||
if (cancelled) return;
|
||||
const message =
|
||||
error instanceof ApiForbiddenError
|
||||
? error.message
|
||||
: "Today's snapshot 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="today-status" role="status" data-testid="today-loading">
|
||||
Loading today…
|
||||
</div>
|
||||
);
|
||||
} else if (state.kind === "error") {
|
||||
body = (
|
||||
<div className="today-status today-status--error" role="alert" data-testid="today-error">
|
||||
<p>{state.message}</p>
|
||||
<button type="button" className="today-retry" onClick={() => setAttempt((n) => n + 1)}>
|
||||
Try again
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
} else {
|
||||
const { snapshot } = state;
|
||||
sub = `${formatLongDate(snapshot.today_iso)} · cycle day ${snapshot.cycle_day} of ${CYCLE_LENGTH_DAYS}`;
|
||||
const attention = attentionItems(snapshot);
|
||||
body = (
|
||||
<>
|
||||
<StatRow snapshot={snapshot} />
|
||||
<ul className="today-card today-list" aria-label="Children">
|
||||
{snapshot.children.map((child) => (
|
||||
<ChildRow key={child.child_id} child={child} />
|
||||
))}
|
||||
</ul>
|
||||
<h3 className="today-section-label">Needs attention</h3>
|
||||
{attention.length > 0 ? (
|
||||
<ul className="today-card today-list" aria-label="Needs attention">
|
||||
{attention.map((item) => (
|
||||
<AttentionRow key={item.instance.id} item={item} />
|
||||
))}
|
||||
</ul>
|
||||
) : (
|
||||
<div className="today-card today-empty">Nothing needs attention.</div>
|
||||
)}
|
||||
<PermissionNote />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="today-screen">
|
||||
<ScreenHeader sub={sub} />
|
||||
<div
|
||||
className="today-scroll"
|
||||
data-testid="today-scroll"
|
||||
style={{ paddingBottom: SCROLL_BOTTOM_PADDING }}
|
||||
>
|
||||
{body}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
/**
|
||||
* Date/time formatting for the Today tab.
|
||||
*
|
||||
* Calendar dates ("YYYY-MM-DD") are parsed by their components and formatted
|
||||
* in UTC, so the printed day never shifts with the viewer's offset — unlike
|
||||
* `new Date("YYYY-MM-DD")`, which is UTC midnight and reads as the previous
|
||||
* day in negative-offset zones.
|
||||
*/
|
||||
|
||||
const LOCALE = "en-US";
|
||||
|
||||
function calendarDate(iso: string): Date {
|
||||
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 new Date(Date.UTC(Number(year), Number(month) - 1, Number(day)));
|
||||
}
|
||||
|
||||
function format(date: Date, options: Intl.DateTimeFormatOptions): string {
|
||||
// Newer ICU puts a narrow no-break space before AM/PM; normalise it.
|
||||
return new Intl.DateTimeFormat(LOCALE, options)
|
||||
.format(date)
|
||||
.replace(/ /g, " ");
|
||||
}
|
||||
|
||||
/** "Wednesday, September 23" */
|
||||
export function formatLongDate(iso: string): string {
|
||||
const date = calendarDate(iso);
|
||||
const weekday = format(date, { weekday: "long", timeZone: "UTC" });
|
||||
const monthDay = format(date, { month: "long", day: "numeric", timeZone: "UTC" });
|
||||
return `${weekday}, ${monthDay}`;
|
||||
}
|
||||
|
||||
/** "Sep 23" */
|
||||
export function formatShortDate(iso: string): string {
|
||||
return format(calendarDate(iso), { month: "short", day: "numeric", timeZone: "UTC" });
|
||||
}
|
||||
|
||||
/** "09:00" -> "9:00 AM" (a wall-clock time, no zone conversion). */
|
||||
export function formatDueTime(hhmm: string): string {
|
||||
const match = /^(\d{2}):(\d{2})/.exec(hhmm);
|
||||
if (!match) return hhmm;
|
||||
const date = new Date(Date.UTC(2000, 0, 1, Number(match[1]), Number(match[2])));
|
||||
return format(date, { hour: "numeric", minute: "2-digit", timeZone: "UTC" });
|
||||
}
|
||||
|
||||
/** A UTC ISO timestamp shown as the viewer's local time, "7:04 AM". */
|
||||
export function formatClockTime(isoTimestamp: string): string {
|
||||
return format(new Date(isoTimestamp), { hour: "numeric", minute: "2-digit" });
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
/**
|
||||
* Inline stroke glyphs for the Today tab (Lucide-style 24px grid). The
|
||||
* shipped Lucide subset is task b50b048f; these keep this screen CDN-free
|
||||
* until then.
|
||||
*/
|
||||
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 SettingsGlyph({ size = 18 }: { size?: number }) {
|
||||
return (
|
||||
<Glyph size={size}>
|
||||
<circle cx="12" cy="12" r="3" />
|
||||
<path d="M12 2v3M12 19v3M2 12h3M19 12h3M4.9 4.9l2.1 2.1M17 17l2.1 2.1M4.9 19.1 7 17M17 7l2.1-2.1" />
|
||||
</Glyph>
|
||||
);
|
||||
}
|
||||
|
||||
export function InfoGlyph({ size = 16 }: { size?: number }) {
|
||||
return (
|
||||
<Glyph size={size}>
|
||||
<circle cx="12" cy="12" r="10" />
|
||||
<path d="M12 16v-4M12 8h.01" />
|
||||
</Glyph>
|
||||
);
|
||||
}
|
||||
|
||||
export function AlertCircleGlyph({ size = 18 }: { size?: number }) {
|
||||
return (
|
||||
<Glyph size={size}>
|
||||
<circle cx="12" cy="12" r="10" />
|
||||
<path d="M12 8v4M12 16h.01" />
|
||||
</Glyph>
|
||||
);
|
||||
}
|
||||
|
||||
export function CheckCircleGlyph({ size = 18 }: { size?: number }) {
|
||||
return (
|
||||
<Glyph size={size}>
|
||||
<circle cx="12" cy="12" r="10" />
|
||||
<path d="m9 12 2 2 4-4" />
|
||||
</Glyph>
|
||||
);
|
||||
}
|
||||
+71
-18
@@ -30,10 +30,10 @@ hand-rolled here), and the call goes to
|
||||
``set_quest_definition_active``. The title policy, the window-name
|
||||
and due-time policy, the assignee checks, the whole-set window
|
||||
replacement and the never-hard-delete policy (deactivation is the only
|
||||
removal path) all live in the core. Assignment is NOT settable
|
||||
through the API: the core's edit path takes no assignees. The edit
|
||||
route forwards only SUPPLIED fields (``exclude_unset``), like the
|
||||
children edit.
|
||||
removal path) all live in the core. The edit route forwards only
|
||||
SUPPLIED fields (``exclude_unset``), like the children edit; a supplied
|
||||
``assignee_child_ids`` list REPLACES the whole assignee set inside the
|
||||
core edit's one transaction (and its one regeneration).
|
||||
|
||||
The presence routes follow the same pattern over
|
||||
:mod:`nestquest_core.presence_management` — the ONE presence write
|
||||
@@ -182,8 +182,9 @@ deliberately narrow):
|
||||
set_active on an unknown id) is mapped to 404.
|
||||
- A core ``ValueError`` reporting a NON-EXISTENT DEFINITION (edit or
|
||||
set_active on an unknown id — the core prefixes that error with
|
||||
``definition_id:``) is mapped to 404. Create's rejected-assignee
|
||||
error names a child from the BODY, not a path id, so it maps to 422.
|
||||
``definition_id:``) is mapped to 404. Create's and edit's
|
||||
rejected-assignee error names a child from the BODY, not a path id,
|
||||
so it maps to 422.
|
||||
- Every OTHER core ``ValueError`` — an empty display name, an explicit
|
||||
``null`` colour/avatar_ref (clearing is not supported), a reorder
|
||||
list that is not a complete permutation of the children (missing,
|
||||
@@ -251,7 +252,13 @@ import datetime
|
||||
from typing import Annotated, Literal, NoReturn
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request, Response
|
||||
from pydantic import BaseModel, field_validator, model_validator
|
||||
from pydantic import (
|
||||
BaseModel,
|
||||
StrictBool,
|
||||
StrictInt,
|
||||
field_validator,
|
||||
model_validator,
|
||||
)
|
||||
|
||||
from api import transitions as api_transitions
|
||||
from api.auth import require_admin
|
||||
@@ -363,11 +370,18 @@ class AdminQuestDefinitionResponse(BaseModel):
|
||||
description: str | None
|
||||
icon: str | None
|
||||
is_active: bool
|
||||
skip_on_away: bool
|
||||
rule: AdminRuleResponse
|
||||
assignees: list[AdminAssigneeResponse]
|
||||
windows: list[AdminDefinitionWindowResponse]
|
||||
|
||||
|
||||
class AdminQuestDefinitionListResponse(BaseModel):
|
||||
"""The definitions list: every definition, active AND inactive, by id."""
|
||||
|
||||
definitions: list[AdminQuestDefinitionResponse]
|
||||
|
||||
|
||||
class AdminChildCreateRequest(BaseModel):
|
||||
"""The body of ``POST /api/v1/admin/children``.
|
||||
|
||||
@@ -444,18 +458,22 @@ class AdminQuestDefinitionCreateRequest(BaseModel):
|
||||
``title``, ``rule``, ``assignee_child_ids`` and ``windows`` are
|
||||
required (the core rejects an empty title, a non-list or empty
|
||||
assignee/window list, and unknown window names); ``description``
|
||||
and ``icon`` are optional and default to NULL through the core.
|
||||
``windows`` entries are a window name (``morning``) or a two-item
|
||||
and ``icon`` are optional and default to NULL through the core, and
|
||||
``skip_on_away`` is optional and defaults to true (no instance on a
|
||||
date the child is absent). ``windows`` entries are a window name (``morning``) or a two-item
|
||||
``[window, due_time]`` pair whose due time is a strict 24-hour
|
||||
HH:MM (or ``null`` for none) — both forms the core normalizes.
|
||||
Assignee ids are strict JSON integers: a boolean, float or numeric
|
||||
string is 422, never coerced to a child id.
|
||||
"""
|
||||
|
||||
title: str
|
||||
rule: AdminRuleRequest
|
||||
assignee_child_ids: list[int]
|
||||
assignee_child_ids: list[StrictInt]
|
||||
windows: list[str | tuple[str, str | None]]
|
||||
description: str | None = None
|
||||
icon: str | None = None
|
||||
skip_on_away: StrictBool = True
|
||||
|
||||
|
||||
class AdminQuestDefinitionEditRequest(BaseModel):
|
||||
@@ -467,10 +485,13 @@ class AdminQuestDefinitionEditRequest(BaseModel):
|
||||
field stays unchanged. An explicit ``null`` ``description``/
|
||||
``icon`` IS passed and CLEARS the stored value (the core supports
|
||||
clearing here, unlike the children edit); an explicit ``null``
|
||||
``rule`` or ``windows`` is passed and rejected by the core. A
|
||||
supplied ``windows`` list REPLACES the whole window set, and
|
||||
``assignees`` are deliberately absent — assignment is not settable
|
||||
through this route.
|
||||
``rule``, ``windows`` or ``assignee_child_ids`` is passed and
|
||||
rejected by the core. A supplied ``windows`` list REPLACES the
|
||||
whole window set, and a supplied ``assignee_child_ids`` list
|
||||
REPLACES the whole assignee set (non-empty, every id an existing
|
||||
active child — create's contract; a rejection changes nothing). An
|
||||
omitted (or ``null``) ``skip_on_away`` keeps the stored value.
|
||||
Assignee ids are strict JSON integers, as on create.
|
||||
"""
|
||||
|
||||
title: str | None = None
|
||||
@@ -478,6 +499,8 @@ class AdminQuestDefinitionEditRequest(BaseModel):
|
||||
icon: str | None = None
|
||||
rule: AdminRuleRequest | None = None
|
||||
windows: list[str | tuple[str, str | None]] | None = None
|
||||
skip_on_away: StrictBool | None = None
|
||||
assignee_child_ids: list[StrictInt] | None = None
|
||||
|
||||
|
||||
class AdminQuestDefinitionActiveRequest(BaseModel):
|
||||
@@ -957,6 +980,7 @@ def _quest_definition_response(
|
||||
description=bundle.definition.description,
|
||||
icon=bundle.definition.icon,
|
||||
is_active=bundle.definition.is_active,
|
||||
skip_on_away=bundle.definition.skip_on_away,
|
||||
rule=_rule_response(bundle.rule),
|
||||
assignees=[
|
||||
AdminAssigneeResponse(id=child.id, display_name=child.display_name)
|
||||
@@ -1168,6 +1192,32 @@ async def admin_reorder_children(
|
||||
return {"status": "ok"}
|
||||
|
||||
|
||||
@router.get(
|
||||
"/quest-definitions",
|
||||
summary="List quest definitions",
|
||||
response_model=AdminQuestDefinitionListResponse,
|
||||
)
|
||||
async def admin_list_quest_definitions(
|
||||
request: Request,
|
||||
) -> AdminQuestDefinitionListResponse:
|
||||
"""Return every quest definition in rising id order.
|
||||
|
||||
Requires a valid admin JWT (the router's shared
|
||||
:func:`~api.auth.require_admin` dependency — the ONE check; this
|
||||
handler performs NO auth of its own). Thin adapter: one
|
||||
:func:`nestquest_core.quest_definitions.list_all_definitions` call,
|
||||
active AND inactive definitions included (the admin screen manages
|
||||
both), each serialized exactly as the create/edit responses are.
|
||||
"""
|
||||
state: DatabaseState = request.app.state.db
|
||||
bundles = await core_quest_definitions.list_all_definitions(
|
||||
state.database
|
||||
)
|
||||
return AdminQuestDefinitionListResponse(
|
||||
definitions=[_quest_definition_response(bundle) for bundle in bundles]
|
||||
)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/quest-definitions",
|
||||
summary="Create a quest definition",
|
||||
@@ -1202,6 +1252,7 @@ async def admin_create_quest_definition(
|
||||
body.windows,
|
||||
description=body.description,
|
||||
icon=body.icon,
|
||||
skip_on_away=body.skip_on_away,
|
||||
)
|
||||
except ValueError as error:
|
||||
_raise_quest_definition_error(error)
|
||||
@@ -1227,10 +1278,12 @@ async def admin_edit_quest_definition(
|
||||
field is never passed to
|
||||
:func:`nestquest_core.quest_definitions.edit_quest_definition` and
|
||||
stays unchanged; a supplied ``windows`` list replaces the WHOLE
|
||||
window set in the core, and a supplied ``rule`` is rebuilt into a
|
||||
ScheduleRule (:func:`_rule_from_request`, validated by the model).
|
||||
An explicit ``null`` ``rule``/``windows`` reaches the core as None
|
||||
and is rejected there (422), while an explicit ``null``
|
||||
window set in the core, a supplied ``assignee_child_ids`` list
|
||||
replaces the WHOLE assignee set in the same core transaction, and a
|
||||
supplied ``rule`` is rebuilt into a ScheduleRule
|
||||
(:func:`_rule_from_request`, validated by the model). An explicit
|
||||
``null`` ``rule``/``windows``/``assignee_child_ids`` reaches the
|
||||
core as None and is rejected there (422), while an explicit ``null``
|
||||
``description``/``icon`` clears the stored value — the core edit
|
||||
path supports clearing, unlike the children edit. An unknown
|
||||
definition id is mapped to 404 and any other core ``ValueError``
|
||||
|
||||
@@ -105,6 +105,19 @@ def _validate_window(window: str) -> None:
|
||||
)
|
||||
|
||||
|
||||
def _validate_skip_on_away(value: object) -> int:
|
||||
"""Return ``value`` as 0/1, raising ValueError for anything else.
|
||||
|
||||
The fresh-schema CHECK constraint enforces this in SQLite, but a
|
||||
migrated database gained the column through ``ADD COLUMN`` without
|
||||
it, so every DAO write path validates here instead. Accepts a bool
|
||||
or the ints 0/1; floats, strings and other ints are rejected.
|
||||
"""
|
||||
if not isinstance(value, int) or value not in (0, 1):
|
||||
raise ValueError(f"skip_on_away must be 0 or 1, got {value!r}")
|
||||
return int(value)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ScheduleRuleRecord:
|
||||
"""One row of ``schedule_rules``."""
|
||||
@@ -152,6 +165,7 @@ class QuestDefinitionRecord:
|
||||
due_time: str | None
|
||||
is_active: bool
|
||||
created_at: str
|
||||
skip_on_away: bool
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
@@ -187,7 +201,7 @@ _RULE_COLUMNS = (
|
||||
)
|
||||
_DEFINITION_COLUMNS = (
|
||||
"id, title, description, icon, schedule_rule_id, due_time, "
|
||||
"is_active, created_at"
|
||||
"is_active, created_at, skip_on_away"
|
||||
)
|
||||
_WINDOW_COLUMNS = "definition_id, window, due_time"
|
||||
|
||||
@@ -216,6 +230,7 @@ def _definition_from_row(row: tuple) -> QuestDefinitionRecord:
|
||||
due_time=row[5],
|
||||
is_active=bool(row[6]),
|
||||
created_at=row[7],
|
||||
skip_on_away=bool(row[8]),
|
||||
)
|
||||
|
||||
|
||||
@@ -632,6 +647,7 @@ class QuestDefinitionsDao:
|
||||
due_time: str | None = None,
|
||||
is_active: bool = True,
|
||||
assignee_child_ids: list[int] | None = None,
|
||||
skip_on_away: bool = True,
|
||||
) -> QuestDefinitionRecord:
|
||||
"""Insert one definition and return the record as stored.
|
||||
|
||||
@@ -644,6 +660,7 @@ class QuestDefinitionsDao:
|
||||
empty list or None creates an unassigned definition, which
|
||||
callers may fill via :meth:`add_assignee`.
|
||||
"""
|
||||
skip_on_away_value = _validate_skip_on_away(skip_on_away)
|
||||
async with _connection_lock(self._database):
|
||||
async with self._database.transaction():
|
||||
await self._validate_rule_exists(schedule_rule_id)
|
||||
@@ -652,7 +669,8 @@ class QuestDefinitionsDao:
|
||||
result = await self._database.execute(
|
||||
"INSERT INTO quest_definitions (title, description, "
|
||||
"icon, schedule_rule_id, due_time, is_active, "
|
||||
"created_at) VALUES (?, ?, ?, ?, ?, ?, ?)",
|
||||
"created_at, skip_on_away) "
|
||||
"VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
|
||||
(
|
||||
title,
|
||||
description,
|
||||
@@ -661,6 +679,7 @@ class QuestDefinitionsDao:
|
||||
due_time,
|
||||
int(is_active),
|
||||
created_at,
|
||||
skip_on_away_value,
|
||||
),
|
||||
)
|
||||
definition_id = result.lastrowid
|
||||
@@ -684,6 +703,7 @@ class QuestDefinitionsDao:
|
||||
*,
|
||||
description: str | None = None,
|
||||
icon: str | None = None,
|
||||
skip_on_away: bool = True,
|
||||
) -> QuestDefinitionSnapshot:
|
||||
"""Insert a rule, definition, assignees and windows atomically.
|
||||
|
||||
@@ -713,6 +733,7 @@ class QuestDefinitionsDao:
|
||||
_validate_date(schedule_rule.start_date, "start_date")
|
||||
if schedule_rule.end_date is not None:
|
||||
_validate_date(schedule_rule.end_date, "end_date")
|
||||
skip_on_away_value = _validate_skip_on_away(skip_on_away)
|
||||
async with _connection_lock(self._database):
|
||||
async with self._database.transaction():
|
||||
for child_id in assignee_child_ids:
|
||||
@@ -721,8 +742,18 @@ class QuestDefinitionsDao:
|
||||
result = await self._database.execute(
|
||||
"INSERT INTO quest_definitions (title, description, "
|
||||
"icon, schedule_rule_id, due_time, is_active, "
|
||||
"created_at) VALUES (?, ?, ?, ?, ?, ?, ?)",
|
||||
(title, description, icon, rule_id, None, 1, created_at),
|
||||
"created_at, skip_on_away) "
|
||||
"VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
|
||||
(
|
||||
title,
|
||||
description,
|
||||
icon,
|
||||
rule_id,
|
||||
None,
|
||||
1,
|
||||
created_at,
|
||||
skip_on_away_value,
|
||||
),
|
||||
)
|
||||
definition_id = result.lastrowid
|
||||
for child_id in assignee_child_ids:
|
||||
@@ -764,8 +795,10 @@ class QuestDefinitionsDao:
|
||||
icon: str | None | object = _UNSET,
|
||||
rule: ScheduleRuleStorage | object = _UNSET,
|
||||
windows: list[tuple[str, str | None]] | object = _UNSET,
|
||||
skip_on_away: bool | object = _UNSET,
|
||||
assignee_child_ids: list[int] | object = _UNSET,
|
||||
) -> QuestDefinitionSnapshot:
|
||||
"""Edit a definition's metadata, rule and windows atomically.
|
||||
"""Edit a definition's metadata, rule, windows and assignees atomically.
|
||||
|
||||
Mirrors :meth:`create_with_rule_and_windows`: everything runs
|
||||
inside ONE transaction under the connection lock, and the
|
||||
@@ -774,9 +807,10 @@ class QuestDefinitionsDao:
|
||||
``_UNSET`` meaning "leave this field alone"; ``None`` writes SQL
|
||||
NULL so optional metadata (description, icon) can be cleared.
|
||||
|
||||
- ``title``/``description``/``icon`` update the definition row
|
||||
in place; the definition-level ``due_time`` column is never
|
||||
written (per-window due times supersede it, D-008).
|
||||
- ``title``/``description``/``icon``/``skip_on_away`` update the
|
||||
definition row in place; the definition-level ``due_time``
|
||||
column is never written (per-window due times supersede it,
|
||||
D-008).
|
||||
- ``rule`` (a pre-validated :class:`ScheduleRuleStorage`)
|
||||
overwrites the referenced ``schedule_rules`` row in place: the
|
||||
definition keeps its ``schedule_rule_id``, so no new or
|
||||
@@ -785,13 +819,19 @@ class QuestDefinitionsDao:
|
||||
are deleted and the given set re-inserted, so missing windows
|
||||
are added, changed due times updated and removed windows
|
||||
dropped.
|
||||
- ``assignee_child_ids`` REPLACES the whole assignee set: every
|
||||
id is validated as existing-and-active BEFORE any write (the
|
||||
same check :meth:`add_assignee` applies), then links for
|
||||
children no longer listed are removed and links for newly
|
||||
listed children inserted; unchanged links are left alone.
|
||||
|
||||
Assignment is deliberately not settable here (multi-assignee is
|
||||
managed by :meth:`add_assignee`/:meth:`remove_assignee`), and
|
||||
activation is untouched. Raises ValueError when the definition
|
||||
does not exist. Edits change future instances only: no
|
||||
Activation is untouched. Raises ValueError when the definition
|
||||
does not exist or an assignee is rejected; either way the
|
||||
transaction rolls back and nothing is written. Edits change future instances only: no
|
||||
``quest_instances`` or ``completion_events`` row is touched.
|
||||
"""
|
||||
if skip_on_away is not _UNSET:
|
||||
skip_on_away = _validate_skip_on_away(skip_on_away)
|
||||
async with _connection_lock(self._database):
|
||||
async with self._database.transaction():
|
||||
definition = await self.get(definition_id)
|
||||
@@ -799,6 +839,9 @@ class QuestDefinitionsDao:
|
||||
raise ValueError(
|
||||
f"quest definition {definition_id} does not exist"
|
||||
)
|
||||
if assignee_child_ids is not _UNSET:
|
||||
for child_id in assignee_child_ids:
|
||||
await self._validate_assignable_child(child_id)
|
||||
assignments: list[str] = []
|
||||
parameters: list[object] = []
|
||||
for column, value in (
|
||||
@@ -809,6 +852,9 @@ class QuestDefinitionsDao:
|
||||
if value is not _UNSET:
|
||||
assignments.append(f"{column} = ?")
|
||||
parameters.append(value)
|
||||
if skip_on_away is not _UNSET:
|
||||
assignments.append("skip_on_away = ?")
|
||||
parameters.append(skip_on_away)
|
||||
if assignments:
|
||||
parameters.append(definition_id)
|
||||
await self._database.execute(
|
||||
@@ -844,6 +890,10 @@ class QuestDefinitionsDao:
|
||||
"VALUES (?, ?, ?)",
|
||||
(definition_id, window, due_time),
|
||||
)
|
||||
if assignee_child_ids is not _UNSET:
|
||||
await self._replace_assignees(
|
||||
definition_id, assignee_child_ids
|
||||
)
|
||||
updated = await self.get(definition_id)
|
||||
assignees = await self.list_assignees(definition_id)
|
||||
window_records = await self.list_windows(definition_id)
|
||||
@@ -859,6 +909,36 @@ class QuestDefinitionsDao:
|
||||
windows=window_records,
|
||||
)
|
||||
|
||||
async def _replace_assignees(
|
||||
self, definition_id: int, child_ids: list[int]
|
||||
) -> None:
|
||||
"""Make the definition's assignee links exactly ``child_ids``.
|
||||
|
||||
Removes the links for children not in ``child_ids`` and inserts
|
||||
the missing ones. MUST be called inside the connection lock and
|
||||
an open transaction, after the ids were validated as assignable.
|
||||
"""
|
||||
rows = await self._database.fetch_all(
|
||||
"SELECT child_id FROM quest_definition_assignees "
|
||||
"WHERE definition_id = ?",
|
||||
(definition_id,),
|
||||
)
|
||||
current = {row[0] for row in rows}
|
||||
wanted = set(child_ids)
|
||||
for child_id in sorted(current - wanted):
|
||||
await self._database.execute(
|
||||
"DELETE FROM quest_definition_assignees "
|
||||
"WHERE definition_id = ? AND child_id = ?",
|
||||
(definition_id, child_id),
|
||||
)
|
||||
for child_id in child_ids:
|
||||
if child_id not in current:
|
||||
await self._database.execute(
|
||||
"INSERT INTO quest_definition_assignees "
|
||||
"(definition_id, child_id) VALUES (?, ?)",
|
||||
(definition_id, child_id),
|
||||
)
|
||||
|
||||
async def _validate_rule_exists(self, schedule_rule_id: int) -> None:
|
||||
"""Raise ValueError unless the rule exists.
|
||||
|
||||
@@ -907,6 +987,13 @@ class QuestDefinitionsDao:
|
||||
)
|
||||
return [_definition_from_row(row) for row in rows]
|
||||
|
||||
async def list_all(self) -> list[QuestDefinitionRecord]:
|
||||
"""Return every definition, active AND inactive, oldest first."""
|
||||
rows = await self._database.fetch_all(
|
||||
f"SELECT {_DEFINITION_COLUMNS} FROM quest_definitions ORDER BY id"
|
||||
)
|
||||
return [_definition_from_row(row) for row in rows]
|
||||
|
||||
async def _snapshots_for(
|
||||
self, definitions: list[QuestDefinitionRecord]
|
||||
) -> list[QuestDefinitionSnapshot]:
|
||||
@@ -951,6 +1038,18 @@ class QuestDefinitionsDao:
|
||||
definitions = await self.list_active()
|
||||
return await self._snapshots_for(definitions)
|
||||
|
||||
async def list_snapshots_all(self) -> list[QuestDefinitionSnapshot]:
|
||||
"""Return every definition (active AND inactive) with its rule,
|
||||
assignees and windows, all read inside one locked transaction.
|
||||
|
||||
Mirrors :meth:`list_snapshots_active` over :meth:`list_all`
|
||||
(oldest first), with the same coherence guarantee.
|
||||
"""
|
||||
async with _connection_lock(self._database):
|
||||
async with self._database.transaction():
|
||||
definitions = await self.list_all()
|
||||
return await self._snapshots_for(definitions)
|
||||
|
||||
async def read_snapshots_active(self) -> list[QuestDefinitionSnapshot]:
|
||||
"""Return every active definition's snapshot WITHOUT locking.
|
||||
|
||||
@@ -990,13 +1089,16 @@ class QuestDefinitionsDao:
|
||||
description: str | None | object = _UNSET,
|
||||
icon: str | None | object = _UNSET,
|
||||
due_time: str | None | object = _UNSET,
|
||||
skip_on_away: bool | None = None,
|
||||
) -> int:
|
||||
"""Update the given fields; returns rows updated (0 if absent).
|
||||
|
||||
Arguments default to the module sentinel ``_UNSET`` meaning
|
||||
"leave this column alone"; passing ``None`` explicitly writes
|
||||
SQL NULL, so callers can remove optional metadata
|
||||
(description, icon, due_time). Assignment and activation are
|
||||
(description, icon, due_time). ``skip_on_away`` is NOT NULL,
|
||||
so its ``None`` default leaves it unchanged; a supplied value
|
||||
must be 0/1 (a bool). Assignment and activation are
|
||||
deliberately NOT settable here: they have their own explicit
|
||||
operations (:meth:`add_assignee`, :meth:`remove_assignee`,
|
||||
:meth:`set_active`) so they stay separately loggable and
|
||||
@@ -1013,6 +1115,9 @@ class QuestDefinitionsDao:
|
||||
if value is not _UNSET:
|
||||
assignments.append(f"{column} = ?")
|
||||
parameters.append(value)
|
||||
if skip_on_away is not None:
|
||||
assignments.append("skip_on_away = ?")
|
||||
parameters.append(_validate_skip_on_away(skip_on_away))
|
||||
if not assignments:
|
||||
return 0
|
||||
parameters.append(definition_id)
|
||||
|
||||
@@ -18,7 +18,8 @@ The walk is deterministic and idempotent:
|
||||
:class:`~.presence.PresenceEngine` from that same coherent read.
|
||||
- For each date in the range, each definition whose decoded rule
|
||||
:func:`~.recurrence.occurs_on` on that date, each assignee who is
|
||||
``is_present`` on that date and each declared window, one instance is
|
||||
``is_present`` on that date (or every assignee, when the definition's
|
||||
``skip_on_away`` is off) and each declared window, one instance is
|
||||
written through :class:`~.dao_instances.QuestInstancesDao.upsert_if_valid`
|
||||
with a ``generated_at`` stamp shared by the whole batch (one coherent
|
||||
time per run).
|
||||
@@ -208,7 +209,8 @@ async def materialize(
|
||||
|
||||
definitions = [
|
||||
(snapshot.definition.id, _decode_rule(snapshot),
|
||||
snapshot.assignees, snapshot.windows)
|
||||
snapshot.assignees, snapshot.windows,
|
||||
snapshot.definition.skip_on_away)
|
||||
for snapshot in snapshots
|
||||
]
|
||||
|
||||
@@ -229,13 +231,15 @@ async def materialize(
|
||||
cursor = start
|
||||
while cursor <= end:
|
||||
iso = cursor.isoformat()
|
||||
for definition_id, rule, assignees, windows in definitions:
|
||||
for (
|
||||
definition_id, rule, assignees, windows, skip_on_away
|
||||
) in definitions:
|
||||
if not occurs_on(rule, cursor):
|
||||
continue
|
||||
for child in assignees:
|
||||
if child_filter is not None and child.id not in child_filter:
|
||||
continue
|
||||
if not engine.is_present(child.id, cursor):
|
||||
if skip_on_away and not engine.is_present(child.id, cursor):
|
||||
continue
|
||||
for window in windows:
|
||||
written = await instances.upsert_if_valid(
|
||||
|
||||
@@ -30,8 +30,9 @@ widened (definition_id, child_id, due_date, window) key, and migration
|
||||
6 rebuilds ``completion_events`` with the D-008 ``actor_child_id``
|
||||
column. Migration 7 adds the generic ``nestquest_meta_state``
|
||||
key/value table for integration-owned bookkeeping (Feature 11's
|
||||
missed-sweep watermark). Each callable migration runs inside its own
|
||||
transaction
|
||||
missed-sweep watermark). Migration 8 adds the ``skip_on_away``
|
||||
column to ``quest_definitions`` (Feature 22), defaulting to 1. Each
|
||||
callable migration runs inside its own transaction
|
||||
together with its version stamp, so a crash mid-step rolls the
|
||||
statements and the stamp back together.
|
||||
|
||||
@@ -400,6 +401,28 @@ async def _add_actor_child_id(
|
||||
await database.execute("PRAGMA foreign_keys = ON")
|
||||
|
||||
|
||||
async def _add_skip_on_away(
|
||||
database: NestQuestDatabase, target_version: int
|
||||
) -> None:
|
||||
"""Add the ``skip_on_away`` column to ``quest_definitions``.
|
||||
|
||||
Strictly additive: ``ALTER TABLE ... ADD COLUMN`` with a NOT NULL
|
||||
DEFAULT 1, so existing rows read as 1 (skip absent dates — the
|
||||
behaviour before the flag existed). Fresh databases already carry
|
||||
the column from the canonical DDL, so the ALTER is guarded and the
|
||||
step only stamps there.
|
||||
"""
|
||||
async with database.transaction():
|
||||
if not await _table_has_column(
|
||||
database, "quest_definitions", "skip_on_away"
|
||||
):
|
||||
await database.execute(
|
||||
"ALTER TABLE quest_definitions ADD COLUMN "
|
||||
"skip_on_away INTEGER NOT NULL DEFAULT 1"
|
||||
)
|
||||
await stamp_schema_version(database, target_version)
|
||||
|
||||
|
||||
#: The ordered migration list. Append-only: never edit an applied
|
||||
#: entry, add the next one instead. (Migration 1's content was
|
||||
#: rewritten pre-release per D-007 — no version 1 database shipped.)
|
||||
@@ -423,6 +446,10 @@ MIGRATIONS: Sequence[MigrationStep] = [
|
||||
# fresh databases (which already made it in migration 1) and
|
||||
# pre-v7 databases. Strictly additive; no existing table changes.
|
||||
list(SCHEMA_V7_META_STATE_DDL),
|
||||
# Migration 8 (Feature 22): the skip_on_away flag on
|
||||
# quest_definitions — an additive column defaulting to 1, so every
|
||||
# pre-existing definition keeps today's skip-when-absent behaviour.
|
||||
_add_skip_on_away,
|
||||
]
|
||||
|
||||
|
||||
|
||||
@@ -4,8 +4,9 @@ Sits between the Feature 09 service gate and the typed
|
||||
:class:`~.dao_rules.QuestDefinitionsDao`: callers get validation and the
|
||||
all-or-nothing create/edit here, and never touch the DAO SQL directly.
|
||||
:func:`create_quest_definition` is the create path,
|
||||
:func:`edit_quest_definition` the edit path, :func:`assign_child` and
|
||||
:func:`unassign_child` the assignment paths, and
|
||||
:func:`edit_quest_definition` the edit path (which may also replace the
|
||||
whole assignee set), :func:`assign_child` and
|
||||
:func:`unassign_child` the single-child assignment paths, and
|
||||
:func:`set_quest_definition_active` the deactivate/reactivate path.
|
||||
There is no permission check here (that is Feature 09).
|
||||
|
||||
@@ -191,6 +192,17 @@ def _validate_definition_id(value: object) -> int:
|
||||
return value
|
||||
|
||||
|
||||
def _validate_skip_on_away(value: object) -> bool:
|
||||
"""Reject a non-bool ``skip_on_away`` before any write.
|
||||
|
||||
The DAO's ``int()`` would otherwise silently coerce strings and
|
||||
numerics ("0", 1) into a state the caller never asked for.
|
||||
"""
|
||||
if not isinstance(value, bool):
|
||||
raise ValueError(f"skip_on_away must be a real bool, got {value!r}")
|
||||
return value
|
||||
|
||||
|
||||
def _validate_child_id(value: object) -> int:
|
||||
"""Reject non-int child ids (bools included) before any lookup.
|
||||
|
||||
@@ -212,6 +224,7 @@ async def create_quest_definition(
|
||||
*,
|
||||
description: str | None = None,
|
||||
icon: str | None = None,
|
||||
skip_on_away: bool = True,
|
||||
) -> CreatedQuestDefinition:
|
||||
"""Create a quest definition (rule + assignees + windows) atomically.
|
||||
|
||||
@@ -231,6 +244,7 @@ async def create_quest_definition(
|
||||
window_specs = _normalize_windows(windows)
|
||||
description_value = _validate_text(description, "description")
|
||||
icon_value = _validate_text(icon, "icon")
|
||||
skip_on_away_value = _validate_skip_on_away(skip_on_away)
|
||||
|
||||
storage = schedule_rule_to_storage(rule)
|
||||
dao = QuestDefinitionsDao(database)
|
||||
@@ -243,6 +257,7 @@ async def create_quest_definition(
|
||||
window_specs,
|
||||
description=description_value,
|
||||
icon=icon_value,
|
||||
skip_on_away=skip_on_away_value,
|
||||
)
|
||||
except ValueError as error:
|
||||
# The DAO's assignee check names only the child; re-raise so the
|
||||
@@ -271,10 +286,12 @@ async def edit_quest_definition(
|
||||
icon: str | None | object = _UNSET,
|
||||
rule: ScheduleRule | object = _UNSET,
|
||||
windows: list[str | tuple[str, str | None]] | object = _UNSET,
|
||||
skip_on_away: bool | None = None,
|
||||
assignee_child_ids: list[int] | object = _UNSET,
|
||||
today: datetime.date | None = None,
|
||||
horizon_days: int | None = None,
|
||||
) -> CreatedQuestDefinition:
|
||||
"""Edit a quest definition's metadata, rule and windows atomically.
|
||||
"""Edit a quest definition's metadata, rule, windows and assignees atomically.
|
||||
|
||||
Arguments default to the sentinel ``_UNSET`` meaning "leave this
|
||||
field alone". A provided ``title`` is validated like create's
|
||||
@@ -285,9 +302,16 @@ async def edit_quest_definition(
|
||||
``schedule_rules`` row is updated, never orphaned or duplicated. A
|
||||
provided ``windows`` list REPLACES the whole window set (each entry
|
||||
a ``const.QUEST_WINDOWS`` name, optionally with a strict HH:MM due
|
||||
time), matching create's non-empty contract. Assignment is NOT
|
||||
settable here. Raises ValueError when the definition does not
|
||||
exist or any argument is rejected.
|
||||
time), matching create's non-empty contract. A provided
|
||||
``skip_on_away`` (a real bool) replaces the stored flag; ``None``
|
||||
leaves it unchanged. A provided ``assignee_child_ids`` REPLACES the
|
||||
whole assignee set under create's contract (a non-empty list of
|
||||
distinct plain ints, each an existing ACTIVE child — the DAO checks
|
||||
that inside the edit's transaction): newly listed children are
|
||||
assigned and unlisted ones unassigned. Raises ValueError when the
|
||||
definition does not exist or any argument is rejected; a rejection
|
||||
writes nothing, so the stored assignees (and every other field)
|
||||
stay unchanged.
|
||||
|
||||
Returns the updated definition together with its decoded rule,
|
||||
assignees and windows — all read back inside the DAO's transaction,
|
||||
@@ -331,6 +355,14 @@ async def edit_quest_definition(
|
||||
if windows is not _UNSET:
|
||||
window_specs = _normalize_windows(windows)
|
||||
|
||||
assignee_ids: list[int] | object = _UNSET
|
||||
if assignee_child_ids is not _UNSET:
|
||||
assignee_ids = _validate_assignee_ids(assignee_child_ids)
|
||||
|
||||
skip_on_away_value: bool | object = _UNSET
|
||||
if skip_on_away is not None:
|
||||
skip_on_away_value = _validate_skip_on_away(skip_on_away)
|
||||
|
||||
dao = QuestDefinitionsDao(database)
|
||||
try:
|
||||
snapshot = await dao.edit_definition(
|
||||
@@ -340,14 +372,18 @@ async def edit_quest_definition(
|
||||
icon=icon_value,
|
||||
rule=rule_storage,
|
||||
windows=window_specs,
|
||||
skip_on_away=skip_on_away_value,
|
||||
assignee_child_ids=assignee_ids,
|
||||
)
|
||||
except ValueError as error:
|
||||
# The DAO's missing-definition error names only the id; re-raise
|
||||
# so the public contract names the field the caller passed (matching
|
||||
# assign/unassign/deactivate).
|
||||
# The DAO's missing-definition and assignee errors name only the
|
||||
# id; re-raise so the public contract names the field the caller
|
||||
# passed (matching create/assign/unassign/deactivate).
|
||||
message = str(error)
|
||||
if message.startswith("quest definition "):
|
||||
raise ValueError(f"definition_id: {message}") from error
|
||||
if message.startswith("child "):
|
||||
raise ValueError(f"assignee_child_ids: {message}") from error
|
||||
raise
|
||||
|
||||
decoded_rule = schedule_rule_from_storage(
|
||||
@@ -558,6 +594,21 @@ async def list_active_definitions(
|
||||
return [_bundle_snapshot(snapshot) for snapshot in snapshots]
|
||||
|
||||
|
||||
async def list_all_definitions(
|
||||
database: NestQuestDatabase,
|
||||
) -> list[CreatedQuestDefinition]:
|
||||
"""Return EVERY definition, active AND inactive (rich view).
|
||||
|
||||
Mirrors :func:`list_active_definitions` without the activity
|
||||
filter: ordered by rising definition id, each entry the same
|
||||
:class:`CreatedQuestDefinition` snapshot, read by the DAO inside one
|
||||
locked transaction.
|
||||
"""
|
||||
dao = QuestDefinitionsDao(database)
|
||||
snapshots = await dao.list_snapshots_all()
|
||||
return [_bundle_snapshot(snapshot) for snapshot in snapshots]
|
||||
|
||||
|
||||
async def list_definitions_for_child(
|
||||
database: NestQuestDatabase,
|
||||
child_id: int,
|
||||
|
||||
@@ -257,7 +257,8 @@ QUEST_DEFINITIONS_TABLE_SQL = """(
|
||||
schedule_rule_id INTEGER NOT NULL REFERENCES schedule_rules(id),
|
||||
due_time TEXT,
|
||||
is_active INTEGER NOT NULL DEFAULT 1 CHECK (typeof(is_active) = 'integer' AND is_active IN (0, 1)),
|
||||
created_at TEXT NOT NULL
|
||||
created_at TEXT NOT NULL,
|
||||
skip_on_away INTEGER NOT NULL DEFAULT 1 CHECK (skip_on_away IN (0, 1))
|
||||
)"""
|
||||
|
||||
SCHEMA_V1_QUEST_DEFINITIONS_DDL: list[str] = [
|
||||
|
||||
@@ -18,8 +18,9 @@ caveat in api/nestquest_core.py):
|
||||
and windows exactly as posted.
|
||||
- ``PATCH /api/v1/admin/quest-definitions/{id}``: an edit PERSISTS the
|
||||
supplied fields and leaves omitted fields untouched (exclude_unset
|
||||
semantics); a supplied ``windows`` list replaces the whole set;
|
||||
assignment is not settable through the edit.
|
||||
semantics); a supplied ``windows`` list replaces the whole set; an
|
||||
omitted ``assignee_child_ids`` leaves the assignees untouched (the
|
||||
supplied-list cases live in test_api_admin_questdef_assignees.py).
|
||||
- ``PATCH /api/v1/admin/quest-definitions/{id}/active`` with
|
||||
``is_active: false`` sets the flag FALSE and the definition STILL
|
||||
EXISTS with its rule, assignees and windows (never hard-deleted);
|
||||
@@ -215,6 +216,7 @@ async def test_create_persists_definition_with_assignees_and_windows(
|
||||
"description",
|
||||
"icon",
|
||||
"is_active",
|
||||
"skip_on_away",
|
||||
"rule",
|
||||
"assignees",
|
||||
"windows",
|
||||
@@ -223,6 +225,7 @@ async def test_create_persists_definition_with_assignees_and_windows(
|
||||
assert payload["description"] == "Put everything back where it belongs."
|
||||
assert payload["icon"] == "toy-box"
|
||||
assert payload["is_active"] is True
|
||||
assert payload["skip_on_away"] is True
|
||||
assert payload["rule"] == _weekly_rule_dict()
|
||||
assert {a["id"]: a["display_name"] for a in payload["assignees"]} == {
|
||||
ada: "Ada",
|
||||
@@ -395,7 +398,7 @@ async def test_edit_persists_supplied_fields_and_leaves_omitted_untouched(
|
||||
)
|
||||
assert stored.definition.icon == "toy-box"
|
||||
assert stored.rule.to_dict() == _weekly_rule_dict()
|
||||
# Assignment is not settable through the edit route: untouched.
|
||||
# assignee_child_ids was omitted: the assignees are untouched.
|
||||
assert sorted(child.id for child in stored.assignees) == [ada, bo]
|
||||
|
||||
# A second edit touching only the rule leaves the metadata and the
|
||||
@@ -521,9 +524,130 @@ async def test_edit_rejections_are_422_and_change_nothing(
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.parametrize("coercible", [0, 1, "true", "false"])
|
||||
async def test_create_rejects_coercible_skip_on_away_and_persists_nothing(
|
||||
admin_questdefs: SimpleNamespace, coercible: object
|
||||
) -> None:
|
||||
"""A numeric or string skip_on_away is 422, not coerced to a bool."""
|
||||
client = admin_questdefs.client
|
||||
dao = _dao_rules.QuestDefinitionsDao(admin_questdefs.database)
|
||||
ada = (await _create_children(client, ("Ada",)))[0]
|
||||
existing = await client.post(
|
||||
"/api/v1/admin/quest-definitions",
|
||||
json={
|
||||
"title": "Feed the cat",
|
||||
"rule": _weekly_rule_body(),
|
||||
"assignee_child_ids": [ada],
|
||||
"windows": ["morning"],
|
||||
"skip_on_away": False,
|
||||
},
|
||||
headers=_admin_headers(),
|
||||
)
|
||||
assert existing.status_code == 201
|
||||
existing_id = existing.json()["id"]
|
||||
|
||||
response = await client.post(
|
||||
"/api/v1/admin/quest-definitions",
|
||||
json={
|
||||
"title": "Tidy the den",
|
||||
"rule": _weekly_rule_body(),
|
||||
"assignee_child_ids": [ada],
|
||||
"windows": ["morning"],
|
||||
"skip_on_away": coercible,
|
||||
},
|
||||
headers=_admin_headers(),
|
||||
)
|
||||
assert response.status_code == 422
|
||||
assert set(await _definitions_by_id(admin_questdefs.database)) == {
|
||||
existing_id
|
||||
}
|
||||
assert (await dao.get(existing_id)).skip_on_away is False
|
||||
|
||||
|
||||
@pytest.mark.parametrize("coercible", [0, 1, "true", "false"])
|
||||
async def test_edit_rejects_coercible_skip_on_away_and_changes_nothing(
|
||||
admin_questdefs: SimpleNamespace, coercible: object
|
||||
) -> None:
|
||||
"""A numeric or string skip_on_away edit is 422; the flag is kept."""
|
||||
client = admin_questdefs.client
|
||||
dao = _dao_rules.QuestDefinitionsDao(admin_questdefs.database)
|
||||
ada = (await _create_children(client, ("Ada",)))[0]
|
||||
definition_id = (await _create_definition(client, [ada])).json()["id"]
|
||||
# Start from each stored value so a coerced write would be visible.
|
||||
for stored in (True, False):
|
||||
set_flag = await client.patch(
|
||||
f"/api/v1/admin/quest-definitions/{definition_id}",
|
||||
json={"skip_on_away": stored},
|
||||
headers=_admin_headers(),
|
||||
)
|
||||
assert set_flag.status_code == 200
|
||||
|
||||
response = await client.patch(
|
||||
f"/api/v1/admin/quest-definitions/{definition_id}",
|
||||
json={"skip_on_away": coercible, "title": "Renamed"},
|
||||
headers=_admin_headers(),
|
||||
)
|
||||
assert response.status_code == 422
|
||||
stored_definition = await dao.get(definition_id)
|
||||
assert stored_definition.skip_on_away is stored
|
||||
assert stored_definition.title == "Tidy the den"
|
||||
|
||||
|
||||
# --- set_active -------------------------------------------------------------
|
||||
|
||||
|
||||
async def test_edit_toggles_skip_on_away_and_omission_keeps_it(
|
||||
admin_questdefs: SimpleNamespace,
|
||||
) -> None:
|
||||
"""A supplied skip_on_away persists; an edit omitting it keeps it."""
|
||||
client = admin_questdefs.client
|
||||
dao = _dao_rules.QuestDefinitionsDao(admin_questdefs.database)
|
||||
ada = (await _create_children(client, ("Ada",)))[0]
|
||||
created = await _create_definition(client, [ada])
|
||||
assert created.status_code == 201
|
||||
definition_id = created.json()["id"]
|
||||
assert created.json()["skip_on_away"] is True
|
||||
|
||||
toggled_off = await client.patch(
|
||||
f"/api/v1/admin/quest-definitions/{definition_id}",
|
||||
json={"skip_on_away": False},
|
||||
headers=_admin_headers(),
|
||||
)
|
||||
assert toggled_off.status_code == 200
|
||||
assert toggled_off.json()["skip_on_away"] is False
|
||||
assert (await dao.get(definition_id)).skip_on_away is False
|
||||
|
||||
# An edit that omits the flag leaves the stored False untouched.
|
||||
renamed = await client.patch(
|
||||
f"/api/v1/admin/quest-definitions/{definition_id}",
|
||||
json={"title": "Tidy the den properly"},
|
||||
headers=_admin_headers(),
|
||||
)
|
||||
assert renamed.status_code == 200
|
||||
assert renamed.json()["skip_on_away"] is False
|
||||
stored = await dao.get(definition_id)
|
||||
assert stored.title == "Tidy the den properly"
|
||||
assert stored.skip_on_away is False
|
||||
|
||||
toggled_on = await client.patch(
|
||||
f"/api/v1/admin/quest-definitions/{definition_id}",
|
||||
json={"skip_on_away": True},
|
||||
headers=_admin_headers(),
|
||||
)
|
||||
assert toggled_on.status_code == 200
|
||||
assert toggled_on.json()["skip_on_away"] is True
|
||||
assert (await dao.get(definition_id)).skip_on_away is True
|
||||
|
||||
# A non-boolean value is a malformed body: 422, nothing changes.
|
||||
rejected = await client.patch(
|
||||
f"/api/v1/admin/quest-definitions/{definition_id}",
|
||||
json={"skip_on_away": "sometimes"},
|
||||
headers=_admin_headers(),
|
||||
)
|
||||
assert rejected.status_code == 422
|
||||
assert (await dao.get(definition_id)).skip_on_away is True
|
||||
|
||||
|
||||
async def test_set_active_toggles_the_flag_and_the_definition_survives(
|
||||
admin_questdefs: SimpleNamespace,
|
||||
) -> None:
|
||||
|
||||
@@ -0,0 +1,431 @@
|
||||
"""Admin-plane editable assignees on the definition edit route (task 1c8096f6).
|
||||
|
||||
``PATCH /api/v1/admin/quest-definitions/{definition_id}`` accepts an
|
||||
optional ``assignee_child_ids`` list that REPLACES the definition's
|
||||
assignee set inside the core edit's ONE transaction
|
||||
(:func:`nestquest_core.quest_definitions.edit_quest_definition`):
|
||||
|
||||
- newly listed children are assigned and unlisted ones unassigned; the
|
||||
response and the database both carry the resulting set;
|
||||
- an empty list, a duplicate, an unknown child or an inactive child is
|
||||
422 and NOTHING is written — the stored assignees (and any other
|
||||
field supplied in the same body) are unchanged;
|
||||
- a JSON boolean, float or numeric string id is 422 on create and edit
|
||||
(never coerced to an int) and nothing is written;
|
||||
- an omitted ``assignee_child_ids`` leaves the assignees unchanged;
|
||||
- an assignment change regenerates the definition's future instances
|
||||
through the existing core path;
|
||||
- the route stays behind the router's ONE ``require_admin`` dependency
|
||||
(absent credential 401, panel service token 403, non-admin JWT 403,
|
||||
admin JWT 200).
|
||||
|
||||
Every case is proven against the DATABASE, read through the SAME core
|
||||
copy the app uses (``nestquest_core.*``; see api/nestquest_core.py).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import datetime
|
||||
import importlib
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
|
||||
from tests.admin_jwt_harness import (
|
||||
ADMIN_KEY,
|
||||
KID,
|
||||
AdminRunner,
|
||||
jwks_for,
|
||||
make_token,
|
||||
)
|
||||
|
||||
_dao_rules = importlib.import_module("nestquest_core.dao_rules")
|
||||
_dao_instances = importlib.import_module("nestquest_core.dao_instances")
|
||||
_materialize = importlib.import_module("nestquest_core.materialize")
|
||||
_quest_definitions = importlib.import_module(
|
||||
"nestquest_core.quest_definitions"
|
||||
)
|
||||
|
||||
#: The panel service token the harness app is configured with.
|
||||
HARNESS_PANEL_TOKEN = "admin-test-panel-token"
|
||||
|
||||
|
||||
def _admin_headers() -> dict[str, str]:
|
||||
"""The Authorization header of a valid nestquest-admins JWT."""
|
||||
return {"Authorization": f"Bearer {make_token()}"}
|
||||
|
||||
|
||||
def _edit_path(definition_id: int) -> str:
|
||||
"""The edit route for one definition."""
|
||||
return f"/api/v1/admin/quest-definitions/{definition_id}"
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def admin_assignees(temp_db_path: str) -> SimpleNamespace:
|
||||
"""An admin-plane app client plus its live database."""
|
||||
runner = AdminRunner(temp_db_path, jwks_for(ADMIN_KEY, KID))
|
||||
async with runner as client:
|
||||
yield SimpleNamespace(
|
||||
client=client,
|
||||
database=runner.app.state.db.database,
|
||||
)
|
||||
|
||||
|
||||
async def _create_children(
|
||||
client: object, names: tuple[str, ...]
|
||||
) -> list[int]:
|
||||
"""Create ACTIVE children through the admin children route."""
|
||||
ids = []
|
||||
for name in names:
|
||||
created = await client.post(
|
||||
"/api/v1/admin/children",
|
||||
json={"display_name": name},
|
||||
headers=_admin_headers(),
|
||||
)
|
||||
assert created.status_code == 201
|
||||
ids.append(created.json()["id"])
|
||||
return ids
|
||||
|
||||
|
||||
async def _create_definition(client: object, assignee_ids: list[int]) -> int:
|
||||
"""POST a daily morning definition for the given assignees."""
|
||||
created = await client.post(
|
||||
"/api/v1/admin/quest-definitions",
|
||||
json={
|
||||
"title": "Brush teeth",
|
||||
"rule": {"rule_type": "daily"},
|
||||
"assignee_child_ids": assignee_ids,
|
||||
"windows": ["morning"],
|
||||
},
|
||||
headers=_admin_headers(),
|
||||
)
|
||||
assert created.status_code == 201
|
||||
return created.json()["id"]
|
||||
|
||||
|
||||
async def _stored_assignee_ids(
|
||||
database: object, definition_id: int
|
||||
) -> set[int]:
|
||||
"""The definition's assignee ids as stored."""
|
||||
dao = _dao_rules.QuestDefinitionsDao(database)
|
||||
return {child.id for child in await dao.list_assignees(definition_id)}
|
||||
|
||||
|
||||
def _response_assignee_ids(response: object) -> set[int]:
|
||||
"""The assignee ids a definition payload reports."""
|
||||
return {assignee["id"] for assignee in response.json()["assignees"]}
|
||||
|
||||
|
||||
# --- add / remove / replace -------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("initial", "requested"),
|
||||
[
|
||||
(("Ada",), ("Ada", "Bo")), # add a child
|
||||
(("Ada", "Bo"), ("Ada",)), # remove a child
|
||||
(("Ada", "Bo"), ("Cy",)), # replace the whole set
|
||||
],
|
||||
)
|
||||
async def test_edit_replaces_the_assignee_set(
|
||||
admin_assignees: SimpleNamespace,
|
||||
initial: tuple[str, ...],
|
||||
requested: tuple[str, ...],
|
||||
) -> None:
|
||||
"""A supplied list becomes exactly the stored and returned set."""
|
||||
client = admin_assignees.client
|
||||
names = ("Ada", "Bo", "Cy")
|
||||
ids = dict(zip(names, await _create_children(client, names)))
|
||||
definition_id = await _create_definition(
|
||||
client, [ids[name] for name in initial]
|
||||
)
|
||||
wanted = {ids[name] for name in requested}
|
||||
|
||||
response = await client.patch(
|
||||
_edit_path(definition_id),
|
||||
json={"assignee_child_ids": [ids[name] for name in requested]},
|
||||
headers=_admin_headers(),
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert _response_assignee_ids(response) == wanted
|
||||
assert {a["display_name"] for a in response.json()["assignees"]} == set(
|
||||
requested
|
||||
)
|
||||
assert await _stored_assignee_ids(
|
||||
admin_assignees.database, definition_id
|
||||
) == wanted
|
||||
# The rest of the definition is untouched by an assignee-only edit.
|
||||
assert response.json()["title"] == "Brush teeth"
|
||||
assert [w["window"] for w in response.json()["windows"]] == ["morning"]
|
||||
|
||||
|
||||
async def test_edit_with_assignees_and_other_fields_applies_both(
|
||||
admin_assignees: SimpleNamespace,
|
||||
) -> None:
|
||||
"""Assignees and metadata supplied together land in one edit."""
|
||||
client = admin_assignees.client
|
||||
ada, bo = await _create_children(client, ("Ada", "Bo"))
|
||||
definition_id = await _create_definition(client, [ada])
|
||||
|
||||
response = await client.patch(
|
||||
_edit_path(definition_id),
|
||||
json={"title": "Floss", "assignee_child_ids": [bo]},
|
||||
headers=_admin_headers(),
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json()["title"] == "Floss"
|
||||
assert _response_assignee_ids(response) == {bo}
|
||||
dao = _dao_rules.QuestDefinitionsDao(admin_assignees.database)
|
||||
assert (await dao.get(definition_id)).title == "Floss"
|
||||
assert await _stored_assignee_ids(
|
||||
admin_assignees.database, definition_id
|
||||
) == {bo}
|
||||
|
||||
|
||||
# --- rejections leave the stored state unchanged ----------------------------
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("case", "expected_detail"),
|
||||
[
|
||||
("empty", "assignee_child_ids must not be empty"),
|
||||
("unknown", "assignee_child_ids: child 999 does not exist"),
|
||||
("inactive", "assignee_child_ids: child {cy} is inactive"),
|
||||
("duplicate", "assignee_child_ids must not contain duplicate"),
|
||||
("null", "assignee_child_ids must be a list"),
|
||||
("mixed_unknown", "assignee_child_ids: child 999 does not exist"),
|
||||
("wrong_type", None), # the body model's own 422
|
||||
],
|
||||
)
|
||||
async def test_rejected_assignee_list_is_422_and_changes_nothing(
|
||||
admin_assignees: SimpleNamespace,
|
||||
case: str,
|
||||
expected_detail: str | None,
|
||||
) -> None:
|
||||
"""Empty/unknown/inactive/malformed lists are 422 with no mutation.
|
||||
|
||||
The same body also renames the definition, proving the whole edit
|
||||
is all-or-nothing: neither the assignees nor the title change.
|
||||
"""
|
||||
client = admin_assignees.client
|
||||
ada, bo, cy, di = await _create_children(
|
||||
client, ("Ada", "Bo", "Cy", "Di")
|
||||
)
|
||||
definition_id = await _create_definition(client, [ada, bo])
|
||||
deactivated = await client.patch(
|
||||
f"/api/v1/admin/children/{cy}/active",
|
||||
json={"is_active": False},
|
||||
headers=_admin_headers(),
|
||||
)
|
||||
assert deactivated.status_code == 200
|
||||
|
||||
requested: object = {
|
||||
"empty": [],
|
||||
"unknown": [999],
|
||||
"inactive": [ada, cy],
|
||||
"duplicate": [ada, ada],
|
||||
"null": None,
|
||||
# A valid new child alongside an unknown one: di must NOT be
|
||||
# assigned (all-or-nothing), and ada/bo must NOT be removed.
|
||||
"mixed_unknown": [di, 999],
|
||||
"wrong_type": ["Ada"],
|
||||
}[case]
|
||||
|
||||
response = await client.patch(
|
||||
_edit_path(definition_id),
|
||||
json={"title": "Renamed", "assignee_child_ids": requested},
|
||||
headers=_admin_headers(),
|
||||
)
|
||||
|
||||
assert response.status_code == 422, case
|
||||
if expected_detail is not None:
|
||||
assert response.json()["detail"].startswith(
|
||||
expected_detail.format(cy=cy)
|
||||
), response.json()
|
||||
assert await _stored_assignee_ids(
|
||||
admin_assignees.database, definition_id
|
||||
) == {ada, bo}
|
||||
dao = _dao_rules.QuestDefinitionsDao(admin_assignees.database)
|
||||
assert (await dao.get(definition_id)).title == "Brush teeth"
|
||||
|
||||
|
||||
#: JSON ids the lax ``int`` parser would coerce (``true`` -> 1).
|
||||
_COERCIBLE_IDS = [True, 1.0, "1"]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("coercible", _COERCIBLE_IDS)
|
||||
async def test_edit_rejects_coercible_assignee_ids_and_changes_nothing(
|
||||
admin_assignees: SimpleNamespace, coercible: object
|
||||
) -> None:
|
||||
"""A bool, float or numeric-string id edit is 422; nothing changes."""
|
||||
client = admin_assignees.client
|
||||
ada, bo = await _create_children(client, ("Ada", "Bo"))
|
||||
# Assign bo only, so a coerced id 1 (ada) would be a visible change.
|
||||
definition_id = await _create_definition(client, [bo])
|
||||
assert ada == 1
|
||||
|
||||
response = await client.patch(
|
||||
_edit_path(definition_id),
|
||||
json={"title": "Renamed", "assignee_child_ids": [coercible]},
|
||||
headers=_admin_headers(),
|
||||
)
|
||||
|
||||
assert response.status_code == 422
|
||||
assert await _stored_assignee_ids(
|
||||
admin_assignees.database, definition_id
|
||||
) == {bo}
|
||||
dao = _dao_rules.QuestDefinitionsDao(admin_assignees.database)
|
||||
assert (await dao.get(definition_id)).title == "Brush teeth"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("coercible", _COERCIBLE_IDS)
|
||||
async def test_create_rejects_coercible_assignee_ids_and_persists_nothing(
|
||||
admin_assignees: SimpleNamespace, coercible: object
|
||||
) -> None:
|
||||
"""A bool, float or numeric-string id create is 422; nothing is written."""
|
||||
client = admin_assignees.client
|
||||
ada, bo = await _create_children(client, ("Ada", "Bo"))
|
||||
assert ada == 1
|
||||
existing_id = await _create_definition(client, [bo])
|
||||
|
||||
response = await client.post(
|
||||
"/api/v1/admin/quest-definitions",
|
||||
json={
|
||||
"title": "Tidy the den",
|
||||
"rule": {"rule_type": "daily"},
|
||||
"assignee_child_ids": [coercible],
|
||||
"windows": ["morning"],
|
||||
},
|
||||
headers=_admin_headers(),
|
||||
)
|
||||
|
||||
assert response.status_code == 422
|
||||
bundles = await _quest_definitions.list_active_definitions(
|
||||
admin_assignees.database
|
||||
)
|
||||
assert [bundle.definition.id for bundle in bundles] == [existing_id]
|
||||
assert await _stored_assignee_ids(
|
||||
admin_assignees.database, existing_id
|
||||
) == {bo}
|
||||
|
||||
|
||||
async def test_rejected_assignees_on_unknown_definition_is_404(
|
||||
admin_assignees: SimpleNamespace,
|
||||
) -> None:
|
||||
"""An unknown definition id is still 404 when assignees are supplied."""
|
||||
client = admin_assignees.client
|
||||
(ada,) = await _create_children(client, ("Ada",))
|
||||
response = await client.patch(
|
||||
_edit_path(999),
|
||||
json={"assignee_child_ids": [ada]},
|
||||
headers=_admin_headers(),
|
||||
)
|
||||
assert response.status_code == 404
|
||||
assert response.json()["detail"] == "Quest definition not found"
|
||||
|
||||
|
||||
# --- omitted keeps ----------------------------------------------------------
|
||||
|
||||
|
||||
async def test_omitted_assignees_are_unchanged(
|
||||
admin_assignees: SimpleNamespace,
|
||||
) -> None:
|
||||
"""An edit without assignee_child_ids never touches the assignees."""
|
||||
client = admin_assignees.client
|
||||
ada, bo = await _create_children(client, ("Ada", "Bo"))
|
||||
definition_id = await _create_definition(client, [ada, bo])
|
||||
|
||||
response = await client.patch(
|
||||
_edit_path(definition_id),
|
||||
json={"title": "Floss"},
|
||||
headers=_admin_headers(),
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert _response_assignee_ids(response) == {ada, bo}
|
||||
assert await _stored_assignee_ids(
|
||||
admin_assignees.database, definition_id
|
||||
) == {ada, bo}
|
||||
|
||||
|
||||
# --- regeneration -----------------------------------------------------------
|
||||
|
||||
|
||||
async def test_assignment_change_regenerates_future_instances(
|
||||
admin_assignees: SimpleNamespace,
|
||||
) -> None:
|
||||
"""Swapping the assignee moves the future open instances with it.
|
||||
|
||||
The regeneration lives in the core edit path the route calls: the
|
||||
definition's open instances at or after today are deleted and the
|
||||
horizon re-materialized for the NEW assignee set.
|
||||
"""
|
||||
client = admin_assignees.client
|
||||
database = admin_assignees.database
|
||||
ada, bo = await _create_children(client, ("Ada", "Bo"))
|
||||
definition_id = await _create_definition(client, [ada])
|
||||
|
||||
today = datetime.date.today()
|
||||
start = today.isoformat()
|
||||
end = (today + datetime.timedelta(days=7)).isoformat()
|
||||
await _materialize.materialize(database, start, end, today=today)
|
||||
instances = _dao_instances.QuestInstancesDao(database)
|
||||
assert await instances.list_by_date_range(ada, start, end)
|
||||
assert await instances.list_by_date_range(bo, start, end) == []
|
||||
|
||||
response = await client.patch(
|
||||
_edit_path(definition_id),
|
||||
json={"assignee_child_ids": [bo]},
|
||||
headers=_admin_headers(),
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert await instances.list_by_date_range(ada, start, end) == []
|
||||
regenerated = await instances.list_by_date_range(bo, start, end)
|
||||
assert regenerated
|
||||
assert {i.definition_id for i in regenerated} == {definition_id}
|
||||
|
||||
|
||||
# --- authentication ---------------------------------------------------------
|
||||
|
||||
|
||||
async def test_assignee_edit_auth_outcomes(
|
||||
admin_assignees: SimpleNamespace,
|
||||
) -> None:
|
||||
"""401 absent, 403 panel token, 403 non-admin, 200 admin."""
|
||||
client = admin_assignees.client
|
||||
ada, bo = await _create_children(client, ("Ada", "Bo"))
|
||||
definition_id = await _create_definition(client, [ada])
|
||||
body = {"assignee_child_ids": [bo]}
|
||||
|
||||
absent = await client.patch(_edit_path(definition_id), json=body)
|
||||
assert absent.status_code == 401
|
||||
|
||||
panel = await client.patch(
|
||||
_edit_path(definition_id),
|
||||
json=body,
|
||||
headers={"Authorization": f"Bearer {HARNESS_PANEL_TOKEN}"},
|
||||
)
|
||||
assert panel.status_code == 403
|
||||
|
||||
non_admin_token = make_token(groups=("some-other-group",))
|
||||
non_admin = await client.patch(
|
||||
_edit_path(definition_id),
|
||||
json=body,
|
||||
headers={"Authorization": f"Bearer {non_admin_token}"},
|
||||
)
|
||||
assert non_admin.status_code == 403
|
||||
|
||||
# None of the refused calls reached the handler.
|
||||
assert await _stored_assignee_ids(
|
||||
admin_assignees.database, definition_id
|
||||
) == {ada}
|
||||
|
||||
admin = await client.patch(
|
||||
_edit_path(definition_id), json=body, headers=_admin_headers()
|
||||
)
|
||||
assert admin.status_code == 200
|
||||
assert await _stored_assignee_ids(
|
||||
admin_assignees.database, definition_id
|
||||
) == {bo}
|
||||
@@ -0,0 +1,299 @@
|
||||
"""Admin-plane quest-definition LIST route tests (task d4667573).
|
||||
|
||||
``GET /api/v1/admin/quest-definitions`` against the ASGI app with httpx,
|
||||
reusing the shared local-RSA-key/stubbed-JWKS harness
|
||||
(:mod:`tests.admin_jwt_harness`) so an admin JWT genuinely authenticates
|
||||
with NO network access.
|
||||
|
||||
- An admin JWT lists EVERY definition — active AND inactive — in rising
|
||||
id order, each in the same documented shape as the create/edit
|
||||
responses (id, title, description, icon, is_active, rule, assignees,
|
||||
windows).
|
||||
- An empty household lists nothing: 200 with an empty list.
|
||||
- Authentication runs through the router's ONE ``require_admin``
|
||||
dependency: an absent credential is 401, the panel service token is
|
||||
refused outright (403, the credential-type detail), and a JWT without
|
||||
the nestquest-admins group is 403.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
|
||||
from tests.admin_jwt_harness import (
|
||||
ADMIN_KEY,
|
||||
KID,
|
||||
AdminRunner,
|
||||
jwks_for,
|
||||
make_token,
|
||||
)
|
||||
|
||||
#: The admin list route under test.
|
||||
LIST_PATH = "/api/v1/admin/quest-definitions"
|
||||
|
||||
#: The panel service token the harness app is configured with.
|
||||
HARNESS_PANEL_TOKEN = "admin-test-panel-token"
|
||||
|
||||
#: The 403 detail for the panel service token on an admin route.
|
||||
PANEL_TOKEN_REJECTION_DETAIL = (
|
||||
"This credential type is not accepted for admin access"
|
||||
)
|
||||
|
||||
#: The 403 detail for a valid JWT without the admin group.
|
||||
FORBIDDEN_DETAIL = (
|
||||
"Admin access requires membership in the nestquest-admins group"
|
||||
)
|
||||
|
||||
|
||||
def _admin_headers() -> dict[str, str]:
|
||||
"""The Authorization header of a valid nestquest-admins JWT."""
|
||||
return {"Authorization": f"Bearer {make_token()}"}
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def admin_client(temp_db_path: str) -> SimpleNamespace:
|
||||
"""An admin-plane app client over a fresh (empty) household."""
|
||||
runner = AdminRunner(temp_db_path, jwks_for(ADMIN_KEY, KID))
|
||||
async with runner as client:
|
||||
yield client
|
||||
|
||||
|
||||
async def _seed_active_and_inactive(client: object) -> tuple[int, int, int]:
|
||||
"""Seed one child, an ACTIVE definition and an INACTIVE one.
|
||||
|
||||
Returns ``(child_id, active_id, inactive_id)``. Seeded through the
|
||||
admin routes so the rows are exactly what the admin plane writes.
|
||||
"""
|
||||
child = await client.post(
|
||||
"/api/v1/admin/children",
|
||||
json={"display_name": "Ada"},
|
||||
headers=_admin_headers(),
|
||||
)
|
||||
assert child.status_code == 201
|
||||
child_id = child.json()["id"]
|
||||
|
||||
active = await client.post(
|
||||
LIST_PATH,
|
||||
json={
|
||||
"title": "Tidy the den",
|
||||
"rule": {
|
||||
"rule_type": "weekly",
|
||||
"interval": 1,
|
||||
"weekday_set": [0, 2],
|
||||
"start_date": "2026-03-02",
|
||||
},
|
||||
"assignee_child_ids": [child_id],
|
||||
"windows": ["morning", ["evening", "19:30"]],
|
||||
"description": "Put everything back where it belongs.",
|
||||
"icon": "toy-box",
|
||||
},
|
||||
headers=_admin_headers(),
|
||||
)
|
||||
assert active.status_code == 201
|
||||
|
||||
inactive = await client.post(
|
||||
LIST_PATH,
|
||||
json={
|
||||
"title": "Water the plants",
|
||||
"rule": {
|
||||
"rule_type": "daily",
|
||||
"interval": 1,
|
||||
"start_date": "2026-03-01",
|
||||
},
|
||||
"assignee_child_ids": [child_id],
|
||||
"windows": ["afternoon"],
|
||||
},
|
||||
headers=_admin_headers(),
|
||||
)
|
||||
assert inactive.status_code == 201
|
||||
inactive_id = inactive.json()["id"]
|
||||
deactivated = await client.patch(
|
||||
f"{LIST_PATH}/{inactive_id}/active",
|
||||
json={"is_active": False},
|
||||
headers=_admin_headers(),
|
||||
)
|
||||
assert deactivated.status_code == 200
|
||||
|
||||
return child_id, active.json()["id"], inactive_id
|
||||
|
||||
|
||||
async def test_admin_lists_active_and_inactive_definitions_with_full_shape(
|
||||
admin_client: object,
|
||||
) -> None:
|
||||
"""Both definitions come back, by rising id, in the documented shape."""
|
||||
child_id, active_id, inactive_id = await _seed_active_and_inactive(
|
||||
admin_client
|
||||
)
|
||||
|
||||
response = await admin_client.get(LIST_PATH, headers=_admin_headers())
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {
|
||||
"definitions": [
|
||||
{
|
||||
"id": active_id,
|
||||
"title": "Tidy the den",
|
||||
"description": "Put everything back where it belongs.",
|
||||
"icon": "toy-box",
|
||||
"is_active": True,
|
||||
"skip_on_away": True,
|
||||
"rule": {
|
||||
"rule_type": "weekly",
|
||||
"interval": 1,
|
||||
"weekday_set": [0, 2],
|
||||
"day_of_month": None,
|
||||
"nth_weekday": None,
|
||||
"nth_weekday_weekday": None,
|
||||
"month": None,
|
||||
"start_date": "2026-03-02",
|
||||
"end_date": None,
|
||||
},
|
||||
"assignees": [{"id": child_id, "display_name": "Ada"}],
|
||||
"windows": [
|
||||
{"window": "morning", "due_time": None},
|
||||
{"window": "evening", "due_time": "19:30"},
|
||||
],
|
||||
},
|
||||
{
|
||||
"id": inactive_id,
|
||||
"title": "Water the plants",
|
||||
"description": None,
|
||||
"icon": None,
|
||||
"is_active": False,
|
||||
"skip_on_away": True,
|
||||
"rule": {
|
||||
"rule_type": "daily",
|
||||
"interval": 1,
|
||||
"weekday_set": None,
|
||||
"day_of_month": None,
|
||||
"nth_weekday": None,
|
||||
"nth_weekday_weekday": None,
|
||||
"month": None,
|
||||
"start_date": "2026-03-01",
|
||||
"end_date": None,
|
||||
},
|
||||
"assignees": [{"id": child_id, "display_name": "Ada"}],
|
||||
"windows": [{"window": "afternoon", "due_time": None}],
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
async def test_list_items_match_the_create_response_shape(
|
||||
admin_client: object,
|
||||
) -> None:
|
||||
"""Each listed item is exactly what the create route returned."""
|
||||
child = await admin_client.post(
|
||||
"/api/v1/admin/children",
|
||||
json={"display_name": "Ben"},
|
||||
headers=_admin_headers(),
|
||||
)
|
||||
created = await admin_client.post(
|
||||
LIST_PATH,
|
||||
json={
|
||||
"title": "Feed the cat",
|
||||
"rule": {
|
||||
"rule_type": "daily",
|
||||
"interval": 1,
|
||||
"start_date": "2026-03-01",
|
||||
},
|
||||
"assignee_child_ids": [child.json()["id"]],
|
||||
"windows": ["morning"],
|
||||
},
|
||||
headers=_admin_headers(),
|
||||
)
|
||||
assert created.status_code == 201
|
||||
|
||||
response = await admin_client.get(LIST_PATH, headers=_admin_headers())
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {"definitions": [created.json()]}
|
||||
|
||||
|
||||
async def test_skip_on_away_round_trips_true_and_false_through_list(
|
||||
admin_client: object,
|
||||
) -> None:
|
||||
"""skip_on_away posted true, false, or omitted lists back as stored."""
|
||||
child = await admin_client.post(
|
||||
"/api/v1/admin/children",
|
||||
json={"display_name": "Cleo"},
|
||||
headers=_admin_headers(),
|
||||
)
|
||||
child_id = child.json()["id"]
|
||||
|
||||
async def create(title: str, extra: dict[str, object]) -> dict:
|
||||
response = await admin_client.post(
|
||||
LIST_PATH,
|
||||
json={
|
||||
"title": title,
|
||||
"rule": {
|
||||
"rule_type": "daily",
|
||||
"interval": 1,
|
||||
"start_date": "2026-03-01",
|
||||
},
|
||||
"assignee_child_ids": [child_id],
|
||||
"windows": ["morning"],
|
||||
**extra,
|
||||
},
|
||||
headers=_admin_headers(),
|
||||
)
|
||||
assert response.status_code == 201
|
||||
return response.json()
|
||||
|
||||
skipping = await create("Skip when away", {"skip_on_away": True})
|
||||
keeping = await create("Keep when away", {"skip_on_away": False})
|
||||
defaulted = await create("Default", {})
|
||||
assert skipping["skip_on_away"] is True
|
||||
assert keeping["skip_on_away"] is False
|
||||
assert defaulted["skip_on_away"] is True
|
||||
|
||||
response = await admin_client.get(LIST_PATH, headers=_admin_headers())
|
||||
|
||||
assert response.status_code == 200
|
||||
listed = {item["id"]: item for item in response.json()["definitions"]}
|
||||
assert listed[skipping["id"]]["skip_on_away"] is True
|
||||
assert listed[keeping["id"]]["skip_on_away"] is False
|
||||
assert listed[defaulted["id"]]["skip_on_away"] is True
|
||||
assert response.json() == {"definitions": [skipping, keeping, defaulted]}
|
||||
|
||||
|
||||
async def test_empty_household_lists_no_definitions(
|
||||
admin_client: object,
|
||||
) -> None:
|
||||
"""With no definitions stored the list is 200 and empty."""
|
||||
response = await admin_client.get(LIST_PATH, headers=_admin_headers())
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {"definitions": []}
|
||||
|
||||
|
||||
async def test_list_without_credentials_is_401(admin_client: object) -> None:
|
||||
"""An absent credential is the uniform 401."""
|
||||
response = await admin_client.get(LIST_PATH)
|
||||
|
||||
assert response.status_code == 401
|
||||
|
||||
|
||||
async def test_list_with_panel_service_token_is_403(
|
||||
admin_client: object,
|
||||
) -> None:
|
||||
"""The panel service token is refused outright, never a JWT."""
|
||||
response = await admin_client.get(
|
||||
LIST_PATH,
|
||||
headers={"Authorization": f"Bearer {HARNESS_PANEL_TOKEN}"},
|
||||
)
|
||||
|
||||
assert response.status_code == 403
|
||||
assert response.json()["detail"] == PANEL_TOKEN_REJECTION_DETAIL
|
||||
|
||||
|
||||
async def test_list_with_non_admin_jwt_is_403(admin_client: object) -> None:
|
||||
"""A valid JWT without the nestquest-admins group is 403."""
|
||||
non_admin = make_token(groups=("some-other-group",))
|
||||
response = await admin_client.get(
|
||||
LIST_PATH, headers={"Authorization": f"Bearer {non_admin}"}
|
||||
)
|
||||
|
||||
assert response.status_code == 403
|
||||
assert response.json()["detail"] == FORBIDDEN_DETAIL
|
||||
@@ -847,6 +847,7 @@ def test_definition_update_cannot_change_assignee_or_active(
|
||||
"description",
|
||||
"icon",
|
||||
"due_time",
|
||||
"skip_on_away",
|
||||
}
|
||||
assert await definitions.update(
|
||||
definition.id, title="A2"
|
||||
@@ -862,6 +863,72 @@ def test_definition_update_cannot_change_assignee_or_active(
|
||||
_with_db(tmp_path, "definition-update-shape.db")(_body)
|
||||
|
||||
|
||||
def test_definition_update_toggles_skip_on_away_and_omission_keeps_it(
|
||||
tmp_path,
|
||||
) -> None:
|
||||
async def _body(database, rules, definitions, children, child):
|
||||
rule = await rules.create("daily", "2026-09-14")
|
||||
definition = await definitions.create("A", rule.id, NOW)
|
||||
assert definition.skip_on_away is True
|
||||
assert await definitions.update(
|
||||
definition.id, skip_on_away=False
|
||||
) == 1
|
||||
assert (await definitions.get(definition.id)).skip_on_away is False
|
||||
# Omitting the flag (None) leaves the stored False untouched.
|
||||
assert await definitions.update(definition.id, title="A2") == 1
|
||||
kept = await definitions.get(definition.id)
|
||||
assert kept.title == "A2"
|
||||
assert kept.skip_on_away is False
|
||||
assert await definitions.update(definition.id) == 0
|
||||
assert await definitions.update(
|
||||
definition.id, skip_on_away=True
|
||||
) == 1
|
||||
assert (await definitions.get(definition.id)).skip_on_away is True
|
||||
|
||||
_with_db(tmp_path, "definition-update-skip-on-away.db")(_body)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("bad", [2, -1, 0.0, 1.5, "1", "true", b"\x01"])
|
||||
def test_skip_on_away_rejected_unless_zero_or_one_at_every_write(
|
||||
tmp_path, bad
|
||||
) -> None:
|
||||
"""Every DAO entry point taking the flag rejects non-0/1 values.
|
||||
|
||||
This is the only guard on migrated databases, whose column was
|
||||
added without the fresh-schema CHECK constraint.
|
||||
"""
|
||||
async def _body(database, rules, definitions, children, child):
|
||||
rule = await rules.create("daily", "2026-09-14")
|
||||
definition = await definitions.create("A", rule.id, NOW)
|
||||
with pytest.raises(ValueError, match="skip_on_away must be 0 or 1"):
|
||||
await definitions.create("B", rule.id, NOW, skip_on_away=bad)
|
||||
with pytest.raises(ValueError, match="skip_on_away must be 0 or 1"):
|
||||
await definitions.create_with_rule_and_windows(
|
||||
"C",
|
||||
schedule_rule_to_storage(
|
||||
ScheduleRule(
|
||||
rule_type=RuleType.DAILY, start_date="2026-09-14"
|
||||
)
|
||||
),
|
||||
NOW,
|
||||
[child.id],
|
||||
[("morning", None)],
|
||||
skip_on_away=bad,
|
||||
)
|
||||
with pytest.raises(ValueError, match="skip_on_away must be 0 or 1"):
|
||||
await definitions.edit_definition(
|
||||
definition.id, title="A2", skip_on_away=bad
|
||||
)
|
||||
with pytest.raises(ValueError, match="skip_on_away must be 0 or 1"):
|
||||
await definitions.update(
|
||||
definition.id, title="A2", skip_on_away=bad
|
||||
)
|
||||
stored = await definitions.list_all()
|
||||
assert [(d.title, d.skip_on_away) for d in stored] == [("A", True)]
|
||||
|
||||
_with_db(tmp_path, "definition-skip-on-away-invalid.db")(_body)
|
||||
|
||||
|
||||
def test_definition_update_no_fields_returns_zero(tmp_path) -> None:
|
||||
async def _body(database, rules, definitions, children, child):
|
||||
rule = await rules.create("daily", "2026-09-14")
|
||||
|
||||
@@ -657,6 +657,133 @@ def test_materialize_honours_overrides(tmp_path) -> None:
|
||||
_with_db(tmp_path, "materialize-override.db")(_body)
|
||||
|
||||
|
||||
async def _absent_midweek_setup(database, skip_on_away: bool):
|
||||
"""One child absent on days 2..4 of a future week, one daily quest.
|
||||
|
||||
Returns ``(child_id, definition_id, start, end_iso, absent_dates)``.
|
||||
"""
|
||||
from custom_components.nestquest.core.dao_presence import (
|
||||
PresenceOverridesDao,
|
||||
)
|
||||
|
||||
child = await ChildrenDao(database).create("Ada", NOW)
|
||||
start = _future_monday()
|
||||
end_iso = (start + datetime.timedelta(days=6)).isoformat()
|
||||
absent = {
|
||||
(start + datetime.timedelta(days=offset)).isoformat()
|
||||
for offset in (2, 3, 4)
|
||||
}
|
||||
await PresenceOverridesDao(database).create(
|
||||
child.id, min(absent), max(absent), False, note="away"
|
||||
)
|
||||
created = await create_quest_definition(
|
||||
database,
|
||||
"Daily chore",
|
||||
ScheduleRule(rule_type=RuleType.DAILY, start_date=start.isoformat()),
|
||||
[child.id],
|
||||
["morning"],
|
||||
skip_on_away=skip_on_away,
|
||||
)
|
||||
assert created.definition.skip_on_away is skip_on_away
|
||||
return child.id, created.definition.id, start, end_iso, absent
|
||||
|
||||
|
||||
def test_materialize_skip_on_away_true_skips_absent_dates(tmp_path) -> None:
|
||||
async def _body(database):
|
||||
child_id, _, start, end_iso, absent = await _absent_midweek_setup(
|
||||
database, skip_on_away=True
|
||||
)
|
||||
count = await materialize(database, start.isoformat(), end_iso)
|
||||
records = await QuestInstancesDao(database).list_by_date_range(
|
||||
child_id, start.isoformat(), end_iso
|
||||
)
|
||||
due_dates = {record.due_date for record in records}
|
||||
assert count == 4
|
||||
assert due_dates.isdisjoint(absent)
|
||||
assert len(due_dates) == 4
|
||||
|
||||
_with_db(tmp_path, "skip-on-away-true.db")(_body)
|
||||
|
||||
|
||||
def test_materialize_skip_on_away_false_generates_absent_dates(
|
||||
tmp_path,
|
||||
) -> None:
|
||||
async def _body(database):
|
||||
child_id, _, start, end_iso, absent = await _absent_midweek_setup(
|
||||
database, skip_on_away=False
|
||||
)
|
||||
count = await materialize(database, start.isoformat(), end_iso)
|
||||
records = await QuestInstancesDao(database).list_by_date_range(
|
||||
child_id, start.isoformat(), end_iso
|
||||
)
|
||||
due_dates = {record.due_date for record in records}
|
||||
assert count == 7
|
||||
assert absent <= due_dates
|
||||
assert len(due_dates) == 7
|
||||
|
||||
_with_db(tmp_path, "skip-on-away-false.db")(_body)
|
||||
|
||||
|
||||
def test_edit_skip_on_away_regenerates_absent_dates(tmp_path) -> None:
|
||||
"""Toggling the flag through the edit path rebuilds future instances."""
|
||||
async def _body(database):
|
||||
child_id, definition_id, start, end_iso, absent = (
|
||||
await _absent_midweek_setup(database, skip_on_away=True)
|
||||
)
|
||||
await materialize(database, start.isoformat(), end_iso)
|
||||
dao = QuestInstancesDao(database)
|
||||
|
||||
async def due_dates() -> set[str]:
|
||||
records = await dao.list_by_date_range(
|
||||
child_id, start.isoformat(), end_iso
|
||||
)
|
||||
return {record.due_date for record in records}
|
||||
|
||||
assert (await due_dates()).isdisjoint(absent)
|
||||
|
||||
edited = await edit_quest_definition(
|
||||
database, definition_id, skip_on_away=False
|
||||
)
|
||||
assert edited.definition.skip_on_away is False
|
||||
assert absent <= await due_dates()
|
||||
|
||||
# None leaves the stored flag unchanged.
|
||||
unchanged = await edit_quest_definition(
|
||||
database, definition_id, title="Daily chore (renamed)"
|
||||
)
|
||||
assert unchanged.definition.skip_on_away is False
|
||||
|
||||
edited = await edit_quest_definition(
|
||||
database, definition_id, skip_on_away=True
|
||||
)
|
||||
assert edited.definition.skip_on_away is True
|
||||
assert (await due_dates()).isdisjoint(absent)
|
||||
|
||||
_with_db(tmp_path, "skip-on-away-edit.db")(_body)
|
||||
|
||||
|
||||
def test_skip_on_away_rejects_non_bool(tmp_path) -> None:
|
||||
async def _body(database):
|
||||
child = await ChildrenDao(database).create("Ada", NOW)
|
||||
rule = ScheduleRule(rule_type=RuleType.DAILY, start_date="2026-01-01")
|
||||
with pytest.raises(ValueError, match="skip_on_away must be a real bool"):
|
||||
await create_quest_definition(
|
||||
database, "Chore", rule, [child.id], ["morning"],
|
||||
skip_on_away=1,
|
||||
)
|
||||
created = await create_quest_definition(
|
||||
database, "Chore", rule, [child.id], ["morning"]
|
||||
)
|
||||
with pytest.raises(ValueError, match="skip_on_away must be a real bool"):
|
||||
await edit_quest_definition(
|
||||
database, created.definition.id, skip_on_away="false"
|
||||
)
|
||||
stored = await QuestDefinitionsDao(database).get(created.definition.id)
|
||||
assert stored.skip_on_away is True
|
||||
|
||||
_with_db(tmp_path, "skip-on-away-validation.db")(_body)
|
||||
|
||||
|
||||
def test_materialize_rejects_malformed_or_inverted_bounds(tmp_path) -> None:
|
||||
async def _body(database):
|
||||
start = (_future_monday()).isoformat()
|
||||
|
||||
@@ -153,7 +153,9 @@ def test_migration_list_shape() -> None:
|
||||
migration 3 brings pre-D-008 definitions to the multi-assignee
|
||||
model, migration 4 adds the windows table, migration 5 rebuilds
|
||||
quest_instances onto the widened (definition, child, date, window)
|
||||
key, and migration 6 adds actor_child_id to completion_events.
|
||||
key, migration 6 adds actor_child_id to completion_events,
|
||||
migration 7 adds the meta_state table and migration 8 adds
|
||||
skip_on_away to quest_definitions.
|
||||
"""
|
||||
assert MIGRATIONS[0] == SCHEMA_V1_STATEMENTS
|
||||
assert callable(MIGRATIONS[1])
|
||||
@@ -162,7 +164,8 @@ def test_migration_list_shape() -> None:
|
||||
assert callable(MIGRATIONS[4])
|
||||
assert callable(MIGRATIONS[5])
|
||||
assert MIGRATIONS[6] == SCHEMA_V7_META_STATE_DDL
|
||||
assert len(MIGRATIONS) == 7
|
||||
assert callable(MIGRATIONS[7])
|
||||
assert len(MIGRATIONS) == 8
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -564,6 +567,13 @@ def test_v2_definitions_rebuilt_to_multi_assignee(tmp_path) -> None:
|
||||
database.fetch_all("PRAGMA table_info(quest_definitions)")
|
||||
)
|
||||
assert "child_id" not in {c[1] for c in columns}
|
||||
# The rebuilt row reads the pre-flag behaviour: skip on away.
|
||||
skip = _run(
|
||||
database.fetch_one(
|
||||
"SELECT skip_on_away FROM quest_definitions WHERE id = 1"
|
||||
)
|
||||
)
|
||||
assert skip == (1,)
|
||||
# The single assignee moved into the assignees table.
|
||||
assignees = _run(
|
||||
database.fetch_all(
|
||||
@@ -637,6 +647,87 @@ def test_v2_definitions_rebuilt_to_multi_assignee(tmp_path) -> None:
|
||||
_run(database.close())
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Migration 8: skip_on_away on quest_definitions (Feature 22)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _seed_v7_database(database) -> None:
|
||||
"""Build a genuine version-7 database holding one definition.
|
||||
|
||||
Migrations 1..7 run first; the fresh v1 DDL already carries
|
||||
``skip_on_away`` (canonical shape), so the column is dropped to
|
||||
reproduce the exact pre-migration-8 table a shipped v7 file holds,
|
||||
and a definition row is written through that old column list.
|
||||
"""
|
||||
assert _migrate(database, MIGRATIONS[:7]) == 7
|
||||
_run(
|
||||
database.execute(
|
||||
"ALTER TABLE quest_definitions DROP COLUMN skip_on_away"
|
||||
)
|
||||
)
|
||||
columns = _run(database.fetch_all("PRAGMA table_info(quest_definitions)"))
|
||||
assert "skip_on_away" not in {c[1] for c in columns}
|
||||
_run(
|
||||
database.execute(
|
||||
"INSERT INTO schedule_rules (id, rule_type, interval, "
|
||||
"start_date) VALUES (1, 'daily', 1, '2026-09-01')"
|
||||
)
|
||||
)
|
||||
_run(
|
||||
database.execute(
|
||||
"INSERT INTO quest_definitions (id, title, schedule_rule_id, "
|
||||
"is_active, created_at) VALUES (1, 'Dishes', 1, 1, "
|
||||
"'2026-09-01T00:00:00+00:00')"
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def test_v7_definitions_gain_skip_on_away_defaulting_true(tmp_path) -> None:
|
||||
from custom_components.nestquest.core.dao_rules import (
|
||||
QuestDefinitionsDao,
|
||||
)
|
||||
|
||||
database = _open_db(tmp_path / "v7-upgrade.db")
|
||||
try:
|
||||
_seed_v7_database(database)
|
||||
before = _version(database)
|
||||
assert before == 7
|
||||
|
||||
final = _migrate(database)
|
||||
|
||||
assert final == before + 1
|
||||
assert _version(database) == before + 1
|
||||
columns = {
|
||||
c[1]: c
|
||||
for c in _run(
|
||||
database.fetch_all("PRAGMA table_info(quest_definitions)")
|
||||
)
|
||||
}
|
||||
assert "skip_on_away" in columns
|
||||
# (cid, name, type, notnull, dflt_value, pk)
|
||||
assert columns["skip_on_away"][2:5] == ("INTEGER", 1, "1")
|
||||
record = _run(QuestDefinitionsDao(database).get(1))
|
||||
assert record is not None
|
||||
assert record.title == "Dishes"
|
||||
assert record.skip_on_away is True
|
||||
finally:
|
||||
_run(database.close())
|
||||
|
||||
|
||||
def test_skip_on_away_migration_is_noop_on_fresh_schema(tmp_path) -> None:
|
||||
"""A fresh file already has the column; step 8 only stamps."""
|
||||
database = _open_db(tmp_path / "fresh-v8.db")
|
||||
try:
|
||||
assert _migrate(database) == len(MIGRATIONS)
|
||||
columns = _run(
|
||||
database.fetch_all("PRAGMA table_info(quest_definitions)")
|
||||
)
|
||||
assert [c[1] for c in columns].count("skip_on_away") == 1
|
||||
finally:
|
||||
_run(database.close())
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Idempotence: second start is a no-op
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -322,6 +322,7 @@ def test_quest_definitions_columns_types_and_constraints(tmp_path) -> None:
|
||||
("due_time", "TEXT", 0, None, 0),
|
||||
("is_active", "INTEGER", 1, "1", 0),
|
||||
("created_at", "TEXT", 1, None, 0),
|
||||
("skip_on_away", "INTEGER", 1, "1", 0),
|
||||
]
|
||||
finally:
|
||||
_run(database.close())
|
||||
@@ -633,6 +634,30 @@ def test_schedule_rules_rule_type_check_enforced(tmp_path) -> None:
|
||||
_run(database.close())
|
||||
|
||||
|
||||
@pytest.mark.parametrize("bad_flag", [2, -1])
|
||||
def test_quest_definitions_skip_on_away_check_enforced(
|
||||
tmp_path, bad_flag
|
||||
) -> None:
|
||||
database = _open_db(tmp_path / f"skip-on-away-{bad_flag}.db")
|
||||
try:
|
||||
_apply(database)
|
||||
_child(database)
|
||||
_insert_rule(database)
|
||||
with pytest.raises(sqlite3.IntegrityError, match="CHECK"):
|
||||
_insert_definition(database, skip_on_away=bad_flag)
|
||||
definition_id = _insert_definition(database, skip_on_away=0)
|
||||
with pytest.raises(sqlite3.IntegrityError, match="CHECK"):
|
||||
_run(
|
||||
database.execute(
|
||||
"UPDATE quest_definitions SET skip_on_away = ? "
|
||||
"WHERE id = ?",
|
||||
(bad_flag, definition_id),
|
||||
)
|
||||
)
|
||||
finally:
|
||||
_run(database.close())
|
||||
|
||||
|
||||
def test_schedule_rules_interval_zero_fails(tmp_path) -> None:
|
||||
database = _open_db(tmp_path / "interval-zero.db")
|
||||
try:
|
||||
|
||||
Reference in New Issue
Block a user