Files
HealthLog/src/lib/record-activity/activity-verb.ts
T
Marc-André Bombeck c92a80ee4c feat: describe managed sharing activity
Every action a manage-level route files now renders as its own sentence in
the owner's activity view, in all six locales. A write delegate can only add,
so the generic "made a change" was at least the right shape of fact; a manager
changes and removes entries the owner made themselves, and the generic line
withholds the only part that matters — whether a reading was corrected or
deleted, whether a dose history was purged.

The inventory reads the routes and calls the map rather than matching `case`
in its source, so an action that falls through to the fallback fails, and it
follows each resolved key into all six bundles. Every sentence interpolates
the actor's name and nothing else: the audit table is not encrypted and a
value in a verb line would be a health figure in a second store.

The revoke dialog's retention disclosure no longer depends on which of two
requests answers first. `retentionDays` rides the grant list, so a client
holding a row to revoke holds the window it must state, and the branch that
omitted the sentence is gone.
2026-08-06 23:30:07 +02:00

263 lines
12 KiB
TypeScript

/** The `t` from `useTranslations()`, injected so this stays pure. */
type Translator = (
key: string,
params?: Record<string, string | number>,
) => string;
/**
* What somebody did in your record, in a sentence.
*
* ## Why this exists
*
* The activity view renders every non-read row as one generic line: "somebody
* made a change in your record". That was honest while sharing was read-only
* and no delegate could make one. Now a delegate can enter a reading, a lab
* result, an illness entry, a biomarker, a side effect, a medication, and can
* mark a dose taken — and "made a change" for seven different things is a way
* of answering nothing. An owner reading this at breakfast wants the verb.
*
* The map keeps a line for a tracked value even though a delegate can no
* longer add one: rows written while it was admitted sit in the same table and
* still have to render as something, which is the same reason the fallback
* below exists.
*
* ## v1.37.0 — the MANAGE half
*
* A WRITE delegate can only add, so the generic line was at least the right
* shape of fact. A MANAGE delegate changes and removes entries the owner made
* themselves, and there "somebody made a change in your record" withholds the
* only part that matters: whether a reading was corrected or deleted, whether
* a medication was stopped, whether a whole dose history was purged. The level
* ships under "reconstructable or refused", and a feed that cannot say which
* verb ran reconstructs nothing.
*
* So every action a MANAGE-declared route files has an arm below, and
* `src/__tests__/manage-activity-inventory.test.ts` reads the routes to prove
* it — by CALLING this function, not by matching `case` in its source, so an
* action that quietly falls through to the fallback fails there.
*
* ## What a line may say
*
* The verb and the kind of thing, never the value. Every sentence here takes
* `name` and nothing else, and the inventory guard holds that across all six
* bundles. The activity feed reads out of the audit table, which is not
* encrypted and has its own retention: "changed your weight from 81 to 78"
* would put a health figure in a second store with no rotation story. What
* changed is a question the record itself answers.
*
* ## Why unknown actions fall back rather than fail
*
* The action string comes from the audit row, which outlives this map: a
* release that admits a twelfth verb writes rows this map has never heard of,
* and rows written by an older image sit in the same table. So an unmapped
* action renders the generic line it renders today. A blank row would be the
* one outcome worse than a vague one.
*
* Pure and translator-injected so the mapping can be pinned without rendering
* a card, and so the keys stay literal for the i18n call-site guard.
*/
export function recordActivityVerbLine(
t: Translator,
action: string,
name: string,
): string {
switch (action) {
case "measurement.create":
return t("recordSharing.activityVerb.measurementCreate", { name });
case "labResult.create":
return t("recordSharing.activityVerb.labResultCreate", { name });
case "allergy.create":
return t("recordSharing.activityVerb.allergyCreate", { name });
case "family-history.create":
return t("recordSharing.activityVerb.familyHistoryCreate", { name });
case "illness.episode.create":
return t("recordSharing.activityVerb.illnessEpisodeCreate", { name });
case "biomarker.create":
return t("recordSharing.activityVerb.biomarkerCreate", { name });
case "customMetricEntry.create":
return t("recordSharing.activityVerb.customMetricEntryCreate", { name });
case "medication.sideEffect.create":
return t("recordSharing.activityVerb.medicationSideEffectCreate", {
name,
});
case "medication.create":
return t("recordSharing.activityVerb.medicationCreate", { name });
// Both spellings, and the reason is worth keeping. The web client posts to
// `POST /api/medications/[id]/intake`, which has audited as
// `medication.intake` since v1.0.0; `medications.intake.update` is the
// canonical route's action and the one the plan's copy deck named. Mapping
// only the second meant the single most-used delegated act — a caregiver
// marking a dose — rendered as the generic "made a change" for the person
// whose tablets they are.
case "medication.intake":
case "medications.intake.update":
return t("recordSharing.activityVerb.medicationIntake", { name });
// The refused half of the same route, and it earns a line of its own for
// the reason the whole panel exists. Deferring a reminder and rewriting a
// dose the owner already recorded are the two things that request can
// express and delegation does not cover. Folding them into "marked a dose"
// is what made the gap dangerous in the first place: the owner would have
// seen the sentence for a contribution against an attempt to switch their
// reminders off. An attempt that was refused is still something the person
// whose record it is should be able to see.
case "medications.intake.update.refused":
return t("recordSharing.activityVerb.medicationIntakeRefused", { name });
// A batch post is still readings arriving; the owner does not care that the
// client sent them together.
case "measurement.create.batch":
return t("recordSharing.activityVerb.measurementCreate", { name });
/* ---------------------------------------------------------------- */
/* v1.37.0 — MANAGE. Everything below can change or remove something */
/* the owner put there themselves. */
/* ---------------------------------------------------------------- */
case "measurement.update":
return t("recordSharing.activityVerb.measurementUpdate", { name });
case "measurement.delete":
return t("recordSharing.activityVerb.measurementDelete", { name });
// Its own line rather than the singular one. "Removed a reading" for a
// bulk delete is the sentence that would let a month of readings go
// missing while the feed reported a tidy-up.
case "measurement.delete.bulk":
return t("recordSharing.activityVerb.measurementDeleteBulk", { name });
case "measurement.restore":
return t("recordSharing.activityVerb.measurementRestore", { name });
case "measurementReminder.create":
return t("recordSharing.activityVerb.measurementReminderCreate", {
name,
});
case "measurementReminder.update":
return t("recordSharing.activityVerb.measurementReminderUpdate", {
name,
});
case "measurementReminder.delete":
return t("recordSharing.activityVerb.measurementReminderDelete", {
name,
});
case "measurementReminder.complete":
return t("recordSharing.activityVerb.measurementReminderComplete", {
name,
});
case "measurementReminder.satisfy":
return t("recordSharing.activityVerb.measurementReminderSatisfy", {
name,
});
case "labResult.update":
return t("recordSharing.activityVerb.labResultUpdate", { name });
case "labResult.delete":
return t("recordSharing.activityVerb.labResultDelete", { name });
case "labResult.restore":
return t("recordSharing.activityVerb.labResultRestore", { name });
case "biomarker.update":
return t("recordSharing.activityVerb.biomarkerUpdate", { name });
case "biomarker.delete":
return t("recordSharing.activityVerb.biomarkerDelete", { name });
case "allergy.update":
return t("recordSharing.activityVerb.allergyUpdate", { name });
case "allergy.delete":
return t("recordSharing.activityVerb.allergyDelete", { name });
case "family-history.update":
return t("recordSharing.activityVerb.familyHistoryUpdate", { name });
case "family-history.delete":
return t("recordSharing.activityVerb.familyHistoryDelete", { name });
case "illness.episode.update":
return t("recordSharing.activityVerb.illnessEpisodeUpdate", { name });
case "illness.episode.delete":
return t("recordSharing.activityVerb.illnessEpisodeDelete", { name });
case "illness.episode.resolve":
return t("recordSharing.activityVerb.illnessEpisodeResolve", { name });
case "illness.episode.restore":
return t("recordSharing.activityVerb.illnessEpisodeRestore", { name });
case "illness.day-log.upsert":
return t("recordSharing.activityVerb.illnessDayLogUpsert", { name });
case "medication.update":
return t("recordSharing.activityVerb.medicationUpdate", { name });
case "medication.delete":
return t("recordSharing.activityVerb.medicationDelete", { name });
case "medication.glp1.update":
return t("recordSharing.activityVerb.medicationGlp1Update", { name });
case "medication.schedule_revision.created":
return t("recordSharing.activityVerb.medicationScheduleCreate", { name });
case "medication.schedule_revision.updated":
return t("recordSharing.activityVerb.medicationScheduleUpdate", { name });
case "medication.schedule_revision.deleted":
return t("recordSharing.activityVerb.medicationScheduleDelete", { name });
case "medication.inventory.create":
return t("recordSharing.activityVerb.medicationInventoryCreate", {
name,
});
case "medication.inventory.update":
return t("recordSharing.activityVerb.medicationInventoryUpdate", {
name,
});
case "medication.inventory.delete":
return t("recordSharing.activityVerb.medicationInventoryDelete", {
name,
});
case "medication.sideEffect.delete":
return t("recordSharing.activityVerb.medicationSideEffectDelete", {
name,
});
case "medication.intake.update":
return t("recordSharing.activityVerb.medicationIntakeUpdate", { name });
case "medication.intake.delete":
return t("recordSharing.activityVerb.medicationIntakeDelete", { name });
case "medication.intake.bulk_delete":
return t("recordSharing.activityVerb.medicationIntakeBulkDelete", {
name,
});
// The one that empties a medication's whole history in one request. It
// says so, because "removed several doses" would read as a correction.
case "medication.intake.purge":
return t("recordSharing.activityVerb.medicationIntakePurge", { name });
case "medication.intake.import.kickoff":
return t("recordSharing.activityVerb.medicationIntakeImport", { name });
case "medication.intake.history.import.kickoff":
return t("recordSharing.activityVerb.medicationIntakeHistoryImport", {
name,
});
case "moodEntry.create":
return t("recordSharing.activityVerb.moodEntryCreate", { name });
case "moodEntry.update":
return t("recordSharing.activityVerb.moodEntryUpdate", { name });
case "moodEntry.delete":
return t("recordSharing.activityVerb.moodEntryDelete", { name });
case "mood.delete.bulk":
return t("recordSharing.activityVerb.moodDeleteBulk", { name });
case "mood.restore":
return t("recordSharing.activityVerb.moodRestore", { name });
case "mental-health.create":
return t("recordSharing.activityVerb.mentalHealthCreate", { name });
case "cycle.cycle.delete":
return t("recordSharing.activityVerb.cycleDelete", { name });
case "cycle.day-log.upsert":
return t("recordSharing.activityVerb.cycleDayLogUpsert", { name });
case "cycle.day-log.update":
return t("recordSharing.activityVerb.cycleDayLogUpdate", { name });
case "cycle.day-log.delete":
return t("recordSharing.activityVerb.cycleDayLogDelete", { name });
case "cycle.period.boundary":
return t("recordSharing.activityVerb.cyclePeriodBoundary", { name });
case "cycle.symptom.custom.create":
return t("recordSharing.activityVerb.cycleSymptomCreate", { name });
case "nutrient.water.write":
return t("recordSharing.activityVerb.nutrientWaterWrite", { name });
// Not a change to the record, and the one act on this list that takes a
// copy of all of it out of the instance. Named plainly for that reason.
case "health-record.export":
return t("recordSharing.activityVerb.healthRecordExport", { name });
default:
return t("recordSharing.activity.acted", { name });
}
}