Replace the profile tab with prescription management (inline reminders and family invite), add a shared tab top bar, separate notifications from orders, and support any-branch guest checkout with GSH codes. Co-authored-by: Cursor <cursoragent@cursor.com>
284 lines
9.8 KiB
TypeScript
284 lines
9.8 KiB
TypeScript
import { useLocalSearchParams } from "expo-router";
|
|
import { useMemo, useState } from "react";
|
|
import { Alert, ScrollView, View } from "react-native";
|
|
import { Badge } from "@/components/ui/Badge";
|
|
import { Button } from "@/components/ui/Button";
|
|
import { Card } from "@/components/ui/Card";
|
|
import { Chip } from "@/components/ui/Chip";
|
|
import { Field, SwitchRow } from "@/components/ui/Field";
|
|
import { AppHeader } from "@/components/ui/Header";
|
|
import { EmptyState } from "@/components/ui/EmptyState";
|
|
import { Screen } from "@/components/ui/Screen";
|
|
import { Text } from "@/components/ui/Text";
|
|
import {
|
|
scheduleDailyDoseReminders,
|
|
scheduleDoseReminder,
|
|
scheduleEscalationCheck,
|
|
} from "@/lib/notifications";
|
|
import { useEmergencyContactStore } from "@/lib/stores/emergencyContactStore";
|
|
import { useNotificationStore } from "@/lib/stores/notificationStore";
|
|
import { usePrescriptionStore } from "@/lib/stores/prescriptionStore";
|
|
import { colors, gutter } from "@/lib/theme";
|
|
import { routes } from "@/lib/routes";
|
|
|
|
const PRESETS = ["08:00", "12:00", "14:00", "18:00", "20:00"] as const;
|
|
|
|
export default function RxCalendarScreen() {
|
|
const params = useLocalSearchParams<{ id: string }>();
|
|
const id = Array.isArray(params.id) ? params.id[0] : params.id;
|
|
const rx = usePrescriptionStore((s) => s.prescriptions.find((p) => p.id === id));
|
|
const upsertReminder = usePrescriptionStore((s) => s.upsertReminder);
|
|
const schedulePendingDose = usePrescriptionStore((s) => s.schedulePendingDose);
|
|
const confirmDose = usePrescriptionStore((s) => s.confirmDose);
|
|
const escalateDose = usePrescriptionStore((s) => s.escalateDose);
|
|
const doseLogs = usePrescriptionStore((s) => s.doseLogs);
|
|
const reminder = usePrescriptionStore((s) =>
|
|
s.reminders.find((r) => r.prescriptionId === id)
|
|
);
|
|
const grace = useEmergencyContactStore((s) => s.graceMinutesDefault);
|
|
const setGrace = useEmergencyContactStore((s) => s.setGrace);
|
|
const escalationEnabled = useEmergencyContactStore((s) => s.escalationEnabled);
|
|
const setEscalationEnabled = useEmergencyContactStore((s) => s.setEscalationEnabled);
|
|
const contacts = useEmergencyContactStore((s) => s.contacts);
|
|
const markEscalated = useEmergencyContactStore((s) => s.markEscalated);
|
|
const pushNotification = useNotificationStore((s) => s.push);
|
|
const [times, setTimes] = useState(reminder?.times.join(",") || "08:00,20:00");
|
|
const [pendingLogId, setPendingLogId] = useState<string | null>(null);
|
|
|
|
const reminderId = `rem_${id}`;
|
|
const relatedLogs = useMemo(
|
|
() => doseLogs.filter((l) => l.reminderId === reminderId).slice(0, 8),
|
|
[doseLogs, reminderId]
|
|
);
|
|
|
|
if (!rx) {
|
|
return (
|
|
<Screen>
|
|
<EmptyState icon="calendar-outline" title="Prescription not found" />
|
|
</Screen>
|
|
);
|
|
}
|
|
|
|
const togglePreset = (t: string) => {
|
|
const list = times
|
|
.split(",")
|
|
.map((s) => s.trim())
|
|
.filter(Boolean);
|
|
if (list.includes(t)) {
|
|
setTimes(list.filter((x) => x !== t).join(","));
|
|
} else {
|
|
setTimes([...list, t].sort().join(","));
|
|
}
|
|
};
|
|
|
|
const saveAndSchedule = async () => {
|
|
const parsed = times
|
|
.split(",")
|
|
.map((s) => s.trim())
|
|
.filter(Boolean);
|
|
if (!parsed.length) {
|
|
Alert.alert("Times required", "Pick at least one reminder time.");
|
|
return;
|
|
}
|
|
upsertReminder({
|
|
id: reminderId,
|
|
prescriptionId: rx.id,
|
|
medicineName: rx.medicineName,
|
|
times: parsed,
|
|
daysOfWeek: [0, 1, 2, 3, 4, 5, 6],
|
|
graceMinutes: grace,
|
|
escalateOnMiss: escalationEnabled,
|
|
enabled: true,
|
|
});
|
|
const result = await scheduleDailyDoseReminders({
|
|
prescriptionId: rx.id,
|
|
medicineName: rx.medicineName,
|
|
times: parsed,
|
|
});
|
|
const log = schedulePendingDose(reminderId);
|
|
setPendingLogId(log.id);
|
|
|
|
pushNotification({
|
|
kind: "dose",
|
|
title: `Calendar set · ${rx.medicineName}`,
|
|
body: result.granted
|
|
? `Daily push at ${result.times.join(", ")}`
|
|
: `Saved ${result.times.join(", ")} — enable notifications for push`,
|
|
href: routes.rxCalendar(rx.id),
|
|
});
|
|
|
|
if (escalationEnabled && contacts.length) {
|
|
await scheduleEscalationCheck({
|
|
title: "Dose not confirmed",
|
|
body: `Notifying ${contacts[0].name} if still unconfirmed`,
|
|
graceSeconds: Math.max(20, grace * 60),
|
|
data: { logId: log.id, type: "escalation" },
|
|
});
|
|
setTimeout(() => {
|
|
const current = usePrescriptionStore
|
|
.getState()
|
|
.doseLogs.find((l) => l.id === log.id);
|
|
if (current?.status === "pending") {
|
|
escalateDose(log.id);
|
|
markEscalated();
|
|
Alert.alert("Emergency", `${contacts[0].name} would be notified (mock SMS/push)`);
|
|
}
|
|
}, Math.max(25000, grace * 1000));
|
|
} else if (escalationEnabled && !contacts.length) {
|
|
Alert.alert(
|
|
"No emergency contact",
|
|
"Add a contact under Profile → Emergency contacts for escalation."
|
|
);
|
|
}
|
|
|
|
Alert.alert(
|
|
result.granted ? "Scheduled" : "Saved",
|
|
result.granted
|
|
? `Daily calendar push at ${result.times.join(", ")}. Demo ping in ~12s.`
|
|
: `Reminders saved at ${result.times.join(", ")}. Allow notifications for daily push.`
|
|
);
|
|
};
|
|
|
|
return (
|
|
<Screen>
|
|
<AppHeader title="Reminders" subtitle={rx.medicineName} />
|
|
<ScrollView
|
|
contentContainerStyle={{
|
|
paddingHorizontal: gutter,
|
|
paddingBottom: 40,
|
|
gap: 14,
|
|
}}
|
|
>
|
|
<Card>
|
|
<Text variant="caption" color={colors.muted}>
|
|
Patient
|
|
</Text>
|
|
<Text variant="h3" style={{ marginTop: 4 }}>
|
|
{rx.patientName || "Self"}
|
|
</Text>
|
|
<Text variant="bodySm" color={colors.muted} style={{ marginTop: 4 }}>
|
|
{rx.status} · {rx.daysSupply} day supply
|
|
</Text>
|
|
</Card>
|
|
|
|
<Card>
|
|
<Text variant="h3">Dose times</Text>
|
|
<Text variant="caption" color={colors.muted} style={{ marginTop: 4 }}>
|
|
Tap presets or edit the comma-separated list
|
|
</Text>
|
|
<View style={{ flexDirection: "row", flexWrap: "wrap", gap: 8, marginTop: 12 }}>
|
|
{PRESETS.map((p) => {
|
|
const selected = times
|
|
.split(",")
|
|
.map((s) => s.trim())
|
|
.includes(p);
|
|
return (
|
|
<Chip
|
|
key={p}
|
|
label={p}
|
|
selected={selected}
|
|
onPress={() => togglePreset(p)}
|
|
size="sm"
|
|
/>
|
|
);
|
|
})}
|
|
</View>
|
|
<View style={{ marginTop: 12 }}>
|
|
<Field
|
|
label="Times (HH:mm)"
|
|
value={times}
|
|
onChangeText={setTimes}
|
|
hint="Example: 08:00,14:00,20:00"
|
|
/>
|
|
</View>
|
|
</Card>
|
|
|
|
<Card padded={false}>
|
|
<SwitchRow
|
|
icon="alert-circle-outline"
|
|
title="Escalate if missed"
|
|
subtitle={
|
|
contacts[0]
|
|
? `Notify ${contacts[0].name} after grace period`
|
|
: "Add an emergency contact first"
|
|
}
|
|
value={escalationEnabled}
|
|
onValueChange={setEscalationEnabled}
|
|
/>
|
|
<View style={{ padding: 16, paddingTop: 4 }}>
|
|
<Field
|
|
label="Grace minutes"
|
|
value={String(grace)}
|
|
keyboardType="number-pad"
|
|
onChangeText={(v) => setGrace(Number(v) || 30)}
|
|
/>
|
|
</View>
|
|
</Card>
|
|
|
|
{pendingLogId ? (
|
|
<Card tone="brand" elevated={false}>
|
|
<Text variant="h3">Confirm this dose</Text>
|
|
<Text variant="bodySm" color={colors.muted} style={{ marginTop: 4 }}>
|
|
Demo reminder is active. Mark taken or snooze.
|
|
</Text>
|
|
<View style={{ flexDirection: "row", gap: 10, marginTop: 14 }}>
|
|
<Button
|
|
title="Taken"
|
|
style={{ flex: 1 }}
|
|
onPress={() => {
|
|
confirmDose(pendingLogId);
|
|
setPendingLogId(null);
|
|
Alert.alert("Logged", "Dose confirmed");
|
|
}}
|
|
/>
|
|
<Button
|
|
title="Snooze 10m"
|
|
variant="outline"
|
|
style={{ flex: 1 }}
|
|
onPress={() =>
|
|
scheduleDoseReminder({
|
|
title: `Snoozed: ${rx.medicineName}`,
|
|
body: "Reminder",
|
|
secondsFromNow: 600,
|
|
})
|
|
}
|
|
/>
|
|
</View>
|
|
</Card>
|
|
) : null}
|
|
|
|
<Button title="Save & schedule push" onPress={saveAndSchedule} />
|
|
|
|
<Text variant="h3">Recent dose logs</Text>
|
|
{relatedLogs.length === 0 && doseLogs.length === 0 ? (
|
|
<Text variant="bodySm" color={colors.muted}>
|
|
No logs yet. Schedule a reminder to start tracking.
|
|
</Text>
|
|
) : (
|
|
(relatedLogs.length ? relatedLogs : doseLogs.slice(0, 8)).map((l) => (
|
|
<Card key={l.id} elevated={false}>
|
|
<View style={{ flexDirection: "row", alignItems: "center", gap: 10 }}>
|
|
<Badge
|
|
label={l.status.replace("_", " ")}
|
|
tone={
|
|
l.status === "taken"
|
|
? "brand"
|
|
: l.status === "missed_escalated"
|
|
? "danger"
|
|
: l.status === "pending"
|
|
? "warn"
|
|
: "neutral"
|
|
}
|
|
/>
|
|
<Text variant="bodySm" color={colors.muted} style={{ flex: 1 }}>
|
|
{new Date(l.scheduledAt).toLocaleString()}
|
|
</Text>
|
|
</View>
|
|
</Card>
|
|
))
|
|
)}
|
|
</ScrollView>
|
|
</Screen>
|
|
);
|
|
}
|