mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-13 22:32:04 +00:00
feat(cycle): serve the cycle forecast as a subscribable calendar
GET /api/cycle/feed/<hlf_token>[.ics] answers any calendar app with the
record's forecast as RFC 5545: all-day events for the next few periods
over their widening windows, optionally past periods, and for a
conception goal that asks for it the next fertile window. Dates and a
title only; discreet titles and the calendar name "HealthLog" by default.
The body is built live on every fetch in the record's own zone and
language, never the request's. ETag is the content hash and a matching
If-None-Match answers 304; DTSTAMP moves only when the content does.
The feed is anonymous, so every gate is read per fetch: the auth-surface
limit before any lookup, the token's shape, the HMAC lookup of a live
feed, a per-token bucket, then the record and carrier not pending
deletion, the operator's external-access switch, cycle tracking for the
record, and for a feed a delegate minted, a live MANAGE grant covering
cycle. Every miss is the same 404.
/api/cycle/feed-subscription (status, turn on, options, turn off) and
its rotate are cookie-only through requireRecordAuth("manage", "cycle"),
with a fresh proof on turning on and rotating and a per-actor mint
bucket. The URL is returned once; the status carries the prefix, the
options, the fetch statistics as a calendar-app family and the
projection state.
The hlf_ family is registered with the path and token redactors, the
secret-shape refusal, the proxy's public paths and edge headers, the
URL-credential, sharing, recent-proof, step-up and module-gate guards,
the error-code catalogue, the activity feed and the destructive-control
registry. The cycle settings tab gains the subscription card and the
predictions panel lists the later cycles.
This commit is contained in:
1 parent
ac31ac40b5
commit
d9a31524a2
70 files changed
+6063
-654
No files matched your search
@@ -0,0 +1,71 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
|
||||
import { I18nProvider } from "@/lib/i18n/context";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
|
||||
import { CycleCalendarFeedCard } from "../cycle-calendar-feed-card";
|
||||
import type { CycleFeedStatus } from "../use-cycle-feed";
|
||||
|
||||
function render(status: CycleFeedStatus) {
|
||||
const client = new QueryClient();
|
||||
client.setQueryData(queryKeys.cycleFeedSubscription(), status);
|
||||
return renderToStaticMarkup(
|
||||
<QueryClientProvider client={client}>
|
||||
<I18nProvider initialLocale="en">
|
||||
<CycleCalendarFeedCard />
|
||||
</I18nProvider>
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
const FEED = {
|
||||
tokenPrefix: "hlf_ab12",
|
||||
createdAt: "2026-11-01T10:00:00.000Z",
|
||||
lastFetchedAt: null,
|
||||
fetchCount: 0,
|
||||
lastFetcher: null,
|
||||
horizonCycles: 3,
|
||||
historyCycles: 0,
|
||||
includeFertile: false,
|
||||
titleMode: "DISCREET" as const,
|
||||
};
|
||||
|
||||
describe("<CycleCalendarFeedCard>", () => {
|
||||
it("says what a provider keeps before it offers the button", () => {
|
||||
const html = render({
|
||||
feed: null,
|
||||
projectionState: null,
|
||||
fertileAvailable: false,
|
||||
});
|
||||
const disclosure = html.indexOf("stores these dates on its servers");
|
||||
const button = html.indexOf("Turn on subscription");
|
||||
expect(disclosure).toBeGreaterThan(-1);
|
||||
expect(button).toBeGreaterThan(disclosure);
|
||||
});
|
||||
|
||||
it("shows only the prefix of a live link, and no URL", () => {
|
||||
const html = render({
|
||||
feed: FEED,
|
||||
projectionState: "OK",
|
||||
fertileAvailable: false,
|
||||
});
|
||||
expect(html).toContain("hlf_ab12");
|
||||
expect(html).not.toContain("/api/cycle/feed/");
|
||||
expect(html).toContain("Not fetched yet.");
|
||||
// The fertile option exists only for a conception goal.
|
||||
expect(html).not.toContain("Fertile window");
|
||||
});
|
||||
|
||||
it("explains a shorter feed in the muted tone", () => {
|
||||
const html = render({
|
||||
feed: FEED,
|
||||
projectionState: "LATE",
|
||||
fertileAvailable: true,
|
||||
});
|
||||
expect(html).toContain('data-slot="cycle-feed-state"');
|
||||
expect(html).toContain("later than expected");
|
||||
expect(html).toContain("Fertile window");
|
||||
});
|
||||
});
|
||||
@@ -104,3 +104,59 @@ describe("<PredictionsPanel>", () => {
|
||||
expect(html).toContain("Prediction is off");
|
||||
});
|
||||
});
|
||||
|
||||
describe("<PredictionsPanel> later cycles (#1350)", () => {
|
||||
const projection = [
|
||||
{
|
||||
k: 1,
|
||||
start: "2026-07-01",
|
||||
periodLength: 5,
|
||||
bandStart: "2026-06-29",
|
||||
bandEnd: "2026-07-03",
|
||||
},
|
||||
{
|
||||
k: 2,
|
||||
start: "2026-07-29",
|
||||
periodLength: 5,
|
||||
bandStart: "2026-07-26",
|
||||
bandEnd: "2026-08-01",
|
||||
},
|
||||
{
|
||||
k: 3,
|
||||
start: "2026-08-26",
|
||||
periodLength: 5,
|
||||
bandStart: "2026-08-22",
|
||||
bandEnd: "2026-08-30",
|
||||
},
|
||||
];
|
||||
|
||||
it("lists the cycles after the next one, as the server's windows", () => {
|
||||
const html = render(
|
||||
<PredictionsPanel
|
||||
prediction={basePrediction}
|
||||
rawChartMode={false}
|
||||
history={emptyHistory}
|
||||
projection={projection}
|
||||
projectionState="OK"
|
||||
/>,
|
||||
);
|
||||
expect(html).toContain('data-slot="cycle-projection"');
|
||||
expect(html).toContain("Later cycles");
|
||||
// Two later rows; cycle 1 is the next-period row above them.
|
||||
expect(html.match(/Around /g)).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("states a late period instead of listing later cycles", () => {
|
||||
const html = render(
|
||||
<PredictionsPanel
|
||||
prediction={basePrediction}
|
||||
rawChartMode={false}
|
||||
history={emptyHistory}
|
||||
projection={[]}
|
||||
projectionState="LATE"
|
||||
/>,
|
||||
);
|
||||
expect(html).toContain('data-slot="cycle-projection-late"');
|
||||
expect(html).not.toContain('data-slot="cycle-projection"');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,395 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { CalendarSync, Loader2 } from "lucide-react";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { ConfirmButton } from "@/components/ui/confirm-button";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { NativeSelect } from "@/components/ui/native-select";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { QueryErrorCard } from "@/components/ui/query-error-card";
|
||||
import { SettingsCard } from "@/components/settings/settings-card";
|
||||
import { SettingsCardHeader } from "@/components/settings/_card-header";
|
||||
import { SettingsCardActions } from "@/components/settings/_card-actions";
|
||||
import {
|
||||
recentProofErrorMessage,
|
||||
useRecentProof,
|
||||
} from "@/components/settings/security-section/use-recent-proof";
|
||||
import { formatRelativeTime } from "@/lib/i18n/relative-time";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
|
||||
import {
|
||||
useCycleFeedStatus,
|
||||
useMintCycleFeed,
|
||||
useRevokeCycleFeed,
|
||||
useUpdateCycleFeed,
|
||||
type CycleFeedMinted,
|
||||
type CycleFeedOptionsPatch,
|
||||
type CycleFeedStatus,
|
||||
type FeedTitleMode,
|
||||
} from "./use-cycle-feed";
|
||||
|
||||
/**
|
||||
* #1350 — the calendar subscription card on the cycle settings tab.
|
||||
*
|
||||
* Off by default, and the disclosure comes before the button: a calendar
|
||||
* provider keeps what it fetched, and turning the link off later reaches none
|
||||
* of it. The link itself is shown once, right after it is made; after that the
|
||||
* card knows only its prefix. Rendered only for the owner and a MANAGE
|
||||
* delegate (the tab's own gate); the routes refuse everyone else regardless.
|
||||
*/
|
||||
|
||||
const HORIZONS = [1, 3, 6] as const;
|
||||
const HISTORIES = [0, 3, 6, 12] as const;
|
||||
const TITLE_MODES: FeedTitleMode[] = ["DISCREET", "DESCRIPTIVE"];
|
||||
|
||||
/** A long URL with its middle elided, so both ends stay recognisable. */
|
||||
function middleTruncate(url: string, keep = 28): string {
|
||||
return url.length <= keep * 2 + 1
|
||||
? url
|
||||
: `${url.slice(0, keep)}…${url.slice(-keep / 2)}`;
|
||||
}
|
||||
|
||||
export function CycleCalendarFeedCard() {
|
||||
const { t } = useTranslations();
|
||||
const status = useCycleFeedStatus();
|
||||
const proof = useRecentProof();
|
||||
const mint = useMintCycleFeed(proof);
|
||||
const [minted, setMinted] = useState<CycleFeedMinted | null>(null);
|
||||
const [mintError, setMintError] = useState<string | null>(null);
|
||||
|
||||
async function handleMint(kind: "create" | "rotate") {
|
||||
setMintError(null);
|
||||
try {
|
||||
setMinted(await mint.mutateAsync(kind));
|
||||
} catch (err) {
|
||||
setMintError(recentProofErrorMessage(err, t("cycle.feed.error")));
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<SettingsCard data-slot="cycle-calendar-feed">
|
||||
<SettingsCardHeader
|
||||
icon={CalendarSync}
|
||||
title={t("cycle.feed.title")}
|
||||
description={t("cycle.feed.description")}
|
||||
/>
|
||||
{status.isError ? (
|
||||
<QueryErrorCard
|
||||
error={status.error}
|
||||
title={t("cycle.loadError")}
|
||||
onRetry={() => void status.refetch()}
|
||||
/>
|
||||
) : status.isLoading || !status.data ? (
|
||||
<div className="flex h-16 items-center justify-center">
|
||||
<Loader2 className="text-muted-foreground h-5 w-5 animate-spin motion-reduce:animate-none" />
|
||||
</div>
|
||||
) : status.data.feed === null ? (
|
||||
<OffState
|
||||
pending={mint.isPending}
|
||||
error={mintError}
|
||||
onTurnOn={() => void handleMint("create")}
|
||||
/>
|
||||
) : (
|
||||
<OnState
|
||||
status={status.data}
|
||||
minted={minted}
|
||||
pending={mint.isPending}
|
||||
error={mintError}
|
||||
onRotate={() => void handleMint("rotate")}
|
||||
onRevoked={() => setMinted(null)}
|
||||
/>
|
||||
)}
|
||||
{proof.dialog}
|
||||
</SettingsCard>
|
||||
);
|
||||
}
|
||||
|
||||
function OffState({
|
||||
pending,
|
||||
error,
|
||||
onTurnOn,
|
||||
}: {
|
||||
pending: boolean;
|
||||
error: string | null;
|
||||
onTurnOn: () => void;
|
||||
}) {
|
||||
const { t } = useTranslations();
|
||||
return (
|
||||
<>
|
||||
<p className="text-sm">{t("cycle.feed.disclosure")}</p>
|
||||
{error ? (
|
||||
<p className="text-destructive text-sm" role="alert">
|
||||
{error}
|
||||
</p>
|
||||
) : null}
|
||||
<SettingsCardActions>
|
||||
<Button
|
||||
size="sm"
|
||||
className="min-h-11 w-full sm:w-auto"
|
||||
onClick={onTurnOn}
|
||||
disabled={pending}
|
||||
>
|
||||
{pending ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin motion-reduce:animate-none" />
|
||||
) : null}
|
||||
{t("cycle.feed.turnOn")}
|
||||
</Button>
|
||||
</SettingsCardActions>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function OnState({
|
||||
status,
|
||||
minted,
|
||||
pending,
|
||||
error,
|
||||
onRotate,
|
||||
onRevoked,
|
||||
}: {
|
||||
status: CycleFeedStatus;
|
||||
minted: CycleFeedMinted | null;
|
||||
pending: boolean;
|
||||
error: string | null;
|
||||
onRotate: () => void;
|
||||
onRevoked: () => void;
|
||||
}) {
|
||||
const { t } = useTranslations();
|
||||
const revoke = useRevokeCycleFeed();
|
||||
const feed = status.feed!;
|
||||
const state = status.projectionState;
|
||||
|
||||
return (
|
||||
<>
|
||||
{minted ? (
|
||||
<LinkPanel minted={minted} />
|
||||
) : (
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{t("cycle.feed.linkHidden", { prefix: feed.tokenPrefix })}
|
||||
</p>
|
||||
)}
|
||||
<FetchLine status={status} />
|
||||
{state === "LEARNING" || state === "LATE" || state === "OFF" ? (
|
||||
<p
|
||||
className="text-muted-foreground text-sm"
|
||||
data-slot="cycle-feed-state"
|
||||
>
|
||||
{t(`cycle.projection.state.${state}`)}
|
||||
</p>
|
||||
) : null}
|
||||
<Separator />
|
||||
<FeedOptions status={status} />
|
||||
<HelpList />
|
||||
{error || revoke.isError ? (
|
||||
<p className="text-destructive text-sm" role="alert">
|
||||
{error ?? t("cycle.feed.error")}
|
||||
</p>
|
||||
) : null}
|
||||
<SettingsCardActions className="flex-col items-stretch sm:flex-row sm:items-center">
|
||||
<ConfirmButton
|
||||
label={t("cycle.feed.turnOff")}
|
||||
title={t("cycle.feed.turnOffTitle")}
|
||||
body={t("cycle.feed.turnOffBody")}
|
||||
confirmLabel={t("cycle.feed.turnOff")}
|
||||
onConfirm={() => revoke.mutate(undefined, { onSuccess: onRevoked })}
|
||||
pending={revoke.isPending}
|
||||
variant="destructive"
|
||||
destructive
|
||||
size="sm"
|
||||
className="min-h-11 w-full sm:w-auto"
|
||||
/>
|
||||
<ConfirmButton
|
||||
label={t("cycle.feed.rotate")}
|
||||
title={t("cycle.feed.rotateTitle")}
|
||||
body={t("cycle.feed.rotateBody")}
|
||||
confirmLabel={t("cycle.feed.rotate")}
|
||||
onConfirm={onRotate}
|
||||
pending={pending}
|
||||
size="sm"
|
||||
className="min-h-11 w-full sm:w-auto"
|
||||
/>
|
||||
</SettingsCardActions>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
/** The link, the one time it exists in the client. */
|
||||
function LinkPanel({ minted }: { minted: CycleFeedMinted }) {
|
||||
const { t } = useTranslations();
|
||||
const [copy, setCopy] = useState<"idle" | "copied" | "failed">("idle");
|
||||
|
||||
async function handleCopy() {
|
||||
try {
|
||||
await navigator.clipboard.writeText(minted.url);
|
||||
setCopy("copied");
|
||||
} catch {
|
||||
setCopy("failed");
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-3" data-slot="cycle-feed-link">
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="cycle-feed-url">{t("cycle.feed.linkLabel")}</Label>
|
||||
<code
|
||||
id="cycle-feed-url"
|
||||
className="bg-muted block rounded-md px-3 py-2 text-sm break-all"
|
||||
title={minted.url}
|
||||
>
|
||||
{middleTruncate(minted.url)}
|
||||
</code>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{t("cycle.feed.linkOnce")}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2 sm:flex-row">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="min-h-11 w-full sm:w-auto"
|
||||
onClick={() => void handleCopy()}
|
||||
>
|
||||
{t("cycle.feed.copy")}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
asChild
|
||||
className="min-h-11 w-full sm:w-auto"
|
||||
>
|
||||
<a href={minted.webcalUrl}>{t("cycle.feed.openInCalendar")}</a>
|
||||
</Button>
|
||||
</div>
|
||||
<p className="text-muted-foreground text-sm" aria-live="polite">
|
||||
{copy === "copied"
|
||||
? t("cycle.feed.copied")
|
||||
: copy === "failed"
|
||||
? t("cycle.feed.copyFailed")
|
||||
: ""}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function FetchLine({ status }: { status: CycleFeedStatus }) {
|
||||
const { t, locale } = useTranslations();
|
||||
const feed = status.feed!;
|
||||
if (feed.lastFetchedAt === null) {
|
||||
return (
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{t("cycle.feed.notFetched")}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
const when = formatRelativeTime(feed.lastFetchedAt, t, locale);
|
||||
return (
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{t("cycle.feed.lastFetched", {
|
||||
when,
|
||||
fetcher: t(`cycle.feed.fetcher.${feed.lastFetcher ?? "OTHER"}`),
|
||||
})}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
function FeedOptions({ status }: { status: CycleFeedStatus }) {
|
||||
const { t } = useTranslations();
|
||||
const update = useUpdateCycleFeed();
|
||||
const feed = status.feed!;
|
||||
const save = (patch: CycleFeedOptionsPatch) => update.mutate(patch);
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="grid gap-4 sm:grid-cols-3">
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="cycle-feed-horizon">{t("cycle.feed.horizon")}</Label>
|
||||
<NativeSelect
|
||||
id="cycle-feed-horizon"
|
||||
value={String(feed.horizonCycles)}
|
||||
disabled={update.isPending}
|
||||
onChange={(e) => save({ horizonCycles: Number(e.target.value) })}
|
||||
>
|
||||
{HORIZONS.map((n) => (
|
||||
<option key={n} value={n}>
|
||||
{n}
|
||||
</option>
|
||||
))}
|
||||
</NativeSelect>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="cycle-feed-history">{t("cycle.feed.history")}</Label>
|
||||
<NativeSelect
|
||||
id="cycle-feed-history"
|
||||
value={String(feed.historyCycles)}
|
||||
disabled={update.isPending}
|
||||
onChange={(e) => save({ historyCycles: Number(e.target.value) })}
|
||||
>
|
||||
{HISTORIES.map((n) => (
|
||||
<option key={n} value={n}>
|
||||
{n === 0 ? t("cycle.feed.historyNone") : n}
|
||||
</option>
|
||||
))}
|
||||
</NativeSelect>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="cycle-feed-title">{t("cycle.feed.titles")}</Label>
|
||||
<NativeSelect
|
||||
id="cycle-feed-title"
|
||||
value={feed.titleMode}
|
||||
disabled={update.isPending}
|
||||
onChange={(e) =>
|
||||
save({ titleMode: e.target.value as FeedTitleMode })
|
||||
}
|
||||
>
|
||||
{TITLE_MODES.map((m) => (
|
||||
<option key={m} value={m}>
|
||||
{t(`cycle.feed.titleMode.${m}`)}
|
||||
</option>
|
||||
))}
|
||||
</NativeSelect>
|
||||
</div>
|
||||
</div>
|
||||
{status.fertileAvailable ? (
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div className="space-y-0.5">
|
||||
<Label htmlFor="cycle-feed-fertile" className="text-sm">
|
||||
{t("cycle.feed.fertile")}
|
||||
</Label>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{t("cycle.feed.fertileDescription")}
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
id="cycle-feed-fertile"
|
||||
checked={feed.includeFertile}
|
||||
disabled={update.isPending}
|
||||
onCheckedChange={(v) => save({ includeFertile: v })}
|
||||
className="mt-0.5 shrink-0"
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
{update.isError ? (
|
||||
<p className="text-destructive text-sm" role="alert">
|
||||
{t("cycle.feed.error")}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function HelpList() {
|
||||
const { t } = useTranslations();
|
||||
return (
|
||||
<div className="space-y-1.5">
|
||||
<p className="text-sm font-medium">{t("cycle.feed.help.title")}</p>
|
||||
<ul className="text-muted-foreground list-disc space-y-1 pl-5 text-sm">
|
||||
<li>{t("cycle.feed.help.google")}</li>
|
||||
<li>{t("cycle.feed.help.apple")}</li>
|
||||
<li>{t("cycle.feed.help.outlook")}</li>
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
"use client";
|
||||
|
||||
import { CycleCalendarFeedCard } from "./cycle-calendar-feed-card";
|
||||
import { CycleSettings } from "./cycle-settings";
|
||||
import type { CycleProfileDTO } from "./types";
|
||||
|
||||
/**
|
||||
* The cycle page's settings tab: the profile form, then the calendar
|
||||
* subscription (#1350). The form remounts on a profile refetch (`key`), the
|
||||
* subscription card keeps its own state, including a link it just showed.
|
||||
*/
|
||||
export function CycleSettingsTab({ profile }: { profile: CycleProfileDTO }) {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<CycleSettings key={profile.updatedAt} profile={profile} />
|
||||
<CycleCalendarFeedCard />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -23,7 +23,7 @@ import { PhaseEducationCard } from "./phase-education-card";
|
||||
import { PredictionsPanel } from "./predictions-panel";
|
||||
import { CyclePhaseHeadline, CyclePhaseCrosstab } from "./cycle-phase-crosstab";
|
||||
import { CycleSymptomPatterns } from "./cycle-symptom-patterns";
|
||||
import { CycleSettings } from "./cycle-settings";
|
||||
import { CycleSettingsTab } from "./cycle-settings-tab";
|
||||
import { PHASE_HUE } from "./phase-tokens";
|
||||
import { monthOf, monthWindowOutside } from "./calendar-window";
|
||||
import {
|
||||
@@ -417,6 +417,8 @@ export function CycleView() {
|
||||
prediction={calendar.data?.prediction ?? null}
|
||||
rawChartMode={calendar.data?.profile.rawChartMode ?? false}
|
||||
history={history.data?.data}
|
||||
projection={calendar.data?.projection}
|
||||
projectionState={calendar.data?.projectionState ?? null}
|
||||
/>
|
||||
<CycleHistoryChart
|
||||
history={history.data?.data}
|
||||
@@ -471,10 +473,7 @@ export function CycleView() {
|
||||
<Loader2 className="text-muted-foreground h-6 w-6 animate-spin motion-reduce:animate-none" />
|
||||
</div>
|
||||
) : profileQuery.data && canManageCycle ? (
|
||||
<CycleSettings
|
||||
key={profileQuery.data.updatedAt}
|
||||
profile={profileQuery.data}
|
||||
/>
|
||||
<CycleSettingsTab profile={profileQuery.data} />
|
||||
) : null}
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
|
||||
@@ -14,7 +14,13 @@ import { TileHeader } from "@/components/insights/tile-header";
|
||||
import { useTranslations, useFormatters } from "@/lib/i18n/context";
|
||||
import type { Formatters } from "@/lib/format-locale";
|
||||
import { FERTILE_HUE, FLOW_HUE, OVULATION_HUE } from "./phase-tokens";
|
||||
import type { CyclePrediction, CycleHistoryResponse } from "./types";
|
||||
import type {
|
||||
CyclePrediction,
|
||||
CycleHistoryResponse,
|
||||
ProjectedCycle,
|
||||
ProjectionState,
|
||||
} from "./types";
|
||||
import { UpcomingCycles } from "./upcoming-cycles";
|
||||
import { EmptyState } from "@/components/ui/empty-state";
|
||||
|
||||
/**
|
||||
@@ -59,12 +65,17 @@ export interface PredictionsPanelProps {
|
||||
prediction: CyclePrediction | null;
|
||||
rawChartMode: boolean;
|
||||
history: CycleHistoryResponse | undefined;
|
||||
/** #1350 — the server's projection of the cycles after the next one. */
|
||||
projection?: readonly ProjectedCycle[];
|
||||
projectionState?: ProjectionState | null;
|
||||
}
|
||||
|
||||
export function PredictionsPanel({
|
||||
prediction,
|
||||
rawChartMode,
|
||||
history,
|
||||
projection = [],
|
||||
projectionState = null,
|
||||
}: PredictionsPanelProps) {
|
||||
const { t } = useTranslations();
|
||||
const fmt = useFormatters();
|
||||
@@ -94,6 +105,7 @@ export function PredictionsPanel({
|
||||
body={t("cycle.predictions.stillLearningBody")}
|
||||
/>
|
||||
<NextPeriod prediction={prediction} />
|
||||
<UpcomingCycles projection={projection} state={projectionState} />
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
@@ -119,6 +131,7 @@ export function PredictionsPanel({
|
||||
value={formatDate(prediction.predictedOvulation, fmt)}
|
||||
/>
|
||||
) : null}
|
||||
<UpcomingCycles projection={projection} state={projectionState} />
|
||||
</>
|
||||
)}
|
||||
</CardContent>
|
||||
|
||||
@@ -114,10 +114,28 @@ export interface CalendarResponse {
|
||||
* phase band — render the calm "learning your cycle" state over the grid.
|
||||
*/
|
||||
stillLearning: boolean;
|
||||
/**
|
||||
* #1350 — the next cycles with widening bands, resolved server-side (the
|
||||
* calendar feed serves the same list). Render as given; never extrapolate.
|
||||
*/
|
||||
projection: ProjectedCycle[];
|
||||
projectionState: ProjectionState;
|
||||
days: CalendarDay[];
|
||||
meta: { generatedAt: string };
|
||||
}
|
||||
|
||||
/** Mirrors `ProjectionState` in `src/lib/cycle/projection.ts`. */
|
||||
export type ProjectionState = "OK" | "LEARNING" | "LATE" | "OFF";
|
||||
|
||||
/** Mirrors `ProjectedCycle` in `src/lib/cycle/projection.ts`. */
|
||||
export interface ProjectedCycle {
|
||||
k: number;
|
||||
start: string;
|
||||
periodLength: number;
|
||||
bandStart: string;
|
||||
bandEnd: string;
|
||||
}
|
||||
|
||||
export interface MenstrualCycleDTO {
|
||||
id: string;
|
||||
startDate: string;
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
"use client";
|
||||
|
||||
import { useFormatters, useTranslations } from "@/lib/i18n/context";
|
||||
|
||||
import { FLOW_HUE } from "./phase-tokens";
|
||||
import type { ProjectedCycle, ProjectionState } from "./types";
|
||||
|
||||
/**
|
||||
* #1350 — the periods after the next one, each as the window its start may
|
||||
* fall in. The list and its bands are the server's (`/api/cycle/calendar`
|
||||
* `projection`, the same list the calendar feed serves); this only renders
|
||||
* them. The band widens from one row to the next, and the server stops the
|
||||
* list where a date would be a guess.
|
||||
*
|
||||
* The next period itself is the row above this one in the predictions card,
|
||||
* so cycle 1 is skipped here. A late period is stated, in the muted tone: a
|
||||
* late period is a fact about the calendar, not a warning.
|
||||
*/
|
||||
export function UpcomingCycles({
|
||||
projection,
|
||||
state,
|
||||
}: {
|
||||
projection: readonly ProjectedCycle[];
|
||||
state: ProjectionState | null;
|
||||
}) {
|
||||
const { t } = useTranslations();
|
||||
const fmt = useFormatters();
|
||||
|
||||
if (state === "LATE") {
|
||||
return (
|
||||
<p
|
||||
className="text-muted-foreground text-sm"
|
||||
data-slot="cycle-projection-late"
|
||||
>
|
||||
{t("cycle.projection.state.LATE")}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
const later = projection.filter((c) => c.k >= 2);
|
||||
if (later.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div className="space-y-2" data-slot="cycle-projection">
|
||||
<p className="text-muted-foreground text-xs font-medium">
|
||||
{t("cycle.projection.title")}
|
||||
</p>
|
||||
<ul className="divide-border divide-y">
|
||||
{later.map((c) => (
|
||||
<li key={c.k} className="flex items-center gap-2 py-2 text-sm">
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="h-2.5 w-2.5 shrink-0 rounded-full opacity-60"
|
||||
style={{ backgroundColor: FLOW_HUE }}
|
||||
/>
|
||||
<span className="text-muted-foreground">
|
||||
{t("cycle.projection.around", {
|
||||
date: fmt.dateShortSmartCalendar(c.start),
|
||||
})}
|
||||
</span>
|
||||
<span className="text-foreground ml-auto font-medium tabular-nums">
|
||||
{t("cycle.predictions.nextPeriodWindow", {
|
||||
low: fmt.dateShortSmartCalendar(c.bandStart),
|
||||
high: fmt.dateShortSmartCalendar(c.bandEnd),
|
||||
})}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* #1350 — the calendar subscription's read and write hooks.
|
||||
*
|
||||
* Every value the card shows is the server's: the status read carries the
|
||||
* options, the fetch statistics as a calendar-app family, and the projection
|
||||
* state that explains a shorter feed. The URL exists in the client only in
|
||||
* the one mint or rotate response that returns it, held in the card's state
|
||||
* and gone when the card unmounts.
|
||||
*/
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
|
||||
import { apiDelete, apiGet, apiPatch, apiPost } from "@/lib/api/api-fetch";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
|
||||
import type { ProjectionState } from "./types";
|
||||
|
||||
export type FeedTitleMode = "DISCREET" | "DESCRIPTIVE";
|
||||
export type FeedFetcher = "GOOGLE" | "APPLE" | "MICROSOFT" | "OTHER";
|
||||
|
||||
export interface CycleFeedSummary {
|
||||
tokenPrefix: string;
|
||||
createdAt: string;
|
||||
lastFetchedAt: string | null;
|
||||
fetchCount: number;
|
||||
lastFetcher: FeedFetcher | null;
|
||||
horizonCycles: number;
|
||||
historyCycles: number;
|
||||
includeFertile: boolean;
|
||||
titleMode: FeedTitleMode;
|
||||
}
|
||||
|
||||
export interface CycleFeedStatus {
|
||||
feed: CycleFeedSummary | null;
|
||||
projectionState: ProjectionState | null;
|
||||
fertileAvailable: boolean;
|
||||
}
|
||||
|
||||
export interface CycleFeedMinted {
|
||||
url: string;
|
||||
webcalUrl: string;
|
||||
feed: CycleFeedSummary;
|
||||
}
|
||||
|
||||
export type CycleFeedOptionsPatch = Partial<
|
||||
Pick<
|
||||
CycleFeedSummary,
|
||||
"horizonCycles" | "historyCycles" | "includeFertile" | "titleMode"
|
||||
>
|
||||
>;
|
||||
|
||||
const PATH = "/api/cycle/feed-subscription";
|
||||
|
||||
export function useCycleFeedStatus(enabled = true) {
|
||||
return useQuery({
|
||||
queryKey: queryKeys.cycleFeedSubscription(),
|
||||
queryFn: () => apiGet<CycleFeedStatus>(PATH),
|
||||
staleTime: 60_000,
|
||||
enabled,
|
||||
});
|
||||
}
|
||||
|
||||
function useInvalidateStatus() {
|
||||
const qc = useQueryClient();
|
||||
return () =>
|
||||
qc.invalidateQueries({ queryKey: queryKeys.cycleFeedSubscription() });
|
||||
}
|
||||
|
||||
/**
|
||||
* Turn the subscription on, or rotate its link. `proof` is the recent-proof
|
||||
* flow: both answer 401 `auth.reproof.required` without a fresh proof, and
|
||||
* `proof.run` opens the re-proof dialog and retries.
|
||||
*/
|
||||
export function useMintCycleFeed(proof: {
|
||||
run: <T>(action: () => Promise<T>) => Promise<T>;
|
||||
}) {
|
||||
const invalidate = useInvalidateStatus();
|
||||
return useMutation({
|
||||
mutationKey: queryKeys.cycleFeedSubscription(),
|
||||
mutationFn: (kind: "create" | "rotate") =>
|
||||
kind === "create"
|
||||
? proof.run(() =>
|
||||
apiPost<CycleFeedMinted>("/api/cycle/feed-subscription"),
|
||||
)
|
||||
: proof.run(() =>
|
||||
apiPost<CycleFeedMinted>("/api/cycle/feed-subscription/rotate"),
|
||||
),
|
||||
onSettled: () => invalidate(),
|
||||
});
|
||||
}
|
||||
|
||||
export function useUpdateCycleFeed() {
|
||||
const invalidate = useInvalidateStatus();
|
||||
return useMutation({
|
||||
mutationKey: queryKeys.cycleFeedSubscription(),
|
||||
mutationFn: (patch: CycleFeedOptionsPatch) =>
|
||||
apiPatch<CycleFeedStatus>(PATH, patch),
|
||||
onSettled: () => invalidate(),
|
||||
});
|
||||
}
|
||||
|
||||
export function useRevokeCycleFeed() {
|
||||
const invalidate = useInvalidateStatus();
|
||||
return useMutation({
|
||||
mutationKey: queryKeys.cycleFeedSubscription(),
|
||||
mutationFn: () => apiDelete<{ revoked: true }>(PATH),
|
||||
onSettled: () => invalidate(),
|
||||
});
|
||||
}
|
||||
Reference in new issue
Block a user