fix(visits): keep the appointment rail action's wire tokens for the shipped app

The shipped iOS app routes the rail by intent and resolves the label
from its own catalogue; a new `visit.view` intent would leave its tap on
an appointment inert. The upcoming-visit action keeps `checkup.view`
and `daily.action.viewCheckups` on the wire and only its `href` moves
to the visit. The web card names what that link opens: "View
appointment", or "View appointments" for the visits list.
This commit is contained in:
Marc-André Bombeck committed 2026-10-21 12:38:08 +02:00
1 parent 99fec79177
commit 2914832ed8
5 files changed
+73 -20

No files matched your search

@@ -161,6 +161,37 @@ describe("<PriorityCard>", () => {
expect(html).toContain("View check-ups");
});
// An appointment keeps the check-up label on the wire for the shipped iOS
// app; the web names what the link opens. The check-up item above is the
// negative control.
it("names an appointment's action for the visit it opens", () => {
const action = (href: string) => ({
labelKey: "daily.action.viewCheckups",
intent: "checkup.view",
href,
});
const one = render(
<PriorityCard
item={item({
kind: "upcoming_visit",
actions: [action("/checkups?visit=v1")],
})}
/>,
);
expect(one).toContain("View appointment");
expect(one).not.toContain("View check-ups");
expect(one).toContain('href="/checkups?visit=v1"');
const several = render(
<PriorityCard
item={item({
kind: "upcoming_visit",
actions: [action("/checkups?view=visits")],
})}
/>,
);
expect(several).toContain("View appointments");
});
describe("dismiss affordance — observational kinds only", () => {
const DISMISSIBLE: PriorityItemKind[] = [
"milestone",
+20 -1
View File
@@ -36,6 +36,7 @@ import { Card, CardContent } from "@/components/ui/card";
import { TileHeader } from "@/components/insights/tile-header";
import { ProseBlocks } from "@/components/insights/prose-blocks";
import { Button, buttonVariants } from "@/components/ui/button";
import { VISITS_PATH } from "@/lib/encounters/visit-links";
import { useTranslations } from "@/lib/i18n/context";
import { cn } from "@/lib/utils";
import {
@@ -205,7 +206,7 @@ export function PriorityCard({
<ActionLink
key={action.intent}
href={action.href}
label={t(action.labelKey)}
label={t(actionLabelKey(item, action))}
/>
) : onAction ? (
<ActionButton
@@ -225,6 +226,24 @@ export function PriorityCard({
}
/** A navigation action. Always offered — reading is never delegated away. */
/**
* The label key a web card shows for an action.
*
* The server's `labelKey`, except on an appointment: that item still carries
* the check-up item's label on the wire ("View check-ups"), because the
* shipped iOS app resolves the label and routes the intent it already knows.
* Its `href` already opens the visit, so the web names what it opens.
*/
export function actionLabelKey(
item: Pick<PriorityItem, "kind">,
action: Pick<PriorityItemAction, "labelKey" | "href">,
): string {
if (item.kind !== "upcoming_visit" || !action.href) return action.labelKey;
return action.href === VISITS_PATH
? "daily.action.viewAppointments"
: "daily.action.viewAppointment";
}
function ActionLink({ href, label }: { href: string; label: string }) {
return (
<Link
+2 -2
View File
@@ -30,8 +30,8 @@
* stands in its place — not an alarming empty card.
*
* Every `PriorityItem` action carries an `href` (dose.log → /medications,
* sync.reconnect → /settings/integrations, checkup.view → /checkups,
* visit.view → the visit on the checkups page's visits tab), so
* sync.reconnect → /settings/integrations, checkup.view → /checkups, or the
* visit itself on an upcoming_visit item), so
* `PriorityCard` wires each tap as a `<Link>` to its existing destination
* by construction; S2 invents no new backend action.
*
+9 -8
View File
@@ -1507,16 +1507,17 @@ describe("buildDailyDigest — due check-ups and today's visits hold their place
).toEqual(["Dr. Weiss today", "Dentist tomorrow"]);
});
// The tap opens where the appointment lives. The item used to borrow the
// check-up item's action whole (label, intent and the bare `/checkups`),
// which opens the preventive-care tab an appointment is never on.
it("opens the visit itself, with its own label and intent", () => {
// The tap opens where the appointment lives: the bare `/checkups` it carried
// opens the preventive-care tab an appointment is never on. Label and intent
// stay the check-up item's on the wire, for the shipped iOS app; the web
// card relabels (`priority-card.test.tsx`).
it("opens the visit itself, keeping the intent the app routes", () => {
const d = buildDailyDigest(input({ upcomingVisits: [visit()] }), t);
const item = d.worthALook.find((i) => i.kind === "upcoming_visit");
expect(item?.actions).toEqual([
{
labelKey: "daily.action.viewAppointment",
intent: "visit.view",
labelKey: "daily.action.viewCheckups",
intent: "checkup.view",
href: "/checkups?visit=v1",
},
]);
@@ -1535,8 +1536,8 @@ describe("buildDailyDigest — due check-ups and today's visits hold their place
const item = d.worthALook.find((i) => i.kind === "upcoming_visit");
expect(item?.actions).toEqual([
{
labelKey: "daily.action.viewAppointments",
intent: "visit.view",
labelKey: "daily.action.viewCheckups",
intent: "checkup.view",
href: "/checkups?view=visits",
},
]);
+11 -9
View File
@@ -681,10 +681,9 @@ function buildPreventiveCareItem(
* visit's own status field with a second place to set it.
*
* The action opens where the appointment lives: the visit itself, or the
* visits list for a day with several. It is `visit.view` and not the checkup
* item's `checkup.view`: the two kinds used to share that intent, its label
* and its bare `/checkups`, which sent every tap on an appointment to the
* preventive-care list the appointment is not on.
* visits list for a day with several. The bare `/checkups` it used to carry
* sent every tap on an appointment to the preventive-care list the
* appointment is not on.
*/
function buildUpcomingVisitItems(
visits: readonly DailyDigestUpcomingVisit[],
@@ -713,11 +712,14 @@ function buildUpcomingVisitItems(
status: "info",
actions: [
{
labelKey:
group.length === 1
? "daily.action.viewAppointment"
: "daily.action.viewAppointments",
intent: "visit.view",
// The wire keeps the check-up item's label and intent: the shipped
// iOS app routes `checkup.view` and knows no other token for this
// item, so a new intent would leave its tap inert. The web card
// relabels it from the kind (`actionLabelKey` in
// `priority-card.tsx`); the switch to `visit.view` waits for an
// app that understands it.
labelKey: "daily.action.viewCheckups",
intent: "checkup.view",
href: visitsPath(group.map((visit) => visit.id)),
},
],