mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 04:50:59 +00:00
Rework the proactive Coach nudge so it reads like one human checking in on
another instead of a terse English alert.
Copy: the title is a name-led greeting that rotates day to day ("Guten
Morgen, …" / "Hey …"; the 05:15 frame makes a morning opener safe) and the
body is one calm observation plus a gentle invite — never an imperative.
Drop the verbatim quoting of the user's decrypted self-context sentence:
the focus is referenced only by presence, abstractly, and is no longer
decrypted at all. Remove the 80-char mid-word clamp; each channel composes
to a length budget instead.
Cadence: suppress the nudge when the user already talked to the Coach in
the last ~24 h. With the existing rolling frequency cap (never two in a
window) and the no-trigger-no-nudge rule, the surface stays anti-nag.
AI enrichment (opt-in via the coach prefs blob, default off): when a
provider is healthy the body is composed through the model under hard
guards — a per-user budget gate, a tight per-call timeout, a per-tick
ceiling so the sequential tick can't stall across the user base, and the
outbound safety screen on the output. Any error, timeout, budget refusal
or unsafe reply falls silently back to the deterministic template, which is
always the default and the fail-closed fallback.
Localized greetings, per-trigger bodies and the focus note across all six
locales; the prefs opt-in is surfaced in the notification settings.
546 lines
19 KiB
TypeScript
546 lines
19 KiB
TypeScript
/**
|
||
* v1.4.49 M-DOUBLE-REMINDER — per-user notification preferences blob.
|
||
*
|
||
* Persisted on `User.notificationPrefs` as Postgres jsonb. Null = the
|
||
* documented defaults (every category off). The flag is opt-in: the
|
||
* iOS app flips it to `true` only after confirming local Spezi
|
||
* reminders are wired, so existing users without a working local
|
||
* notification path see zero regression.
|
||
*
|
||
* Shape (additive — future categories slot in next to `medication`):
|
||
* {
|
||
* "medication": { "clientManaged": boolean },
|
||
* "mood": { "reminderHour": 0..23 },
|
||
* "cycle": { "clientManaged": boolean }
|
||
* }
|
||
*
|
||
* The schema is intentionally additive — every new category grows the
|
||
* schema with a new optional sub-object plus an entry in
|
||
* `DEFAULT_NOTIFICATION_PREFS`. Forward-compat: an unknown / drifted
|
||
* shape falls back to defaults rather than throwing.
|
||
*/
|
||
import { z } from "zod/v4";
|
||
|
||
/**
|
||
* Medication category schema. Currently a single boolean
|
||
* (`clientManaged`); the sub-object exists so future per-category
|
||
* knobs (e.g. quiet-hours overrides) can be added without breaking
|
||
* the persisted layout.
|
||
*/
|
||
const medicationPrefsSchema = z
|
||
.object({
|
||
clientManaged: z.boolean(),
|
||
/**
|
||
* v1.7.0 — roaming user-level delivery default. "server" = the
|
||
* server fires APNs (the legacy behaviour); "client" = the iOS app
|
||
* manages reminders locally. Kept as a SEPARATE field from
|
||
* `clientManaged` (the established cron gate the reminder worker
|
||
* reads verbatim at `reminder-worker.ts`); the resolver maps
|
||
* `deliveryDefault === "client"` onto `clientManaged: true` for
|
||
* backward compatibility so the cron keeps reading one boolean.
|
||
*/
|
||
deliveryDefault: z.enum(["server", "client"]),
|
||
/**
|
||
* v1.16.11 — low-stock alert threshold as REMAINING RUNWAY: notify
|
||
* when a tracked medication's projected supply covers fewer than
|
||
* this many days. 1–60; `null` switches the alert off entirely.
|
||
* Default 7 (see `DEFAULT_NOTIFICATION_PREFS`). Read once daily by
|
||
* the medication-low-stock cron.
|
||
*/
|
||
lowStockRunwayDays: z.number().int().min(1).max(60).nullable(),
|
||
/**
|
||
* v1.17.0 — reorder lead time (days) the low-stock alert assumes
|
||
* between placing a refill order and the new supply arriving. The
|
||
* engine fires the alert when the projected runway drops to or below
|
||
* `max(lowStockRunwayDays, reorderLeadDays + one dose-interval)`, so
|
||
* the warning lands BEFORE the last dose for any cadence (a weekly
|
||
* injection no longer alerts only when ~1 dose is left). 0–60;
|
||
* default 10. A per-medication `Medication.reorderLeadDays` overrides
|
||
* this user default when set.
|
||
*/
|
||
reorderLeadDays: z.number().int().min(0).max(60),
|
||
})
|
||
.partial();
|
||
|
||
/**
|
||
* v1.7.0 — mood category schema. `reminderHour` is the local-time hour
|
||
* (0–23) at which the daily mood reminder fires. Sub-object form keeps
|
||
* the layout open for future mood knobs (quiet days, snooze, …).
|
||
* Default is 22:00 — see `DEFAULT_NOTIFICATION_PREFS` — so an unset
|
||
* value is identical to the legacy hardcoded behaviour.
|
||
*/
|
||
const moodPrefsSchema = z
|
||
.object({
|
||
reminderHour: z.number().int().min(0).max(23),
|
||
})
|
||
.partial();
|
||
|
||
/**
|
||
* v1.15 — cycle category schema. `clientManaged` mirrors the medication
|
||
* gate: when the iOS app owns local cycle reminders (period-soon /
|
||
* period-start-confirm), it flips this to `true` and the server-side
|
||
* cycle cron suppresses its APNs send for that user, exactly as the
|
||
* medication path does. Sub-object form keeps the layout open for future
|
||
* cycle-notification knobs.
|
||
*/
|
||
const cyclePrefsSchema = z
|
||
.object({
|
||
clientManaged: z.boolean(),
|
||
})
|
||
.partial();
|
||
|
||
/**
|
||
* v1.17.1 — measurement / Vorsorge reminder category schema.
|
||
* `clientManaged` mirrors the medication + cycle gate: when the iOS app
|
||
* owns local Vorsorge reminders it flips this to `true` and the
|
||
* server-side measurement-reminder cron suppresses its APNs send for that
|
||
* user. Sub-object form keeps the layout open for future Vorsorge knobs.
|
||
*/
|
||
const measurementReminderPrefsSchema = z
|
||
.object({
|
||
clientManaged: z.boolean(),
|
||
})
|
||
.partial();
|
||
|
||
/**
|
||
* v1.15.20 — coach category schema. `nudgesEnabled` gates the proactive
|
||
* Coach nudge cron (05:15 Europe/Berlin): default ON (the nudge is an
|
||
* opt-out surface — it only fires for users who kept the Coach enabled
|
||
* AND have a working provider), flipped off from Settings →
|
||
* Notifications. Sub-object form keeps the layout open for future
|
||
* coach-notification knobs (quiet days, trigger selection, …).
|
||
*/
|
||
const coachPrefsSchema = z
|
||
.object({
|
||
nudgesEnabled: z.boolean(),
|
||
/**
|
||
* v1.16.5 — per-trigger-group opt-outs underneath the master
|
||
* switch. Groups, not individual triggers, so the UI stays three
|
||
* comprehensible toggles as the trigger list grows:
|
||
* medication → compliance; vitals → bp / score / weight / sleep;
|
||
* routine → measurement-gap / self-context check-up.
|
||
*/
|
||
nudgeMedication: z.boolean(),
|
||
nudgeVitals: z.boolean(),
|
||
nudgeRoutine: z.boolean(),
|
||
/** v1.16.5 — frequency cap: one nudge per 7 or per 14 days. */
|
||
nudgeFrequency: z.enum(["weekly", "biweekly"]),
|
||
/**
|
||
* v1.25.0 — opt-out for the proactive ambient SUGGESTIONS surfaced in the
|
||
* UI (the daily seeded example opener on the Coach hero + the "try asking"
|
||
* prompt chips). Default ON. Separate from `nudgesEnabled` (the push-nudge
|
||
* master switch) so a user can keep the nudges but silence the example
|
||
* prompts, or vice versa.
|
||
*/
|
||
ambientSuggestions: z.boolean(),
|
||
/**
|
||
* v1.25.0 — opt-IN for AI-composed nudge copy. Default OFF: the proactive
|
||
* nudge ships a deterministic warm localized template, and that template is
|
||
* always the fail-closed fallback. When this is on AND a provider is
|
||
* healthy the 05:15 tick composes the nudge through the model instead —
|
||
* under a per-user budget gate, a tight per-call timeout and a per-tick
|
||
* ceiling, with any error/timeout/budget falling silently back to the
|
||
* template. Off by default so provider saturation is opt-in, never the
|
||
* baseline.
|
||
*/
|
||
nudgeAiComposed: z.boolean(),
|
||
})
|
||
.partial();
|
||
|
||
/**
|
||
* v1.7.0 — per-device delivery override schema for the device PATCH.
|
||
* NULL clears the override (the device inherits the user-level roaming
|
||
* default).
|
||
*/
|
||
export const deviceDeliverySchema = z.enum(["server", "client"]).nullable();
|
||
|
||
/**
|
||
* Top-level prefs schema. Every category is optional so a PATCH that
|
||
* only touches `medication` doesn't have to re-state future siblings.
|
||
* The route layer deep-merges the partial input over the persisted
|
||
* row before persisting so the column shape stays stable.
|
||
*/
|
||
export const notificationPrefsSchema = z
|
||
.object({
|
||
medication: medicationPrefsSchema,
|
||
mood: moodPrefsSchema,
|
||
cycle: cyclePrefsSchema,
|
||
coach: coachPrefsSchema,
|
||
measurementReminder: measurementReminderPrefsSchema,
|
||
})
|
||
.partial();
|
||
|
||
export type NotificationPrefsInput = z.infer<typeof notificationPrefsSchema>;
|
||
|
||
/**
|
||
* Fully-resolved prefs. Every key required so consumers (cron
|
||
* suppression, GET response) don't need to thread "is this present?"
|
||
* checks through their paths.
|
||
*/
|
||
export interface NotificationPrefs {
|
||
medication: {
|
||
clientManaged: boolean;
|
||
/** v1.7.0 — roaming user-level delivery default. */
|
||
deliveryDefault: "server" | "client";
|
||
/**
|
||
* v1.16.11 — low-stock runway threshold in days (1–60), or `null`
|
||
* when the alert is off. Default 7.
|
||
*/
|
||
lowStockRunwayDays: number | null;
|
||
/**
|
||
* v1.17.0 — reorder lead time (days) the low-stock alert assumes.
|
||
* 0–60; default 10. Per-medication `reorderLeadDays` overrides it.
|
||
*/
|
||
reorderLeadDays: number;
|
||
};
|
||
mood: {
|
||
/** v1.7.0 — local-time hour (0–23) for the daily mood reminder. */
|
||
reminderHour: number;
|
||
};
|
||
cycle: {
|
||
/**
|
||
* v1.15 — when true the iOS app owns local cycle reminders and the
|
||
* server-side cycle cron suppresses its APNs send (the medication
|
||
* `clientManaged` precedent).
|
||
*/
|
||
clientManaged: boolean;
|
||
};
|
||
coach: {
|
||
/**
|
||
* v1.15.20 — proactive Coach nudge opt-out. Default `true`; the
|
||
* 05:15 nudge cron skips the user when `false`.
|
||
*/
|
||
nudgesEnabled: boolean;
|
||
/** v1.16.5 — medication-group triggers (compliance). Default on. */
|
||
nudgeMedication: boolean;
|
||
/** v1.16.5 — vitals-group triggers (bp / score / weight / sleep). */
|
||
nudgeVitals: boolean;
|
||
/** v1.16.5 — routine-group triggers (measurement gap / self-context). */
|
||
nudgeRoutine: boolean;
|
||
/** v1.16.5 — cap interval: "weekly" (7 d) or "biweekly" (14 d). */
|
||
nudgeFrequency: "weekly" | "biweekly";
|
||
/**
|
||
* v1.25.0 — proactive ambient example suggestions in the UI (seeded
|
||
* opener + suggested-prompt chips). Default `true`.
|
||
*/
|
||
ambientSuggestions: boolean;
|
||
/**
|
||
* v1.25.0 — opt-in for AI-composed nudge copy. Default `false`; the
|
||
* deterministic template stays the default + fail-closed fallback.
|
||
*/
|
||
nudgeAiComposed: boolean;
|
||
};
|
||
measurementReminder: {
|
||
/**
|
||
* v1.17.1 — when true the iOS app owns local Vorsorge reminders and
|
||
* the server-side measurement-reminder cron suppresses its APNs send
|
||
* (the medication / cycle `clientManaged` precedent).
|
||
*/
|
||
clientManaged: boolean;
|
||
};
|
||
}
|
||
|
||
/**
|
||
* v1.7.0 — default mood-reminder hour. 22:00 reproduces the legacy
|
||
* hardcoded window, so any user with an unset value sees zero change.
|
||
*/
|
||
export const DEFAULT_MOOD_REMINDER_HOUR = 22;
|
||
|
||
/**
|
||
* v1.16.11 — default low-stock runway threshold: notify when the
|
||
* remaining supply covers fewer than 7 days.
|
||
*/
|
||
export const DEFAULT_LOW_STOCK_RUNWAY_DAYS = 7;
|
||
|
||
/**
|
||
* v1.17.0 — default reorder lead time: assume 10 days between ordering a
|
||
* refill and the new supply arriving. The alert engine widens the
|
||
* trigger by this lead plus one dose-interval so a sparse cadence is
|
||
* warned before its last dose.
|
||
*/
|
||
export const DEFAULT_REORDER_LEAD_DAYS = 10;
|
||
|
||
/**
|
||
* Safe defaults. Every category is off so the server reminders flow
|
||
* unchanged for any user the iOS app has not explicitly opted in.
|
||
* The maintainer's directive: zero regression for clients without working
|
||
* local reminders.
|
||
*/
|
||
export const DEFAULT_NOTIFICATION_PREFS: NotificationPrefs = {
|
||
medication: {
|
||
clientManaged: false,
|
||
deliveryDefault: "server",
|
||
lowStockRunwayDays: DEFAULT_LOW_STOCK_RUNWAY_DAYS,
|
||
reorderLeadDays: DEFAULT_REORDER_LEAD_DAYS,
|
||
},
|
||
mood: {
|
||
reminderHour: DEFAULT_MOOD_REMINDER_HOUR,
|
||
},
|
||
cycle: {
|
||
clientManaged: false,
|
||
},
|
||
coach: {
|
||
nudgesEnabled: true,
|
||
nudgeMedication: true,
|
||
nudgeVitals: true,
|
||
nudgeRoutine: true,
|
||
nudgeFrequency: "weekly",
|
||
ambientSuggestions: true,
|
||
nudgeAiComposed: false,
|
||
},
|
||
measurementReminder: {
|
||
clientManaged: false,
|
||
},
|
||
};
|
||
|
||
/**
|
||
* Parse the persisted `notificationPrefs` JSON blob into a typed
|
||
* `NotificationPrefs`, falling back to the defaults when the row is
|
||
* null OR the persisted shape has drifted (a forward-compat field
|
||
* rename, an admin-side hand-edit, etc.). Missing keys are filled
|
||
* from the defaults so callers always get a fully-resolved object.
|
||
*/
|
||
export function parseNotificationPrefs(raw: unknown): NotificationPrefs {
|
||
if (raw == null) return cloneDefaults();
|
||
const parsed = notificationPrefsSchema.safeParse(raw);
|
||
if (!parsed.success) return cloneDefaults();
|
||
return mergeOverDefaults(parsed.data);
|
||
}
|
||
|
||
/**
|
||
* Deep-merge a partial input (typically from a PATCH body) over the
|
||
* current persisted row, layering each supplied category's keys over
|
||
* the existing category sub-object so future siblings are not
|
||
* overwritten when the client only PATCHes `medication`.
|
||
*/
|
||
export function resolveNotificationPrefs(
|
||
current: unknown,
|
||
incoming: NotificationPrefsInput,
|
||
): NotificationPrefs {
|
||
const base = parseNotificationPrefs(current);
|
||
return applyDeliveryDefaultMapping({
|
||
medication: {
|
||
...base.medication,
|
||
...(incoming.medication ?? {}),
|
||
},
|
||
mood: {
|
||
...base.mood,
|
||
...(incoming.mood ?? {}),
|
||
},
|
||
cycle: {
|
||
...base.cycle,
|
||
...(incoming.cycle ?? {}),
|
||
},
|
||
coach: {
|
||
...base.coach,
|
||
...(incoming.coach ?? {}),
|
||
},
|
||
measurementReminder: {
|
||
...base.measurementReminder,
|
||
...(incoming.measurementReminder ?? {}),
|
||
},
|
||
});
|
||
}
|
||
|
||
/**
|
||
* v1.7.0 — map the human-meaningful `deliveryDefault` onto the
|
||
* established `clientManaged` cron gate. When the user sets
|
||
* `deliveryDefault: "client"` ("Alle Geräte" → local), the reminder
|
||
* worker must suppress server APNs, which it does by reading
|
||
* `clientManaged`. Keeping two fields lets the iOS UI surface the
|
||
* "server / client" choice while the cron keeps reading one boolean.
|
||
* The mapping never flips `clientManaged` back to false on its own —
|
||
* an explicit `clientManaged` in the input still wins.
|
||
*/
|
||
function applyDeliveryDefaultMapping(
|
||
prefs: NotificationPrefs,
|
||
): NotificationPrefs {
|
||
const clientManaged =
|
||
prefs.medication.deliveryDefault === "client"
|
||
? true
|
||
: prefs.medication.clientManaged;
|
||
return {
|
||
medication: {
|
||
...prefs.medication,
|
||
clientManaged,
|
||
},
|
||
mood: { ...prefs.mood },
|
||
cycle: { ...prefs.cycle },
|
||
coach: { ...prefs.coach },
|
||
measurementReminder: { ...prefs.measurementReminder },
|
||
};
|
||
}
|
||
|
||
/**
|
||
* Cron-side helper. Returns `true` when the user has opted in to
|
||
* client-managed medication reminders and the server-side push
|
||
* should be suppressed. Tolerates a null / missing prefs row. v1.7.0 —
|
||
* also true when the roaming `deliveryDefault` is "client".
|
||
*/
|
||
export function isMedicationReminderClientManaged(raw: unknown): boolean {
|
||
const prefs = parseNotificationPrefs(raw);
|
||
return (
|
||
prefs.medication.clientManaged === true ||
|
||
prefs.medication.deliveryDefault === "client"
|
||
);
|
||
}
|
||
|
||
/**
|
||
* Cron-side helper. Returns `true` when the user has opted in to
|
||
* client-managed CYCLE reminders (iOS owns the local period-soon /
|
||
* period-start-confirm nudges) and the server-side push should be
|
||
* suppressed. Tolerates a null / missing prefs row. Mirrors
|
||
* `isMedicationReminderClientManaged`.
|
||
*/
|
||
export function isCycleReminderClientManaged(raw: unknown): boolean {
|
||
return parseNotificationPrefs(raw).cycle.clientManaged === true;
|
||
}
|
||
|
||
/**
|
||
* v1.17.1 — cron-side helper. Returns `true` when the user has opted in
|
||
* to client-managed Vorsorge (measurement) reminders so the server-side
|
||
* push should be suppressed. Tolerates a null / missing prefs row.
|
||
* Mirrors `isMedicationReminderClientManaged` / `isCycleReminderClientManaged`.
|
||
*/
|
||
export function isMeasurementReminderClientManaged(raw: unknown): boolean {
|
||
return parseNotificationPrefs(raw).measurementReminder.clientManaged === true;
|
||
}
|
||
|
||
/**
|
||
* v1.7.0 — resolve the effective delivery channel for a device. The
|
||
* per-device override wins; otherwise the user-level roaming default;
|
||
* otherwise "server" (the safe default). This is an iOS-display concern
|
||
* in v1.7.0 — which device shows the local banner — not a server
|
||
* fan-out concern, so the reminder cron stays user-level (APNs already
|
||
* fans out to all the user's devices and iOS dedupes locally).
|
||
*/
|
||
export function resolveDeviceDelivery(
|
||
raw: unknown,
|
||
deviceOverride: string | null | undefined,
|
||
): "server" | "client" {
|
||
if (deviceOverride === "server" || deviceOverride === "client") {
|
||
return deviceOverride;
|
||
}
|
||
return parseNotificationPrefs(raw).medication.deliveryDefault;
|
||
}
|
||
|
||
/**
|
||
* Cron-side helper. Resolve the user's local-time mood-reminder hour
|
||
* (0–23) from the persisted prefs blob, falling back to the default
|
||
* (22:00) for a null / drifted row. Pulled out so the mood-reminder
|
||
* cron reads one number instead of threading the whole prefs object.
|
||
*/
|
||
export function resolveMoodReminderHour(raw: unknown): number {
|
||
return parseNotificationPrefs(raw).mood.reminderHour;
|
||
}
|
||
|
||
/**
|
||
* Cron-side helper. Resolve the user's low-stock runway threshold in
|
||
* days from the persisted prefs blob: 1–60, or `null` when the user
|
||
* switched the alert off. A null / drifted row resolves to the default
|
||
* (7 days) so the alert works out of the box.
|
||
*/
|
||
export function resolveLowStockRunwayDays(raw: unknown): number | null {
|
||
return parseNotificationPrefs(raw).medication.lowStockRunwayDays;
|
||
}
|
||
|
||
/**
|
||
* v1.17.0 — resolve the EFFECTIVE reorder lead time (days) for one
|
||
* medication: the per-medication override when set, otherwise the
|
||
* user-level default from the prefs blob (a null / drifted row resolves
|
||
* to the documented 10-day default). The low-stock alert adds this lead
|
||
* plus one dose-interval on top of the runway threshold.
|
||
*/
|
||
export function resolveReorderLeadDays(
|
||
raw: unknown,
|
||
medicationOverride: number | null | undefined,
|
||
): number {
|
||
if (typeof medicationOverride === "number" && medicationOverride >= 0) {
|
||
return medicationOverride;
|
||
}
|
||
return parseNotificationPrefs(raw).medication.reorderLeadDays;
|
||
}
|
||
|
||
/**
|
||
* v1.16.5 — fully-resolved Coach-nudge view for the cron: the master
|
||
* switch, the three per-group toggles, and the frequency cap mapped to
|
||
* days. One call so the nudge tick reads a single shape instead of
|
||
* threading the whole prefs object.
|
||
*/
|
||
export interface CoachNudgePrefs {
|
||
enabled: boolean;
|
||
groups: {
|
||
medication: boolean;
|
||
vitals: boolean;
|
||
routine: boolean;
|
||
};
|
||
/** 7 for "weekly", 14 for "biweekly". */
|
||
minIntervalDays: number;
|
||
/**
|
||
* v1.25.0 — when true the nudge tick composes the copy through the model
|
||
* (under hard guards) instead of the deterministic template. Default false.
|
||
*/
|
||
aiComposed: boolean;
|
||
}
|
||
|
||
/**
|
||
* v1.25.0 — the single source every proactive ambient SUGGESTION surface
|
||
* consults (the daily seeded example opener on the Coach hero + the
|
||
* suggested-prompt chips). Default `true`; a null / drifted row resolves to
|
||
* the documented default so the suggestions work out of the box.
|
||
*/
|
||
export function resolveCoachAmbientSuggestionsEnabled(raw: unknown): boolean {
|
||
return parseNotificationPrefs(raw).coach.ambientSuggestions;
|
||
}
|
||
|
||
export function resolveCoachNudgePrefs(raw: unknown): CoachNudgePrefs {
|
||
const coach = parseNotificationPrefs(raw).coach;
|
||
return {
|
||
enabled: coach.nudgesEnabled,
|
||
groups: {
|
||
medication: coach.nudgeMedication,
|
||
vitals: coach.nudgeVitals,
|
||
routine: coach.nudgeRoutine,
|
||
},
|
||
minIntervalDays: coach.nudgeFrequency === "biweekly" ? 14 : 7,
|
||
aiComposed: coach.nudgeAiComposed,
|
||
};
|
||
}
|
||
|
||
function cloneDefaults(): NotificationPrefs {
|
||
return {
|
||
medication: { ...DEFAULT_NOTIFICATION_PREFS.medication },
|
||
mood: { ...DEFAULT_NOTIFICATION_PREFS.mood },
|
||
cycle: { ...DEFAULT_NOTIFICATION_PREFS.cycle },
|
||
coach: { ...DEFAULT_NOTIFICATION_PREFS.coach },
|
||
measurementReminder: {
|
||
...DEFAULT_NOTIFICATION_PREFS.measurementReminder,
|
||
},
|
||
};
|
||
}
|
||
|
||
function mergeOverDefaults(input: NotificationPrefsInput): NotificationPrefs {
|
||
return applyDeliveryDefaultMapping({
|
||
medication: {
|
||
...DEFAULT_NOTIFICATION_PREFS.medication,
|
||
...(input.medication ?? {}),
|
||
},
|
||
mood: {
|
||
...DEFAULT_NOTIFICATION_PREFS.mood,
|
||
...(input.mood ?? {}),
|
||
},
|
||
cycle: {
|
||
...DEFAULT_NOTIFICATION_PREFS.cycle,
|
||
...(input.cycle ?? {}),
|
||
},
|
||
coach: {
|
||
...DEFAULT_NOTIFICATION_PREFS.coach,
|
||
...(input.coach ?? {}),
|
||
},
|
||
measurementReminder: {
|
||
...DEFAULT_NOTIFICATION_PREFS.measurementReminder,
|
||
...(input.measurementReminder ?? {}),
|
||
},
|
||
});
|
||
}
|