From 4055b3f197ddadc6fe14efed42a3720259c0d457 Mon Sep 17 00:00:00 2001 From: Sishir P Date: Sun, 26 Jul 2026 15:35:42 -0400 Subject: [PATCH 1/2] Present a full-screen in-app alarm overlay when a shift is about to start When the 30-second shift alert lands while the app is open, the caregiver now gets a full-screen soft alarm instead of the small system banner: pulsing bell on the brand gradient, live countdown, client name, time, address and service code, plus a repeating haptic pulse. "Go to clock-in" routes to /clockin with the same params as the dashboard card and the notification tap; "Dismiss" (or the back button) closes it, and it auto-dismisses after 75 seconds if ignored. Two triggers converge on presentShiftAlarm() in src/lib/shift-alarm.ts: the root layout's addNotificationReceivedListener (any screen; the notification handler suppresses the banner for shift alerts but keeps the alarm chime playing) and the dashboard's foreground tick (covers Expo Go on Android, where local notifications are unavailable). Presentation is deduped per assignmentId@scheduledTime, replacing the dashboard's local fired-set, so the overlay shows at most once per shift occurrence. --- packages/mobile/README.md | 20 +- packages/mobile/app/_layout.tsx | 60 ++-- .../src/features/evv/DashboardScreen.tsx | 24 +- .../src/features/evv/ShiftAlarmHost.tsx | 42 +++ .../src/features/evv/ShiftAlarmOverlay.tsx | 305 ++++++++++++++++++ packages/mobile/src/lib/shift-alarm.test.ts | 90 ++++++ packages/mobile/src/lib/shift-alarm.ts | 78 +++++ 7 files changed, 571 insertions(+), 48 deletions(-) create mode 100644 packages/mobile/src/features/evv/ShiftAlarmHost.tsx create mode 100644 packages/mobile/src/features/evv/ShiftAlarmOverlay.tsx create mode 100644 packages/mobile/src/lib/shift-alarm.test.ts create mode 100644 packages/mobile/src/lib/shift-alarm.ts 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..4523f959 --- /dev/null +++ b/packages/mobile/src/features/evv/ShiftAlarmOverlay.tsx @@ -0,0 +1,305 @@ +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, shadow, 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. The OS notification (with the bundled chime) 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 }); +} + +function countdownLabel(msUntilStart: number): string { + if (msUntilStart <= 0) return 'Starting now'; + const s = Math.ceil(msUntilStart / 1000); + if (s < 90) return `Starts in ${s}s`; + return `Starts in ${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 ; +} + +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 countdown = Number.isFinite(shiftStart) ? countdownLabel(shiftStart - nowTs) : ''; + const startTime = formatStartTime(payload.scheduledTime); + + return ( + + + + + + + + + + + + + + + + + Shift starting soon + + + {payload.clientName} + + + {countdown ? ( + + {countdown} + + ) : null} + + + {startTime ? ( + + + Scheduled for {startTime} + + ) : null} + {payload.clientAddress ? ( + + + + {payload.clientAddress} + + + ) : null} + {payload.serviceCode ? ( + + + {payload.serviceCode} + + ) : null} + + + + + [styles.primaryBtn, shadow.raised, pressed && styles.pressed]} + > + Go to clock-in + + + [styles.ghostBtn, pressed && styles.pressed]} + > + Dismiss + + + + + ); +} + +const styles = StyleSheet.create({ + content: { + flex: 1, + paddingHorizontal: 28, + justifyContent: 'space-between', + }, + top: { flex: 1, alignItems: 'center', justifyContent: 'center' }, + bellWrap: { + width: 108, + height: 108, + alignItems: 'center', + justifyContent: 'center', + marginBottom: 26, + }, + ring: { + position: 'absolute', + width: 108, + height: 108, + borderRadius: 54, + borderWidth: 2, + borderColor: colors.onGradient, + }, + bellBadge: { + width: 96, + height: 96, + borderRadius: 48, + backgroundColor: 'rgba(255,255,255,0.14)', + borderWidth: 1, + borderColor: 'rgba(255,255,255,0.28)', + alignItems: 'center', + justifyContent: 'center', + }, + kicker: { + ...typography.label, + color: colors.onGradientSoft, + marginBottom: 10, + }, + clientName: { + ...typography.hero, + fontSize: 30, + color: colors.onGradient, + textAlign: 'center', + }, + countdown: { + marginTop: 10, + fontSize: 17, + fontWeight: '800', + color: colors.onGradientSoft, + }, + metaCard: { + marginTop: 26, + alignSelf: 'stretch', + backgroundColor: 'rgba(255,255,255,0.10)', + borderWidth: 1, + borderColor: 'rgba(255,255,255,0.18)', + borderRadius: radii.lg, + paddingHorizontal: 18, + paddingVertical: 14, + gap: 10, + }, + metaRow: { flexDirection: 'row', alignItems: 'center', gap: 10 }, + metaText: { ...typography.body, color: colors.onGradient, flexShrink: 1 }, + actions: { gap: 12 }, + primaryBtn: { + height: 56, + borderRadius: radii.lg, + backgroundColor: colors.cardBg, + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'center', + gap: 8, + }, + primaryBtnText: { fontSize: 17, fontWeight: '900', color: colors.brandBlue }, + ghostBtn: { + height: 52, + borderRadius: radii.lg, + borderWidth: 1, + borderColor: 'rgba(255,255,255,0.35)', + alignItems: 'center', + justifyContent: 'center', + }, + ghostBtnText: { fontSize: 15, fontWeight: '800', color: colors.onGradient }, + 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(); +} From 3c00958213277cad8f5972ef2ea329e37525125a Mon Sep 17 00:00:00 2001 From: Sishir P Date: Sun, 26 Jul 2026 15:54:28 -0400 Subject: [PATCH 2/2] Restyle the shift alarm overlay to match the Up next hero card Same design language as NextVisitHero on the dashboard: the diagonal navy-to-blue hero gradient, eyebrow with pulsing dot plus a tabular countdown in the top corners, hero-scale client name, the 13px icon meta row with dot dividers, a frosted service panel matching the route-preview slot, and the frosted Tap to clock in CTA row. The full-screen alarm now reads as the Up next card, urgently. --- .../src/features/evv/ShiftAlarmOverlay.tsx | 245 +++++++++++------- 1 file changed, 153 insertions(+), 92 deletions(-) diff --git a/packages/mobile/src/features/evv/ShiftAlarmOverlay.tsx b/packages/mobile/src/features/evv/ShiftAlarmOverlay.tsx index 4523f959..48e609fb 100644 --- a/packages/mobile/src/features/evv/ShiftAlarmOverlay.tsx +++ b/packages/mobile/src/features/evv/ShiftAlarmOverlay.tsx @@ -17,14 +17,17 @@ import Animated, { withSpring, withTiming, } from 'react-native-reanimated'; -import { colors, gradients, radii, shadow, typography } from '../common/tokens'; +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. The OS notification (with the bundled chime) covers sound; this - * overlay covers the visual takeover plus a repeating haptic pulse, and - * offers one tap into the clock-in screen. + * 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. @@ -42,11 +45,12 @@ function formatStartTime(iso: string): string { return d.toLocaleTimeString([], { hour: 'numeric', minute: '2-digit', hour12: true }); } -function countdownLabel(msUntilStart: number): string { - if (msUntilStart <= 0) return 'Starting now'; +/** 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 `Starts in ${s}s`; - return `Starts in ${Math.ceil(s / 60)} min`; + if (s < 90) return `${s}s`; + return `${Math.ceil(s / 60)} min`; } function PulseRing({ delay }: { delay: number }) { @@ -64,6 +68,20 @@ function PulseRing({ delay }: { delay: number }) { 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, @@ -129,7 +147,9 @@ export default function ShiftAlarmOverlay({ }, [onDismiss]); const shiftStart = new Date(payload.scheduledTime).getTime(); - const countdown = Number.isFinite(shiftStart) ? countdownLabel(shiftStart - nowTs) : ''; + 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 ( @@ -140,78 +160,95 @@ export default function ShiftAlarmOverlay({ accessibilityViewIsModal accessibilityRole="alert" > - - - + + + + + + SHIFT STARTING SOON + + {timingValue ? ( + + {timingLabel} + {timingValue} + + ) : null} + + + - + - - Shift starting soon - {payload.clientName} - {countdown ? ( - - {countdown} - - ) : null} - - + {startTime ? ( - - - Scheduled for {startTime} - + <> + + {startTime} + ) : null} + {startTime && payload.clientAddress ? · : null} {payload.clientAddress ? ( - - - + <> + + {payload.clientAddress} - - ) : null} - {payload.serviceCode ? ( - - - {payload.serviceCode} - + ) : null} + + {payload.serviceCode ? ( + + + + + + Service + {payload.serviceCode} + + + ) : null} - + [styles.primaryBtn, shadow.raised, pressed && styles.pressed]} + style={({ pressed }) => [styles.cta, pressed && styles.pressed]} > - Go to clock-in - + + Tap to clock in + [styles.ghostBtn, pressed && styles.pressed]} + style={({ pressed }) => [styles.dismissBtn, pressed && styles.pressed]} > - Dismiss + Dismiss @@ -219,87 +256,111 @@ export default function ShiftAlarmOverlay({ ); } +// 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: 28, + paddingHorizontal: 24, + }, + topRow: { + flexDirection: 'row', justifyContent: 'space-between', + alignItems: 'flex-start', }, - top: { flex: 1, alignItems: 'center', justifyContent: 'center' }, + 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: 108, - height: 108, + width: 104, + height: 104, alignItems: 'center', justifyContent: 'center', - marginBottom: 26, + marginBottom: 24, }, ring: { position: 'absolute', - width: 108, - height: 108, - borderRadius: 54, + width: 104, + height: 104, + borderRadius: 52, borderWidth: 2, borderColor: colors.onGradient, }, bellBadge: { - width: 96, - height: 96, - borderRadius: 48, - backgroundColor: 'rgba(255,255,255,0.14)', + width: 92, + height: 92, + borderRadius: 46, + backgroundColor: '#ffffff14', borderWidth: 1, - borderColor: 'rgba(255,255,255,0.28)', + borderColor: '#ffffff20', alignItems: 'center', justifyContent: 'center', }, - kicker: { - ...typography.label, - color: colors.onGradientSoft, - marginBottom: 10, - }, clientName: { ...typography.hero, - fontSize: 30, + fontSize: 28, color: colors.onGradient, + letterSpacing: -0.4, textAlign: 'center', }, - countdown: { - marginTop: 10, - fontSize: 17, - fontWeight: '800', - color: colors.onGradientSoft, + metaRow: { + flexDirection: 'row', + alignItems: 'center', + gap: 4, + marginTop: 8, + flexWrap: 'wrap', + justifyContent: 'center', + maxWidth: '92%', }, - metaCard: { - marginTop: 26, + 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', - backgroundColor: 'rgba(255,255,255,0.10)', borderWidth: 1, - borderColor: 'rgba(255,255,255,0.18)', - borderRadius: radii.lg, - paddingHorizontal: 18, - paddingVertical: 14, - gap: 10, + borderColor: '#ffffff20', }, - metaRow: { flexDirection: 'row', alignItems: 'center', gap: 10 }, - metaText: { ...typography.body, color: colors.onGradient, flexShrink: 1 }, - actions: { gap: 12 }, - primaryBtn: { - height: 56, - borderRadius: radii.lg, - backgroundColor: colors.cardBg, + 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, - }, - primaryBtnText: { fontSize: 17, fontWeight: '900', color: colors.brandBlue }, - ghostBtn: { - height: 52, - borderRadius: radii.lg, + backgroundColor: '#ffffff26', + borderRadius: radii.md, + height: 50, borderWidth: 1, - borderColor: 'rgba(255,255,255,0.35)', - alignItems: 'center', - justifyContent: 'center', + borderColor: '#ffffff33', }, - ghostBtnText: { fontSize: 15, fontWeight: '800', color: colors.onGradient }, + 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 }] }, });