diff --git a/src/components/illness/__tests__/illness-view-nesting.test.tsx b/src/components/illness/__tests__/illness-view-nesting.test.tsx new file mode 100644 index 000000000..547a4a20d --- /dev/null +++ b/src/components/illness/__tests__/illness-view-nesting.test.tsx @@ -0,0 +1,174 @@ +/** + * Every episode in the journal stays reachable from the illness page. + * + * A recurring bout filed under its condition nests inside that condition's + * card. The list view drew no nested episodes at all, so an ended recurring + * bout of an ended condition (both in "Resolved") had no row anywhere and was + * found only through search; a bout nested under an already-nested parent was + * lost in either view. + */ +import { describe, expect, it, vi } from "vitest"; +import { renderToStaticMarkup } from "react-dom/server"; + +import type { IllnessEpisodeDTO } from "../types"; + +const state = vi.hoisted(() => ({ + episodes: [] as unknown[], + view: "list" as "list" | "cards", +})); + +vi.mock("../use-illness", () => ({ + useIllnessEpisodes: () => ({ + data: state.episodes, + isLoading: false, + isError: false, + refetch: () => {}, + }), + useResolveEpisode: () => ({ isPending: false, mutate: () => {} }), +})); + +vi.mock("@/lib/module-list-prefs", () => ({ + useModuleListPrefs: () => ({ prefs: { view: state.view, order: [] } }), + applyOrder: (items: T[]) => items, +})); + +vi.mock("@/hooks/use-record-capabilities", () => ({ + useRecordCapabilities: () => ({ + canWriteDomain: () => true, + canManageDomain: () => false, + inSharedRecord: false, + }), +})); + +vi.mock("../illness-insights-card", () => ({ + IllnessInsightsCard: () => null, +})); +vi.mock("@/components/symptoms/symptoms-section", () => ({ + SymptomsSection: () => null, +})); +vi.mock("../new-episode-sheet", () => ({ NewEpisodeSheet: () => null })); +vi.mock("../log-day-sheet", () => ({ LogDaySheet: () => null })); +vi.mock("@/components/day/day-link", () => ({ + DAY_LINK_SLOT: "{date}", + DayLinkStated: ({ children }: { children: React.ReactNode }) => children, + withDayLinkSlot: (text: string, node: React.ReactNode) => [text, node], +})); + +import { I18nProvider } from "@/lib/i18n/context"; +import { groupEpisodes } from "../group-episodes"; +import { IllnessView } from "../illness-view"; + +function episode(over: Partial): IllnessEpisodeDTO { + return { + id: "e", + label: "Episode", + type: "CHRONIC", + lifecycle: "ACUTE", + onsetAt: "2026-10-08T12:00:00.000Z", + resolvedAt: null, + parentConditionId: null, + note: null, + bodySite: null, + laterality: null, + createdAt: "2026-10-08T12:00:00.000Z", + updatedAt: "2026-10-08T12:00:00.000Z", + ...over, + }; +} + +function render(): string { + return renderToStaticMarkup( + + + , + ); +} + +const condition = episode({ + id: "cond", + label: "Back pain", + lifecycle: "CHRONIC_ONGOING", +}); +const bout = episode({ + id: "bout", + label: "Back pain bout", + lifecycle: "RECURRING", + parentConditionId: "cond", +}); + +describe("illness page nesting", () => { + it.each(["list", "cards"] as const)( + "shows a recurring bout inside its condition in the %s view", + (view) => { + state.view = view; + state.episodes = [condition, bout]; + const html = render(); + expect(html).toContain("Back pain bout"); + expect(html).toContain('href="/illness/bout"'); + // A recurring bout is named as one, not counted as a flare. + expect(html).toContain("Recurring"); + expect(html).not.toContain("1 flare"); + }, + ); + + it("places every episode exactly once, ended recurring bouts included", () => { + const resolvedCondition = episode({ + id: "old", + label: "Prostatitis", + resolvedAt: "2023-05-04T12:00:00.000Z", + }); + const endedBout = episode({ + id: "ended", + label: "Ended bout", + lifecycle: "RECURRING", + parentConditionId: "old", + resolvedAt: "2026-10-14T12:00:00.000Z", + }); + const flareOfBout = episode({ + id: "flare", + label: "Flare of the bout", + lifecycle: "FLARE", + parentConditionId: "ended", + resolvedAt: "2026-10-12T12:00:00.000Z", + }); + const activeBoutOfEnded = episode({ + id: "again", + label: "Back again", + lifecycle: "RECURRING", + parentConditionId: "old", + }); + const loopA = episode({ id: "a", parentConditionId: "b" }); + const loopB = episode({ id: "b", parentConditionId: "a" }); + const list = [ + resolvedCondition, + endedBout, + flareOfBout, + activeBoutOfEnded, + loopA, + loopB, + ]; + + const grouped = groupEpisodes(list); + const placed = [ + ...grouped.activeParents, + ...grouped.resolvedParents, + ...[...grouped.childrenByParent.values()].flat(), + ].map((e) => e.id); + expect(placed.sort()).toEqual(list.map((e) => e.id).sort()); + + const shownCards = new Set( + [...grouped.activeParents, ...grouped.resolvedParents].map((e) => e.id), + ); + // Every nested episode hangs off a card that is itself shown. + for (const parentId of grouped.childrenByParent.keys()) { + expect(shownCards.has(parentId)).toBe(true); + } + // The ended bout and the flare under it both sit in the ended + // condition's card; the still-running bout is a card of its own. + expect(grouped.childrenByParent.get("old")?.map((e) => e.id)).toEqual([ + "ended", + "flare", + ]); + expect(grouped.activeParents.map((e) => e.id)).toContain("again"); + }); +}); diff --git a/src/components/illness/__tests__/use-illness-episodes-paging.test.ts b/src/components/illness/__tests__/use-illness-episodes-paging.test.ts new file mode 100644 index 000000000..ae403d1b5 --- /dev/null +++ b/src/components/illness/__tests__/use-illness-episodes-paging.test.ts @@ -0,0 +1,50 @@ +/** + * The journal reads every episode, not the list route's first page. + * + * The route pages at 50 by default; one unpaged call left the oldest episodes + * off the illness page, its settings and every picker once a record held more + * than that, and they could then be found only through search. + */ +import { describe, expect, it, vi } from "vitest"; + +const calls = vi.hoisted(() => [] as string[]); + +vi.mock("@tanstack/react-query", () => ({ + // The hook's options are what is under test; return them as they are. + useQuery: (options: unknown) => options, + useMutation: () => ({}), + useQueryClient: () => ({}), +})); + +vi.mock("@/lib/api/api-fetch", () => ({ + apiGet: async (url: string) => { + calls.push(url); + // The route's own paging: 50 unless asked otherwise, from offset 0. + const params = new URL(url, "http://x").searchParams; + const offset = Number(params.get("offset") ?? 0); + const limit = Number(params.get("limit") ?? 50); + const total = 230; + const count = Math.max(0, Math.min(limit, total - offset)); + return Array.from({ length: count }, (_, i) => ({ id: `e${offset + i}` })); + }, + apiPost: vi.fn(), + apiPatch: vi.fn(), + apiDelete: vi.fn(), +})); + +import { useIllnessEpisodes } from "../use-illness"; + +describe("useIllnessEpisodes", () => { + it("reads page after page until the whole journal is in", async () => { + const options = useIllnessEpisodes(true) as unknown as { + queryFn: () => Promise<{ id: string }[]>; + }; + const episodes = await options.queryFn(); + expect(episodes).toHaveLength(230); + expect(new Set(episodes.map((e) => e.id)).size).toBe(230); + expect(calls).toHaveLength(3); + expect(calls.every((url) => url.includes("includeResolved=true"))).toBe( + true, + ); + }); +}); diff --git a/src/components/illness/group-episodes.ts b/src/components/illness/group-episodes.ts new file mode 100644 index 000000000..4dbb5d97c --- /dev/null +++ b/src/components/illness/group-episodes.ts @@ -0,0 +1,62 @@ +import type { IllnessEpisodeDTO } from "./types"; + +export interface GroupedEpisodes { + activeParents: IllnessEpisodeDTO[]; + resolvedParents: IllnessEpisodeDTO[]; + /** Episodes shown inside a top-level card, keyed by that card's id. */ + childrenByParent: Map; +} + +const isActive = (e: IllnessEpisodeDTO) => e.resolvedAt === null; + +/** + * Split the journal into active and resolved, nesting each flare or recurring + * bout inside the card of the condition it belongs to. + * + * Every episode lands in exactly one place: as a card of its own, or inside a + * card that is itself shown. An episode nests under the highest ancestor that + * shares its status, so a bout of a bout still reaches a visible card; one + * whose parent sits in the other group (an active flare of a resolved + * condition, or an ended recurring bout of a still-running one) is a card of + * its own in its own group. Nesting only under the direct parent lost the + * grandchild of a nested parent, because a nested card does not render its + * own children. + */ +export function groupEpisodes(list: IllnessEpisodeDTO[]): GroupedEpisodes { + const byId = new Map(list.map((e) => [e.id, e])); + const activeParents: IllnessEpisodeDTO[] = []; + const resolvedParents: IllnessEpisodeDTO[] = []; + const childrenByParent = new Map(); + + const parentOf = (e: IllnessEpisodeDTO) => + e.parentConditionId ? byId.get(e.parentConditionId) : undefined; + + for (const e of list) { + let root = e; + const seen = new Set([e.id]); + let parent = parentOf(e); + while (parent !== undefined && isActive(parent) === isActive(e)) { + if (seen.has(parent.id)) { + // A parent chain that loops has no top; every member stays a card + // of its own rather than all of them nesting out of sight. + root = e; + break; + } + seen.add(parent.id); + root = parent; + parent = parentOf(parent); + } + + if (root !== e) { + const bucket = childrenByParent.get(root.id) ?? []; + bucket.push(e); + childrenByParent.set(root.id, bucket); + } else if (isActive(e)) { + activeParents.push(e); + } else { + resolvedParents.push(e); + } + } + + return { activeParents, resolvedParents, childrenByParent }; +} diff --git a/src/components/illness/illness-view.tsx b/src/components/illness/illness-view.tsx index 4133f5ecf..68f774bc1 100644 --- a/src/components/illness/illness-view.tsx +++ b/src/components/illness/illness-view.tsx @@ -47,6 +47,7 @@ import { NewEpisodeSheet } from "./new-episode-sheet"; import { EpisodeMenu } from "./episode-menu"; import { IllnessInsightsCard } from "./illness-insights-card"; import { SymptomsSection } from "@/components/symptoms/symptoms-section"; +import { groupEpisodes } from "./group-episodes"; import { useIllnessEpisodes, useResolveEpisode } from "./use-illness"; import type { IllnessEpisodeDTO } from "./types"; @@ -59,6 +60,51 @@ function todayLocal(): string { return `${y}-${m}-${d}`; } +/** + * The flares and recurring bouts filed under a condition, inside its card or + * list row. Each one names its course, since a recurring bout is not a flare, + * and an ended one says so; the count heading reads "flares" only when that is + * what they all are. + */ +function NestedEpisodes({ episodes }: { episodes?: IllnessEpisodeDTO[] }) { + const { t } = useTranslations(); + const fmt = useFormatters(); + const count = episodes?.length ?? 0; + if (!episodes || count === 0) return null; + const allFlares = episodes.every((e) => e.lifecycle === "FLARE"); + return ( +
+ {allFlares ? ( +

+ {count === 1 + ? t("illness.flares.countOne") + : t("illness.flares.countOther", { count })} +

+ ) : null} +
    + {episodes.map((child) => ( +
  • + + {child.label} + + {allFlares + ? fmt.dateShortSmart(new Date(child.onsetAt)) + : `${t(`illness.lifecycle.${child.lifecycle}`)} · ${fmt.dateShortSmart(new Date(child.onsetAt))}`} + {child.resolvedAt + ? `–${fmt.dateShortSmart(new Date(child.resolvedAt))}` + : ""} + + +
  • + ))} +
+
+ ); +} + interface EpisodeCardProps { episode: IllnessEpisodeDTO; /** Flares that hang off this episode (rendered nested), already filtered. */ @@ -85,7 +131,6 @@ function EpisodeCard({ const fmt = useFormatters(); const active = episode.resolvedAt === null; const isChronic = episode.lifecycle === "CHRONIC_ONGOING"; - const flareCount = flares?.length ?? 0; // v1.18.6 (MOD-03) — compact list row: a single divided line per episode // (label + status chips + onset; tap navigates to the detail surface). @@ -117,6 +162,9 @@ function EpisodeCard({ + {/* The list row carries the nested episodes too: a bout filed + under this condition has no row of its own. */} + {canManageIllness && (
@@ -202,30 +250,7 @@ function EpisodeCard({ ) : null}

- {flareCount > 0 ? ( -
-

- {flareCount === 1 - ? t("illness.flares.countOne") - : t("illness.flares.countOther", { count: flareCount })} -

-
    - {flares!.map((flare) => ( -
  • - - {flare.label} - - {fmt.dateShortSmart(new Date(flare.onsetAt))} - - -
  • - ))} -
-
- ) : null} + {/* Bottom-pinned single primary action — the daily-use "log a day" flow, promoted from a busy inline cluster. The card navigates to @@ -360,38 +385,13 @@ export function IllnessView() { const logEpisode = episodes?.find((e) => e.id === logEpisodeId) ?? null; /** - * Group the flat list into active / resolved, nesting flares under their - * parent condition. A flare nests only when its parent lives in the same - * status group (so an active flare under a resolved parent still surfaces as - * its own active card rather than vanishing); otherwise it renders as a - * standalone parent card in its own group. + * Group the flat list into active / resolved, nesting flares and recurring + * bouts inside the card of the condition they belong to (`groupEpisodes`). */ const grouped = useMemo(() => { - const list = episodes ?? []; - const byId = new Map(list.map((e) => [e.id, e])); - const isActive = (e: IllnessEpisodeDTO) => e.resolvedAt === null; - - const activeParents: IllnessEpisodeDTO[] = []; - const resolvedParents: IllnessEpisodeDTO[] = []; - const childrenByParent = new Map(); - - for (const e of list) { - const parent = e.parentConditionId - ? byId.get(e.parentConditionId) - : undefined; - const nestsUnderParent = - parent !== undefined && isActive(parent) === isActive(e); - - if (nestsUnderParent) { - const bucket = childrenByParent.get(parent.id) ?? []; - bucket.push(e); - childrenByParent.set(parent.id, bucket); - } else if (isActive(e)) { - activeParents.push(e); - } else { - resolvedParents.push(e); - } - } + const { activeParents, resolvedParents, childrenByParent } = groupEpisodes( + episodes ?? [], + ); return { // v1.18.6 (MOD-03) — honour the user's persisted manual order within diff --git a/src/components/illness/use-illness.ts b/src/components/illness/use-illness.ts index 71858888b..b4b69288a 100644 --- a/src/components/illness/use-illness.ts +++ b/src/components/illness/use-illness.ts @@ -29,15 +29,30 @@ import type { IllnessInsightsResponse, } from "./types"; -/** Newest-first episode list. */ +/** The list route's largest page. */ +const EPISODE_PAGE_SIZE = 100; + +/** + * Every episode, newest first. The list route pages (50 by default, 100 at + * most), and the journal, its settings and every picker read this as the + * whole record; a single unpaged call dropped the oldest episodes once there + * were more than one page of them, and a person then found an old condition + * only through search. So this reads page after page until a short one. + */ export function useIllnessEpisodes(includeResolved = true, enabled = true) { return useQuery({ queryKey: queryKeys.illnessEpisodes(includeResolved), enabled, - queryFn: () => - apiGet( - `/api/illness/episodes?includeResolved=${includeResolved}`, - ), + queryFn: async () => { + const all: IllnessEpisodeDTO[] = []; + for (let offset = 0; ; offset += EPISODE_PAGE_SIZE) { + const page = await apiGet( + `/api/illness/episodes?includeResolved=${includeResolved}&limit=${EPISODE_PAGE_SIZE}&offset=${offset}`, + ); + all.push(...page); + if (page.length < EPISODE_PAGE_SIZE) return all; + } + }, }); }