368 lines
11 KiB
TypeScript
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>
|
|
);
|
|
}
|