Skip to Content
Mobile AppSupply Tracking

Supply Tracking

The Loop Health mobile app features intelligent supply tracking with dashboard cards, automatic calculations, low supply alerts, and seamless reorder integration — helping users stay ahead of their peptide supply needs.

Overview

Supply tracking provides comprehensive visibility into peptide inventory:

  • Dashboard Cards — Visual supply level indicators on main screen
  • Automatic Calculations — Smart usage tracking based on protocols
  • Low Supply Alerts — Proactive notifications before running out
  • Reorder Integration — Direct links to purchase replacement supplies
  • Manual Adjustments — Override calculations when needed
  • Usage Analytics — Historical consumption patterns and trends

Architecture

Component Structure

SupplyDashboard (components/SupplyDashboard.tsx) ├── SupplyCard (components/SupplyCard.tsx) │ ├── SupplyLevel (components/SupplyLevel.tsx) │ ├── SupplyActions (components/SupplyActions.tsx) │ └── LastUpdated (components/LastUpdated.tsx) ├── SupplyFilters (components/SupplyFilters.tsx) └── AddSupplyButton (components/AddSupplyButton.tsx) SupplyDetail (screens/SupplyDetail.tsx) ├── UsageChart (components/UsageChart.tsx) ├── AdjustmentHistory (components/AdjustmentHistory.tsx) ├── ReorderOptions (components/ReorderOptions.tsx) └── SupplySettings (components/SupplySettings.tsx)

Data Model

