mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 21:10:59 +00:00
fix(illness): keep every episode reachable from the illness page
A recurring bout filed under its condition nests inside that condition's card, but the list view drew no nested episodes, so an ended recurring bout of an ended condition had no row anywhere and was found only through search. A bout nested under an already nested parent was lost in either view, and the page read only the list route's first page of 50, so the oldest episodes dropped off the page, its settings and every picker. Grouping moves into groupEpisodes: each episode nests under the highest ancestor that shares its status, or is a card of its own, and a looping parent chain leaves every member a card. Both views render the nested episodes with their course and end date; the count heading says "flares" only when they all are. useIllnessEpisodes reads page after page until a short one. Refs #1224
This commit is contained in:
1 parent
cdd5cbdb61
commit
f3c4fbff2c
5 files changed
+361
-60
No files matched your search
@@ -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: <T,>(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>): 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(
|
||||
<I18nProvider initialLocale="en">
|
||||
<IllnessView />
|
||||
</I18nProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
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");
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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<string, IllnessEpisodeDTO[]>;
|
||||
}
|
||||
|
||||
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<string, IllnessEpisodeDTO[]>();
|
||||
|
||||
const parentOf = (e: IllnessEpisodeDTO) =>
|
||||
e.parentConditionId ? byId.get(e.parentConditionId) : undefined;
|
||||
|
||||
for (const e of list) {
|
||||
let root = e;
|
||||
const seen = new Set<string>([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 };
|
||||
}
|
||||
@@ -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 (
|
||||
<div className="space-y-1 border-l-2 pl-3" data-slot="nested-episodes">
|
||||
{allFlares ? (
|
||||
<p className="text-muted-foreground text-xs font-medium">
|
||||
{count === 1
|
||||
? t("illness.flares.countOne")
|
||||
: t("illness.flares.countOther", { count })}
|
||||
</p>
|
||||
) : null}
|
||||
<ul className="space-y-1">
|
||||
{episodes.map((child) => (
|
||||
<li key={child.id}>
|
||||
<Link
|
||||
href={`/illness/${child.id}`}
|
||||
className="text-foreground/80 hover:text-foreground flex items-center gap-2 text-xs hover:underline"
|
||||
>
|
||||
<span className="truncate">{child.label}</span>
|
||||
<span className="text-muted-foreground shrink-0">
|
||||
{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))}`
|
||||
: ""}
|
||||
</span>
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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({
|
||||
</DayLinkStated>
|
||||
</span>
|
||||
</div>
|
||||
{/* The list row carries the nested episodes too: a bout filed
|
||||
under this condition has no row of its own. */}
|
||||
<NestedEpisodes episodes={flares} />
|
||||
</div>
|
||||
{canManageIllness && (
|
||||
<div className="flex shrink-0 items-center gap-1">
|
||||
@@ -202,30 +250,7 @@ function EpisodeCard({
|
||||
) : null}
|
||||
</p>
|
||||
|
||||
{flareCount > 0 ? (
|
||||
<div className="space-y-1 border-l-2 pl-3">
|
||||
<p className="text-muted-foreground text-xs font-medium">
|
||||
{flareCount === 1
|
||||
? t("illness.flares.countOne")
|
||||
: t("illness.flares.countOther", { count: flareCount })}
|
||||
</p>
|
||||
<ul className="space-y-1">
|
||||
{flares!.map((flare) => (
|
||||
<li key={flare.id}>
|
||||
<Link
|
||||
href={`/illness/${flare.id}`}
|
||||
className="text-foreground/80 hover:text-foreground flex items-center gap-2 text-xs hover:underline"
|
||||
>
|
||||
<span className="truncate">{flare.label}</span>
|
||||
<span className="text-muted-foreground shrink-0">
|
||||
{fmt.dateShortSmart(new Date(flare.onsetAt))}
|
||||
</span>
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
) : null}
|
||||
<NestedEpisodes episodes={flares} />
|
||||
|
||||
{/* 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<string, IllnessEpisodeDTO[]>();
|
||||
|
||||
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
|
||||
|
||||
@@ -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<IllnessEpisodeDTO[]>(
|
||||
`/api/illness/episodes?includeResolved=${includeResolved}`,
|
||||
),
|
||||
queryFn: async () => {
|
||||
const all: IllnessEpisodeDTO[] = [];
|
||||
for (let offset = 0; ; offset += EPISODE_PAGE_SIZE) {
|
||||
const page = await apiGet<IllnessEpisodeDTO[]>(
|
||||
`/api/illness/episodes?includeResolved=${includeResolved}&limit=${EPISODE_PAGE_SIZE}&offset=${offset}`,
|
||||
);
|
||||
all.push(...page);
|
||||
if (page.length < EPISODE_PAGE_SIZE) return all;
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user