368 lines
11 KiB
TypeScript

import { useCallback, useEffect, useMemo, useState } from 'react';
import { View, Text, ActivityIndicator } from 'react-native';
import { Ionicons } from '@expo/vector-icons';
import { useTranslation } from 'react-i18next';
import { apiFetch } from '../../lib/api';
import { colors } from '../../lib/theme';
type Emotion = 'stress' | 'sadness' | 'anger' | 'empty' | 'boredom' | 'other';
type UrgeLog = {
id: string;
timestamp: string;
emotion: Emotion;
wasOvercome: boolean;
breathingDone: boolean;
};
const WEEKDAY_LABELS = ['Mo', 'Di', 'Mi', 'Do', 'Fr', 'Sa', 'So'];
function emotionLabel(key: string, t: (k: string) => string): string {
const map: Record<string, string> = {
stress: t('urge.emotion_stress'),
sadness: t('urge.emotion_sadness'),
anger: t('urge.emotion_anger'),
empty: t('urge.emotion_empty'),
boredom: t('urge.emotion_boredom'),
other: t('urge.emotion_other'),
};
return map[key] ?? key;
}
function StatCard({ label, value, color }: { label: string; value: string; color: string }) {
return (
<View
style={{
flex: 1,
marginHorizontal: 4,
borderRadius: 12,
borderWidth: 1,
borderColor: '#f3f4f6',
backgroundColor: '#fafafa',
paddingVertical: 10,
alignItems: 'center',
}}
>
<Text style={{ fontFamily: 'Nunito_800ExtraBold', fontSize: 22, color }}>{value}</Text>
<Text
style={{
marginTop: 2,
textAlign: 'center',
fontFamily: 'Nunito_400Regular',
fontSize: 11,
color: '#6b7280',
}}
>
{label}
</Text>
</View>
);
}
export function UrgeStats() {
const { t } = useTranslation();
const [logs, setLogs] = useState<UrgeLog[]>([]);
const [loading, setLoading] = useState(true);
const load = useCallback(async () => {
try {
setLoading(true);
const data = await apiFetch<UrgeLog[]>('/api/urge?limit=100');
setLogs(Array.isArray(data) ? data : []);
} catch {
setLogs([]);
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
load();
}, [load]);
const weeklyStats = useMemo(() => {
const weekAgo = Date.now() - 7 * 24 * 60 * 60 * 1000;
const thisWeek = logs.filter((log) => new Date(log.timestamp).getTime() > weekAgo);
return {
total: thisWeek.length,
overcome: thisWeek.filter((log) => log.wasOvercome).length,
breathingDone: thisWeek.filter((log) => log.breathingDone).length,
};
}, [logs]);
const patterns = useMemo(() => {
if (logs.length < 5) return null;
const weekday = new Array(7).fill(0) as number[];
const timeBlockCounts = [0, 0, 0, 0] as number[];
const emotionCount: Record<string, number> = {};
for (const log of logs) {
const d = new Date(log.timestamp);
const jsDay = d.getDay();
const mondayIndex = jsDay === 0 ? 6 : jsDay - 1;
weekday[mondayIndex]!++;
const hour = d.getHours();
if (hour >= 6 && hour < 12) timeBlockCounts[0]!++;
else if (hour >= 12 && hour < 18) timeBlockCounts[1]!++;
else if (hour >= 18 && hour < 23) timeBlockCounts[2]!++;
else timeBlockCounts[3]!++;
emotionCount[log.emotion] = (emotionCount[log.emotion] ?? 0) + 1;
}
const maxWeekday = Math.max(...weekday, 1);
const maxTime = Math.max(...timeBlockCounts, 1);
const topEmotions = Object.entries(emotionCount)
.sort((a, b) => b[1] - a[1])
.slice(0, 3);
const peakDayIdx = weekday.indexOf(Math.max(...weekday));
const peakTimeIdx = timeBlockCounts.indexOf(Math.max(...timeBlockCounts));
const peakTimeLabel = ['morgens', 'mittags', 'abends', 'nachts'][peakTimeIdx] ?? '';
const peakDayLabel = peakDayIdx >= 5 ? 'am Wochenende' : `${WEEKDAY_LABELS[peakDayIdx]}s`;
return {
weekday: weekday.map((countValue, i) => ({
label: WEEKDAY_LABELS[i]!,
count: countValue,
pct: Math.round((countValue / maxWeekday) * 100),
})),
timeBlocks: [
{
emoji: '🌅',
label: t('urge.block_morning'),
count: timeBlockCounts[0]!,
pct: Math.round((timeBlockCounts[0]! / maxTime) * 100),
},
{
emoji: '☀️',
label: t('urge.block_noon'),
count: timeBlockCounts[1]!,
pct: Math.round((timeBlockCounts[1]! / maxTime) * 100),
},
{
emoji: '🌆',
label: t('urge.block_evening'),
count: timeBlockCounts[2]!,
pct: Math.round((timeBlockCounts[2]! / maxTime) * 100),
},
{
emoji: '🌙',
label: t('urge.block_night'),
count: timeBlockCounts[3]!,
pct: Math.round((timeBlockCounts[3]! / maxTime) * 100),
},
],
topEmotions,
insight: `${t('urge.pattern_insight_prefix')} ${peakDayLabel} ${peakTimeLabel}.`,
};
}, [logs, t]);
if (loading) {
return (
<View style={{ paddingVertical: 24, alignItems: 'center' }}>
<ActivityIndicator color={colors.brandOrange} />
</View>
);
}
return (
<View style={{ gap: 12 }}>
{/* Weekly counters */}
<View
style={{
borderRadius: 18,
borderWidth: 1,
borderColor: '#e5e7eb',
backgroundColor: '#fff',
padding: 14,
}}
>
<Text
style={{ fontFamily: 'Nunito_700Bold', fontSize: 15, color: '#111827', marginBottom: 10 }}
>
{t('urge.this_week')}
</Text>
<View style={{ flexDirection: 'row', justifyContent: 'space-between' }}>
<StatCard label={t('urge.total_urges')} value={String(weeklyStats.total)} color="#111827" />
<StatCard
label={t('urge.overcome_count')}
value={String(weeklyStats.overcome)}
color="#16a34a"
/>
<StatCard
label={t('urge.breathing_exercises')}
value={String(weeklyStats.breathingDone)}
color={colors.brandOrange}
/>
</View>
</View>
{patterns && (
<>
{/* Insight */}
<View
style={{
borderRadius: 18,
borderWidth: 1,
borderColor: '#e5e7eb',
backgroundColor: '#fff',
padding: 14,
flexDirection: 'row',
alignItems: 'center',
}}
>
<Ionicons name="bulb-outline" size={16} color={colors.brandOrange} />
<Text
style={{
marginLeft: 8,
color: '#374151',
fontFamily: 'Nunito_600SemiBold',
flex: 1,
}}
>
{patterns.insight}
</Text>
</View>
{/* Weekday chart */}
<View
style={{
borderRadius: 18,
borderWidth: 1,
borderColor: '#e5e7eb',
backgroundColor: '#fff',
padding: 14,
}}
>
<Text style={{ fontFamily: 'Nunito_700Bold', fontSize: 15, color: '#111827' }}>
{t('urge.chart_weekday_title')}
</Text>
<View style={{ flexDirection: 'row', alignItems: 'flex-end', height: 70, marginTop: 10 }}>
{patterns.weekday.map((day) => (
<View key={day.label} style={{ flex: 1, alignItems: 'center' }}>
<View
style={{
width: 12,
height: day.pct > 0 ? Math.max(6, day.pct * 0.5) : 4,
borderRadius: 5,
backgroundColor:
day.pct >= 80 ? '#fb7185' : day.pct >= 50 ? '#f59e0b' : '#60a5fa',
marginBottom: 6,
}}
/>
<Text
style={{ fontFamily: 'Nunito_600SemiBold', fontSize: 10, color: '#6b7280' }}
>
{day.label}
</Text>
</View>
))}
</View>
</View>
{/* Time blocks */}
<View
style={{
borderRadius: 18,
borderWidth: 1,
borderColor: '#e5e7eb',
backgroundColor: '#fff',
padding: 14,
}}
>
<Text style={{ fontFamily: 'Nunito_700Bold', fontSize: 15, color: '#111827' }}>
{t('urge.chart_time_title')}
</Text>
<View style={{ marginTop: 8, gap: 8 }}>
{patterns.timeBlocks.map((b) => (
<View key={b.label} style={{ flexDirection: 'row', alignItems: 'center' }}>
<Text style={{ width: 26 }}>{b.emoji}</Text>
<Text
style={{
width: 74,
fontSize: 12,
color: '#6b7280',
fontFamily: 'Nunito_600SemiBold',
}}
>
{b.label}
</Text>
<View
style={{
flex: 1,
height: 7,
borderRadius: 4,
backgroundColor: '#e5e7eb',
}}
>
<View
style={{
width: `${b.pct}%`,
height: 7,
borderRadius: 4,
backgroundColor: '#60a5fa',
}}
/>
</View>
<Text
style={{
width: 24,
textAlign: 'right',
fontSize: 12,
color: '#6b7280',
marginLeft: 6,
}}
>
{b.count}
</Text>
</View>
))}
</View>
</View>
{/* Top emotions */}
<View
style={{
borderRadius: 18,
borderWidth: 1,
borderColor: '#e5e7eb',
backgroundColor: '#fff',
padding: 14,
}}
>
<Text style={{ fontFamily: 'Nunito_700Bold', fontSize: 15, color: '#111827' }}>
{t('urge.chart_top_emotions')}
</Text>
<View style={{ flexDirection: 'row', flexWrap: 'wrap', marginTop: 8 }}>
{patterns.topEmotions.map(([emo, c]) => (
<View
key={emo}
style={{
backgroundColor: '#f3f4f6',
borderWidth: 1,
borderColor: '#e5e7eb',
borderRadius: 999,
paddingHorizontal: 10,
paddingVertical: 6,
marginRight: 8,
marginBottom: 8,
}}
>
<Text
style={{ fontSize: 12, color: '#374151', fontFamily: 'Nunito_600SemiBold' }}
>
{emotionLabel(emo, t)} x{c}
</Text>
</View>
))}
</View>
</View>
</>
)}
</View>
);
}