Skip to Content
Mobile AppPush Notifications

Push Notifications

The Loop Health mobile app integrates with Knock to deliver intelligent, personalized push notifications across 6 key workflows — from daily dose reminders to supply alerts and adherence milestones — all with deep linking and smart scheduling.

Overview

Push notifications keep users engaged with their health optimization journey:

  • 6 Notification Workflows — Daily doses, supply alerts, milestones, lab results, protocol updates, and check-ins
  • Knock Integration — Centralized notification orchestration with templates and preferences
  • Deep Linking — Direct navigation to relevant app sections
  • Smart Scheduling — Personalized timing based on user preferences and behavior
  • Batch Management — Efficient grouping to avoid notification fatigue
  • Rich Content — Images, actions, and formatted text support

Architecture

Knock Integration Flow

Mobile SDK Setup

import { Knock } from '@knocklabs/react-native'; // Initialize Knock client const knockClient = new Knock(process.env.EXPO_PUBLIC_KNOCK_PUBLISHABLE_KEY!); // Authenticate user await knockClient.authenticate(userId, userToken); // Register for push notifications const { status } = await Notifications.requestPermissionsAsync(); if (status === 'granted') { const token = await Notifications.getExpoPushTokenAsync(); await knockClient.identify(userId, { name: user.firstName, email: user.email, pushToken: token.data, }); }

Notification Workflows

1. Daily Dose Reminders (daily-dose-reminder)

Trigger: Scheduled based on user’s protocol timing preferences.

Template:

{ "title": "Time for your {{ compound_name }}", "body": "{{ dosage }} {{ route }} - {{ protocol_name }}", "data": { "type": "dose_reminder", "protocol_id": "{{ protocol_id }}", "compound_name": "{{ compound_name }}", "deep_link": "loop://protocols/{{ protocol_id }}/log" }, "actions": [ { "id": "log_dose", "title": "Log Dose", "deep_link": "loop://protocols/{{ protocol_id }}/log" }, { "id": "snooze_15", "title": "Snooze 15m" } ] }

Backend Trigger:

