diff --git a/packages/mobile/README.md b/packages/mobile/README.md index 074e43bf..1217f379 100644 --- a/packages/mobile/README.md +++ b/packages/mobile/README.md @@ -98,12 +98,20 @@ timestamp are silently skipped. channel is deleted on the next `ensureShiftAlertChannel()` run. The custom sound requires a real build (the expo-notifications config plugin bundles it); Expo Go falls back to the default notification sound. -- **Foreground:** scheduled notifications can be suppressed by the runtime, - so the dashboard also runs a 5-second interval that fires - `Haptics.notificationAsync(Warning)` when any assignment lands in the - 28-32 second window. A `useRef>` keyed on - `${assignmentId}-${dayKey}` guarantees we buzz at most once per shift per - day even if the user keeps the dashboard open. +- **Foreground:** the app presents a full-screen in-app alarm overlay + (`ShiftAlarmOverlay`, hosted by `ShiftAlarmHost` in `app/_layout.tsx`) + instead of the small system banner: pulsing bell, live countdown, client + details, a repeating haptic pulse, and two actions, "Go to clock-in" and + "Dismiss". Two triggers converge on `presentShiftAlarm()` + (`src/lib/shift-alarm.ts`): the `addNotificationReceivedListener` in the + root layout (fires on any screen when the scheduled notification is + delivered while foregrounded; the notification handler suppresses the + banner for shift alerts but keeps the chime playing) and the dashboard's + 5-second interval tick (covers Expo Go on Android, where local + notifications are unavailable). `presentShiftAlarm` dedups per + `${assignmentId}@${scheduledTime}`, so the overlay shows at most once per + shift occurrence no matter which trigger lands first. The overlay + auto-dismisses after 75 seconds if ignored. - **Tap on the notification:** `app/_layout.tsx` subscribes to `addNotificationResponseReceivedListener` and deep-links to `/clockin` with the same params shape the dashboard `Pressable` uses diff --git a/packages/mobile/app/_layout.tsx b/packages/mobile/app/_layout.tsx index b3fc45a2..efe07b27 100644 --- a/packages/mobile/app/_layout.tsx +++ b/packages/mobile/app/_layout.tsx @@ -7,6 +7,8 @@ import { AuthProvider, useAuth } from '../src/lib/AuthContext'; import { useOfflineEvvSync } from '../src/lib/use-offline-sync'; import AppAlertProvider from '../src/features/common/alerts/AppAlertProvider'; import { showAppToast } from '../src/features/common/alerts/appAlert'; +import ShiftAlarmHost from '../src/features/evv/ShiftAlarmHost'; +import { isShiftAlarmPayload, presentShiftAlarm } from '../src/lib/shift-alarm'; // Expo Go (SDK 53+) removed remote push support, so expo-notifications' push- // token auto-registration logs a warning the moment the module is imported. @@ -21,37 +23,23 @@ if (Constants.executionEnvironment === ExecutionEnvironment.StoreClient) { ]); } -// Show shift-alert notifications even when the app is foregrounded so the -// system banner + sound + vibration still fire. We mirror the haptic -// independently from the dashboard tick, but letting the banner show is the -// cue the user expects. +// This handler only runs while the app is foregrounded. Shift alerts get the +// full-screen in-app alarm overlay instead of the small system banner (the +// received-listener below presents it), so suppress the banner for those but +// keep the sound: the notification is what plays the bundled alarm chime. +// Anything that isn't a shift alert keeps the stock banner behavior. Notifications.setNotificationHandler({ - handleNotification: async () => ({ - shouldShowBanner: true, - shouldShowList: true, - shouldPlaySound: true, - shouldSetBadge: false - }) + handleNotification: async (notification) => { + const isShiftAlert = isShiftAlarmPayload(notification.request.content.data); + return { + shouldShowBanner: !isShiftAlert, + shouldShowList: true, + shouldPlaySound: true, + shouldSetBadge: false + }; + } }); -interface ShiftAlertPayload { - assignmentId: string; - clientName: string; - scheduledTime: string; - serviceCode: string; -} - -function isShiftAlertPayload(value: unknown): value is ShiftAlertPayload { - if (!value || typeof value !== 'object') return false; - const v = value as Record; - return ( - typeof v.assignmentId === 'string' && - typeof v.clientName === 'string' && - typeof v.scheduledTime === 'string' && - typeof v.serviceCode === 'string' - ); -} - export default function RootLayout() { return ( @@ -98,20 +86,31 @@ function RootContent() { useEffect(() => { const sub = Notifications.addNotificationResponseReceivedListener((response) => { const data = response.notification.request.content.data; - if (!isShiftAlertPayload(data)) return; + if (!isShiftAlarmPayload(data)) return; router.push({ pathname: '/clockin', params: { assignmentId: data.assignmentId, clientName: data.clientName, scheduledTime: data.scheduledTime, - serviceCode: data.serviceCode + serviceCode: data.serviceCode ?? '' } }); }); return () => sub.remove(); }, [router]); + // Shift alert delivered while the app is foregrounded (any screen): present + // the full-screen alarm overlay. The handler above already suppressed the + // system banner for this case; the notification still plays the chime. + useEffect(() => { + const sub = Notifications.addNotificationReceivedListener((notification) => { + const data = notification.request.content.data; + if (isShiftAlarmPayload(data)) presentShiftAlarm(data); + }); + return () => sub.remove(); + }, []); + return ( @@ -127,6 +126,7 @@ function RootContent() { + ); diff --git a/packages/mobile/src/features/evv/DashboardScreen.tsx b/packages/mobile/src/features/evv/DashboardScreen.tsx index e36fd627..73ead187 100644 --- a/packages/mobile/src/features/evv/DashboardScreen.tsx +++ b/packages/mobile/src/features/evv/DashboardScreen.tsx @@ -11,7 +11,6 @@ import { useSafeAreaInsets } from 'react-native-safe-area-context'; import { StatusBar } from 'expo-status-bar'; import { LinearGradient } from 'expo-linear-gradient'; import { Ionicons } from '@expo/vector-icons'; -import * as Haptics from 'expo-haptics'; import Animated, { FadeInDown } from 'react-native-reanimated'; import { useAuth } from '../../lib/AuthContext'; import { useFocusEffect, useRouter } from 'expo-router'; @@ -22,6 +21,7 @@ import { SkeletonList } from '../common/Skeleton'; import { colors, typography, radii, shadow, gradients } from '../common/tokens'; import { ensureNotificationPermission } from '../../lib/notification-permissions'; import { fireDevTestShiftAlert, scheduleShiftAlerts } from '../../lib/shift-alert-scheduler'; +import { presentShiftAlarm } from '../../lib/shift-alarm'; import { deriveVisitState, resumableVisit, type VisitState } from '../../lib/visit-state'; import { offlineEvvQueue } from '../../lib/offline-queue'; import { @@ -53,15 +53,11 @@ type TodayScheduleRow = CachedVisitScheduleRow; // The fire window (8s) is wider than the tick (5s) so a tick always lands // inside it once as the countdown passes through, the previous 4s window could -// be straddled and skipped. firedForegroundRef keeps it to a single haptic. +// be straddled and skipped. presentShiftAlarm dedups per shift occurrence. const FOREGROUND_FIRE_WINDOW_MS = 33_000; const FOREGROUND_FIRE_MIN_MS = 25_000; const FOREGROUND_TICK_MS = 5_000; -function dayKey(d: Date): string { - return `${d.getFullYear()}-${d.getMonth()}-${d.getDate()}`; -} - function formatTime(iso: string | undefined): string { if (!iso) return 'Time TBD'; const d = new Date(iso); @@ -174,7 +170,6 @@ export default function DashboardScreen() { // Drives the live next-visit countdown / in-progress elapsed clock on the // hero card. Ticks once a second only while there are visits to count toward. const [nowTs, setNowTs] = useState(() => Date.now()); - const firedForegroundRef = useRef>(new Set()); // Device-clock skew vs the server (serverTime − deviceNow), captured on each // /today fetch. Passed to the clock-in screen so its time-window UX agrees // with the server's decision even on a badly set phone clock. @@ -269,17 +264,22 @@ export default function DashboardScreen() { if (assignments.length === 0) return; const tick = () => { const now = Date.now(); - const today = dayKey(new Date(now)); for (const a of assignments) { if (!a.time) continue; const shiftStart = new Date(a.time).getTime(); if (!Number.isFinite(shiftStart)) continue; const delta = shiftStart - now; if (delta < FOREGROUND_FIRE_MIN_MS || delta > FOREGROUND_FIRE_WINDOW_MS) continue; - const key = `${a.id}-${today}`; - if (firedForegroundRef.current.has(key)) continue; - firedForegroundRef.current.add(key); - void Haptics.notificationAsync(Haptics.NotificationFeedbackType.Warning); + // presentShiftAlarm dedups per shift occurrence, so re-entering the + // window on later ticks (or racing the notification-received trigger + // in app/_layout.tsx) never re-presents the overlay. + presentShiftAlarm({ + assignmentId: a.id, + clientName: a.clientName, + scheduledTime: a.time, + serviceCode: a.serviceCode, + clientAddress: a.clientAddress, + }); } }; tick(); diff --git a/packages/mobile/src/features/evv/ShiftAlarmHost.tsx b/packages/mobile/src/features/evv/ShiftAlarmHost.tsx new file mode 100644 index 00000000..a7ff7d31 --- /dev/null +++ b/packages/mobile/src/features/evv/ShiftAlarmHost.tsx @@ -0,0 +1,42 @@ +import React, { useCallback, useEffect, useState } from 'react'; +import { useRouter } from 'expo-router'; +import { __registerShiftAlarmController, type ShiftAlarmPayload } from '../../lib/shift-alarm'; +import ShiftAlarmOverlay from './ShiftAlarmOverlay'; + +/** + * Mount-once host for the in-app shift alarm, rendered in app/_layout.tsx as + * a sibling after (and before so dialogs and + * toasts still paint above the alarm). Screens never render this directly; + * triggers call presentShiftAlarm() from src/lib/shift-alarm.ts. + */ +export default function ShiftAlarmHost() { + const router = useRouter(); + const [payload, setPayload] = useState(null); + + useEffect(() => { + __registerShiftAlarmController({ present: (next) => setPayload(next) }); + return () => __registerShiftAlarmController(null); + }, []); + + const dismiss = useCallback(() => setPayload(null), []); + + // Same param shape as the dashboard card press and the notification tap + // deep link, so /clockin renders identically regardless of entry point. + const openVisit = useCallback(() => { + if (!payload) return; + setPayload(null); + router.push({ + pathname: '/clockin', + params: { + assignmentId: payload.assignmentId, + clientName: payload.clientName, + scheduledTime: payload.scheduledTime, + serviceCode: payload.serviceCode ?? '', + clientAddress: payload.clientAddress ?? '', + }, + }); + }, [payload, router]); + + if (!payload) return null; + return ; +} diff --git a/packages/mobile/src/features/evv/ShiftAlarmOverlay.tsx b/packages/mobile/src/features/evv/ShiftAlarmOverlay.tsx new file mode 100644 index 00000000..48e609fb --- /dev/null +++ b/packages/mobile/src/features/evv/ShiftAlarmOverlay.tsx @@ -0,0 +1,366 @@ +import React, { useEffect, useState } from 'react'; +import { BackHandler, Pressable, StyleSheet, Text, View } from 'react-native'; +import { useSafeAreaInsets } from 'react-native-safe-area-context'; +import { LinearGradient } from 'expo-linear-gradient'; +import { Ionicons } from '@expo/vector-icons'; +import * as Haptics from 'expo-haptics'; +import Animated, { + Easing, + FadeIn, + FadeInDown, + FadeOut, + useAnimatedStyle, + useSharedValue, + withDelay, + withRepeat, + withSequence, + withSpring, + withTiming, +} from 'react-native-reanimated'; +import { colors, gradients, radii, typography } from '../common/tokens'; +import type { ShiftAlarmPayload } from '../../lib/shift-alarm'; + +/** + * Full-screen soft alarm shown when a shift is about to start while the app + * is open. Styled as a blown-up NextVisitHero card (DashboardScreen): the + * same diagonal hero gradient, eyebrow + timing corners, client line, icon + * meta row, and frosted CTA, so the takeover reads as "the Up next card, + * urgently". The OS notification covers sound; this overlay covers the + * visual takeover plus a repeating haptic pulse, and offers one tap into + * the clock-in screen. + * + * Purely presentational: presentation, dedup, and routing live in + * ShiftAlarmHost / src/lib/shift-alarm.ts. + */ + +/** Stop the haptic pulse after this long even if the overlay stays up. */ +const HAPTIC_LOOP_MS = 30_000; +const HAPTIC_PULSE_EVERY_MS = 2_000; +/** Auto-dismiss so an ignored alarm never strands a stale overlay. */ +const AUTO_DISMISS_MS = 75_000; + +function formatStartTime(iso: string): string { + const d = new Date(iso); + if (!Number.isFinite(d.getTime())) return ''; + return d.toLocaleTimeString([], { hour: 'numeric', minute: '2-digit', hour12: true }); +} + +/** Same spirit as the hero's countdown: compact, tabular-friendly. */ +function countdownValue(msUntilStart: number): string { + if (msUntilStart <= 0) return 'Now'; + const s = Math.ceil(msUntilStart / 1000); + if (s < 90) return `${s}s`; + return `${Math.ceil(s / 60)} min`; +} + +function PulseRing({ delay }: { delay: number }) { + const anim = useSharedValue(0); + useEffect(() => { + anim.value = withDelay( + delay, + withRepeat(withTiming(1, { duration: 2200, easing: Easing.out(Easing.quad) }), -1, false) + ); + }, [anim, delay]); + const style = useAnimatedStyle(() => ({ + opacity: 0.45 * (1 - anim.value), + transform: [{ scale: 1 + anim.value * 0.9 }], + })); + return ; +} + +/** The hero's live-dot, pulsing: this shift needs attention right now. */ +function AlarmDot() { + const anim = useSharedValue(0); + useEffect(() => { + anim.value = withRepeat( + withSequence(withTiming(1, { duration: 600 }), withTiming(0, { duration: 600 })), + -1, + false + ); + }, [anim]); + const style = useAnimatedStyle(() => ({ opacity: 0.35 + anim.value * 0.65 })); + return ; +} + +export default function ShiftAlarmOverlay({ + payload, + onOpenVisit, + onDismiss, +}: { + payload: ShiftAlarmPayload; + onOpenVisit: () => void; + onDismiss: () => void; +}) { + const insets = useSafeAreaInsets(); + const [nowTs, setNowTs] = useState(() => Date.now()); + const bellSwing = useSharedValue(0); + + // Bell wiggle: quick swing, settle, repeat. Runs for the overlay's lifetime. + useEffect(() => { + bellSwing.value = withRepeat( + withSequence( + withTiming(1, { duration: 90 }), + withTiming(-1, { duration: 160 }), + withTiming(0.6, { duration: 140 }), + withSpring(0, { damping: 7, stiffness: 240 }), + withDelay(1400, withTiming(0, { duration: 1 })) + ), + -1, + false + ); + }, [bellSwing]); + const bellStyle = useAnimatedStyle(() => ({ + transform: [{ rotate: `${bellSwing.value * 14}deg` }], + })); + + // Repeating haptic pulse: a strong nudge on arrival, then a heartbeat while + // the alarm is up. The chime itself comes from the delivered notification. + useEffect(() => { + void Haptics.notificationAsync(Haptics.NotificationFeedbackType.Warning); + const pulse = setInterval(() => { + void Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Heavy); + }, HAPTIC_PULSE_EVERY_MS); + const stop = setTimeout(() => clearInterval(pulse), HAPTIC_LOOP_MS); + return () => { + clearInterval(pulse); + clearTimeout(stop); + }; + }, []); + + // Live countdown readout. + useEffect(() => { + const handle = setInterval(() => setNowTs(Date.now()), 1000); + return () => clearInterval(handle); + }, []); + + useEffect(() => { + const handle = setTimeout(onDismiss, AUTO_DISMISS_MS); + return () => clearTimeout(handle); + }, [onDismiss]); + + useEffect(() => { + const sub = BackHandler.addEventListener('hardwareBackPress', () => { + onDismiss(); + return true; + }); + return () => sub.remove(); + }, [onDismiss]); + + const shiftStart = new Date(payload.scheduledTime).getTime(); + const hasTime = Number.isFinite(shiftStart); + const timingLabel = hasTime && shiftStart <= nowTs ? 'STARTS' : 'STARTS IN'; + const timingValue = hasTime ? countdownValue(shiftStart - nowTs) : ''; + const startTime = formatStartTime(payload.scheduledTime); + + return ( + + + + + + + SHIFT STARTING SOON + + {timingValue ? ( + + {timingLabel} + {timingValue} + + ) : null} + + + + + + + + + + + + + + + + {payload.clientName} + + + + {startTime ? ( + <> + + {startTime} + + ) : null} + {startTime && payload.clientAddress ? · : null} + {payload.clientAddress ? ( + <> + + + {payload.clientAddress} + + + ) : null} + + + {payload.serviceCode ? ( + + + + + + Service + {payload.serviceCode} + + + ) : null} + + + + [styles.cta, pressed && styles.pressed]} + > + + Tap to clock in + + + [styles.dismissBtn, pressed && styles.pressed]} + > + Dismiss + + + + + ); +} + +// Frosted surfaces, eyebrow, timing block, meta row, and CTA mirror the +// NextVisitHero styles in DashboardScreen so the two read as one component +// family. If the hero's look changes, change this to match. +const styles = StyleSheet.create({ + content: { + flex: 1, + paddingHorizontal: 24, + }, + topRow: { + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'flex-start', + }, + eyebrowWrap: { flexDirection: 'row', alignItems: 'center', gap: 6, marginTop: 6 }, + eyebrowDot: { width: 8, height: 8, borderRadius: 4, backgroundColor: colors.onGradient }, + eyebrow: { ...typography.label, fontSize: 11, letterSpacing: 1, color: colors.onGradientSoft }, + timing: { alignItems: 'flex-end' }, + timingLabel: { ...typography.caption, fontSize: 9, letterSpacing: 0.8, color: colors.onGradientSoft }, + timingValue: { + color: colors.onGradient, + fontSize: 26, + fontWeight: '900', + fontVariant: ['tabular-nums'], + marginTop: 1, + }, + center: { flex: 1, alignItems: 'center', justifyContent: 'center' }, + bellWrap: { + width: 104, + height: 104, + alignItems: 'center', + justifyContent: 'center', + marginBottom: 24, + }, + ring: { + position: 'absolute', + width: 104, + height: 104, + borderRadius: 52, + borderWidth: 2, + borderColor: colors.onGradient, + }, + bellBadge: { + width: 92, + height: 92, + borderRadius: 46, + backgroundColor: '#ffffff14', + borderWidth: 1, + borderColor: '#ffffff20', + alignItems: 'center', + justifyContent: 'center', + }, + clientName: { + ...typography.hero, + fontSize: 28, + color: colors.onGradient, + letterSpacing: -0.4, + textAlign: 'center', + }, + metaRow: { + flexDirection: 'row', + alignItems: 'center', + gap: 4, + marginTop: 8, + flexWrap: 'wrap', + justifyContent: 'center', + maxWidth: '92%', + }, + meta: { ...typography.sub, color: colors.onGradientSoft, flexShrink: 1 }, + metaDivider: { color: colors.onGradientSoft, marginHorizontal: 2 }, + servicePanel: { + flexDirection: 'row', + alignItems: 'center', + gap: 12, + backgroundColor: '#ffffff14', + borderRadius: radii.md, + padding: 12, + marginTop: 18, + alignSelf: 'stretch', + borderWidth: 1, + borderColor: '#ffffff20', + }, + servicePanelIcon: { + width: 38, + height: 38, + borderRadius: radii.sm, + backgroundColor: '#ffffff1a', + justifyContent: 'center', + alignItems: 'center', + }, + servicePanelTitle: { ...typography.caption, fontWeight: '800', color: colors.onGradient }, + servicePanelSub: { ...typography.caption, color: colors.onGradientSoft, marginTop: 2 }, + actions: { gap: 10 }, + cta: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'center', + gap: 8, + backgroundColor: '#ffffff26', + borderRadius: radii.md, + height: 50, + borderWidth: 1, + borderColor: '#ffffff33', + }, + ctaText: { color: colors.onGradient, fontSize: 16, fontWeight: '800', letterSpacing: 0.2 }, + dismissBtn: { height: 44, alignItems: 'center', justifyContent: 'center' }, + dismissText: { ...typography.sub, fontWeight: '800', color: colors.onGradientSoft }, + pressed: { opacity: 0.85, transform: [{ scale: 0.98 }] }, +}); diff --git a/packages/mobile/src/lib/shift-alarm.test.ts b/packages/mobile/src/lib/shift-alarm.test.ts new file mode 100644 index 00000000..c01b4bc1 --- /dev/null +++ b/packages/mobile/src/lib/shift-alarm.test.ts @@ -0,0 +1,90 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { + __registerShiftAlarmController, + __resetPresentedShiftAlarms, + isShiftAlarmPayload, + presentShiftAlarm, + shiftAlarmKey, + type ShiftAlarmPayload, +} from './shift-alarm'; + +const payload: ShiftAlarmPayload = { + assignmentId: 'a1', + clientName: 'Pat Doe', + scheduledTime: '2026-07-27T13:30:00.000Z', + serviceCode: 'T1019', + clientAddress: '12 Main St', +}; + +afterEach(() => { + __registerShiftAlarmController(null); + __resetPresentedShiftAlarms(); +}); + +describe('isShiftAlarmPayload', () => { + it('accepts a full payload', () => { + expect(isShiftAlarmPayload(payload)).toBe(true); + }); + + it('accepts a payload without the optional fields', () => { + expect( + isShiftAlarmPayload({ assignmentId: 'a1', clientName: 'Pat', scheduledTime: 'iso' }) + ).toBe(true); + }); + + it('rejects null, primitives, and missing required fields', () => { + expect(isShiftAlarmPayload(null)).toBe(false); + expect(isShiftAlarmPayload('a1')).toBe(false); + expect(isShiftAlarmPayload({ clientName: 'Pat', scheduledTime: 'iso' })).toBe(false); + expect(isShiftAlarmPayload({ assignmentId: '', clientName: 'Pat', scheduledTime: 'iso' })).toBe(false); + expect(isShiftAlarmPayload({ assignmentId: 'a1', clientName: 'Pat' })).toBe(false); + }); + + it('rejects optional fields with wrong types', () => { + expect( + isShiftAlarmPayload({ ...payload, serviceCode: 7 }) + ).toBe(false); + expect( + isShiftAlarmPayload({ ...payload, clientAddress: { street: 'x' } }) + ).toBe(false); + }); +}); + +describe('presentShiftAlarm', () => { + it('presents once per shift occurrence, no matter how many triggers fire', () => { + const present = vi.fn(); + __registerShiftAlarmController({ present }); + + expect(presentShiftAlarm(payload)).toBe(true); + expect(presentShiftAlarm(payload)).toBe(false); + expect(presentShiftAlarm({ ...payload })).toBe(false); + expect(present).toHaveBeenCalledTimes(1); + expect(present).toHaveBeenCalledWith(payload); + }); + + it('treats the same assignment at a different time as a new occurrence', () => { + const present = vi.fn(); + __registerShiftAlarmController({ present }); + + expect(presentShiftAlarm(payload)).toBe(true); + expect( + presentShiftAlarm({ ...payload, scheduledTime: '2026-07-28T13:30:00.000Z' }) + ).toBe(true); + expect(present).toHaveBeenCalledTimes(2); + }); + + it('does not consume the dedup slot while no host is mounted', () => { + expect(presentShiftAlarm(payload)).toBe(false); + + const present = vi.fn(); + __registerShiftAlarmController({ present }); + expect(presentShiftAlarm(payload)).toBe(true); + expect(present).toHaveBeenCalledTimes(1); + }); +}); + +describe('shiftAlarmKey', () => { + it('keys on assignment and scheduled start', () => { + expect(shiftAlarmKey(payload)).toBe('a1@2026-07-27T13:30:00.000Z'); + }); +}); diff --git a/packages/mobile/src/lib/shift-alarm.ts b/packages/mobile/src/lib/shift-alarm.ts new file mode 100644 index 00000000..8923cd83 --- /dev/null +++ b/packages/mobile/src/lib/shift-alarm.ts @@ -0,0 +1,78 @@ +// In-app shift alarm: the full-screen "your shift starts soon" overlay. +// +// Module-level controller registered by in app/_layout.tsx, +// same imperative-singleton pattern as appAlert.ts: both trigger paths are +// plain functions with no component to call a hook from. +// +// Two triggers converge here: +// 1. app/_layout.tsx, addNotificationReceivedListener: the OS-scheduled +// local notification is delivered while the app is foregrounded (any +// screen). The notification handler suppresses the system banner for +// shift alerts in the foreground and this overlay shows instead; the +// notification's alarm chime still plays. +// 2. DashboardScreen's foreground tick: covers environments where local +// notifications are unavailable (Expo Go on Android) while the +// dashboard is open. +// Deduping by assignment + scheduled start keeps the overlay to a single +// presentation per shift no matter which trigger lands first, or how many +// times a tick re-fires inside the alert window. + +export interface ShiftAlarmPayload { + assignmentId: string; + clientName: string; + /** ISO timestamp of the scheduled shift start. */ + scheduledTime: string; + serviceCode?: string; + clientAddress?: string; +} + +interface ShiftAlarmController { + present: (payload: ShiftAlarmPayload) => void; +} + +let controller: ShiftAlarmController | null = null; +const presentedKeys = new Set(); + +export function __registerShiftAlarmController(next: ShiftAlarmController | null): void { + controller = next; +} + +/** + * Runtime guard for untyped sources (notification `data` blobs). Optional + * fields may be absent but must be strings when present. + */ +export function isShiftAlarmPayload(value: unknown): value is ShiftAlarmPayload { + if (!value || typeof value !== 'object') return false; + const v = value as Record; + if (typeof v.assignmentId !== 'string' || v.assignmentId.length === 0) return false; + if (typeof v.clientName !== 'string') return false; + if (typeof v.scheduledTime !== 'string') return false; + if (v.serviceCode !== undefined && typeof v.serviceCode !== 'string') return false; + if (v.clientAddress !== undefined && typeof v.clientAddress !== 'string') return false; + return true; +} + +/** One overlay per shift occurrence: same assignment at a new time re-alarms. */ +export function shiftAlarmKey(payload: Pick): string { + return `${payload.assignmentId}@${payload.scheduledTime}`; +} + +/** + * Present the overlay for this shift. Returns true when the overlay was + * actually presented; false when no host is mounted yet or this shift + * occurrence already alarmed. A missing host does NOT consume the dedup + * slot, so a trigger that races the host's mount can succeed on retry. + */ +export function presentShiftAlarm(payload: ShiftAlarmPayload): boolean { + if (!controller) return false; + const key = shiftAlarmKey(payload); + if (presentedKeys.has(key)) return false; + presentedKeys.add(key); + controller.present(payload); + return true; +} + +/** Test hook: clear the per-run dedup memory. */ +export function __resetPresentedShiftAlarms(): void { + presentedKeys.clear(); +}