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"]
}
]
}
}
}Deep Link Handler
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
- Knock Documentation — Notification platform integration
- Expo Notifications — Local notification handling
- Luna Chat Interface — Chat notifications and deep linking
- Supply Tracking — Supply alert workflows