Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 14 additions & 6 deletions packages/mobile/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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<Set<string>>` 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
Expand Down
60 changes: 30 additions & 30 deletions packages/mobile/app/_layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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<string, unknown>;
return (
typeof v.assignmentId === 'string' &&
typeof v.clientName === 'string' &&
typeof v.scheduledTime === 'string' &&
typeof v.serviceCode === 'string'
);
}

export default function RootLayout() {
return (
<AuthProvider>
Expand Down Expand Up @@ -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 (
<View style={{ flex: 1 }}>
<Stack>
Expand All @@ -127,6 +126,7 @@ function RootContent() {
<Stack.Screen name="change-password" options={{ headerShown: false }} />
<Stack.Screen name="help" options={{ headerShown: false }} />
</Stack>
<ShiftAlarmHost />
<AppAlertProvider />
</View>
);
Expand Down
24 changes: 12 additions & 12 deletions packages/mobile/src/features/evv/DashboardScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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 {
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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<Set<string>>(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.
Expand Down Expand Up @@ -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();
Expand Down
42 changes: 42 additions & 0 deletions packages/mobile/src/features/evv/ShiftAlarmHost.tsx
Original file line number Diff line number Diff line change
@@ -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 <Stack> (and before <AppAlertProvider/> 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<ShiftAlarmPayload | null>(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 <ShiftAlarmOverlay payload={payload} onOpenVisit={openVisit} onDismiss={dismiss} />;
}
Loading