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
Direct Purchase Links
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
- Push Notifications — Supply alert notification workflows
- Luna Chat Interface — AI-powered supply management assistance
- Rimo SSO — Seamless reorder checkout integration
- Victory Native Charts — Usage analytics visualization