const scheduleDoseReminder = async (userId: string, protocol: Protocol) => { await knock.workflows.trigger('daily-dose-reminder', { recipients: [{ id: userId }], data: { compound_name: protocol.compound, dosage: protocol.dosage, route: protocol.route, protocol_name: protocol.name, protocol_id: protocol.id, }, schedule: { at: protocol.reminderTime, // e.g., "09:00" timezone: protocol.timezone, }, }); };

2. Supply Low Alerts (supply-low-alert)

Trigger: When supply level drops below threshold.

Template:

{ "title": "{{ compound_name }} running low", "body": "{{ current_amount }} {{ unit }} remaining (~{{ days_remaining }} days)", "data": { "type": "supply_alert", "supply_id": "{{ supply_id }}", "severity": "low", "deep_link": "loop://supplies/{{ supply_id }}" }, "actions": [ { "id": "reorder_now", "title": "Reorder Now", "url": "{{ reorder_url }}" }, { "id": "view_supply", "title": "View Details", "deep_link": "loop://supplies/{{ supply_id }}" } ] }

Backend Trigger:

const triggerSupplyAlert = async (userId: string, supply: Supply, calculation: SupplyCalculation) => { await knock.workflows.trigger('supply-low-alert', { recipients: [{ id: userId }], data: { compound_name: supply.compoundName, current_amount: supply.currentAmount, unit: supply.unit, days_remaining: calculation.daysRemaining, supply_id: supply.id, reorder_url: `https://loopbiolabs.com/products/${supply.compoundName.toLowerCase()}`, }, }); };

3. Adherence Milestones (adherence-milestone)

Trigger: When user reaches adherence goals (7-day streak, 30-day streak, etc.).

Template:

{ "title": "🎉 {{ milestone_days }}-day streak!", "body": "You've been consistent with {{ protocol_name }} for {{ milestone_days }} days straight", "data": { "type": "milestone", "milestone_type": "adherence_streak", "days": "{{ milestone_days }}", "protocol_id": "{{ protocol_id }}", "deep_link": "loop://achievements" }, "image": "https://assets.loop.health/achievements/streak-{{ milestone_days }}.png" }

4. Lab Results Available (lab-results-ready)

Trigger: When new lab results are uploaded and processed.

Template:

{ "title": "Your lab results are ready", "body": "{{ lab_date }} results from {{ provider }} have been analyzed", "data": { "type": "lab_results", "lab_id": "{{ lab_id }}", "deep_link": "loop://labs/{{ lab_id }}" }, "actions": [ { "id": "view_results", "title": "View Results", "deep_link": "loop://labs/{{ lab_id }}" }, { "id": "chat_luna", "title": "Ask Luna", "deep_link": "loop://chat?context=lab_{{ lab_id }}" } ] }

5. Protocol Updates (protocol-updated)

Trigger: When clinician modifies user’s protocol.

Template:

{ "title": "Protocol update from Dr. {{ clinician_name }}", "body": "{{ protocol_name }} has been modified. Review the changes.", "data": { "type": "protocol_update", "protocol_id": "{{ protocol_id }}", "clinician_id": "{{ clinician_id }}", "deep_link": "loop://protocols/{{ protocol_id }}" } }

6. Weekly Check-in Reminder (weekly-checkin)

Trigger: Scheduled weekly on user’s preferred day.

Template:

{ "title": "Time for your weekly check-in", "body": "How are you feeling this week? Share your progress with Luna.", "data": { "type": "checkin_reminder", "deep_link": "loop://checkin" }, "actions": [ { "id": "start_checkin", "title": "Start Check-in", "deep_link": "loop://checkin" } ] }

Deep Linking Implementation

URL Scheme Registration

// app.json { "expo": { "scheme": "loop", "ios": { "associatedDomains": ["applinks:loop.health"] }, "android": { "intentFilters": [ { "action": "VIEW", "data": [ { "scheme": "loop" }, { "scheme": "https", "host": "loop.health" } ], "category": ["BROWSABLE", "DEFAULT"] } ] } } }
import { Linking } from 'react-native'; import { router } from 'expo-router'; const handleDeepLink = (url: string) => { try { const { hostname, pathname, searchParams } = new URL(url); // Handle different deep link patterns switch (hostname) { case 'protocols': const protocolId = pathname.split('/')[1]; const action = pathname.split('/')[2]; if (action === 'log') { router.push(`/protocols/${protocolId}/log`); } else { router.push(`/protocols/${protocolId}`); } break; case 'supplies': const supplyId = pathname.split('/')[1]; router.push(`/supplies/${supplyId}`); break; case 'labs': const labId = pathname.split('/')[1]; router.push(`/labs/${labId}`); break; case 'chat': const context = searchParams.get('context'); router.push(`/chat${context ? `?context=${context}` : ''}`); break; case 'checkin': router.push('/checkin'); break; case 'achievements': router.push('/achievements'); break; default: router.push('/'); } // Analytics tracking analytics.track('deep_link_opened', { url, hostname, pathname, }); } catch (error) { console.error('Failed to handle deep link:', error); router.push('/'); } }; // Set up deep link listener useEffect(() => { // Handle app opened from notification const handleInitialURL = async () => { const initialURL = await Linking.getInitialURL(); if (initialURL) { handleDeepLink(initialURL); } }; // Handle app already open const subscription = Linking.addEventListener('url', ({ url }) => { handleDeepLink(url); }); handleInitialURL(); return () => subscription?.remove(); }, []);

Notification Action Handling

import * as Notifications from 'expo-notifications'; // Set notification handler Notifications.setNotificationHandler({ handleNotification: async () => ({ shouldShowAlert: true, shouldPlaySound: true, shouldSetBadge: true, }), }); // Handle notification responses useEffect(() => { const subscription = Notifications.addNotificationResponseReceivedListener(response => { const { notification, actionIdentifier } = response; const data = notification.request.content.data; switch (actionIdentifier) { case 'log_dose': router.push(`/protocols/${data.protocol_id}/log`); break; case 'snooze_15': scheduleSnoozeReminder(data.protocol_id, 15); break; case 'reorder_now': Linking.openURL(data.reorder_url); break; case 'view_supply': router.push(`/supplies/${data.supply_id}`); break; case 'view_results': router.push(`/labs/${data.lab_id}`); break; case 'chat_luna': router.push(`/chat?context=${data.context}`); break; case 'start_checkin': router.push('/checkin'); break; default: // Handle default tap (no action button) if (data.deep_link) { handleDeepLink(data.deep_link); } } }); return () => subscription.remove(); }, []);

Smart Scheduling

User Preference Management

interface NotificationPreferences { dailyDoses: { enabled: boolean; times: string[]; // e.g., ["09:00", "21:00"] timezone: string; advanceMinutes: number; // How many minutes before dose time }; supplyAlerts: { enabled: boolean; thresholds: { low: number; // Percentage critical: number; }; }; milestones: { enabled: boolean; types: ('adherence_streak' | 'lab_improvement' | 'goal_reached')[]; }; labResults: { enabled: boolean; immediate: boolean; // Send as soon as available }; protocolUpdates: { enabled: boolean; immediate: boolean; }; weeklyCheckin: { enabled: boolean; day: 'monday' | 'tuesday' | 'wednesday' | 'thursday' | 'friday' | 'saturday' | 'sunday'; time: string; // e.g., "10:00" }; quietHours: { enabled: boolean; start: string; // e.g., "22:00" end: string; // e.g., "08:00" }; } const updateNotificationPreferences = async (userId: string, preferences: NotificationPreferences) => { // Update in Knock user profile await knockClient.users.setPreferences(userId, { workflows: { 'daily-dose-reminder': { channel_types: { push: preferences.dailyDoses.enabled, }, }, 'supply-low-alert': { channel_types: { push: preferences.supplyAlerts.enabled, }, }, 'adherence-milestone': { channel_types: { push: preferences.milestones.enabled, }, }, 'lab-results-ready': { channel_types: { push: preferences.labResults.enabled, }, }, 'protocol-updated': { channel_types: { push: preferences.protocolUpdates.enabled, }, }, 'weekly-checkin': { channel_types: { push: preferences.weeklyCheckin.enabled, }, }, }, }); // Store detailed preferences in app database await saveUserPreferences(userId, preferences); };

Intelligent Timing

const scheduleSmartNotification = async ( workflowId: string, userId: string, data: any, preferences: NotificationPreferences ) => { const now = new Date(); const userTimezone = preferences.dailyDoses.timezone; // Check quiet hours if (preferences.quietHours.enabled) { const quietStart = parseTime(preferences.quietHours.start); const quietEnd = parseTime(preferences.quietHours.end); const currentTime = parseTime(format(now, 'HH:mm')); if (isWithinQuietHours(currentTime, quietStart, quietEnd)) { // Schedule for after quiet hours const scheduleTime = addMinutes( setHours(setMinutes(now, quietEnd.minutes), quietEnd.hours), 5 // 5 minutes after quiet hours end ); await knock.workflows.trigger(workflowId, { recipients: [{ id: userId }], data, schedule: { at: scheduleTime.toISOString(), }, }); return; } } // Send immediately if not in quiet hours await knock.workflows.trigger(workflowId, { recipients: [{ id: userId }], data, }); };

Batch Management

const batchNotifications = async (userId: string, notifications: PendingNotification[]) => { // Group similar notifications const grouped = notifications.reduce((acc, notif) => { const key = notif.type; if (!acc[key]) acc[key] = []; acc[key].push(notif); return acc; }, {} as Record<string, PendingNotification[]>); // Send batched notifications for (const [type, notifs] of Object.entries(grouped)) { if (notifs.length === 1) { // Send individual notification await sendNotification(notifs[0]); } else { // Send summary notification await sendBatchedNotification(type, notifs); } } }; const sendBatchedNotification = async (type: string, notifications: PendingNotification[]) => { switch (type) { case 'supply_alert': await knock.workflows.trigger('supply-batch-alert', { recipients: [{ id: userId }], data: { count: notifications.length, supplies: notifications.map(n => n.data.compound_name), deep_link: 'loop://supplies', }, }); break; case 'dose_reminder': await knock.workflows.trigger('dose-batch-reminder', { recipients: [{ id: userId }], data: { count: notifications.length, protocols: notifications.map(n => n.data.protocol_name), deep_link: 'loop://protocols', }, }); break; } };

Notification Settings UI

Settings Screen

const NotificationSettings = () => { const [preferences, setPreferences] = useState<NotificationPreferences>(); const [loading, setLoading] = useState(true); useEffect(() => { loadPreferences(); }, []); const loadPreferences = async () => { try { const prefs = await getUserPreferences(userId); setPreferences(prefs); } catch (error) { console.error('Failed to load preferences:', error); } finally { setLoading(false); } }; const updatePreference = async (path: string, value: any) => { if (!preferences) return; const updated = { ...preferences }; setNestedValue(updated, path, value); setPreferences(updated); await updateNotificationPreferences(userId, updated); }; if (loading) return <LoadingSpinner />; return ( <ScrollView style={styles.container}> <Section title="Daily Reminders"> <ToggleRow label="Dose reminders" value={preferences.dailyDoses.enabled} onToggle={(value) => updatePreference('dailyDoses.enabled', value)} /> {preferences.dailyDoses.enabled && ( <> <TimePickerRow label="Reminder times" values={preferences.dailyDoses.times} onUpdate={(times) => updatePreference('dailyDoses.times', times)} /> <SliderRow label="Advance notice" value={preferences.dailyDoses.advanceMinutes} min={0} max={60} step={5} unit="minutes" onUpdate={(value) => updatePreference('dailyDoses.advanceMinutes', value)} /> </> )} </Section> <Section title="Supply Alerts"> <ToggleRow label="Low supply alerts" value={preferences.supplyAlerts.enabled} onToggle={(value) => updatePreference('supplyAlerts.enabled', value)} /> {preferences.supplyAlerts.enabled && ( <> <SliderRow label="Low threshold" value={preferences.supplyAlerts.thresholds.low} min={10} max={50} step={5} unit="%" onUpdate={(value) => updatePreference('supplyAlerts.thresholds.low', value)} /> <SliderRow label="Critical threshold" value={preferences.supplyAlerts.thresholds.critical} min={5} max={25} step={5} unit="%" onUpdate={(value) => updatePreference('supplyAlerts.thresholds.critical', value)} /> </> )} </Section> <Section title="Quiet Hours"> <ToggleRow label="Enable quiet hours" value={preferences.quietHours.enabled} onToggle={(value) => updatePreference('quietHours.enabled', value)} /> {preferences.quietHours.enabled && ( <> <TimePickerRow label="Start time" value={preferences.quietHours.start} onUpdate={(time) => updatePreference('quietHours.start', time)} /> <TimePickerRow label="End time" value={preferences.quietHours.end} onUpdate={(time) => updatePreference('quietHours.end', time)} /> </> )} </Section> </ScrollView> ); };

Testing

Notification Testing

describe('Push Notifications', () => { it('should schedule dose reminder correctly', async () => { const mockKnock = jest.spyOn(knock.workflows, 'trigger'); await scheduleDoseReminder('user_123', { id: 'protocol_1', compound: 'BPC-157', dosage: '250mcg', route: 'subcutaneous', reminderTime: '09:00', timezone: 'America/New_York', }); expect(mockKnock).toHaveBeenCalledWith('daily-dose-reminder', { recipients: [{ id: 'user_123' }], data: expect.objectContaining({ compound_name: 'BPC-157', dosage: '250mcg', }), schedule: expect.objectContaining({ at: '09:00', timezone: 'America/New_York', }), }); }); it('should handle deep links correctly', () => { const mockRouter = jest.spyOn(router, 'push'); handleDeepLink('loop://protocols/protocol_1/log'); expect(mockRouter).toHaveBeenCalledWith('/protocols/protocol_1/log'); }); it('should respect quiet hours', async () => { const preferences = { quietHours: { enabled: true, start: '22:00', end: '08:00', }, }; // Mock current time as 23:00 (within quiet hours) jest.spyOn(Date, 'now').mockReturnValue(new Date('2024-01-01T23:00:00Z').getTime()); const mockKnock = jest.spyOn(knock.workflows, 'trigger'); await scheduleSmartNotification('test-workflow', 'user_123', {}, preferences); // Should schedule for after quiet hours (08:05) expect(mockKnock).toHaveBeenCalledWith('test-workflow', { recipients: [{ id: 'user_123' }], data: {}, schedule: { at: expect.stringMatching(/08:05/), }, }); }); });

See Also