mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-12 06:06:10 +00:00
Give the caregiver a front door and a receipt
Sharing lived in a settings panel and a menu three levels deep. That is where a grant is managed and not where it is used: somebody who opens this app every morning to check on a parent does not go through settings to do it, and the design standards draw the same line, settings being account utilities rather than feature destinations. So the dashboard carries one compact card listing the records shared with this account, each row a one-tap switch through the existing flow. It is a doorway and not a preview. No numbers, no last reading. A slice of somebody else's record on the delegate's own dashboard would sit outside the one frame the design leans on, the banner naming the record you are inside. An account nobody has shared with sees nothing at all. The other half is the receipt: a reading, a lab result or a dose recorded under a switch now says which record it landed in, because "Saved" alone is the confirmation somebody acting for another person does not need. The undo on the dose toast goes at the same time, since a delegate cannot remove what they recorded and an undo they can see is one the server refuses. Also here: the lines the owner's activity view owes each admitted verb, as a translated mapping with a fallback, so a later release filing a verb it has never heard of still renders a sentence rather than a blank row.
This commit is contained in:
1 parent
ab53d42874
commit
d5a9360c52
14 files changed
+604
-10
No files matched your search
@@ -0,0 +1,139 @@
|
||||
/**
|
||||
* The caregiver's front door, rendered.
|
||||
*
|
||||
* SSR-only, like the rest of the component suite: what is proven is the
|
||||
* paint — who the card lists and when it exists at all. The tap that switches
|
||||
* records is the switcher's existing mutation and is driven end to end in
|
||||
* `e2e/account-sharing.spec.ts`.
|
||||
*
|
||||
* Mutation checks, run:
|
||||
* - drop the `accounts.length === 0` bail → "an account nobody shares with
|
||||
* sees nothing" goes red.
|
||||
* - drop the `active != null` bail → "no doorway into the room you are in"
|
||||
* goes red.
|
||||
* - render the level from `entry.access` instead of `entry.canWrite` → "the
|
||||
* level is the server's resolved boolean" goes red.
|
||||
*/
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
|
||||
import { I18nProvider } from "@/lib/i18n/context";
|
||||
import type { AccountAccess } from "@/lib/sharing/account-access-view";
|
||||
|
||||
const GRANDMOTHER = {
|
||||
accountId: "acct-gran",
|
||||
username: "gran",
|
||||
displayName: "Margarethe",
|
||||
access: "read" as const,
|
||||
canWrite: false,
|
||||
};
|
||||
const FATHER = {
|
||||
accountId: "acct-dad",
|
||||
username: "dad",
|
||||
displayName: null,
|
||||
access: "write" as const,
|
||||
canWrite: true,
|
||||
};
|
||||
|
||||
const mockAccessRef: { value: AccountAccess } = {
|
||||
value: { accounts: [], active: null, canSwitch: false },
|
||||
};
|
||||
|
||||
vi.mock("@/hooks/use-auth", () => ({
|
||||
useAuth: () => ({
|
||||
user: {
|
||||
id: "delegate",
|
||||
username: "delegate",
|
||||
email: null,
|
||||
role: "USER",
|
||||
avatarUrl: null,
|
||||
modules: {},
|
||||
accountAccess: mockAccessRef.value,
|
||||
},
|
||||
isAuthenticated: true,
|
||||
isLoading: false,
|
||||
refetch: vi.fn(),
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("@/hooks/use-account-switch", () => ({
|
||||
useAccountSwitch: () => ({ mutate: vi.fn(), isPending: false }),
|
||||
}));
|
||||
|
||||
import { LookingAfterCard } from "../looking-after-card";
|
||||
|
||||
function render(access: AccountAccess): string {
|
||||
mockAccessRef.value = access;
|
||||
return renderToStaticMarkup(
|
||||
<I18nProvider initialLocale="en">
|
||||
<LookingAfterCard />
|
||||
</I18nProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
describe("<LookingAfterCard>", () => {
|
||||
it("an account nobody shares with sees nothing", () => {
|
||||
// The v1.36.0 posture: the feature is invisible until it applies to you.
|
||||
// Not an empty state, not a prompt to ask somebody for access.
|
||||
expect(render({ accounts: [], active: null, canSwitch: false })).toBe("");
|
||||
});
|
||||
|
||||
it("lists every record shared with this account", () => {
|
||||
const html = render({
|
||||
accounts: [GRANDMOTHER, FATHER],
|
||||
active: null,
|
||||
canSwitch: true,
|
||||
});
|
||||
expect(html).toContain('data-slot="looking-after-card"');
|
||||
expect(html).toContain('data-account-id="acct-gran"');
|
||||
expect(html).toContain('data-account-id="acct-dad"');
|
||||
// The display name where there is one, the username where there is not.
|
||||
expect(html).toContain("Margarethe");
|
||||
expect(html).toContain("dad");
|
||||
});
|
||||
|
||||
it("the level is the server's resolved boolean", () => {
|
||||
const html = render({
|
||||
accounts: [GRANDMOTHER, FATHER],
|
||||
active: null,
|
||||
canSwitch: true,
|
||||
});
|
||||
expect(html).toContain("Can view and add");
|
||||
expect(html).toContain("Can view");
|
||||
});
|
||||
|
||||
it("a grant labelled write but resolved read reads as read", () => {
|
||||
// `access` describes the row; `canWrite` is the decision the server made
|
||||
// about it this second. A grant that lapsed, or a level this build does
|
||||
// not honour, must not promise an add the next request would refuse.
|
||||
const html = render({
|
||||
accounts: [{ ...FATHER, access: "write", canWrite: false }],
|
||||
active: null,
|
||||
canSwitch: true,
|
||||
});
|
||||
expect(html).toContain("Can view");
|
||||
expect(html).not.toContain("Can view and add");
|
||||
});
|
||||
|
||||
it("shows no health data, only a doorway", () => {
|
||||
const html = render({
|
||||
accounts: [GRANDMOTHER],
|
||||
active: null,
|
||||
canSwitch: true,
|
||||
});
|
||||
// A preview would put somebody else's readings on this dashboard, outside
|
||||
// the banner frame that says which record you are looking at. The card
|
||||
// carries names and levels and nothing else.
|
||||
expect(html).not.toMatch(/\d+(\.\d+)?\s*(kg|mmHg|bpm)/);
|
||||
});
|
||||
|
||||
it("no doorway into the room you are already in", () => {
|
||||
expect(
|
||||
render({
|
||||
accounts: [GRANDMOTHER],
|
||||
active: GRANDMOTHER,
|
||||
canSwitch: true,
|
||||
}),
|
||||
).toBe("");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,129 @@
|
||||
"use client";
|
||||
|
||||
import { HeartHandshake, Loader2 } from "lucide-react";
|
||||
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { TileHeader } from "@/components/insights/tile-header";
|
||||
import { useAccountSwitch } from "@/hooks/use-account-switch";
|
||||
import { useAuth } from "@/hooks/use-auth";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import {
|
||||
accountLabel,
|
||||
type AccountAccessEntry,
|
||||
} from "@/lib/sharing/account-access-view";
|
||||
|
||||
/**
|
||||
* v1.36.x — the caregiver's front door.
|
||||
*
|
||||
* ## Why this is on the dashboard and not in settings
|
||||
*
|
||||
* Sharing shipped as a settings panel and a menu three levels deep: user menu
|
||||
* → switcher → the record. That is the right place to MANAGE a grant, and the
|
||||
* wrong place to use one. Somebody who opens this app every morning to check
|
||||
* on a parent does not go to settings to do it, and UI-STANDARDS §10 draws
|
||||
* exactly that line: settings are account utilities, not feature
|
||||
* destinations. Looking after somebody is a feature destination.
|
||||
*
|
||||
* ## What it is not
|
||||
*
|
||||
* It is a doorway, not a preview. No numbers, no last reading, no status.
|
||||
* Putting a slice of somebody else's health record on a delegate's own
|
||||
* dashboard would place it outside the one frame the whole design leans on —
|
||||
* the banner that says which record you are inside. Everything about the
|
||||
* other person is read after the switch, under that banner, or not at all.
|
||||
*
|
||||
* ## Where the content comes from
|
||||
*
|
||||
* `accountAccess.accounts`, the same resolved list the switcher renders, and
|
||||
* the switch is the same mutation the switcher fires. No second mechanism, no
|
||||
* second source of truth about who may open what. An account nobody has
|
||||
* shared with sees nothing at all — no empty state, no invitation to ask
|
||||
* somebody for access (the v1.36.0 posture: the feature is invisible until it
|
||||
* applies to you).
|
||||
*
|
||||
* Design: `Card` + `TileHeader` per UI-STANDARDS §1 and §5 (icon and title in
|
||||
* the foreground colour, title `text-base`), the person's name as content in
|
||||
* `text-foreground` and the grant level as meta in `text-muted-foreground`
|
||||
* per §3, and the §11 row shape — label block `min-w-0 flex-1`, the whole row
|
||||
* a single tap target at the `min-h-11` mobile floor.
|
||||
*/
|
||||
export function LookingAfterCard() {
|
||||
const { t } = useTranslations();
|
||||
const { user } = useAuth();
|
||||
const switchAccount = useAccountSwitch();
|
||||
|
||||
const access = user?.accountAccess;
|
||||
const accounts = access?.accounts ?? [];
|
||||
|
||||
// Inside somebody else's record the card would be a doorway to the room you
|
||||
// are already in; the banner owns that context and carries the way out.
|
||||
if (access?.active != null) return null;
|
||||
if (accounts.length === 0) return null;
|
||||
|
||||
return (
|
||||
<Card data-slot="looking-after-card">
|
||||
<CardContent className="space-y-3">
|
||||
<TileHeader
|
||||
icon={HeartHandshake}
|
||||
title={t("recordSharing.lookingAfter.title")}
|
||||
titleAs="h2"
|
||||
/>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{t("recordSharing.lookingAfter.description")}
|
||||
</p>
|
||||
<ul className="divide-y">
|
||||
{accounts.map((entry) => (
|
||||
<li key={entry.accountId}>
|
||||
<button
|
||||
type="button"
|
||||
data-slot="looking-after-row"
|
||||
data-account-id={entry.accountId}
|
||||
disabled={switchAccount.isPending}
|
||||
onClick={() => switchAccount.mutate(entry.accountId)}
|
||||
aria-label={t("recordSharing.lookingAfter.open", {
|
||||
name: accountLabel(entry),
|
||||
})}
|
||||
className="hover:bg-accent/40 focus-visible:ring-ring/50 flex min-h-11 w-full items-center gap-3 rounded-md px-2 py-2 text-left transition-colors focus-visible:ring-2 focus-visible:outline-none disabled:opacity-50"
|
||||
>
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="text-foreground block truncate text-sm font-medium">
|
||||
{accountLabel(entry)}
|
||||
</span>
|
||||
<span className="text-muted-foreground block truncate text-xs">
|
||||
{levelLabel(entry, t)}
|
||||
</span>
|
||||
</span>
|
||||
{switchAccount.isPending && (
|
||||
<Loader2
|
||||
className="text-muted-foreground size-4 shrink-0 animate-spin motion-reduce:animate-none"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
)}
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The grant level, in the words the person reading it would use.
|
||||
*
|
||||
* Bound from the server's resolved `canWrite`, never from the `access` label
|
||||
* beside it: the label describes the row, the boolean is the decision, and a
|
||||
* lapsed WRITE grant must read as view-only here exactly as it behaves.
|
||||
*
|
||||
* No "last opened" line: `lastUsedAt` is not on this payload, and a relative
|
||||
* time invented from something else would be a placeholder wearing a fact's
|
||||
* clothes.
|
||||
*/
|
||||
function levelLabel(
|
||||
entry: AccountAccessEntry,
|
||||
t: (key: string) => string,
|
||||
): string {
|
||||
return entry.canWrite
|
||||
? t("recordSharing.lookingAfter.levelWrite")
|
||||
: t("recordSharing.lookingAfter.levelRead");
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useActiveRecordName } from "@/hooks/use-record-capabilities";
|
||||
import { useId, useRef, useState } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
@@ -95,6 +96,7 @@ export function LabForm({
|
||||
footerSlot,
|
||||
}: LabFormProps) {
|
||||
const { t } = useTranslations();
|
||||
const recordName = useActiveRecordName();
|
||||
const queryClient = useQueryClient();
|
||||
const formId = useId();
|
||||
|
||||
@@ -199,7 +201,16 @@ export function LabForm({
|
||||
takenAt: new Date(takenAt).toISOString(),
|
||||
...(note.trim() ? { note: note.trim() } : {}),
|
||||
});
|
||||
toast.success(t("labs.form.savedToast"));
|
||||
toast.success(
|
||||
t("labs.form.savedToast"),
|
||||
recordName
|
||||
? {
|
||||
description: t("recordSharing.toast.savedTo", {
|
||||
name: recordName,
|
||||
}),
|
||||
}
|
||||
: undefined,
|
||||
);
|
||||
if (keepOpen) {
|
||||
// A real lab report shares one blood-draw date across every
|
||||
// analyte — clear the reading but deliberately KEEP `takenAt` so
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useActiveRecordName } from "@/hooks/use-record-capabilities";
|
||||
import { useId, useState } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
@@ -238,6 +239,7 @@ export function MeasurementForm({
|
||||
footerSlot,
|
||||
}: MeasurementFormProps) {
|
||||
const { t } = useTranslations();
|
||||
const recordName = useActiveRecordName();
|
||||
const queryClient = useQueryClient();
|
||||
const unitDisplay = useUnitDisplay();
|
||||
|
||||
@@ -371,7 +373,19 @@ export function MeasurementForm({
|
||||
setNotes("");
|
||||
await invalidateKeys(queryClient, measurementDependentKeys);
|
||||
await refetchInactiveDailyReads(queryClient);
|
||||
toast.success(t("common.saved"));
|
||||
// v1.36.x — a delegate's receipt names the record. "Saved" alone is
|
||||
// the one confirmation somebody acting for another person does not
|
||||
// need, and the reading has just left their own history for good.
|
||||
toast.success(
|
||||
t("common.saved"),
|
||||
recordName
|
||||
? {
|
||||
description: t("recordSharing.toast.savedTo", {
|
||||
name: recordName,
|
||||
}),
|
||||
}
|
||||
: undefined,
|
||||
);
|
||||
onSuccess?.();
|
||||
} catch (err) {
|
||||
setError(localizedApiError(err, t, "measurements.saveError"));
|
||||
|
||||
Reference in new issue
Block a user