Add admin Definitions list and edit sheet
Tasks tab lists quest definitions (assignees · recurrence · windows, inactive rows, per-child filter chips) and a bottom edit sheet with multi-select assignees, repeats + interval, per-window due times, multi-select windows and a skip-on-away toggle. Saving POSTs or PATCHes the admin quest-definition routes and re-fetches the list; 403 and 422 responses are shown in the sheet. Task: 2e96585c-ca63-413d-bf99-7e203c39ddde Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -36,4 +36,13 @@ describe("NestQuest Admin shell", () => {
|
|||||||
"true",
|
"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");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
+4
-1
@@ -1,5 +1,6 @@
|
|||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import "./App.css";
|
import "./App.css";
|
||||||
|
import DefinitionsTab from "./definitions/DefinitionsTab";
|
||||||
import TodayTab from "./today/TodayTab";
|
import TodayTab from "./today/TodayTab";
|
||||||
|
|
||||||
const TABS = [
|
const TABS = [
|
||||||
@@ -15,7 +16,7 @@ export default function App() {
|
|||||||
const [active, setActive] = useState<TabId>("today");
|
const [active, setActive] = useState<TabId>("today");
|
||||||
// Built screens own their header (ADMIN-SPEC §1); the generic shell header
|
// Built screens own their header (ADMIN-SPEC §1); the generic shell header
|
||||||
// stays for placeholder tabs and is visually hidden otherwise.
|
// stays for placeholder tabs and is visually hidden otherwise.
|
||||||
const hasScreen = active === "today";
|
const hasScreen = active === "today" || active === "tasks";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="admin-shell">
|
<div className="admin-shell">
|
||||||
@@ -30,6 +31,8 @@ export default function App() {
|
|||||||
>
|
>
|
||||||
{active === "today" ? (
|
{active === "today" ? (
|
||||||
<TodayTab />
|
<TodayTab />
|
||||||
|
) : active === "tasks" ? (
|
||||||
|
<DefinitionsTab />
|
||||||
) : (
|
) : (
|
||||||
<p className="admin-placeholder">
|
<p className="admin-placeholder">
|
||||||
{TABS.find((tab) => tab.id === active)?.label} placeholder — screens
|
{TABS.find((tab) => tab.id === active)?.label} placeholder — screens
|
||||||
|
|||||||
@@ -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,524 @@
|
|||||||
|
/* 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-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-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,510 @@
|
|||||||
|
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,
|
||||||
|
}
|
||||||
|
: 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 not offered and not submitted.
|
||||||
|
fireEvent.click(within(s).getByRole("button", { name: /Assigned children/ }));
|
||||||
|
expect(within(s).queryByRole("checkbox", { name: "Old" })).toBeNull();
|
||||||
|
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" }));
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
fireEvent.click(within(s).getByRole("button", { name: "Save changes" }));
|
||||||
|
});
|
||||||
|
|
||||||
|
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("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,731 @@
|
|||||||
|
/**
|
||||||
|
* 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 ReactNode } from "react";
|
||||||
|
import { ApiForbiddenError } from "../api/client";
|
||||||
|
import {
|
||||||
|
ApiRequestError,
|
||||||
|
createDefinition,
|
||||||
|
fetchChildren,
|
||||||
|
fetchDefinitions,
|
||||||
|
updateDefinition,
|
||||||
|
type AdminChild,
|
||||||
|
type DefinitionCreateBody,
|
||||||
|
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 };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildBody(draft: Draft): DefinitionCreateBody | string {
|
||||||
|
const title = draft.title.trim();
|
||||||
|
if (!title) return "Give the task a title.";
|
||||||
|
if (draft.assigneeIds.length === 0) 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];
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── 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),
|
||||||
|
);
|
||||||
|
const [pickerOpen, setPickerOpen] = useState(false);
|
||||||
|
const [saving, setSaving] = 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 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() {
|
||||||
|
if (inFlight.current) return;
|
||||||
|
const body = buildBody(draft);
|
||||||
|
if (typeof body === "string") {
|
||||||
|
setError(body);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
inFlight.current = true;
|
||||||
|
setSaving(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
if (target === "new") await createDefinition(body);
|
||||||
|
else await updateDefinition(target.id, body);
|
||||||
|
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
|
||||||
|
className="defs-sheet"
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-labelledby="defs-sheet-title"
|
||||||
|
>
|
||||||
|
<div className="defs-grabber" aria-hidden="true" />
|
||||||
|
<h3 className="defs-sheet-title" id="defs-sheet-title">
|
||||||
|
{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>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</ul>
|
||||||
|
) : 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}
|
||||||
|
|
||||||
|
<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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -11,6 +11,7 @@
|
|||||||
|
|
||||||
--nq-a-page: #f4f4f6;
|
--nq-a-page: #f4f4f6;
|
||||||
--nq-a-surface: #ffffff;
|
--nq-a-surface: #ffffff;
|
||||||
|
--nq-a-selected: #ebebf8;
|
||||||
--nq-a-border: #e4e4ea;
|
--nq-a-border: #e4e4ea;
|
||||||
--nq-a-divider: #ededf1;
|
--nq-a-divider: #ededf1;
|
||||||
|
|
||||||
@@ -21,6 +22,7 @@
|
|||||||
--nq-a-success: #15803d;
|
--nq-a-success: #15803d;
|
||||||
--nq-a-danger: #dc2626;
|
--nq-a-danger: #dc2626;
|
||||||
--nq-a-danger-strong: #b91c1c;
|
--nq-a-danger-strong: #b91c1c;
|
||||||
|
--nq-a-danger-bg: #fee2e2;
|
||||||
--nq-a-info-bg: #dbeafe;
|
--nq-a-info-bg: #dbeafe;
|
||||||
--nq-a-info-ink: #1e3a8a;
|
--nq-a-info-ink: #1e3a8a;
|
||||||
--nq-a-info-icon: #1d4ed8;
|
--nq-a-info-icon: #1d4ed8;
|
||||||
@@ -28,7 +30,10 @@
|
|||||||
--nq-a-font: Nunito, system-ui, sans-serif;
|
--nq-a-font: Nunito, system-ui, sans-serif;
|
||||||
--nq-a-size-screen: 24px;
|
--nq-a-size-screen: 24px;
|
||||||
--nq-a-size-stat: 22px;
|
--nq-a-size-stat: 22px;
|
||||||
|
--nq-a-size-row: 13.5px;
|
||||||
--nq-a-size-meta: 11px;
|
--nq-a-size-meta: 11px;
|
||||||
|
--nq-a-size-label: 10px;
|
||||||
|
--nq-a-track-label: 0.1em;
|
||||||
|
|
||||||
--nq-a-density-gap: 10px;
|
--nq-a-density-gap: 10px;
|
||||||
--nq-a-density-card-pad: 13px;
|
--nq-a-density-card-pad: 13px;
|
||||||
@@ -38,7 +43,13 @@
|
|||||||
--nq-a-tabbar-height: 70px;
|
--nq-a-tabbar-height: 70px;
|
||||||
--nq-a-tap-min: 44px;
|
--nq-a-tap-min: 44px;
|
||||||
--nq-a-radius-pill: 9999px;
|
--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-a-progress-height: 6px;
|
||||||
|
|
||||||
|
--nq-ease-out: cubic-bezier(0, 0, 0.2, 1);
|
||||||
|
--nq-dur-base: 200ms;
|
||||||
|
--nq-dur-modal: 350ms;
|
||||||
}
|
}
|
||||||
|
|
||||||
* {
|
* {
|
||||||
|
|||||||
Reference in New Issue
Block a user