interface Supply { id: string; compoundName: string; concentration: string; // e.g., "5mg/vial" currentAmount: number; unit: 'vials' | 'ml' | 'mg' | 'mcg'; totalCapacity: number; lowThreshold: number; // Percentage (e.g., 20 = 20%) criticalThreshold: number; // Percentage (e.g., 10 = 10%) expirationDate?: Date; batchNumber?: string; supplier: string; costPerUnit: number; lastUpdated: Date; autoCalculate: boolean; status: 'adequate' | 'low' | 'critical' | 'expired'; } interface UsageRecord { id: string; supplyId: string; amount: number; timestamp: Date; source: 'protocol' | 'manual' | 'adjustment'; notes?: string; } interface SupplyCalculation { dailyUsage: number; weeklyUsage: number; monthlyUsage: number; daysRemaining: number; projectedRunOutDate: Date; reorderRecommendation: Date; }

Dashboard Cards

Supply Card Component

const SupplyCard = ({ supply }: SupplyCardProps) => { const calculation = useSupplyCalculation(supply.id); const [showActions, setShowActions] = useState(false); const getStatusColor = (status: Supply['status']) => { switch (status) { case 'adequate': return '#34C759'; // Green case 'low': return '#FF9500'; // Orange case 'critical': return '#FF3B30'; // Red case 'expired': return '#8E8E93'; // Gray } }; const getProgressPercentage = () => { return (supply.currentAmount / supply.totalCapacity) * 100; }; return ( <TouchableOpacity style={styles.card} onPress={() => router.push(`/supply/${supply.id}`)} onLongPress={() => setShowActions(true)} > <View style={styles.cardHeader}> <Text style={styles.compoundName}>{supply.compoundName}</Text> <View style={[styles.statusBadge, { backgroundColor: getStatusColor(supply.status) }]}> <Text style={styles.statusText}>{supply.status.toUpperCase()}</Text> </View> </View> <View style={styles.supplyLevel}> <Text style={styles.amountText}> {supply.currentAmount} / {supply.totalCapacity} {supply.unit} </Text> <ProgressBar progress={getProgressPercentage()} color={getStatusColor(supply.status)} /> </View> {calculation && ( <View style={styles.projections}> <Text style={styles.projectionText}> ~{calculation.daysRemaining} days remaining </Text> <Text style={styles.runOutDate}> Runs out: {format(calculation.projectedRunOutDate, 'MMM dd')} </Text> </View> )} <View style={styles.cardFooter}> <Text style={styles.lastUpdated}> Updated {formatDistanceToNow(supply.lastUpdated)} ago </Text> {supply.status === 'low' || supply.status === 'critical' ? ( <TouchableOpacity style={styles.reorderButton} onPress={() => handleReorder(supply)} > <Text style={styles.reorderText}>Reorder</Text> </TouchableOpacity> ) : null} </View> {showActions && ( <SupplyActions supply={supply} onClose={() => setShowActions(false)} /> )} </TouchableOpacity> ); };

Progress Visualization

const ProgressBar = ({ progress, color }: ProgressBarProps) => { const animatedWidth = useSharedValue(0); useEffect(() => { animatedWidth.value = withTiming(progress, { duration: 1000 }); }, [progress]); const animatedStyle = useAnimatedStyle(() => ({ width: `${animatedWidth.value}%`, })); return ( <View style={styles.progressContainer}> <View style={styles.progressTrack}> <Animated.View style={[ styles.progressFill, { backgroundColor: color }, animatedStyle ]} /> </View> <Text style={styles.progressText}>{Math.round(progress)}%</Text> </View> ); };

Supply Calculations

Automatic Usage Tracking

const useSupplyCalculation = (supplyId: string) => { const [calculation, setCalculation] = useState<SupplyCalculation | null>(null); useEffect(() => { const calculateUsage = async () => { try { // Get usage records from last 30 days const usageRecords = await getUsageRecords(supplyId, 30); if (usageRecords.length === 0) { setCalculation(null); return; } // Calculate daily average const totalUsage = usageRecords.reduce((sum, record) => sum + record.amount, 0); const daysCovered = usageRecords.length; const dailyUsage = totalUsage / daysCovered; // Get current supply level const supply = await getSupply(supplyId); // Calculate projections const daysRemaining = Math.floor(supply.currentAmount / dailyUsage); const projectedRunOutDate = addDays(new Date(), daysRemaining); const reorderRecommendation = addDays(new Date(), daysRemaining * 0.8); // 80% threshold setCalculation({ dailyUsage, weeklyUsage: dailyUsage * 7, monthlyUsage: dailyUsage * 30, daysRemaining, projectedRunOutDate, reorderRecommendation, }); } catch (error) { console.error('Failed to calculate supply usage:', error); setCalculation(null); } }; calculateUsage(); // Recalculate daily const interval = setInterval(calculateUsage, 24 * 60 * 60 * 1000); return () => clearInterval(interval); }, [supplyId]); return calculation; };

Protocol Integration

const trackProtocolUsage = async (protocolId: string, compoundName: string, amount: number) => { try { // Find matching supply const supply = await findSupplyByCompound(compoundName); if (!supply) return; // Record usage await createUsageRecord({ supplyId: supply.id, amount, timestamp: new Date(), source: 'protocol', notes: `Protocol: ${protocolId}`, }); // Update supply level if auto-calculate is enabled if (supply.autoCalculate) { await updateSupplyLevel(supply.id, supply.currentAmount - amount); } // Check if supply is now low const updatedSupply = await getSupply(supply.id); if (updatedSupply.status === 'low' || updatedSupply.status === 'critical') { await triggerLowSupplyAlert(updatedSupply); } } catch (error) { console.error('Failed to track protocol usage:', error); } };

Low Supply Alerts

Alert Thresholds

const checkSupplyLevels = async () => { const supplies = await getAllSupplies(); for (const supply of supplies) { const percentage = (supply.currentAmount / supply.totalCapacity) * 100; let newStatus: Supply['status'] = 'adequate'; // Check expiration if (supply.expirationDate && isAfter(new Date(), supply.expirationDate)) { newStatus = 'expired'; } // Check critical threshold else if (percentage <= supply.criticalThreshold) { newStatus = 'critical'; } // Check low threshold else if (percentage <= supply.lowThreshold) { newStatus = 'low'; } // Update status if changed if (newStatus !== supply.status) { await updateSupplyStatus(supply.id, newStatus); // Trigger appropriate alert if (newStatus === 'critical') { await triggerCriticalSupplyAlert(supply); } else if (newStatus === 'low') { await triggerLowSupplyAlert(supply); } } } }; // Run check daily const scheduleSupplyCheck = () => { const now = new Date(); const tomorrow = addDays(startOfDay(now), 1); const msUntilTomorrow = tomorrow.getTime() - now.getTime(); setTimeout(() => { checkSupplyLevels(); // Then run daily setInterval(checkSupplyLevels, 24 * 60 * 60 * 1000); }, msUntilTomorrow); };

Push Notification Integration

const triggerLowSupplyAlert = async (supply: Supply) => { try { // Send push notification via Knock await knock.workflows.trigger('supply-low-alert', { recipients: [{ id: userId }], data: { compoundName: supply.compoundName, currentAmount: supply.currentAmount, unit: supply.unit, daysRemaining: Math.floor(supply.currentAmount / (supply.dailyUsage || 1)), reorderUrl: `https://loopbiolabs.com/products/${supply.compoundName.toLowerCase()}`, }, }); // Log alert await logSupplyAlert({ supplyId: supply.id, type: 'low_supply', threshold: supply.lowThreshold, currentLevel: (supply.currentAmount / supply.totalCapacity) * 100, }); } catch (error) { console.error('Failed to send low supply alert:', error); } }; const triggerCriticalSupplyAlert = async (supply: Supply) => { try { // Send urgent push notification await knock.workflows.trigger('supply-critical-alert', { recipients: [{ id: userId }], data: { compoundName: supply.compoundName, currentAmount: supply.currentAmount, unit: supply.unit, urgency: 'critical', reorderUrl: `https://loopbiolabs.com/products/${supply.compoundName.toLowerCase()}`, }, }); // Also send in-app notification await showInAppAlert({ title: 'Critical Supply Level', message: `Your ${supply.compoundName} is critically low (${supply.currentAmount} ${supply.unit} remaining)`, actions: [ { text: 'Reorder Now', action: () => openReorderUrl(supply) }, { text: 'Adjust Level', action: () => openAdjustmentModal(supply) }, ], }); } catch (error) { console.error('Failed to send critical supply alert:', error); } };

Reorder Integration

const handleReorder = async (supply: Supply) => { try { // Generate SSO token for seamless checkout const response = await fetch('/api/sso/generate', { method: 'POST', headers: { 'Authorization': `Bearer ${clerkToken}`, 'Content-Type': 'application/json', }, }); const { checkoutUrl } = await response.json(); // Open in-app browser with pre-filled cart await WebBrowser.openBrowserAsync(checkoutUrl, { presentationStyle: WebBrowser.WebBrowserPresentationStyle.FORM_SHEET, controlsColor: '#007AFF', }); // Track reorder action analytics.track('supply_reorder_initiated', { supplyId: supply.id, compoundName: supply.compoundName, currentLevel: supply.currentAmount, status: supply.status, }); } catch (error) { console.error('Failed to initiate reorder:', error); Alert.alert('Error', 'Failed to open reorder page. Please try again.'); } };

Reorder Recommendations

const ReorderRecommendations = ({ supply }: ReorderRecommendationsProps) => { const calculation = useSupplyCalculation(supply.id); const getRecommendedQuantity = () => { if (!calculation) return 1; // Recommend enough for 90 days based on current usage const monthlyUsage = calculation.monthlyUsage; const recommendedMonths = 3; return Math.ceil(monthlyUsage * recommendedMonths); }; const getSavingsInfo = (quantity: number) => { const unitPrice = supply.costPerUnit; const bulkDiscount = quantity >= 3 ? 0.15 : quantity >= 2 ? 0.1 : 0; const savings = unitPrice * quantity * bulkDiscount; return { totalCost: unitPrice * quantity * (1 - bulkDiscount), savings, bulkDiscount, }; }; const recommendedQty = getRecommendedQuantity(); const savingsInfo = getSavingsInfo(recommendedQty); return ( <View style={styles.recommendations}> <Text style={styles.sectionTitle}>Recommended Reorder</Text> <View style={styles.recommendationCard}> <Text style={styles.quantityText}> {recommendedQty} {supply.unit} </Text> <Text style={styles.durationText}> ~90 days supply </Text> {savingsInfo.bulkDiscount > 0 && ( <View style={styles.savings}> <Text style={styles.savingsText}> Save ${savingsInfo.savings.toFixed(2)} ({(savingsInfo.bulkDiscount * 100).toFixed(0)}% bulk discount) </Text> </View> )} <TouchableOpacity style={styles.reorderButton} onPress={() => handleReorderWithQuantity(supply, recommendedQty)} > <Text style={styles.reorderButtonText}> Reorder ${savingsInfo.totalCost.toFixed(2)} </Text> </TouchableOpacity> </View> </View> ); };

Manual Adjustments

Adjustment Modal

const SupplyAdjustmentModal = ({ supply, visible, onClose }: SupplyAdjustmentModalProps) => { const [newAmount, setNewAmount] = useState(supply.currentAmount.toString()); const [reason, setReason] = useState(''); const [adjustmentType, setAdjustmentType] = useState<'set' | 'add' | 'subtract'>('set'); const handleAdjustment = async () => { try { const amount = parseFloat(newAmount); if (isNaN(amount) || amount < 0) { Alert.alert('Invalid Amount', 'Please enter a valid amount'); return; } let finalAmount: number; switch (adjustmentType) { case 'set': finalAmount = amount; break; case 'add': finalAmount = supply.currentAmount + amount; break; case 'subtract': finalAmount = Math.max(0, supply.currentAmount - amount); break; } // Update supply level await updateSupplyLevel(supply.id, finalAmount); // Record adjustment await createUsageRecord({ supplyId: supply.id, amount: finalAmount - supply.currentAmount, timestamp: new Date(), source: 'adjustment', notes: reason || 'Manual adjustment', }); // Analytics analytics.track('supply_manual_adjustment', { supplyId: supply.id, oldAmount: supply.currentAmount, newAmount: finalAmount, adjustmentType, reason, }); onClose(); } catch (error) { console.error('Failed to adjust supply:', error); Alert.alert('Error', 'Failed to update supply level'); } }; return ( <Modal visible={visible} animationType="slide"> <View style={styles.modalContainer}> <View style={styles.modalHeader}> <Text style={styles.modalTitle}>Adjust {supply.compoundName}</Text> <TouchableOpacity onPress={onClose}> <Icon name="close" size={24} /> </TouchableOpacity> </View> <View style={styles.currentLevel}> <Text style={styles.currentLevelText}> Current: {supply.currentAmount} {supply.unit} </Text> </View> <View style={styles.adjustmentTypes}> {(['set', 'add', 'subtract'] as const).map(type => ( <TouchableOpacity key={type} style={[ styles.typeButton, adjustmentType === type && styles.typeButtonActive ]} onPress={() => setAdjustmentType(type)} > <Text style={styles.typeButtonText}> {type === 'set' ? 'Set to' : type === 'add' ? 'Add' : 'Subtract'} </Text> </TouchableOpacity> ))} </View> <TextInput style={styles.amountInput} value={newAmount} onChangeText={setNewAmount} placeholder={`Amount (${supply.unit})`} keyboardType="numeric" /> <TextInput style={styles.reasonInput} value={reason} onChangeText={setReason} placeholder="Reason for adjustment (optional)" multiline /> <TouchableOpacity style={styles.saveButton} onPress={handleAdjustment} > <Text style={styles.saveButtonText}>Save Adjustment</Text> </TouchableOpacity> </View> </Modal> ); };

Usage Analytics

Historical Charts

import { VictoryChart, VictoryLine, VictoryArea, VictoryAxis } from 'victory-native'; const UsageChart = ({ supplyId, timeRange = '30d' }: UsageChartProps) => { const [usageData, setUsageData] = useState<UsageDataPoint[]>([]); useEffect(() => { const loadUsageData = async () => { const days = timeRange === '7d' ? 7 : timeRange === '30d' ? 30 : 90; const records = await getUsageRecords(supplyId, days); // Group by day and calculate daily totals const dailyUsage = records.reduce((acc, record) => { const day = format(record.timestamp, 'yyyy-MM-dd'); acc[day] = (acc[day] || 0) + record.amount; return acc; }, {} as Record<string, number>); // Convert to chart data const chartData = Object.entries(dailyUsage).map(([date, amount]) => ({ x: new Date(date), y: amount, })); setUsageData(chartData); }; loadUsageData(); }, [supplyId, timeRange]); return ( <View style={styles.chartContainer}> <Text style={styles.chartTitle}>Usage Trend ({timeRange})</Text> <VictoryChart theme={VictoryTheme.material} width={350} height={200} padding={{ left: 60, top: 20, right: 40, bottom: 60 }} > <VictoryAxis dependentAxis /> <VictoryAxis fixLabelOverlap={true} tickFormat={(date) => format(date, 'MMM dd')} /> <VictoryArea data={usageData} style={{ data: { fill: "#007AFF", fillOpacity: 0.3, stroke: "#007AFF" } }} /> <VictoryLine data={usageData} style={{ data: { stroke: "#007AFF", strokeWidth: 2 } }} /> </VictoryChart> {usageData.length > 0 && ( <View style={styles.chartStats}> <Text style={styles.statText}> Avg: {(usageData.reduce((sum, d) => sum + d.y, 0) / usageData.length).toFixed(1)} units/day </Text> </View> )} </View> ); };

Testing

Component Tests

describe('SupplyCard', () => { const mockSupply: Supply = { id: '1', compoundName: 'BPC-157', concentration: '5mg/vial', currentAmount: 2, totalCapacity: 10, unit: 'vials', lowThreshold: 20, criticalThreshold: 10, status: 'low', supplier: 'Loop Bio Labs', costPerUnit: 149.99, lastUpdated: new Date(), autoCalculate: true, }; it('should display supply information correctly', () => { const { getByText } = render(<SupplyCard supply={mockSupply} />); expect(getByText('BPC-157')).toBeTruthy(); expect(getByText('2 / 10 vials')).toBeTruthy(); expect(getByText('LOW')).toBeTruthy(); }); it('should show reorder button for low supplies', () => { const { getByText } = render(<SupplyCard supply={mockSupply} />); expect(getByText('Reorder')).toBeTruthy(); }); it('should calculate progress percentage correctly', () => { const { getByText } = render(<SupplyCard supply={mockSupply} />); expect(getByText('20%')).toBeTruthy(); // 2/10 = 20% }); });

Integration Tests

describe('Supply Tracking Integration', () => { it('should update supply level when protocol is logged', async () => { const supply = await createTestSupply({ compoundName: 'BPC-157', currentAmount: 5, autoCalculate: true, }); // Simulate protocol usage await trackProtocolUsage('protocol_1', 'BPC-157', 0.25); // Check updated supply level const updatedSupply = await getSupply(supply.id); expect(updatedSupply.currentAmount).toBe(4.75); }); it('should trigger alert when supply goes low', async () => { const mockKnock = jest.spyOn(knock.workflows, 'trigger'); const supply = await createTestSupply({ currentAmount: 1, totalCapacity: 10, lowThreshold: 20, }); await checkSupplyLevels(); expect(mockKnock).toHaveBeenCalledWith('supply-low-alert', expect.any(Object)); }); });

See Also