Building an Open Source Anxiety Tracker App
Mental health applications collect some of the most sensitive personal data imaginable. Building an anxiety tracker as open source software provides transparency, allows community auditing of privacy practices, and enables users to verify that their data is handled responsibly. This tutorial covers the full development process from architecture decisions through deployment.
Design Principles for Mental Health Apps
Before writing any code, establish the design principles that will guide every technical decision throughout the project.
Privacy by default: All data stays on the device unless the user explicitly exports it. No cloud accounts, no analytics tracking of mood data, no third-party data sharing.
Low friction: Anxious users should not face complex onboarding flows or mandatory account creation. The app should be usable within seconds of installation.
Clinical utility: The tracking methodology should align with established psychological frameworks, specifically the Cognitive Behavioral Therapy (CBT) model of tracking thoughts, feelings, and behaviors.
Accessibility: Support for screen readers, dynamic type, and high contrast modes. Anxiety can co-occur with other conditions that affect app usability.
Data portability: Users own their data. Export in standard formats (CSV, JSON, FHIR) so they can share it with therapists or migrate to other tools.
Technology Stack
The stack is chosen for developer experience, performance, and alignment with privacy goals:
- React Native with TypeScript for cross-platform mobile development
- SQLite via
react-native-quick-sqlitefor local-only data storage - Recharts (via
react-native-svg) for visualizing anxiety patterns - Date-fns for date manipulation and formatting
- React Navigation for screen management
- Zustand for lightweight state management
No backend services are required. All computation happens on the device.
Project Structure
anxiety-tracker/
├── src/
│ ├── components/
│ │ ├── AnxietySlider.tsx
│ │ ├── TriggerChip.tsx
│ │ ├── MoodCalendar.tsx
│ │ └── ExportButton.tsx
│ ├── screens/
│ │ ├── HomeScreen.tsx
│ │ ├── LogEntryScreen.tsx
│ │ ├── HistoryScreen.tsx
│ │ ├── InsightsScreen.tsx
│ │ └── SettingsScreen.tsx
│ ├── database/
│ │ ├── migrations.ts
│ │ ├── repository.ts
│ │ └── types.ts
│ ├── hooks/
│ │ ├── useAnxietyEntries.ts
│ │ └── useInsights.ts
│ ├── utils/
│ │ ├── export.ts
│ │ ├── patterns.ts
│ │ └── notifications.ts
│ └── App.tsx
├── __tests__/
├── package.json
└── README.md
Database Schema Design
The database schema models anxiety entries following the CBT framework, linking triggers, physical symptoms, cognitive patterns, and coping strategies to each entry.
Create src/database/migrations.ts:
import { open } from 'react-native-quick-sqlite';
export async function runMigrations(): Promise<void> {
const db = open('anxiety_tracker');
db.execute(`
CREATE TABLE IF NOT EXISTS entries (
id TEXT PRIMARY KEY,
timestamp INTEGER NOT NULL,
anxiety_level INTEGER NOT NULL CHECK(anxiety_level BETWEEN 1 AND 10),
mood TEXT NOT NULL CHECK(mood IN ('calm', 'mild', 'moderate', 'high', 'severe', 'panic')),
notes TEXT,
situation_description TEXT,
created_at INTEGER NOT NULL DEFAULT (strftime('%s', 'now')),
updated_at INTEGER NOT NULL DEFAULT (strftime('%s', 'now'))
);
`);
db.execute(`
CREATE TABLE IF NOT EXISTS triggers (
id TEXT PRIMARY KEY,
entry_id TEXT NOT NULL,
name TEXT NOT NULL,
category TEXT NOT NULL CHECK(category IN (
'work', 'social', 'health', 'financial', 'family',
'environmental', 'existential', 'other'
)),
intensity INTEGER CHECK(intensity BETWEEN 1 AND 10),
FOREIGN KEY (entry_id) REFERENCES entries(id) ON DELETE CASCADE
);
`);
db.execute(`
CREATE TABLE IF NOT EXISTS symptoms (
id TEXT PRIMARY KEY,
entry_id TEXT NOT NULL,
name TEXT NOT NULL,
category TEXT NOT NULL CHECK(category IN (
'physical', 'cognitive', 'behavioral', 'emotional'
)),
severity INTEGER CHECK(severity BETWEEN 1 AND 10),
FOREIGN KEY (entry_id) REFERENCES entries(id) ON DELETE CASCADE
);
`);
db.execute(`
CREATE TABLE IF NOT EXISTS coping_strategies (
id TEXT PRIMARY KEY,
entry_id TEXT NOT NULL,
name TEXT NOT NULL,
effectiveness INTEGER CHECK(effectiveness BETWEEN 1 AND 10),
FOREIGN KEY (entry_id) REFERENCES entries(id) ON DELETE CASCADE
);
`);
// Indexes for common queries
db.execute('CREATE INDEX IF NOT EXISTS idx_entries_timestamp ON entries(timestamp);');
db.execute('CREATE INDEX IF NOT EXISTS idx_triggers_entry_id ON triggers(entry_id);');
db.execute('CREATE INDEX IF NOT EXISTS idx_triggers_name ON triggers(name);');
db.execute('CREATE INDEX IF NOT EXISTS idx_symptoms_entry_id ON symptoms(entry_id);');
// Predefined trigger and symptom suggestions
db.execute(`
CREATE TABLE IF NOT EXISTS trigger_suggestions (
id TEXT PRIMARY KEY,
name TEXT NOT NULL UNIQUE,
category TEXT NOT NULL
);
`);
db.execute(`
CREATE TABLE IF NOT EXISTS symptom_suggestions (
id TEXT PRIMARY KEY,
name TEXT NOT NULL UNIQUE,
category TEXT NOT NULL
);
`);
// Seed suggestions
const triggerSuggestions = [
{ name: 'Work deadline', category: 'work' },
{ name: 'Public speaking', category: 'social' },
{ name: 'Social gathering', category: 'social' },
{ name: 'Health concerns', category: 'health' },
{ name: 'Financial worries', category: 'financial' },
{ name: 'Conflict with family', category: 'family' },
{ name: 'Crowded places', category: 'environmental' },
{ name: 'Uncertainty about future', category: 'existential' },
{ name: 'Phone calls', category: 'social' },
{ name: 'Driving', category: 'environmental' },
];
const symptomSuggestions = [
{ name: 'Rapid heartbeat', category: 'physical' },
{ name: 'Shortness of breath', category: 'physical' },
{ name: 'Muscle tension', category: 'physical' },
{ name: 'Sweating', category: 'physical' },
{ name: 'Nausea', category: 'physical' },
{ name: 'Racing thoughts', category: 'cognitive' },
{ name: 'Difficulty concentrating', category: 'cognitive' },
{ name: 'Catastrophizing', category: 'cognitive' },
{ name: 'Avoidance', category: 'behavioral' },
{ name: 'Restlessness', category: 'behavioral' },
{ name: 'Irritability', category: 'emotional' },
{ name: 'Feeling overwhelmed', category: 'emotional' },
];
for (const trigger of triggerSuggestions) {
db.execute(
`INSERT OR IGNORE INTO trigger_suggestions (id, name, category) VALUES (?, ?, ?);`,
[crypto.randomUUID(), trigger.name, trigger.category]
);
}
for (const symptom of symptomSuggestions) {
db.execute(
`INSERT OR IGNORE INTO symptom_suggestions (id, name, category) VALUES (?, ?, ?);`,
[crypto.randomUUID(), symptom.name, symptom.category]
);
}
}
Data Repository Layer
Create src/database/repository.ts to encapsulate all database operations:
import { open, ResultSet } from 'react-native-quick-sqlite';
import { v4 as uuidv4 } from 'uuid';
let db: ReturnType<typeof open>;
function getDb() {
if (!db) {
db = open('anxiety_tracker');
}
return db;
}
export interface AnxietyEntry {
id: string;
timestamp: number;
anxietyLevel: number;
mood: string;
notes: string | null;
situationDescription: string | null;
triggers: Trigger[];
symptoms: Symptom[];
copingStrategies: CopingStrategy[];
}
export interface Trigger {
id: string;
entryId: string;
name: string;
category: string;
intensity: number | null;
}
export interface Symptom {
id: string;
entryId: string;
name: string;
category: string;
severity: number | null;
}
export interface CopingStrategy {
id: string;
entryId: string;
name: string;
effectiveness: number | null;
}
export const anxietyRepository = {
createEntry(params: {
anxietyLevel: number;
mood: string;
notes?: string;
situationDescription?: string;
triggers: Array<{ name: string; category: string; intensity?: number }>;
symptoms: Array<{ name: string; category: string; severity?: number }>;
copingStrategies: Array<{ name: string; effectiveness?: number }>;
}): AnxietyEntry {
const id = uuidv4();
const now = Date.now();
const database = getDb();
database.executeSql(
`INSERT INTO entries (id, timestamp, anxiety_level, mood, notes, situation_description)
VALUES (?, ?, ?, ?, ?, ?);`,
[id, now, params.anxietyLevel, params.mood, params.notes || null, params.situationDescription || null]
);
const triggers: Trigger[] = params.triggers.map((t) => {
const triggerId = uuidv4();
database.executeSql(
`INSERT INTO triggers (id, entry_id, name, category, intensity) VALUES (?, ?, ?, ?, ?);`,
[triggerId, id, t.name, t.category, t.intensity || null]
);
return { id: triggerId, entryId: id, ...t, intensity: t.intensity || null };
});
const symptoms: Symptom[] = params.symptoms.map((s) => {
const symptomId = uuidv4();
database.executeSql(
`INSERT INTO symptoms (id, entry_id, name, category, severity) VALUES (?, ?, ?, ?, ?);`,
[symptomId, id, s.name, s.category, s.severity || null]
);
return { id: symptomId, entryId: id, ...s, severity: s.severity || null };
});
const copingStrategies: CopingStrategy[] = params.copingStrategies.map((c) => {
const copingId = uuidv4();
database.executeSql(
`INSERT INTO coping_strategies (id, entry_id, name, effectiveness) VALUES (?, ?, ?, ?);`,
[copingId, id, c.name, c.effectiveness || null]
);
return { id: copingId, entryId: id, ...c, effectiveness: c.effectiveness || null };
});
return {
id,
timestamp: now,
anxietyLevel: params.anxietyLevel,
mood: params.mood,
notes: params.notes || null,
situationDescription: params.situationDescription || null,
triggers,
symptoms,
copingStrategies,
};
},
getEntries(startDate: number, endDate: number): AnxietyEntry[] {
const database = getDb();
const resultSet = database.executeSql(
`SELECT * FROM entries WHERE timestamp BETWEEN ? AND ? ORDER BY timestamp DESC;`,
[startDate, endDate]
);
return resultSet.map((row: any) => ({
id: row.id,
timestamp: row.timestamp,
anxietyLevel: row.anxiety_level,
mood: row.mood,
notes: row.notes,
situationDescription: row.situation_description,
triggers: this.getTriggersForEntry(row.id),
symptoms: this.getSymptomsForEntry(row.id),
copingStrategies: this.getCopingStrategiesForEntry(row.id),
}));
},
getTriggersForEntry(entryId: string): Trigger[] {
const database = getDb();
return database.executeSql(
`SELECT * FROM triggers WHERE entry_id = ?;`,
[entryId]
).map((row: any) => ({
id: row.id,
entryId: row.entry_id,
name: row.name,
category: row.category,
intensity: row.intensity,
}));
},
getSymptomsForEntry(entryId: string): Symptom[] {
const database = getDb();
return database.executeSql(
`SELECT * FROM symptoms WHERE entry_id = ?;`,
[entryId]
).map((row: any) => ({
id: row.id,
entryId: row.entry_id,
name: row.name,
category: row.category,
severity: row.severity,
}));
},
getCopingStrategiesForEntry(entryId: string): CopingStrategy[] {
const database = getDb();
return database.executeSql(
`SELECT * FROM coping_strategies WHERE entry_id = ?;`,
[entryId]
).map((row: any) => ({
id: row.id,
entryId: row.entry_id,
name: row.name,
effectiveness: row.effectiveness,
}));
},
getMostCommonTriggers(limit: number = 10): Array<{ name: string; count: number; avgIntensity: number }> {
const database = getDb();
return database.executeSql(
`SELECT name, COUNT(*) as count, AVG(COALESCE(intensity, 5)) as avgIntensity
FROM triggers
GROUP BY name
ORDER BY count DESC
LIMIT ?;`,
[limit]
).map((row: any) => ({
name: row.name,
count: row.count,
avgIntensity: Math.round(row.avgIntensity * 10) / 10,
}));
},
getAverageAnxietyByDayOfWeek(): Array<{ dayOfWeek: number; avgLevel: number }> {
const database = getDb();
return database.executeSql(
`SELECT CAST(strftime('%w', timestamp / 1000, 'unixepoch') AS INTEGER) as dayOfWeek,
AVG(anxiety_level) as avgLevel
FROM entries
GROUP BY dayOfWeek
ORDER BY dayOfWeek;`
).map((row: any) => ({
dayOfWeek: row.dayOfWeek,
avgLevel: Math.round(row.avgLevel * 10) / 10,
}));
},
getTriggerSuggestions(category?: string): Array<{ name: string; category: string }> {
const database = getDb();
const query = category
? `SELECT name, category FROM trigger_suggestions WHERE category = ? ORDER BY name;`
: `SELECT name, category FROM trigger_suggestions ORDER BY category, name;`;
const params = category ? [category] : [];
return database.executeSql(query, params).map((row: any) => ({
name: row.name,
category: row.category,
}));
},
};
The Logging Screen
The core user interaction is logging an anxiety entry. The screen must be simple, fast, and not anxiety-inducing itself.
Create src/screens/LogEntryScreen.tsx:
import React, { useState } from 'react';
import {
View,
Text,
TextInput,
ScrollView,
TouchableOpacity,
StyleSheet,
Alert,
} from 'react-native';
import { anxietyRepository } from '../database/repository';
const MOOD_OPTIONS = [
{ value: 'calm', label: 'Calm', color: '#4ade80' },
{ value: 'mild', label: 'Mild', color: '#a3e635' },
{ value: 'moderate', label: 'Moderate', color: '#facc15' },
{ value: 'high', label: 'High', color: '#fb923c' },
{ value: 'severe', label: 'Severe', color: '#f87171' },
{ value: 'panic', label: 'Panic', color: '#dc2626' },
];
const QUICK_TRIGGERS = [
{ name: 'Work', category: 'work' },
{ name: 'Social', category: 'social' },
{ name: 'Health', category: 'health' },
{ name: 'Financial', category: 'financial' },
{ name: 'Family', category: 'family' },
];
const QUICK_SYMPTOMS = [
'Rapid heartbeat',
'Racing thoughts',
'Muscle tension',
'Shortness of breath',
'Restlessness',
];
const COPING_OPTIONS = [
'Deep breathing',
'Meditation',
'Physical exercise',
'Talking to someone',
'Journaling',
'Progressive muscle relaxation',
'Grounding (5-4-3-2-1)',
'Distraction activity',
];
export default function LogEntryScreen({ navigation }: any) {
const [anxietyLevel, setAnxietyLevel] = useState(5);
const [mood, setMood] = useState<string | null>(null);
const [selectedTriggers, setSelectedTriggers] = useState<Set<string>>(new Set());
const [selectedSymptoms, setSelectedSymptoms] = useState<Set<string>>(new Set());
const [selectedCoping, setSelectedCoping] = useState<Set<string>>(new Set());
const [notes, setNotes] = useState('');
const [situation, setSituation] = useState('');
const [isSaving, setIsSaving] = useState(false);
const toggleItem = (set: Set<string>, item: string, setter: (s: Set<string>) => void) => {
const next = new Set(set);
if (next.has(item)) {
next.delete(item);
} else {
next.add(item);
}
setter(next);
};
const handleSave = async () => {
if (!mood) {
Alert.alert('Missing Information', 'Please select how you are feeling.');
return;
}
setIsSaving(true);
try {
anxietyRepository.createEntry({
anxietyLevel,
mood,
notes: notes || undefined,
situationDescription: situation || undefined,
triggers: Array.from(selectedTriggers).map((name) => {
const predefined = QUICK_TRIGGERS.find((t) => t.name === name);
return { name, category: predefined?.category || 'other' };
}),
symptoms: Array.from(selectedSymptoms).map((name) => ({
name,
category: name === 'Racing thoughts' ? 'cognitive'
: name === 'Restlessness' ? 'behavioral'
: 'physical',
})),
copingStrategies: Array.from(selectedCoping).map((name) => ({ name })),
});
Alert.alert('Entry Saved', 'Your anxiety entry has been recorded.', [
{ text: 'OK', onPress: () => navigation.goBack() },
]);
} catch (error: any) {
Alert.alert('Error', 'Failed to save entry. Please try again.');
} finally {
setIsSaving(false);
}
};
return (
<ScrollView style={styles.container} keyboardShouldPersistTaps="handled">
<Text style={styles.sectionTitle}>How anxious do you feel right now?</Text>
<View style={styles.sliderContainer}>
<Text style={styles.sliderLabel}>1 - Minimal</Text>
<Text style={styles.sliderValue}>{anxietyLevel}/10</Text>
<Text style={styles.sliderLabel}>10 - Extreme</Text>
</View>
{/* Custom slider implementation would go here */}
<Text style={styles.sectionTitle}>What is your overall mood?</Text>
<View style={styles.moodRow}>
{MOOD_OPTIONS.map((option) => (
<TouchableOpacity
key={option.value}
style={[
styles.moodChip,
mood === option.value && { backgroundColor: option.color },
]}
onPress={() => setMood(option.value)}
>
<Text style={[styles.moodText, mood === option.value && styles.moodTextSelected]}>
{option.label}
</Text>
</TouchableOpacity>
))}
</View>
<Text style={styles.sectionTitle}>What triggered this?</Text>
<View style={styles.chipRow}>
{QUICK_TRIGGERS.map((trigger) => (
<TouchableOpacity
key={trigger.name}
style={[styles.chip, selectedTriggers.has(trigger.name) && styles.chipSelected]}
onPress={() => toggleItem(selectedTriggers, trigger.name, setSelectedTriggers)}
>
<Text style={[styles.chipText, selectedTriggers.has(trigger.name) && styles.chipTextSelected]}>
{trigger.name}
</Text>
</TouchableOpacity>
))}
</View>
<Text style={styles.sectionTitle}>Physical and mental symptoms</Text>
<View style={styles.chipRow}>
{QUICK_SYMPTOMS.map((symptom) => (
<TouchableOpacity
key={symptom}
style={[styles.chip, selectedSymptoms.has(symptom) && styles.chipSelected]}
onPress={() => toggleItem(selectedSymptoms, symptom, setSelectedSymptoms)}
>
<Text style={[styles.chipText, selectedSymptoms.has(symptom) && styles.chipTextSelected]}>
{symptom}
</Text>
</TouchableOpacity>
))}
</View>
<Text style={styles.sectionTitle}>What are you doing to cope?</Text>
<View style={styles.chipRow}>
{COPING_OPTIONS.map((option) => (
<TouchableOpacity
key={option}
style={[styles.chip, selectedCoping.has(option) && styles.chipSelected]}
onPress={() => toggleItem(selectedCoping, option, setSelectedCoping)}
>
<Text style={[styles.chipText, selectedCoping.has(option) && styles.chipTextSelected]}>
{option}
</Text>
</TouchableOpacity>
))}
</View>
<Text style={styles.sectionTitle}>Situation description (optional)</Text>
<TextInput
style={styles.textArea}
multiline
numberOfLines={3}
placeholder="Describe what happened..."
placeholderTextColor="#64748b"
value={situation}
onChangeText={setSituation}
/>
<Text style={styles.sectionTitle}>Additional notes</Text>
<TextInput
style={styles.textArea}
multiline
numberOfLines={2}
placeholder="Any other thoughts..."
placeholderTextColor="#64748b"
value={notes}
onChangeText={setNotes}
/>
<TouchableOpacity
style={[styles.saveButton, isSaving && styles.saveButtonDisabled]}
onPress={handleSave}
disabled={isSaving}
>
<Text style={styles.saveButtonText}>
{isSaving ? 'Saving...' : 'Save Entry'}
</Text>
</TouchableOpacity>
</ScrollView>
);
}
const styles = StyleSheet.create({
container: { flex: 1, backgroundColor: '#0f172a', padding: 20 },
sectionTitle: { color: '#e2e8f0', fontSize: 16, fontWeight: '600', marginTop: 20, marginBottom: 10 },
sliderContainer: { flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center', marginBottom: 10 },
sliderLabel: { color: '#94a3b8', fontSize: 12 },
sliderValue: { color: '#e2e8f0', fontSize: 24, fontWeight: '700' },
moodRow: { flexDirection: 'row', flexWrap: 'wrap', gap: 8 },
moodChip: { paddingHorizontal: 14, paddingVertical: 8, borderRadius: 20, backgroundColor: '#1e293b' },
moodText: { color: '#94a3b8', fontSize: 13 },
moodTextSelected: { color: '#0f172a', fontWeight: '600' },
chipRow: { flexDirection: 'row', flexWrap: 'wrap', gap: 8 },
chip: { paddingHorizontal: 14, paddingVertical: 8, borderRadius: 20, backgroundColor: '#1e293b', borderWidth: 1, borderColor: '#334155' },
chipSelected: { backgroundColor: '#6366f1', borderColor: '#6366f1' },
chipText: { color: '#94a3b8', fontSize: 13 },
chipTextSelected: { color: '#fff' },
textArea: { backgroundColor: '#1e293b', borderRadius: 12, padding: 14, color: '#e2e8f0', fontSize: 14, minHeight: 60, textAlignVertical: 'top' },
saveButton: { backgroundColor: '#6366f1', borderRadius: 12, paddingVertical: 16, alignItems: 'center', marginTop: 24, marginBottom: 40 },
saveButtonDisabled: { opacity: 0.6 },
saveButtonText: { color: '#fff', fontSize: 16, fontWeight: '600' },
});
Pattern Analysis Engine
The insights engine identifies recurring patterns in anxiety data without requiring a backend.
Create src/utils/patterns.ts:
import { anxietyRepository, AnxietyEntry } from '../database/repository';
export interface AnxietyInsight {
type: 'trigger_pattern' | 'time_pattern' | 'symptom_correlation' | 'coping_effectiveness';
title: string;
description: string;
confidence: 'low' | 'medium' | 'high';
data: Record<string, unknown>;
}
export function analyzePatterns(entries: AnxietyEntry[]): AnxietyInsight[] {
const insights: AnxietyInsight[] = [];
if (entries.length < 5) {
return insights;
}
// Find most impactful triggers
const triggerImpact = new Map<string, { count: number; totalAnxiety: number }>();
entries.forEach((entry) => {
entry.triggers.forEach((trigger) => {
const existing = triggerImpact.get(trigger.name) || { count: 0, totalAnxiety: 0 };
triggerImpact.set(trigger.name, {
count: existing.count + 1,
totalAnxiety: existing.totalAnxiety + entry.anxietyLevel,
});
});
});
triggerImpact.forEach((data, name) => {
if (data.count >= 3) {
const avgAnxiety = data.totalAnxiety / data.count;
insights.push({
type: 'trigger_pattern',
title: `"${name}" is a recurring trigger`,
description: `This trigger has appeared ${data.count} times with an average anxiety level of ${avgAnxiety.toFixed(1)}/10.`,
confidence: data.count >= 10 ? 'high' : data.count >= 5 ? 'medium' : 'low',
data: { trigger: name, count: data.count, averageAnxiety: avgAnxiety },
});
}
});
// Time-of-day analysis
const hourBuckets = new Map<number, { count: number; totalAnxiety: number }>();
entries.forEach((entry) => {
const hour = new Date(entry.timestamp).getHours();
const existing = hourBuckets.get(hour) || { count: 0, totalAnxiety: 0 };
hourBuckets.set(hour, {
count: existing.count + 1,
totalAnxiety: existing.totalAnxiety + entry.anxietyLevel,
});
});
const overallAvg = entries.reduce((sum, e) => sum + e.anxietyLevel, 0) / entries.length;
hourBuckets.forEach((data, hour) => {
const avgAnxiety = data.totalAnxiety / data.count;
if (avgAnxiety > overallAvg * 1.3 && data.count >= 3) {
const timeRange = `${hour}:00 - ${hour + 1}:00`;
insights.push({
type: 'time_pattern',
title: `Anxiety peaks around ${timeRange}`,
description: `Your average anxiety during this hour (${avgAnxiety.toFixed(1)}) is noticeably higher than your overall average (${overallAvg.toFixed(1)}).`,
confidence: data.count >= 10 ? 'high' : 'medium',
data: { hour, count: data.count, averageAnxiety: avgAnxiety },
});
}
});
// Coping strategy effectiveness
const copingResults = new Map<string, { count: number; totalAnxietyAfter: number }>();
entries.forEach((entry, index) => {
entry.copingStrategies.forEach((strategy) => {
const existing = copingResults.get(strategy.name) || { count: 0, totalAnxietyAfter: 0 };
copingResults.set(strategy.name, {
count: existing.count + 1,
totalAnxietyAfter: existing.totalAnxietyAfter + entry.anxietyLevel,
});
});
});
copingResults.forEach((data, name) => {
if (data.count >= 3) {
const avg = data.totalAnxietyAfter / data.count;
insights.push({
type: 'coping_effectiveness',
title: `"${name}" usage patterns`,
description: `Used ${data.count} times. Average anxiety level when used: ${avg.toFixed(1)}/10.`,
confidence: data.count >= 8 ? 'high' : 'medium',
data: { strategy: name, count: data.count, averageAnxiety: avg },
});
}
});
return insights.sort((a, b) => {
const confidenceOrder = { high: 3, medium: 2, low: 1 };
return confidenceOrder[b.confidence] - confidenceOrder[a.confidence];
});
}
Data Export for Therapy Sessions
Users need to share their anxiety data with therapists. Create src/utils/export.ts:
import { anxietyRepository, AnxietyEntry } from '../database/repository';
export function exportToCSV(entries: AnxietyEntry[]): string {
const headers = [
'Date', 'Time', 'Anxiety Level', 'Mood', 'Triggers',
'Symptoms', 'Coping Strategies', 'Situation', 'Notes',
];
const rows = entries.map((entry) => {
const date = new Date(entry.timestamp);
return [
date.toLocaleDateString(),
date.toLocaleTimeString(),
entry.anxietyLevel.toString(),
entry.mood,
entry.triggers.map((t) => t.name).join('; '),
entry.symptoms.map((s) => s.name).join('; '),
entry.copingStrategies.map((c) => c.name).join('; '),
`"${(entry.situationDescription || '').replace(/"/g, '""')}"`,
`"${(entry.notes || '').replace(/"/g, '""')}"`,
].join(',');
});
return [headers.join(','), ...rows].join('\n');
}
export function exportToJSON(entries: AnxietyEntry[]): string {
return JSON.stringify(
entries.map((entry) => ({
date: new Date(entry.timestamp).toISOString(),
anxietyLevel: entry.anxietyLevel,
mood: entry.mood,
triggers: entry.triggers.map((t) => ({ name: t.name, category: t.category })),
symptoms: entry.symptoms.map((s) => ({ name: s.name, category: s.category })),
copingStrategies: entry.copingStrategies.map((c) => ({ name: c.name, effectiveness: c.effectiveness })),
situationDescription: entry.situationDescription,
notes: entry.notes,
})),
null,
2
);
}
export function generateTherapistSummary(entries: AnxietyEntry[]): string {
const totalEntries = entries.length;
const avgAnxiety = entries.reduce((sum, e) => sum + e.anxietyLevel, 0) / totalEntries;
const highAnxietyCount = entries.filter((e) => e.anxietyLevel >= 7).length;
const mostCommonTriggers = getTopItems(entries.flatMap((e) => e.triggers.map((t) => t.name)), 5);
const mostCommonSymptoms = getTopItems(entries.flatMap((e) => e.symptoms.map((s) => s.name)), 5);
const copingStrategiesUsed = getTopItems(
entries.flatMap((e) => e.copingStrategies.map((c) => c.name)), 5
);
return [
`ANXIETY TRACKING SUMMARY`,
`Period: ${new Date(entries[entries.length - 1].timestamp).toLocaleDateString()} - ${new Date(entries[0].timestamp).toLocaleDateString()}`,
`Total entries: ${totalEntries}`,
`Average anxiety level: ${avgAnxiety.toFixed(1)}/10`,
`High anxiety episodes (7+): ${highAnxietyCount} (${Math.round((highAnxietyCount / totalEntries) * 100)}%)`,
``,
`Most common triggers: ${mostCommonTriggers.join(', ')}`,
`Most common symptoms: ${mostCommonSymptoms.join(', ')}`,
`Coping strategies used: ${copingStrategiesUsed.join(', ')}`,
].join('\n');
}
function getTopItems(items: string[], limit: number): string[] {
const counts = new Map<string, number>();
items.forEach((item) => counts.set(item, (counts.get(item) || 0) + 1));
return Array.from(counts.entries())
.sort((a, b) => b[1] - a[1])
.slice(0, limit)
.map(([name]) => name);
}
Testing Strategy
Testing a mental health app requires special consideration for the sensitive nature of the data.
import { anxietyRepository } from '../database/repository';
describe('AnxietyRepository', () => {
describe('createEntry', () => {
it('creates an entry with all fields', () => {
const entry = anxietyRepository.createEntry({
anxietyLevel: 7,
mood: 'high',
notes: 'Felt overwhelmed at work',
situationDescription: 'Team meeting with tight deadline',
triggers: [
{ name: 'Work deadline', category: 'work', intensity: 8 },
{ name: 'Public speaking', category: 'social', intensity: 6 },
],
symptoms: [
{ name: 'Rapid heartbeat', category: 'physical', severity: 7 },
{ name: 'Racing thoughts', category: 'cognitive', severity: 8 },
],
copingStrategies: [
{ name: 'Deep breathing', effectiveness: 6 },
],
});
expect(entry.anxietyLevel).toBe(7);
expect(entry.triggers).toHaveLength(2);
expect(entry.symptoms).toHaveLength(2);
expect(entry.copingStrategies).toHaveLength(1);
});
it('creates a minimal entry with only required fields', () => {
const entry = anxietyRepository.createEntry({
anxietyLevel: 3,
mood: 'calm',
triggers: [],
symptoms: [],
copingStrategies: [],
});
expect(entry.anxietyLevel).toBe(3);
expect(entry.triggers).toHaveLength(0);
});
});
});
Privacy and Open Source Licensing
Choose a license that protects users while encouraging contribution:
- AGPL-3.0: Requires anyone who modifies and hosts the app to share their source code, preventing private forks from becoming closed-source mental health products.
- Include a
PRIVACY.mdfile that clearly states the no-data-collection policy. - Add a
SECURITY.mdfile with instructions for responsible disclosure of vulnerabilities. - Run automated dependency audits in CI to catch libraries that may introduce tracking.
Conclusion
Building an open source anxiety tracker combines thoughtful UX design for a vulnerable user population with rigorous privacy engineering. The key technical decisions--local-only storage, CBT-aligned data models, and therapist-friendly export--are all driven by the principle that users should have full control over their mental health data.
By making the source code available, users and security researchers can verify that the app truly does not collect or transmit sensitive information. This transparency builds trust, which is essential for any mental health tool.
For those looking to contribute or adapt this project, the repository includes comprehensive documentation, contribution guidelines, and a roadmap for future features including integration with wearable devices for physiological anxiety indicators.