feat(profile): Profile-Page komplett + Header-Dropdown + UI-Pattern-Fixes

Profile (3 Iterationen):
- app/profile/index.tsx + components/profile/* (Header, StatsBar, Approved,
  Streak, UrgeStats, Demographics, DigaMissionBanner)
- echte Live-Daten via useMe-Hook (Avatar/Nickname/Plan/Email/Provider-Pill)
- Demographics mit echten Inputs (TextInput + Bottom-Sheet-Selects),
  debounced auto-save, Pro-Trial-Reward-Banner, Mikro-Why-Texte
- Approved Domains als plain integer (KEIN Plan-Slot/Cap)
- Friendly Hint-Text statt Progress-Bar (alignSelf:'stretch' Pattern)
- StatsBar zentriert mit 3 prominenten Cards (vertikale Dividers)
- Cooldown-Timeline als Liste mit 1px-Rail
- ApprovedDomainsList: Collapse-Chevron rechts in Title-Row (Pattern-Fix)
- Eigene vs fremde Profile-Ansicht streng getrennt (DSGVO/Anonymität)

Header-Dropdown (kein 3-Punkte-Icon):
- Avatar als Trigger im AppHeader (User-Wunsch)
- Custom-Modal beide Plattformen, Card-Style
- SOS prominent oben (nur Wort 'SOS' rot, Tagline 'wir sind für dich da' klein darunter)
- Profile/Settings/Games/Debug(__DEV__)/Logout
- Logout neutral (nicht rot — Recovery-tonal)
- AppHeader: neue showBack + title Props für Sub-Routes

Routes (Stub bis Phase C):
- app/profile/[userId].tsx — anonym (nur public-Stats)
- app/settings.tsx — Coming-Soon-Skeleton
- app/games.tsx — Standalone Games-Page mit GameCard-Grid
- app/debug.tsx — __DEV__-only

Game-Picker (Migration aus Nuxt):
- components/games/{GameCard, StarRating, GameRatingStars}
- 2x2 Grid, 56pt SVG-Icons (inline aus components/urge/gameSvgs.ts)
- Live-Backend /api/games/ratings (silent-fail)
- Re-use UrgeGames.tsx ohne TTS/Cooldown-Loop

UI-Pattern-Fixes (alle aus screenshot-User-Feedback 2026-05-07):
- Snake-Bug (food-pellet React-18-StrictMode-Reducer-double-call) gefixt
- Snake-Buttons platform-native (iOS-blue / Android-ripple)
- Tetris-Margins (16px paddingHorizontal)
- PostCard-Buttons Apple-44pt-Hit-Area (Image-Select, Image-Remove,
  Cancel, Share-Pill — via hitSlop)
- ProfileHeader Demographics-Hint: alignSelf:'stretch' Pattern
- ApprovedDomainsList Collapse: Title flex:1 + Chevron rechts
- ProtectionDetailsSheet FAQ-Items: alignSelf:'stretch' defensive
- AppHeader Back-Button: neue showBack-Prop + chevron-back

Memory + Plan-Docs:
- 17 Memory-Files dokumentieren System-Wissen + Patterns
- ops/{CUTOVER, UI_MIGRATION, PROFILE_PAGE, WEBHOOK, GAMES_1V1,
  RELEASE_READINESS, TESTING_STATE, MAESTRO_HOSTING}_*.md

Backend bleibt unverändert (Tier-LLM + Nickname + sort:latency
sind seit gestern deployed).
This commit is contained in:
chahinebrini 2026-05-07 18:22:58 +02:00
parent 355166c194
commit e76be7ee78
33 changed files with 5532 additions and 416 deletions

View File

@ -106,6 +106,38 @@ function RootLayoutInner() {
animation: 'slide_from_right',
}}
/>
<Stack.Screen
name="profile/index"
options={{
headerShown: false,
presentation: 'card',
animation: 'slide_from_right',
}}
/>
<Stack.Screen
name="profile/[userId]"
options={{
headerShown: false,
presentation: 'card',
animation: 'slide_from_right',
}}
/>
<Stack.Screen
name="games"
options={{
headerShown: false,
presentation: 'card',
animation: 'slide_from_right',
}}
/>
<Stack.Screen
name="debug"
options={{
headerShown: false,
presentation: 'card',
animation: 'slide_from_right',
}}
/>
</Stack>
</>
);

View File

@ -0,0 +1,162 @@
import { useEffect } from 'react';
import { View, Text, ScrollView, Pressable } from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
import { useRouter } from 'expo-router';
import { Ionicons } from '@expo/vector-icons';
import { colors } from '../lib/theme';
export default function DebugScreen() {
const router = useRouter();
useEffect(() => {
if (!__DEV__) {
router.replace('/');
}
}, [router]);
if (!__DEV__) {
return <View style={{ flex: 1, backgroundColor: '#ffffff' }} />;
}
return (
<SafeAreaView style={{ flex: 1, backgroundColor: '#ffffff' }} edges={['top']}>
<View
style={{
paddingHorizontal: 12,
paddingTop: 4,
paddingBottom: 12,
flexDirection: 'row',
alignItems: 'center',
gap: 8,
borderBottomWidth: 1,
borderBottomColor: 'rgba(0,0,0,0.06)',
}}
>
<Pressable
onPress={() => router.back()}
hitSlop={8}
style={({ pressed }) => ({
width: 40,
height: 40,
alignItems: 'center',
justifyContent: 'center',
opacity: pressed ? 0.6 : 1,
})}
>
<Ionicons name="chevron-back" size={26} color={colors.text} />
</Pressable>
<Text style={{ fontSize: 20, color: '#0a0a0a', fontFamily: 'Nunito_700Bold' }}>
Debug
</Text>
</View>
<ScrollView
style={{ flex: 1 }}
contentContainerStyle={{ paddingHorizontal: 16, paddingTop: 16, paddingBottom: 60 }}
showsVerticalScrollIndicator={false}
>
<View
style={{
backgroundColor: '#fef3c7',
borderRadius: 14,
padding: 14,
marginBottom: 20,
borderWidth: 1,
borderColor: '#fde68a',
flexDirection: 'row',
gap: 10,
alignItems: 'flex-start',
}}
>
<Ionicons name="warning-outline" size={20} color="#b45309" style={{ marginTop: 1 }} />
<View style={{ flex: 1 }}>
<Text style={{ fontSize: 14, fontFamily: 'Nunito_700Bold', color: '#78350f' }}>
Dev only
</Text>
<Text
style={{
fontSize: 12,
fontFamily: 'Nunito_400Regular',
color: '#92400e',
marginTop: 4,
lineHeight: 17,
}}
>
Diese Page ist nur in __DEV__ verfügbar. Production-Builds redirecten auf /.
</Text>
</View>
</View>
<DebugStub
title="LLM-Provider Toggle"
subtitle="Phase C: aus app/urge.tsx hierher migrieren"
icon="bulb-outline"
/>
<DebugStub
title="TTS-Provider Toggle"
subtitle="Phase C: aus app/urge.tsx hierher migrieren"
icon="volume-high-outline"
/>
<DebugStub
title="Bench-Output"
subtitle="TTS/LLM-Latenz-Logs anzeigen (TODO)"
icon="speedometer-outline"
/>
</ScrollView>
</SafeAreaView>
);
}
function DebugStub({
title,
subtitle,
icon,
}: {
title: string;
subtitle: string;
icon: React.ComponentProps<typeof Ionicons>['name'];
}) {
return (
<View
style={{
backgroundColor: '#fafafa',
borderRadius: 14,
borderWidth: 1,
borderColor: 'rgba(0,0,0,0.05)',
padding: 14,
marginBottom: 12,
flexDirection: 'row',
gap: 12,
alignItems: 'flex-start',
opacity: 0.6,
}}
>
<View
style={{
width: 36,
height: 36,
borderRadius: 11,
backgroundColor: '#e5e7eb',
alignItems: 'center',
justifyContent: 'center',
}}
>
<Ionicons name={icon} size={18} color="#525252" />
</View>
<View style={{ flex: 1 }}>
<Text style={{ fontSize: 14, color: '#0a0a0a', fontFamily: 'Nunito_700Bold' }}>{title}</Text>
<Text
style={{
fontSize: 12,
color: '#737373',
fontFamily: 'Nunito_400Regular',
marginTop: 3,
lineHeight: 17,
}}
>
{subtitle}
</Text>
</View>
</View>
);
}

View File

@ -0,0 +1,240 @@
import { useEffect, useState } from 'react';
import { View, Text, Pressable, ScrollView } from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
import { useRouter } from 'expo-router';
import { Ionicons } from '@expo/vector-icons';
import { useTranslation } from 'react-i18next';
import {
GAME_META,
type GameType,
MemoryGame,
TicTacToeGame,
SnakeGame,
TetrisGame,
} from '../components/urge/UrgeGames';
import { GameCard } from '../components/games/GameCard';
import { colors } from '../lib/theme';
import { apiFetch } from '../lib/api';
type GameStat = { avgStars: number; count: number };
type GameStats = Record<GameType, GameStat>;
const EMPTY_STATS: GameStats = {
memory: { avgStars: 0, count: 0 },
tictactoe: { avgStars: 0, count: 0 },
snake: { avgStars: 0, count: 0 },
tetris: { avgStars: 0, count: 0 },
};
type LastScore = { game: GameType; score: number } | null;
export default function GamesScreen() {
const router = useRouter();
const { t } = useTranslation();
const [active, setActive] = useState<GameType | null>(null);
const [lastScore, setLastScore] = useState<LastScore>(null);
const [gameStats, setGameStats] = useState<GameStats>(EMPTY_STATS);
useEffect(() => {
let cancelled = false;
(async () => {
try {
// Backend response: { ratings, stats: [{ gameName, avgStars, count }] }
const data = await apiFetch<{
stats: Array<{ gameName: string; avgStars: number; count: number }>;
}>('/api/games/ratings');
if (cancelled) return;
const next: GameStats = { ...EMPTY_STATS };
for (const s of data.stats ?? []) {
const key = s.gameName.toLowerCase() as GameType;
if (key in next) {
next[key] = { avgStars: s.avgStars ?? 0, count: s.count ?? 0 };
}
}
setGameStats(next);
} catch {
// Silent fail — UI shows 0 stars/count, kein Crash
}
})();
return () => {
cancelled = true;
};
}, []);
function exit(score?: number) {
if (typeof score === 'number' && active) {
setLastScore({ game: active, score });
}
setActive(null);
}
if (active) {
return (
<SafeAreaView style={{ flex: 1, backgroundColor: '#ffffff' }} edges={['top']}>
<View
style={{
paddingHorizontal: 12,
paddingVertical: 8,
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
borderBottomWidth: 1,
borderBottomColor: 'rgba(0,0,0,0.06)',
}}
>
<Pressable
onPress={() => exit()}
hitSlop={10}
style={({ pressed }) => ({
flexDirection: 'row',
alignItems: 'center',
gap: 4,
paddingHorizontal: 6,
paddingVertical: 6,
opacity: pressed ? 0.6 : 1,
})}
>
<Ionicons name="chevron-back" size={22} color={colors.text} />
<Text style={{ fontSize: 15, fontFamily: 'Nunito_600SemiBold', color: colors.text }}>
{t('games.back_to_picker')}
</Text>
</Pressable>
<Text style={{ fontSize: 14, fontFamily: 'Nunito_700Bold', color: '#0a0a0a' }}>
{t(GAME_META.find((g) => g.id === active)!.titleKey)}
</Text>
<View style={{ width: 60 }} />
</View>
<View style={{ flex: 1 }}>
{active === 'memory' ? (
<MemoryGame onComplete={(s) => exit(s)} onAbandon={() => exit()} />
) : null}
{active === 'tictactoe' ? (
<TicTacToeGame onComplete={(s) => exit(s)} onAbandon={() => exit()} />
) : null}
{active === 'snake' ? (
<SnakeGame onComplete={(s) => exit(s)} onAbandon={() => exit()} />
) : null}
{active === 'tetris' ? (
<TetrisGame onComplete={(s) => exit(s)} onAbandon={() => exit()} />
) : null}
</View>
</SafeAreaView>
);
}
return (
<SafeAreaView style={{ flex: 1, backgroundColor: '#ffffff' }} edges={['top']}>
<View
style={{
paddingHorizontal: 12,
paddingTop: 4,
paddingBottom: 12,
flexDirection: 'row',
alignItems: 'center',
gap: 8,
borderBottomWidth: 1,
borderBottomColor: 'rgba(0,0,0,0.06)',
}}
>
<Pressable
onPress={() => router.back()}
hitSlop={8}
style={({ pressed }) => ({
width: 40,
height: 40,
alignItems: 'center',
justifyContent: 'center',
opacity: pressed ? 0.6 : 1,
})}
>
<Ionicons name="chevron-back" size={26} color={colors.text} />
</Pressable>
<Text style={{ fontSize: 20, color: '#0a0a0a', fontFamily: 'Nunito_700Bold' }}>
{t('games.title')}
</Text>
</View>
<ScrollView
style={{ flex: 1 }}
contentContainerStyle={{ paddingHorizontal: 16, paddingTop: 16, paddingBottom: 60 }}
showsVerticalScrollIndicator={false}
>
<Text
style={{
fontSize: 13,
color: '#737373',
fontFamily: 'Nunito_400Regular',
lineHeight: 19,
marginBottom: 18,
paddingHorizontal: 4,
}}
>
{t('games.subtitle')}
</Text>
<View
style={{
flexDirection: 'row',
flexWrap: 'wrap',
gap: 12,
justifyContent: 'space-between',
}}
>
{GAME_META.map((game) => {
const stat = gameStats[game.id] ?? { avgStars: 0, count: 0 };
const recent = lastScore?.game === game.id ? lastScore.score : null;
return (
<View key={game.id} style={{ width: '47.5%' }}>
<GameCard
id={game.id}
svg={game.svg}
titleKey={game.titleKey}
descKey={game.descKey}
avgStars={stat.avgStars}
count={stat.count}
onPress={(id) => setActive(id)}
/>
{recent !== null ? (
<View
style={{
marginTop: 6,
paddingHorizontal: 8,
paddingVertical: 3,
borderRadius: 10,
backgroundColor: colors.brandOrange + '18',
alignSelf: 'center',
}}
>
<Text
style={{
fontSize: 11,
fontFamily: 'Nunito_700Bold',
color: colors.brandOrange,
}}
>
{t('games.last_score', { score: recent })}
</Text>
</View>
) : null}
</View>
);
})}
</View>
<Text
style={{
textAlign: 'center',
fontSize: 11,
color: '#a3a3a3',
fontFamily: 'Nunito_400Regular',
marginTop: 24,
opacity: 0.7,
}}
>
{t('games.skeleton_footer')}
</Text>
</ScrollView>
</SafeAreaView>
);
}

View File

@ -0,0 +1,315 @@
import { useState } from 'react';
import { View, Text, ScrollView, Pressable, Image } from 'react-native';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { useLocalSearchParams, useRouter } from 'expo-router';
import { Ionicons } from '@expo/vector-icons';
import { colors } from '../../lib/theme';
import { resolveAvatar } from '../../lib/resolveAvatar';
import type { Plan } from '../../hooks/useUserPlan';
const planLabel: Record<Plan, string> = {
free: 'Free',
pro: 'Pro',
legend: 'Legend',
};
const planColors: Record<Plan, { bg: string; text: string; border: string }> = {
free: { bg: '#f5f5f5', text: '#525252', border: '#e5e5e5' },
pro: { bg: '#fff7ed', text: '#c2410c', border: '#fed7aa' },
legend: { bg: '#fef9c3', text: '#854d0e', border: '#fde68a' },
};
// TODO: GET /api/social/profile/[userId] — extend response um approvedDomainsCount.
// Strikt anonym: nur nickname, avatar, plan, memberSince, postsCount, followersCount,
// approvedDomainsCount, isFollowing. NIEMALS email, demographics, cooldowns, sos-insights.
type ForeignProfile = {
id: string;
nickname: string;
avatar: string | null;
plan: Plan;
memberSince: string;
postsCount: number;
followersCount: number;
approvedDomainsCount: number;
isFollowing: boolean;
};
const DUMMY_FOREIGN: ForeignProfile = {
id: 'foreign-user-id',
nickname: 'Jonas_42',
avatar: 'wolf',
plan: 'pro',
memberSince: 'April 2026',
postsCount: 12,
followersCount: 47,
approvedDomainsCount: 8,
isFollowing: false,
};
type StatProps = {
value: string;
label: string;
};
function ForeignStat({ value, label }: StatProps) {
return (
<View
style={{
flex: 1,
backgroundColor: '#ffffff',
borderWidth: 1,
borderColor: '#e5e5e5',
borderRadius: 14,
paddingVertical: 14,
alignItems: 'center',
justifyContent: 'center',
}}
>
<Text style={{ fontSize: 22, color: colors.text, fontFamily: 'Nunito_700Bold' }}>
{value}
</Text>
<Text
style={{
marginTop: 2,
fontSize: 11,
color: colors.textMuted,
fontFamily: 'Nunito_600SemiBold',
}}
>
{label}
</Text>
</View>
);
}
export default function ForeignProfileScreen() {
const insets = useSafeAreaInsets();
const router = useRouter();
const { userId } = useLocalSearchParams<{ userId: string }>();
const [imageFailed, setImageFailed] = useState(false);
const [isFollowing, setIsFollowing] = useState(DUMMY_FOREIGN.isFollowing);
// TODO: useQuery → apiFetch(`/api/social/profile/${userId}`)
const profile = DUMMY_FOREIGN;
void userId;
const avatarUrl = resolveAvatar(profile.avatar, profile.nickname);
const initials = profile.nickname.slice(0, 2).toUpperCase();
const showImage = !!profile.avatar && !imageFailed;
const planStyle = planColors[profile.plan];
return (
<View style={{ flex: 1, backgroundColor: '#ffffff' }}>
<View
style={{
paddingTop: insets.top,
backgroundColor: '#ffffff',
borderBottomWidth: 1,
borderBottomColor: '#e5e5e5',
}}
>
<View
style={{
height: 56,
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
paddingHorizontal: 12,
}}
>
<Pressable
onPress={() => router.back()}
hitSlop={8}
style={({ pressed }) => ({
opacity: pressed ? 0.5 : 1,
padding: 8,
})}
>
<Ionicons name="chevron-back" size={22} color={colors.text} />
</Pressable>
<Text style={{ fontSize: 15, color: colors.text, fontFamily: 'Nunito_600SemiBold' }}>
Profil
</Text>
<View style={{ width: 38 }} />
</View>
</View>
<ScrollView
style={{ flex: 1 }}
contentContainerStyle={{ paddingBottom: insets.bottom + 24 }}
showsVerticalScrollIndicator={false}
>
<View style={{ alignItems: 'center', paddingVertical: 24, paddingHorizontal: 20 }}>
<View
style={{
width: 96,
height: 96,
borderRadius: 48,
borderWidth: 2,
borderColor: planStyle.border,
alignItems: 'center',
justifyContent: 'center',
overflow: 'hidden',
backgroundColor: showImage ? '#fafafa' : colors.brandOrange,
}}
>
{showImage ? (
<Image
source={{ uri: avatarUrl }}
onError={() => setImageFailed(true)}
style={{ width: 92, height: 92, borderRadius: 46 }}
/>
) : (
<Text style={{ color: '#fff', fontSize: 32, fontFamily: 'Nunito_700Bold' }}>
{initials}
</Text>
)}
</View>
<Text
style={{
marginTop: 16,
fontSize: 22,
color: colors.text,
fontFamily: 'Nunito_700Bold',
}}
>
{profile.nickname}
</Text>
<View style={{ flexDirection: 'row', alignItems: 'center', gap: 10, marginTop: 8 }}>
<View
style={{
paddingHorizontal: 10,
paddingVertical: 3,
borderRadius: 999,
backgroundColor: planStyle.bg,
borderWidth: 1,
borderColor: planStyle.border,
}}
>
<Text
style={{
fontSize: 11,
color: planStyle.text,
fontFamily: 'Nunito_700Bold',
letterSpacing: 0.4,
}}
>
{planLabel[profile.plan].toUpperCase()}
</Text>
</View>
<Text style={{ fontSize: 12, color: colors.textMuted, fontFamily: 'Nunito_400Regular' }}>
Mitglied seit {profile.memberSince}
</Text>
</View>
<View style={{ flexDirection: 'row', gap: 8, marginTop: 16, width: '100%' }}>
<Pressable
onPress={() => {
// TODO: POST /api/social/follow/[userId] resp. DELETE bei unfollow
setIsFollowing((v) => !v);
}}
style={({ pressed }) => ({
flex: 1,
opacity: pressed ? 0.7 : 1,
paddingVertical: 11,
borderRadius: 12,
backgroundColor: isFollowing ? '#f5f5f5' : colors.brandOrange,
borderWidth: 1,
borderColor: isFollowing ? '#e5e5e5' : colors.brandOrange,
alignItems: 'center',
})}
>
<Text
style={{
fontSize: 13,
color: isFollowing ? colors.text : '#ffffff',
fontFamily: 'Nunito_600SemiBold',
}}
>
{isFollowing ? 'Folge ich' : 'Folgen'}
</Text>
</Pressable>
<Pressable
onPress={() => {
// TODO: navigate to DM with this userId
router.push(`/dm`);
}}
style={({ pressed }) => ({
flex: 1,
opacity: pressed ? 0.7 : 1,
paddingVertical: 11,
borderRadius: 12,
backgroundColor: '#ffffff',
borderWidth: 1,
borderColor: '#e5e5e5',
alignItems: 'center',
})}
>
<Text
style={{
fontSize: 13,
color: colors.text,
fontFamily: 'Nunito_600SemiBold',
}}
>
Nachricht
</Text>
</Pressable>
</View>
</View>
<View
style={{
height: 1,
backgroundColor: 'rgba(0,0,0,0.06)',
marginHorizontal: 16,
}}
/>
<View style={{ flexDirection: 'row', gap: 8, marginTop: 16, paddingHorizontal: 16 }}>
<ForeignStat value={String(profile.postsCount)} label="Posts" />
<ForeignStat value={String(profile.followersCount)} label="Follower" />
<ForeignStat value={String(profile.approvedDomainsCount)} label="Approved" />
</View>
{/* TODO: GET /api/community/posts?userId=... — letzte 5 Posts */}
<View style={{ marginTop: 24, paddingHorizontal: 16 }}>
<Text
style={{
fontSize: 11,
color: colors.textMuted,
fontFamily: 'Nunito_700Bold',
letterSpacing: 0.8,
marginBottom: 8,
}}
>
LETZTE POSTS
</Text>
<View
style={{
backgroundColor: '#ffffff',
borderWidth: 1,
borderColor: '#e5e5e5',
borderRadius: 14,
padding: 16,
alignItems: 'center',
}}
>
<Text
style={{
fontSize: 12,
color: colors.textMuted,
fontFamily: 'Nunito_400Regular',
textAlign: 'center',
}}
>
Posts-Liste folgt in Phase C
</Text>
</View>
</View>
</ScrollView>
</View>
);
}

View File

@ -0,0 +1,272 @@
import { useRef, useState } from 'react';
import { View, ScrollView, Text, Alert, findNodeHandle, UIManager } from 'react-native';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { AppHeader } from '../../components/AppHeader';
import { ProfileHeader, type AuthProvider } from '../../components/profile/ProfileHeader';
import { StatsBar } from '../../components/profile/StatsBar';
import { ApprovedDomainsList, type ApprovedDomain } from '../../components/profile/ApprovedDomainsList';
import { StreakSection, type CooldownEntry } from '../../components/profile/StreakSection';
import { UrgeStatsCard, type HelpedByEntry } from '../../components/profile/UrgeStatsCard';
import { DemographicsAccordion, type Demographics } from '../../components/profile/DemographicsAccordion';
import { DigaMissionBanner } from '../../components/profile/DigaMissionBanner';
import { colors } from '../../lib/theme';
import type { Plan } from '../../hooks/useUserPlan';
import { useMe } from '../../hooks/useMe';
import { useAuthStore } from '../../stores/auth';
// TODO Phase C: GET /api/profile/me — aggregate endpoint (profile + stats + streak +
// recentCooldowns + demographics + sosInsights). Until backend live:
// - Core User-Felder (nickname/email/avatar/plan) kommen aus useMe-Hook (live)
// - Stats/Streak/Cooldowns/Demographics bleiben dummy
const DUMMY_PROFILE_FALLBACK = {
memberSince: 'April 2026', // TODO Phase C: aus profile.created_at
provider: 'email' as AuthProvider, // TODO Phase C: aus user.app_metadata.provider
};
const DUMMY_STATS = {
postsCount: 12,
followersCount: 47,
// Approved Domains = Community-Beitrag (KEIN Plan-Slot, kein Cap).
// Source: domain_submissions WHERE userId=me AND status='approved'.
// TODO Phase C: GET /api/profile/me/approved-domains (Endpoint existiert noch NICHT
// — gefunden wurden nur admin-side aggregate counts in
// backend/server/api/admin/stats.get.ts und backend/server/api/blocklist/stats.get.ts).
// Neuer Endpoint nötig: GET /api/profile/me/approved-domains → { count, list[] }.
approvedDomainsCount: 5,
};
const DUMMY_STREAK = {
currentDays: 23,
longestDays: 41,
startDate: '14. April 2026',
};
// TODO: GET /api/profile/me/cooldown-history?cursor=...
const DUMMY_COOLDOWNS: CooldownEntry[] = [
{
id: 'c1',
startedAt: '06.05.',
durationLabel: '24h',
status: 'active',
reason: null,
},
{
id: 'c2',
startedAt: '02.05.',
durationLabel: '4h',
status: 'cancelled',
reason: null,
},
{
id: 'c3',
startedAt: '18.04.',
durationLabel: '16h',
status: 'resolved',
reason: 'Stress nach Arbeit',
},
];
// TODO: GET /api/profile/me/approved-domains
const DUMMY_APPROVED_DOMAINS: ApprovedDomain[] = [
{ domain: 'tipico.de', approvedAt: '12.04.' },
{ domain: 'bwin.com', approvedAt: '15.04.' },
{ domain: 'merkur24.com', approvedAt: '20.04.' },
{ domain: 'sunmaker.com', approvedAt: '28.04.' },
{ domain: 'lottoland.com', approvedAt: '02.05.' },
];
// TODO: GET /api/profile/me/sos-insights
const DUMMY_HELPED_BY: HelpedByEntry[] = [
{ key: 'breathing', label: 'Atemübung', count: 3 },
{ key: 'game', label: 'Spiel', count: 1 },
{ key: 'talk', label: 'Reden mit Lyra', count: 1 },
];
// TODO: GET /api/profile/me/demographics — gehört zur me-aggregat-response
const DUMMY_DEMOGRAPHICS: Demographics = {
birthYear: 1989,
gender: 'diverse',
maritalStatus: null,
profession: null,
bundesland: 'BY',
city: null,
};
function isDemographicsComplete(d: Demographics): boolean {
return (
d.birthYear !== null &&
!!d.gender &&
!!d.maritalStatus &&
!!d.profession &&
!!d.bundesland &&
!!d.city
);
}
export default function ProfileScreen() {
const insets = useSafeAreaInsets();
const [bannerDismissed, setBannerDismissed] = useState(false);
const [demographics, setDemographics] = useState<Demographics>(DUMMY_DEMOGRAPHICS);
const { me } = useMe();
const { user } = useAuthStore();
const scrollViewRef = useRef<ScrollView | null>(null);
const demographicsAnchorRef = useRef<View | null>(null);
// Live-Daten aus DB (für Avatar / Nickname / Plan / Email).
// Provider-Detection: user.app_metadata.provider vom Supabase-OAuth-Flow.
const provider: AuthProvider =
((user?.app_metadata as { provider?: string } | undefined)?.provider as AuthProvider) ?? 'email';
const profile = {
nickname: me?.nickname ?? user?.email?.split('@')[0] ?? 'User',
email: user?.email ?? '',
avatar: me?.avatar ?? null,
plan: ((me as { plan?: string } | null | undefined)?.plan ?? 'free') as Plan,
memberSince: DUMMY_PROFILE_FALLBACK.memberSince,
provider,
};
const showDigaBanner = DUMMY_STREAK.currentDays >= 30 && !bannerDismissed;
const demoComplete = isDemographicsComplete(demographics);
function scrollToDemographics() {
const node = demographicsAnchorRef.current;
const scroll = scrollViewRef.current;
if (!node || !scroll) return;
const handle = findNodeHandle(node);
const scrollHandle = findNodeHandle(scroll);
if (!handle || !scrollHandle) return;
UIManager.measureLayout(
handle,
scrollHandle,
() => {
// measure failure — silent
},
(_x, y) => {
scroll.scrollTo({ y: Math.max(0, y - 16), animated: true });
},
);
}
return (
<View style={{ flex: 1, backgroundColor: '#ffffff' }}>
<AppHeader showBack title="Profil" />
<ScrollView
ref={scrollViewRef}
style={{ flex: 1 }}
contentContainerStyle={{ paddingBottom: insets.bottom + 80 }}
showsVerticalScrollIndicator={false}
>
<ProfileHeader
nickname={profile.nickname}
email={profile.email}
avatar={profile.avatar}
plan={profile.plan}
memberSince={profile.memberSince}
provider={profile.provider}
showDemographicsHint={!demoComplete}
onDemographicsHintPress={scrollToDemographics}
onEditAvatar={() => {
// TODO Phase C: AvatarPickerSheet (preset-grid + custom-upload via expo-image-picker)
Alert.alert(
'Avatar bearbeiten',
'Hero-Auswahl + Foto-Upload kommt in der nächsten Iteration.',
);
}}
onEditNickname={() => {
// TODO Phase C: NicknameEditSheet → PATCH /api/auth/me
Alert.alert(
'Nickname bearbeiten',
'Inline-Edit + Save kommt in der nächsten Iteration.',
);
}}
/>
<View
style={{
height: 1,
backgroundColor: 'rgba(0,0,0,0.06)',
marginHorizontal: 16,
}}
/>
<View style={{ marginTop: 16 }}>
<StatsBar
postsCount={DUMMY_STATS.postsCount}
followersCount={DUMMY_STATS.followersCount}
approvedDomainsCount={DUMMY_STATS.approvedDomainsCount}
onPostsPress={() => {
// TODO: Phase C — navigate to user's own posts list
}}
onFollowersPress={() => {
// TODO: Phase C — open FollowersSheet
}}
onApprovedDomainsPress={() => {
// TODO: Phase C — scroll to ApprovedDomainsList + auto-expand
}}
/>
</View>
<ApprovedDomainsList domains={DUMMY_APPROVED_DOMAINS} />
{showDigaBanner ? (
<DigaMissionBanner
onDismiss={() => {
// TODO: AsyncStorage persist `diga_banner_dismissed_at`
setBannerDismissed(true);
}}
onContribute={() => {
setBannerDismissed(true);
scrollToDemographics();
}}
/>
) : null}
<StreakSection
currentDays={DUMMY_STREAK.currentDays}
longestDays={DUMMY_STREAK.longestDays}
startDate={DUMMY_STREAK.startDate}
cooldowns={DUMMY_COOLDOWNS}
/>
<UrgeStatsCard
sessions={5}
overcome={4}
helpedBy={DUMMY_HELPED_BY}
topEmotion="Stress"
/>
{/* Anchor: Hint-Tap im Header scrollt hierhin */}
<View ref={demographicsAnchorRef} collapsable={false}>
<DemographicsAccordion
demographics={demographics}
plan={profile.plan}
onChange={(next) => {
// TODO Phase C: PATCH /api/profile/me/demographics — Body: next
// Endpoint: profile.demographics_consent_at = NOW() bei erstem Save (DSGVO-Audit-Trail).
// Plan-Trial-Trigger: wenn alle 6 Felder gefüllt + plan='free' → server setzt
// pro_trial_started_at + pro_trial_expires_at + pro_trial_source='demographics_complete'.
setDemographics(next);
}}
onRevokeConsent={() => {
// TODO: Phase C — DELETE /api/profile/me/demographics, confirm-alert first
}}
/>
</View>
<View style={{ height: 24 }} />
<Text
style={{
textAlign: 'center',
fontSize: 11,
color: colors.textMuted,
fontFamily: 'Nunito_400Regular',
opacity: 0.6,
}}
>
Profil-Skeleton (dummy data) Backend wired in Phase C
</Text>
</ScrollView>
</View>
);
}

View File

@ -1,221 +1,310 @@
import { ScrollView, View, Text, Pressable, Switch } from 'react-native';
import { ScrollView, View, Text, Pressable, Platform } from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
import { useRouter } from 'expo-router';
import { Ionicons } from '@expo/vector-icons';
import { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useAuthStore } from '../stores/auth';
import { Card } from '../components/Card';
import { Button } from '../components/Button';
import { colors } from '../lib/theme';
type SettingRow = {
label: string;
sublabel?: string;
type SectionRow = {
icon: React.ComponentProps<typeof Ionicons>['name'];
iconColor: string;
onPress?: () => void;
right?: React.ReactNode;
label: string;
sublabel: string;
};
type Section = {
key: string;
title: string;
rows: SectionRow[];
};
export default function SettingsScreen() {
const router = useRouter();
const { t } = useTranslation();
const { user, signOut } = useAuthStore();
const [notifPush, setNotifPush] = useState(true);
const [notifStreak, setNotifStreak] = useState(true);
const email = user?.email ?? '';
const initials = email.slice(0, 2).toUpperCase();
async function handleSignOut() {
await signOut();
router.replace('/');
}
const accountRows: SettingRow[] = [
const sections: Section[] = [
{
label: t('settings.edit_profile'),
icon: 'pencil-outline',
key: 'profile',
title: t('settings.section_profile'),
rows: [
{
icon: 'person-outline',
iconColor: '#6366f1',
onPress: () => {},
label: t('settings.profile_edit'),
sublabel: t('settings.profile_edit_desc'),
},
{
label: t('settings.devices'),
sublabel: t('settings.devices_desc'),
icon: 'phone-portrait-outline',
iconColor: '#16a34a',
onPress: () => {},
icon: 'image-outline',
iconColor: '#6366f1',
label: t('settings.profile_avatar'),
sublabel: t('settings.profile_avatar_desc'),
},
],
},
{
label: t('settings.subscription'),
sublabel: t('settings.plan_free'),
icon: 'star-outline',
iconColor: colors.brandOrange,
onPress: () => {},
},
];
const prefRows: SettingRow[] = [
key: 'theme',
title: t('settings.section_theme'),
rows: [
{
label: t('settings.push_notifications'),
icon: 'notifications-outline',
iconColor: '#2563eb',
right: (
<Switch
value={notifPush}
onValueChange={setNotifPush}
trackColor={{ false: '#e5e5e5', true: colors.brandOrange + '60' }}
thumbColor={notifPush ? colors.brandOrange : '#a3a3a3'}
/>
),
icon: 'color-palette-outline',
iconColor: '#a78bfa',
label: t('settings.theme'),
sublabel: t('settings.theme_desc'),
},
{
label: t('settings.streak_reminders'),
icon: 'flame-outline',
iconColor: '#f97316',
right: (
<Switch
value={notifStreak}
onValueChange={setNotifStreak}
trackColor={{ false: '#e5e5e5', true: colors.brandOrange + '60' }}
thumbColor={notifStreak ? colors.brandOrange : '#a3a3a3'}
/>
),
},
{
label: t('settings.language'),
sublabel: t('settings.language_current'),
icon: 'language-outline',
iconColor: '#a78bfa',
onPress: () => {},
label: t('settings.language'),
sublabel: t('settings.language_desc'),
},
],
},
{
key: 'notifications',
title: t('settings.section_notifications'),
rows: [
{
icon: 'notifications-outline',
iconColor: '#2563eb',
label: t('settings.notifications_push'),
sublabel: t('settings.notifications_push_desc'),
},
{
icon: 'flame-outline',
iconColor: '#f97316',
label: t('settings.notifications_streak'),
sublabel: t('settings.notifications_streak_desc'),
},
],
},
{
key: 'devices',
title: t('settings.section_devices'),
rows: [
{
icon: 'phone-portrait-outline',
iconColor: '#16a34a',
label: t('settings.devices'),
sublabel: t('settings.devices_desc'),
},
{
icon: 'star-outline',
iconColor: colors.brandOrange,
label: t('settings.subscription'),
sublabel: t('settings.subscription_desc'),
},
],
},
{
key: 'lyra',
title: t('settings.section_lyra'),
rows: [
{
icon: 'mic-outline',
iconColor: '#ec4899',
label: t('settings.lyra_voice'),
sublabel: t('settings.lyra_voice_desc'),
},
],
},
];
if (__DEV__) {
sections.push({
key: 'debug',
title: t('settings.section_debug'),
rows: [
{
icon: 'bug-outline',
iconColor: '#737373',
label: t('settings.debug_llm'),
sublabel: t('settings.debug_llm_desc'),
},
{
icon: 'volume-high-outline',
iconColor: '#737373',
label: t('settings.debug_tts'),
sublabel: t('settings.debug_tts_desc'),
},
],
});
}
return (
<SafeAreaView className="flex-1 bg-neutral-50" edges={['top']}>
<View className="px-3 pt-1 pb-3 flex-row items-center gap-2">
<SafeAreaView style={{ flex: 1, backgroundColor: '#ffffff' }} edges={['top']}>
<View
style={{
paddingHorizontal: 12,
paddingTop: 4,
paddingBottom: 12,
flexDirection: 'row',
alignItems: 'center',
gap: 8,
borderBottomWidth: 1,
borderBottomColor: 'rgba(0,0,0,0.06)',
}}
>
<Pressable
onPress={() => router.replace('/(app)' as never)}
onPress={() => router.back()}
hitSlop={8}
className="w-10 h-10 items-center justify-center"
style={({ pressed }) => ({ opacity: pressed ? 0.6 : 1 })}
style={({ pressed }) => ({
width: 40,
height: 40,
alignItems: 'center',
justifyContent: 'center',
opacity: pressed ? 0.6 : 1,
})}
>
<Ionicons name="chevron-back" size={26} color={colors.text} />
</Pressable>
<Text className="text-neutral-900 text-xl" style={{ fontFamily: 'Nunito_700Bold' }}>{t('settings.title')}</Text>
<Text style={{ fontSize: 20, color: '#0a0a0a', fontFamily: 'Nunito_700Bold' }}>
{t('settings.title')}
</Text>
</View>
<ScrollView
className="flex-1"
contentContainerStyle={{ paddingHorizontal: 20, paddingBottom: 40, paddingTop: 4 }}
style={{ flex: 1 }}
contentContainerStyle={{ paddingHorizontal: 16, paddingTop: 16, paddingBottom: 60 }}
showsVerticalScrollIndicator={false}
>
{/* Account Card */}
<Card className="mb-5">
<View className="flex-row items-center gap-3">
<View className="w-14 h-14 rounded-full bg-rebreak-500 items-center justify-center">
<Text className="text-white text-lg" style={{ fontFamily: 'Nunito_800ExtraBold' }}>{initials}</Text>
</View>
<View className="flex-1">
<Text className="text-neutral-900 text-base" numberOfLines={1} style={{ fontFamily: 'Nunito_700Bold' }}>
{email}
<View
style={{
backgroundColor: '#fef3c7',
borderRadius: 14,
padding: 14,
marginBottom: 20,
borderWidth: 1,
borderColor: '#fde68a',
flexDirection: 'row',
gap: 10,
alignItems: 'flex-start',
}}
>
<Ionicons name="construct-outline" size={20} color="#b45309" style={{ marginTop: 1 }} />
<View style={{ flex: 1 }}>
<Text style={{ fontSize: 14, fontFamily: 'Nunito_700Bold', color: '#78350f' }}>
{t('settings.coming_soon_title')}
</Text>
<Text
style={{
fontSize: 12,
fontFamily: 'Nunito_400Regular',
color: '#92400e',
marginTop: 4,
lineHeight: 17,
}}
>
{t('settings.coming_soon_desc')}
</Text>
<View className="flex-row items-center gap-1.5 mt-1">
<View className="w-1.5 h-1.5 rounded-full bg-green-500" />
<Text className="text-neutral-500 text-xs" style={{ fontFamily: 'Nunito_400Regular' }}>{t('settings.plan_free')}</Text>
</View>
</View>
</View>
<View className="mt-4 pt-3 border-t border-neutral-100">
<Button variant="secondary" onPress={() => {}}>
{t('settings.upgrade_cta')}
</Button>
</View>
</Card>
{/* Account Section */}
<Text className="text-neutral-400 text-xs uppercase tracking-wider mb-2" style={{ fontFamily: 'Nunito_600SemiBold' }}>
{t('settings.account_section')}
{sections.map((section) => (
<View key={section.key} style={{ marginBottom: 22 }}>
<Text
style={{
fontSize: 11,
color: '#a3a3a3',
fontFamily: 'Nunito_600SemiBold',
textTransform: 'uppercase',
letterSpacing: 1,
marginBottom: 8,
marginLeft: 4,
}}
>
{section.title}
</Text>
<Card className="mb-5 py-0 overflow-hidden">
{accountRows.map((row, i) => (
<Pressable
<View
style={{
backgroundColor: '#fafafa',
borderRadius: 14,
borderWidth: 1,
borderColor: 'rgba(0,0,0,0.05)',
overflow: 'hidden',
}}
>
{section.rows.map((row, i) => (
<View
key={row.label}
onPress={row.onPress}
className={`flex-row items-center gap-3 px-4 py-3.5 ${
i < accountRows.length - 1 ? 'border-b border-neutral-100' : ''
}`}
style={({ pressed }) => ({ opacity: pressed ? 0.7 : 1 })}
style={{
flexDirection: 'row',
alignItems: 'center',
gap: 12,
paddingHorizontal: 14,
paddingVertical: 14,
borderBottomWidth: i < section.rows.length - 1 ? 1 : 0,
borderBottomColor: 'rgba(0,0,0,0.04)',
opacity: 0.5,
}}
>
<View
className="w-8 h-8 rounded-xl items-center justify-center"
style={{ backgroundColor: row.iconColor + '18' }}
style={{
width: 32,
height: 32,
borderRadius: 10,
backgroundColor: row.iconColor + '18',
alignItems: 'center',
justifyContent: 'center',
}}
>
<Ionicons name={row.icon} size={16} color={row.iconColor} />
</View>
<View className="flex-1">
<Text className="text-neutral-800 text-sm" style={{ fontFamily: 'Nunito_600SemiBold' }}>{row.label}</Text>
{row.sublabel ? (
<Text className="text-neutral-400 text-xs mt-0.5" style={{ fontFamily: 'Nunito_400Regular' }}>{row.sublabel}</Text>
) : null}
</View>
{row.right ?? (
<Ionicons name="chevron-forward" size={14} color="#a3a3a3" />
)}
</Pressable>
))}
</Card>
{/* Preferences Section */}
<Text className="text-neutral-400 text-xs uppercase tracking-wider mb-2" style={{ fontFamily: 'Nunito_600SemiBold' }}>
{t('settings.prefs_section')}
<View style={{ flex: 1 }}>
<Text style={{ fontSize: 14, color: '#0a0a0a', fontFamily: 'Nunito_600SemiBold' }}>
{row.label}
</Text>
<Card className="mb-5 py-0 overflow-hidden">
{prefRows.map((row, i) => (
<View
key={row.label}
className={`flex-row items-center gap-3 px-4 py-3.5 ${
i < prefRows.length - 1 ? 'border-b border-neutral-100' : ''
}`}
<Text
style={{
fontSize: 12,
color: '#737373',
fontFamily: 'Nunito_400Regular',
marginTop: 2,
}}
>
<View
className="w-8 h-8 rounded-xl items-center justify-center"
style={{ backgroundColor: row.iconColor + '18' }}
{row.sublabel}
</Text>
</View>
<Text
style={{
fontSize: 10,
color: '#a3a3a3',
fontFamily: 'Nunito_600SemiBold',
textTransform: 'uppercase',
letterSpacing: 0.5,
}}
>
<Ionicons name={row.icon} size={16} color={row.iconColor} />
</View>
<View className="flex-1">
<Text className="text-neutral-800 text-sm" style={{ fontFamily: 'Nunito_600SemiBold' }}>{row.label}</Text>
{row.sublabel ? (
<Text className="text-neutral-400 text-xs mt-0.5" style={{ fontFamily: 'Nunito_400Regular' }}>{row.sublabel}</Text>
) : null}
</View>
{row.right ?? (
<Pressable onPress={row.onPress}>
<Ionicons name="chevron-forward" size={14} color="#a3a3a3" />
</Pressable>
)}
{t('settings.soon_badge')}
</Text>
</View>
))}
</View>
</View>
))}
</Card>
{/* Danger Zone */}
<Text className="text-neutral-400 text-xs uppercase tracking-wider mb-2" style={{ fontFamily: 'Nunito_600SemiBold' }}>
{t('settings.danger_section')}
<Text
style={{
textAlign: 'center',
fontSize: 11,
color: '#a3a3a3',
fontFamily: 'Nunito_400Regular',
marginTop: 6,
opacity: 0.7,
}}
>
{t('settings.skeleton_footer')}
</Text>
<Card className="mb-3">
<Button variant="danger" onPress={() => {}} className="mb-2">
{t('settings.delete_account')}
</Button>
<Text className="text-neutral-400 text-xs text-center" style={{ fontFamily: 'Nunito_400Regular' }}>
{t('settings.delete_desc')}
<Text
style={{
textAlign: 'center',
fontSize: 10,
color: '#a3a3a3',
fontFamily: 'Nunito_400Regular',
marginTop: 4,
opacity: 0.5,
}}
>
{Platform.OS}
</Text>
</Card>
<Button variant="secondary" onPress={handleSignOut}>
{t('settings.sign_out')}
</Button>
</ScrollView>
</SafeAreaView>
);

View File

@ -350,7 +350,7 @@ export default function SOSScreen() {
// Latenz-Benchmark — eine Session pro sendToLyra-Call. Marker werden in
// stream/queue über onMetric gesammelt, gedruckt im onIdle (oder als
// Fallback im finally bei Errors / sound-off).
const bench = new BenchSession({ provider: currentProvider(), label: 'send' });
const bench = new BenchSession({ provider: currentProvider(), llm: currentLlmProvider(), label: 'send' });
try {
const visibleHistory = messages.filter((m) => !m.cardType).map((m) => ({ role: m.role, content: m.content }));
@ -624,7 +624,7 @@ export default function SOSScreen() {
const apiBase = Constants.expoConfig?.extra?.apiUrl as string;
// Latenz-Benchmark fürs Greeting — gleiches Pattern wie sendToLyra.
const greetingBench = new BenchSession({ provider: currentProvider(), label: 'greeting' });
const greetingBench = new BenchSession({ provider: currentProvider(), llm: currentLlmProvider(), label: 'greeting' });
// Hybrid-TTS-Queue, gleiches Pattern wie sendToLyra
const ttsQueue = soundEnabledRef.current

View File

@ -1,28 +1,23 @@
import { useState } from 'react';
import { View, Text, Pressable, Modal, Image } from 'react-native';
import { View, Text, Pressable, Image } from 'react-native';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { Ionicons } from '@expo/vector-icons';
import { useRouter, type RelativePathString } from 'expo-router';
import { useTranslation } from 'react-i18next';
import { useAuthStore } from '../stores/auth';
import { useNotificationStore } from '../stores/notifications';
import { supabase } from '../lib/supabase';
import { resolveAvatar } from '../lib/resolveAvatar';
import { useMe } from '../hooks/useMe';
import { NotificationsDropdown } from './NotificationsDropdown';
import { HeaderDropdownMenu } from './header/HeaderDropdownMenu';
type Props = {
notifCount?: number;
showBack?: boolean;
title?: string;
};
type MenuItem = {
icon: React.ComponentProps<typeof Ionicons>['name'];
label: string;
color?: string;
action: () => void;
};
export function AppHeader({ notifCount }: Props = {}) {
export function AppHeader({ notifCount, showBack, title }: Props = {}) {
const insets = useSafeAreaInsets();
const router = useRouter();
const { t } = useTranslation();
@ -30,13 +25,12 @@ export function AppHeader({ notifCount }: Props = {}) {
const { me } = useMe();
const storeUnread = useNotificationStore((s) => s.unread);
const badge = notifCount ?? storeUnread;
const [dropdownOpen, setDropdownOpen] = useState(false);
const [notifOpen, setNotifOpen] = useState(false);
const [menuOpen, setMenuOpen] = useState(false);
const firstName = (user?.user_metadata?.first_name as string | undefined) ?? '';
const lastName = (user?.user_metadata?.last_name as string | undefined) ?? '';
const email = user?.email ?? '';
// Initials-Fallback: erst nickname (DB), dann firstName/email
const initials = (() => {
if (me?.nickname) return me.nickname.slice(0, 2).toUpperCase();
return ((firstName.charAt(0) + (lastName.charAt(0) || email.charAt(0))).toUpperCase() || '?');
@ -45,36 +39,10 @@ export function AppHeader({ notifCount }: Props = {}) {
// Avatar: aus DB (`/api/auth/me` → profiles.avatar). Kann Hero-Avatar-ID
// ("spider"/"hulk"/...) ODER Custom-Photo-URL (https://... von Foto-Upload)
// sein. resolveAvatar handlet beide Fälle.
// user_metadata.avatar_id ist veraltet — wird bei Profile-Edit nicht
// aktualisiert. DB ist Single Source of Truth.
const avatarUrl = me ? resolveAvatar(me.avatar, me.nickname ?? '') : '';
const [avatarLoadFailed, setAvatarLoadFailed] = useState(false);
const showAvatarImage = !!avatarUrl && !avatarLoadFailed && !!me?.avatar;
function closeAndNavigate(path: RelativePathString) {
setDropdownOpen(false);
router.push(path);
}
async function handleSignOut() {
setDropdownOpen(false);
await supabase.auth.signOut();
router.replace('/' as RelativePathString);
}
const menuItems: MenuItem[] = [
{
icon: 'person-outline',
label: t('appHeader.editProfile'),
action: () => closeAndNavigate('/settings' as RelativePathString),
},
{
icon: 'settings-outline',
label: t('appHeader.settings'),
action: () => closeAndNavigate('/settings' as RelativePathString),
},
];
const headerHeight = insets.top + 56;
return (
@ -83,12 +51,24 @@ export function AppHeader({ notifCount }: Props = {}) {
style={{ paddingTop: insets.top }}
>
<View className="h-14 flex-row items-center justify-between px-5">
<View className="flex-row items-center" style={{ gap: 8 }}>
{showBack ? (
<Pressable
onPress={() => router.back()}
hitSlop={10}
className="w-9 h-9 rounded-full items-center justify-center"
style={({ pressed }) => ({ opacity: pressed ? 0.6 : 1, marginLeft: -8 })}
accessibilityLabel="Zurück"
>
<Ionicons name="chevron-back" size={22} color="#0a0a0a" />
</Pressable>
) : null}
<Text className="text-lg text-midnight-900 tracking-tight" style={{ fontFamily: 'Nunito_700Bold' }}>
{t('appHeader.appName')}
{title ?? t('appHeader.appName')}
</Text>
</View>
<View className="flex-row items-center gap-2">
{/* Notifications dropdown trigger */}
<View className="flex-row items-center gap-1">
<Pressable
onPress={() => setNotifOpen(true)}
className="w-9 h-9 rounded-full bg-white items-center justify-center"
@ -104,9 +84,9 @@ export function AppHeader({ notifCount }: Props = {}) {
)}
</Pressable>
{/* Profil-Avatar — tap → dropdown */}
{/* Avatar = Trigger für Dropdown-Menu (kein separates 3-Punkte-Icon) */}
<Pressable
onPress={() => setDropdownOpen(true)}
onPress={() => setMenuOpen(true)}
className={`w-9 h-9 rounded-full items-center justify-center overflow-hidden ${showAvatarImage ? 'bg-neutral-100' : 'bg-rebreak-500'}`}
style={({ pressed }) => ({ opacity: pressed ? 0.7 : 1 })}
>
@ -120,113 +100,14 @@ export function AppHeader({ notifCount }: Props = {}) {
<Text className="text-white text-xs" style={{ fontFamily: 'Nunito_700Bold' }}>{initials}</Text>
)}
</Pressable>
</View>
</View>
{/* Dropdown modal */}
<Modal
visible={dropdownOpen}
transparent
animationType="fade"
statusBarTranslucent
onRequestClose={() => setDropdownOpen(false)}
>
<Pressable
onPress={() => setDropdownOpen(false)}
style={{ flex: 1, backgroundColor: 'rgba(0,0,0,0.18)' }}
>
<View
onStartShouldSetResponder={() => true}
style={{
position: 'absolute',
top: headerHeight + 6,
right: 12,
backgroundColor: '#ffffff',
borderRadius: 18,
shadowColor: '#000',
shadowOffset: { width: 0, height: 8 },
shadowOpacity: 0.18,
shadowRadius: 20,
elevation: 12,
minWidth: 260,
overflow: 'hidden',
}}
>
{/* SOS prominent oben — Pressable mit innerem Row-View */}
<Pressable onPress={() => closeAndNavigate('/urge' as RelativePathString)}>
<View
style={{
flexDirection: 'row',
alignItems: 'center',
paddingHorizontal: 18,
paddingVertical: 16,
}}
>
<View
style={{
width: 36,
height: 36,
borderRadius: 18,
backgroundColor: '#fee2e2',
alignItems: 'center',
justifyContent: 'center',
marginRight: 14,
}}
>
<Ionicons name="heart" size={18} color="#dc2626" />
<HeaderDropdownMenu
visible={menuOpen}
onClose={() => setMenuOpen(false)}
topOffset={headerHeight + 6}
/>
</View>
<View style={{ flex: 1 }}>
<Text style={{ fontSize: 15, fontFamily: 'Nunito_700Bold', color: '#dc2626' }}>
{t('appHeader.sosLabel')}
</Text>
<Text style={{ fontSize: 12, fontFamily: 'Nunito_400Regular', color: '#a3a3a3', marginTop: 1 }}>
{t('appHeader.sosSubtitle')}
</Text>
</View>
<Ionicons name="chevron-forward" size={16} color="#d4d4d8" />
</View>
</Pressable>
<View style={{ height: 1, backgroundColor: '#f0f0f0' }} />
{menuItems.map((item) => (
<Pressable key={item.label} onPress={item.action}>
<View
style={{
flexDirection: 'row',
alignItems: 'center',
paddingHorizontal: 18,
paddingVertical: 14,
}}
>
<Ionicons name={item.icon} size={18} color="#737373" style={{ marginRight: 14 }} />
<Text style={{ fontSize: 14, fontFamily: 'Nunito_600SemiBold', color: '#0a0a0a' }}>
{item.label}
</Text>
</View>
</Pressable>
))}
<View style={{ height: 1, backgroundColor: '#f0f0f0' }} />
<Pressable onPress={handleSignOut}>
<View
style={{
flexDirection: 'row',
alignItems: 'center',
paddingHorizontal: 18,
paddingVertical: 14,
}}
>
<Ionicons name="log-out-outline" size={18} color="#dc2626" style={{ marginRight: 14 }} />
<Text style={{ fontSize: 14, fontFamily: 'Nunito_600SemiBold', color: '#dc2626' }}>
{t('appHeader.signOut')}
</Text>
</View>
</Pressable>
</View>
</Pressable>
</Modal>
<NotificationsDropdown
visible={notifOpen}

View File

@ -126,9 +126,13 @@ export function ComposeCard({ onPosted }: Props) {
style={{ height: 160 }}
resizeMode="cover"
/>
{/* HitSlop +9pt rundum → 28pt visual + 18pt slop ≈ 46pt effektive Tap-Area (HIG ≥44pt). */}
<Pressable
onPress={() => setImageUri(null)}
hitSlop={{ top: 9, bottom: 9, left: 9, right: 9 }}
android_ripple={{ color: 'rgba(255,255,255,0.18)', borderless: true, radius: 22 }}
className="absolute top-2 right-2 w-7 h-7 rounded-full bg-black/50 items-center justify-center"
style={({ pressed }) => ({ opacity: pressed ? 0.7 : 1 })}
>
<Ionicons name="close" size={14} color="#fff" />
</Pressable>
@ -139,9 +143,12 @@ export function ComposeCard({ onPosted }: Props) {
{showActions && (
<View className="flex-row items-center justify-between mt-3 pt-3 border-t border-neutral-100">
{/* Image-Picker: visuell klein (icon 18pt + label), aber hitSlop +12 → effektive Tap-Area ~46pt (HIG-Min 44pt). */}
<Pressable
onPress={pickImage}
className="flex-row items-center gap-1.5"
hitSlop={{ top: 12, bottom: 12, left: 12, right: 12 }}
android_ripple={{ color: 'rgba(0,0,0,0.08)', borderless: true, radius: 22 }}
className="flex-row items-center gap-1.5 py-1.5 pr-1.5"
style={({ pressed }) => ({ opacity: pressed ? 0.6 : 1 })}
>
<Ionicons name="image-outline" size={18} color="#737373" />
@ -149,12 +156,19 @@ export function ComposeCard({ onPosted }: Props) {
</Pressable>
<View className="flex-row items-center gap-2">
<Pressable onPress={cancel}>
{/* Cancel-Label: hitSlop sichert ≥44pt Tap-Area trotz nackter Text-Höhe. */}
<Pressable
onPress={cancel}
hitSlop={{ top: 12, bottom: 12, left: 12, right: 12 }}
style={({ pressed }) => ({ opacity: pressed ? 0.5 : 1 })}
>
<Text className="text-sm text-neutral-400" style={{ fontFamily: 'Nunito_600SemiBold' }}>{t('common.cancel')}</Text>
</Pressable>
{/* Share-Pill: visuell h-8 (32pt) bleibt erhalten — hitSlop +6 vertikal hebt Tap-Area auf 44pt. */}
<Pressable
onPress={submit}
disabled={!content.trim() || posting}
hitSlop={{ top: 6, bottom: 6, left: 8, right: 8 }}
className="h-8 px-4 rounded-full bg-rebreak-500 items-center justify-center flex-row gap-1.5"
style={({ pressed }) => ({
opacity: pressed || !content.trim() || posting ? 0.5 : 1,

View File

@ -309,9 +309,10 @@ export function ProtectionDetailsSheet({
</View>
{/* FAQ-Banner: Heading-Row mit Help-Icon rechts (kein gestapeltes Layout) */}
<View style={{ gap: 8, marginTop: 4 }}>
<View style={{ alignSelf: 'stretch', gap: 8, marginTop: 4 }}>
<View
style={{
alignSelf: 'stretch',
flexDirection: 'row',
alignItems: 'center',
marginBottom: 4,
@ -344,6 +345,7 @@ export function ProtectionDetailsSheet({
<Pressable
onPress={onRequestDeactivation}
style={({ pressed }) => ({
alignSelf: 'stretch',
marginTop: 4,
paddingVertical: 14,
paddingHorizontal: 16,
@ -657,6 +659,7 @@ function FaqItem({ question, answer }: { question: string; answer: string }) {
return (
<View
style={{
alignSelf: 'stretch',
borderWidth: 1,
borderColor: '#e5e5e5',
borderRadius: 12,
@ -667,6 +670,7 @@ function FaqItem({ question, answer }: { question: string; answer: string }) {
<Pressable
onPress={() => setOpen((v) => !v)}
style={({ pressed }) => ({
alignSelf: 'stretch',
flexDirection: 'row',
alignItems: 'center',
paddingHorizontal: 14,

View File

@ -0,0 +1,77 @@
// RN-Port der Vue-Card aus apps/rebreak/app/components/urge/UrgeGamePicker.vue
// 2x2-Grid-Kachel mit SVG-Icon (56x56), Titel, descKey und Star-Rating.
import { View, Text, Pressable } from 'react-native';
import { SvgXml } from 'react-native-svg';
import { useTranslation } from 'react-i18next';
import { GameRatingStars } from './GameRatingStars';
import type { GameType } from '../urge/UrgeGames';
export interface GameCardProps {
id: GameType;
svg: string;
titleKey: string;
descKey: string;
avgStars: number;
count: number;
onPress: (id: GameType) => void;
}
export function GameCard({
id,
svg,
titleKey,
descKey,
avgStars,
count,
onPress,
}: GameCardProps) {
const { t } = useTranslation();
return (
<Pressable
onPress={() => onPress(id)}
style={({ pressed }) => ({
width: '100%',
borderRadius: 18,
borderWidth: 1,
borderColor: '#e5e7eb',
backgroundColor: pressed ? '#f0f9ff' : '#fafafa',
alignItems: 'center',
justifyContent: 'center',
paddingVertical: 18,
paddingHorizontal: 12,
gap: 12,
transform: [{ scale: pressed ? 0.97 : 1 }],
})}
>
<SvgXml xml={svg} width={56} height={56} />
<View style={{ alignItems: 'center', gap: 2 }}>
<Text
style={{
fontFamily: 'Nunito_700Bold',
color: '#0a0a0a',
fontSize: 15,
}}
>
{t(titleKey)}
</Text>
<Text
numberOfLines={2}
style={{
textAlign: 'center',
fontFamily: 'Nunito_400Regular',
color: '#737373',
fontSize: 11,
lineHeight: 14,
minHeight: 28,
paddingHorizontal: 4,
}}
>
{t(descKey)}
</Text>
<View style={{ marginTop: 4 }}>
<GameRatingStars avg={avgStars} count={count} />
</View>
</View>
</Pressable>
);
}

View File

@ -0,0 +1,34 @@
// RN-Port von apps/rebreak/app/components/sos/GameRatingStars.vue
import { View, Text } from 'react-native';
import { StarRating } from './StarRating';
export interface GameRatingStarsProps {
avg: number;
count: number;
}
export function GameRatingStars({ avg, count }: GameRatingStarsProps) {
return (
<View
style={{
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
gap: 4,
}}
>
<StarRating value={avg} size="xs" />
{count > 0 ? (
<Text
style={{
fontFamily: 'Nunito_400Regular',
fontSize: 11,
color: '#737373',
}}
>
({count})
</Text>
) : null}
</View>
);
}

View File

@ -0,0 +1,106 @@
// RN-Port von apps/rebreak/app/components/StarRating.vue
// Unterstützt fractional values (z.B. 3.7) via width-clipping.
import { View, Pressable } from 'react-native';
import { Ionicons } from '@expo/vector-icons';
import { useState } from 'react';
export type StarSize = 'xs' | 'sm' | 'md' | 'lg' | 'xl';
const sizeMap: Record<StarSize, number> = {
xs: 14,
sm: 18,
md: 20,
lg: 28,
xl: 40,
};
export interface StarRatingProps {
value?: number;
max?: number;
size?: StarSize;
interactive?: boolean;
filledColor?: string;
emptyColor?: string;
onChange?: (value: number) => void;
}
export function StarRating({
value = 0,
max = 5,
size = 'md',
interactive = false,
filledColor = '#facc15',
emptyColor = '#e5e7eb',
onChange,
}: StarRatingProps) {
const [hover, setHover] = useState(0);
const px = sizeMap[size];
const display = interactive ? hover || value : value;
const stars = [];
for (let i = 1; i <= max; i++) {
const filledRatio = Math.min(Math.max(display - (i - 1), 0), 1);
const filledWidth = filledRatio * px;
const star = (
<View
key={`star-${i}`}
style={{
width: px,
height: px,
position: 'relative',
}}
>
{/* Empty star (background) */}
<Ionicons
name="star"
size={px}
color={emptyColor}
style={{ position: 'absolute', top: 0, left: 0 }}
/>
{/* Filled star clipped to filledWidth */}
{filledRatio > 0 ? (
<View
style={{
position: 'absolute',
top: 0,
left: 0,
width: filledWidth,
height: px,
overflow: 'hidden',
}}
>
<Ionicons
name="star"
size={px}
color={filledColor}
style={{ position: 'absolute', top: 0, left: 0 }}
/>
</View>
) : null}
</View>
);
if (interactive) {
stars.push(
<Pressable
key={`press-${i}`}
onPress={() => onChange?.(i)}
onHoverIn={() => setHover(i)}
onHoverOut={() => setHover(0)}
hitSlop={4}
>
{star}
</Pressable>
);
} else {
stars.push(star);
}
}
return (
<View style={{ flexDirection: 'row', alignItems: 'center', gap: 2 }}>
{stars}
</View>
);
}

View File

@ -0,0 +1,244 @@
import { View, Text, Pressable, Modal } from 'react-native';
import { useRouter, type RelativePathString } from 'expo-router';
import { Ionicons } from '@expo/vector-icons';
import { useTranslation } from 'react-i18next';
import { useAuthStore } from '../../stores/auth';
// Controlled-Modal-Pattern. Trigger ist NICHT in dieser Komponente — der
// Avatar im AppHeader öffnet das Modal via `visible`-Prop (User-Anweisung
// 2026-05-07: kein separates 3-Punkte-Icon).
//
// Card-Style mit:
// - SOS prominent oben (nur Wort "SOS" rot, Tagline neutral; ernste Sache,
// nicht mit Gaming/Profile in eine Liste werfen)
// - Profile · Settings · Games · [Debug DEV] in der Mitte
// - Abmelden unten, neutral (nicht rot — Recovery-tonal, kein Alarm)
type ItemKey = 'profile' | 'settings' | 'games' | 'debug';
type Item = {
key: ItemKey;
label: string;
icon: React.ComponentProps<typeof Ionicons>['name'];
onSelect: () => void | Promise<void>;
};
type Props = {
visible: boolean;
onClose: () => void;
topOffset?: number;
};
export function HeaderDropdownMenu({ visible, onClose, topOffset = 80 }: Props) {
const router = useRouter();
const { t } = useTranslation();
const { signOut } = useAuthStore();
function nav(path: RelativePathString) {
onClose();
router.push(path);
}
async function handleLogout() {
onClose();
await signOut();
router.replace('/' as RelativePathString);
}
const items: Item[] = [
{
key: 'profile',
label: t('headerMenu.profile'),
icon: 'person-outline',
onSelect: () => nav('/profile' as RelativePathString),
},
{
key: 'settings',
label: t('headerMenu.settings'),
icon: 'settings-outline',
onSelect: () => nav('/settings' as RelativePathString),
},
{
key: 'games',
label: t('headerMenu.games'),
icon: 'game-controller-outline',
onSelect: () => nav('/games' as RelativePathString),
},
];
if (__DEV__) {
items.push({
key: 'debug',
label: t('headerMenu.debug'),
icon: 'bug-outline',
onSelect: () => nav('/debug' as RelativePathString),
});
}
return (
<Modal
visible={visible}
transparent
animationType="fade"
statusBarTranslucent
onRequestClose={onClose}
>
<Pressable
onPress={onClose}
style={{ flex: 1, backgroundColor: 'rgba(0,0,0,0.18)' }}
>
<View
onStartShouldSetResponder={() => true}
style={{
position: 'absolute',
top: topOffset,
right: 12,
backgroundColor: '#ffffff',
borderRadius: 18,
shadowColor: '#000',
shadowOffset: { width: 0, height: 8 },
shadowOpacity: 0.18,
shadowRadius: 20,
elevation: 12,
minWidth: 280,
overflow: 'hidden',
}}
>
{/* SOS prominent — separat, ernst-tonal, nur "SOS" rot */}
<Pressable
onPress={() => {
onClose();
router.push('/urge' as RelativePathString);
}}
android_ripple={{ color: '#fee2e2' }}
>
<View
style={{
flexDirection: 'row',
alignItems: 'center',
paddingHorizontal: 18,
paddingVertical: 16,
}}
>
<View
style={{
width: 36,
height: 36,
borderRadius: 18,
backgroundColor: '#fee2e2',
alignItems: 'center',
justifyContent: 'center',
marginRight: 14,
}}
>
<Ionicons name="heart" size={18} color="#dc2626" />
</View>
<View style={{ flex: 1 }}>
<Text
style={{
fontSize: 15,
fontFamily: 'Nunito_700Bold',
color: '#dc2626',
}}
numberOfLines={1}
>
{t('appHeader.sosLabel')}
</Text>
<Text
style={{
fontSize: 12,
fontFamily: 'Nunito_400Regular',
color: '#a3a3a3',
marginTop: 1,
}}
numberOfLines={1}
>
{t('appHeader.sosTagline')}
</Text>
</View>
<Ionicons name="chevron-forward" size={16} color="#d4d4d8" />
</View>
</Pressable>
<View style={{ height: 1, backgroundColor: '#f0f0f0' }} />
{/* Profile · Settings · Games · [Debug DEV] */}
{items.map((item) => (
<Pressable
key={item.key}
onPress={() => {
onClose();
void item.onSelect();
}}
android_ripple={{ color: '#e5e7eb' }}
style={({ pressed }) => ({
backgroundColor: pressed ? '#f5f5f5' : 'transparent',
})}
>
<View
style={{
flexDirection: 'row',
alignItems: 'center',
paddingHorizontal: 18,
paddingVertical: 14,
}}
>
<Ionicons
name={item.icon}
size={18}
color="#737373"
style={{ marginRight: 14 }}
/>
<Text
style={{
fontSize: 14,
fontFamily: 'Nunito_600SemiBold',
color: '#0a0a0a',
}}
>
{item.label}
</Text>
</View>
</Pressable>
))}
<View style={{ height: 1, backgroundColor: '#f0f0f0' }} />
{/* Abmelden — neutral, nicht rot */}
<Pressable
onPress={handleLogout}
android_ripple={{ color: '#e5e7eb' }}
style={({ pressed }) => ({
backgroundColor: pressed ? '#f5f5f5' : 'transparent',
})}
>
<View
style={{
flexDirection: 'row',
alignItems: 'center',
paddingHorizontal: 18,
paddingVertical: 14,
}}
>
<Ionicons
name="log-out-outline"
size={18}
color="#737373"
style={{ marginRight: 14 }}
/>
<Text
style={{
fontSize: 14,
fontFamily: 'Nunito_600SemiBold',
color: '#0a0a0a',
}}
>
{t('headerMenu.logout')}
</Text>
</View>
</Pressable>
</View>
</Pressable>
</Modal>
);
}

View File

@ -0,0 +1,135 @@
import { useState } from 'react';
import { View, Text, Pressable, LayoutAnimation, Platform, UIManager } from 'react-native';
import { Ionicons } from '@expo/vector-icons';
import { colors } from '../../lib/theme';
if (Platform.OS === 'android' && UIManager.setLayoutAnimationEnabledExperimental) {
UIManager.setLayoutAnimationEnabledExperimental(true);
}
export type ApprovedDomain = {
domain: string;
approvedAt: string;
};
type Props = {
domains: ApprovedDomain[];
loading?: boolean;
};
export function ApprovedDomainsList({ domains, loading }: Props) {
const [expanded, setExpanded] = useState(false);
function toggle() {
LayoutAnimation.configureNext(LayoutAnimation.Presets.easeInEaseOut);
setExpanded((v) => !v);
}
return (
<View style={{ marginHorizontal: 16, marginTop: 12 }}>
<Pressable
onPress={toggle}
style={({ pressed }) => ({
alignSelf: 'stretch',
flexDirection: 'row',
alignItems: 'center',
paddingVertical: 12,
paddingHorizontal: 14,
backgroundColor: '#ffffff',
borderWidth: 1,
borderColor: '#e5e5e5',
borderRadius: 12,
opacity: pressed ? 0.7 : 1,
})}
>
<Ionicons
name="shield-checkmark-outline"
size={16}
color={colors.textMuted}
style={{ marginRight: 8 }}
/>
<Text style={{ flex: 1, fontSize: 13, color: colors.text, fontFamily: 'Nunito_600SemiBold' }}>
Approved Domains{' '}
<Text style={{ color: colors.textMuted, fontFamily: 'Nunito_400Regular' }}>
({domains.length})
</Text>
</Text>
<Ionicons
name={expanded ? 'chevron-up' : 'chevron-down'}
size={16}
color={colors.textMuted}
/>
</Pressable>
{expanded ? (
<View
style={{
marginTop: 6,
backgroundColor: '#ffffff',
borderWidth: 1,
borderColor: '#e5e5e5',
borderRadius: 12,
paddingVertical: 4,
}}
>
{loading ? (
<View style={{ padding: 16 }}>
<SkeletonRow />
<SkeletonRow />
<SkeletonRow />
</View>
) : domains.length === 0 ? (
<Text
style={{
paddingVertical: 16,
paddingHorizontal: 14,
fontSize: 12,
color: colors.textMuted,
fontFamily: 'Nunito_400Regular',
}}
>
Noch keine approved Domains. Submit deine erste in der Community.
</Text>
) : (
domains.map((d, idx) => (
<View
key={d.domain}
style={{
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
paddingVertical: 10,
paddingHorizontal: 14,
borderTopWidth: idx === 0 ? 0 : 1,
borderTopColor: 'rgba(0,0,0,0.06)',
}}
>
<Text style={{ fontSize: 13, color: colors.text, fontFamily: 'Nunito_600SemiBold' }}>
{d.domain}
</Text>
<Text
style={{ fontSize: 11, color: colors.textMuted, fontFamily: 'Nunito_400Regular' }}
>
{d.approvedAt}
</Text>
</View>
))
)}
</View>
) : null}
</View>
);
}
function SkeletonRow() {
return (
<View
style={{
height: 14,
backgroundColor: '#f5f5f5',
borderRadius: 4,
marginVertical: 6,
}}
/>
);
}

View File

@ -0,0 +1,621 @@
import { useEffect, useMemo, useRef, useState } from 'react';
import {
View,
Text,
Pressable,
TextInput,
Modal,
LayoutAnimation,
Platform,
UIManager,
ScrollView,
} from 'react-native';
import { Ionicons } from '@expo/vector-icons';
import { colors } from '../../lib/theme';
import type { Plan } from '../../hooks/useUserPlan';
if (Platform.OS === 'android' && UIManager.setLayoutAnimationEnabledExperimental) {
UIManager.setLayoutAnimationEnabledExperimental(true);
}
export type Demographics = {
birthYear: number | null;
gender: string | null;
maritalStatus: string | null;
profession: string | null;
bundesland: string | null;
city: string | null;
};
type Props = {
demographics: Demographics;
plan: Plan;
defaultExpanded?: boolean;
onChange?: (next: Demographics) => void;
onRevokeConsent?: () => void;
};
// Select-Optionen — Display-Label DE, value für DB-Persistenz
const GENDER_OPTIONS: Array<{ label: string; value: string }> = [
{ label: 'männlich', value: 'male' },
{ label: 'weiblich', value: 'female' },
{ label: 'divers', value: 'diverse' },
{ label: 'keine Angabe', value: 'none' },
];
const MARITAL_OPTIONS: Array<{ label: string; value: string }> = [
{ label: 'ledig', value: 'single' },
{ label: 'Partnerschaft', value: 'partnership' },
{ label: 'verheiratet', value: 'married' },
{ label: 'geschieden', value: 'divorced' },
{ label: 'verwitwet', value: 'widowed' },
{ label: 'keine Angabe', value: 'none' },
];
// ISO-3166-2:DE — value=ISO, label=DE-Display
const BUNDESLAND_OPTIONS: Array<{ label: string; value: string }> = [
{ label: 'Baden-Württemberg', value: 'BW' },
{ label: 'Bayern', value: 'BY' },
{ label: 'Berlin', value: 'BE' },
{ label: 'Brandenburg', value: 'BB' },
{ label: 'Bremen', value: 'HB' },
{ label: 'Hamburg', value: 'HH' },
{ label: 'Hessen', value: 'HE' },
{ label: 'Mecklenburg-Vorpommern', value: 'MV' },
{ label: 'Niedersachsen', value: 'NI' },
{ label: 'Nordrhein-Westfalen', value: 'NW' },
{ label: 'Rheinland-Pfalz', value: 'RP' },
{ label: 'Saarland', value: 'SL' },
{ label: 'Sachsen', value: 'SN' },
{ label: 'Sachsen-Anhalt', value: 'ST' },
{ label: 'Schleswig-Holstein', value: 'SH' },
{ label: 'Thüringen', value: 'TH' },
];
const FIELD_WHY: Record<keyof Demographics, string> = {
birthYear:
'Lyra spricht dich altersgerecht an, DiGA-Berichte erkennen Risiko nach Altersgruppe.',
gender: 'Glücksspiel-Muster unterscheiden sich; Lyra coacht gendersensibel.',
profession:
'Schichtarbeit, Banking-Stress, Selbstständigkeit haben verschiedene Trigger — Lyra kennt deinen Kontext.',
maritalStatus:
'Trennung/Beziehungs-Konflikte sind klassische Trigger — Lyra erkennt sie früher in dir.',
bundesland: 'Lokale Beratungsstellen + anonyme DiGA-Studien.',
city: 'Lokale Beratungsstellen + anonyme DiGA-Studien.',
};
function lookupLabel(options: Array<{ label: string; value: string }>, v: string | null) {
if (!v) return null;
return options.find((o) => o.value === v)?.label ?? v;
}
function isComplete(d: Demographics) {
return (
d.birthYear !== null &&
!!d.gender &&
!!d.maritalStatus &&
!!d.profession &&
!!d.bundesland &&
!!d.city
);
}
// TODO Phase C: PATCH /api/profile/me/demographics — debounced auto-save (~500ms idle).
// Bis Endpoint live: lokaler State + onChange-Callback Richtung Parent.
function mockPersist(_next: Demographics) {
// no-op placeholder — Parent ruft echten Endpoint
}
export function DemographicsAccordion({
demographics,
plan,
defaultExpanded = false,
onChange,
onRevokeConsent,
}: Props) {
const [expanded, setExpanded] = useState(defaultExpanded);
const [local, setLocal] = useState<Demographics>(demographics);
// Select-Sheet-State
const [pickerField, setPickerField] = useState<keyof Demographics | null>(null);
// Debounce-Save Ref
const saveTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
useEffect(() => {
setLocal(demographics);
}, [demographics]);
function toggle() {
LayoutAnimation.configureNext(LayoutAnimation.Presets.easeInEaseOut);
setExpanded((v) => !v);
}
function persist(next: Demographics) {
setLocal(next);
if (saveTimer.current) clearTimeout(saveTimer.current);
saveTimer.current = setTimeout(() => {
mockPersist(next);
onChange?.(next);
}, 500);
}
function flushSave(next: Demographics) {
if (saveTimer.current) clearTimeout(saveTimer.current);
mockPersist(next);
onChange?.(next);
setLocal(next);
}
const completed = isComplete(local);
const showProTrialBanner = plan === 'free' && completed;
return (
<View style={{ marginHorizontal: 16, marginTop: 24 }}>
{/* Privacy-Header */}
<Pressable
onPress={toggle}
style={({ pressed }) => ({
opacity: pressed ? 0.7 : 1,
backgroundColor: '#ffffff',
borderWidth: 1,
borderColor: '#e5e5e5',
borderRadius: 14,
padding: 16,
})}
>
<View
style={{
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
}}
>
<View style={{ flex: 1 }}>
<Text
style={{
fontSize: 11,
color: colors.textMuted,
fontFamily: 'Nunito_700Bold',
letterSpacing: 0.8,
}}
>
ANONYMER BEITRAG ZUR FORSCHUNG
</Text>
<Text
style={{
marginTop: 6,
fontSize: 13,
color: colors.text,
fontFamily: 'Nunito_600SemiBold',
}}
>
Optional. Niemals mit Name oder Email verknüpft. Jederzeit löschbar.
</Text>
</View>
<Ionicons
name={expanded ? 'chevron-up' : 'chevron-down'}
size={18}
color={colors.textMuted}
/>
</View>
</Pressable>
{expanded ? (
<View
style={{
marginTop: 8,
backgroundColor: '#ffffff',
borderWidth: 1,
borderColor: '#e5e5e5',
borderRadius: 14,
paddingVertical: 4,
}}
>
{/* Pro-Trial-Reward-Banner nur free + (idealerweise) nicht-vollständig.
Wir zeigen ihn aber auch im "completed"-State als sanfte Bestätigung,
tatsächliche Trial-Vergabe ist Backend-Sache (Phase C). */}
{plan === 'free' ? (
<View
style={{
marginHorizontal: 8,
marginVertical: 8,
backgroundColor: '#fff7ed',
borderColor: '#fed7aa',
borderWidth: 1,
borderRadius: 12,
padding: 12,
flexDirection: 'row',
gap: 10,
alignItems: 'flex-start',
}}
>
<Ionicons name="gift-outline" size={18} color="#c2410c" style={{ marginTop: 1 }} />
<View style={{ flex: 1 }}>
<Text
style={{
fontSize: 13,
color: '#7c2d12',
fontFamily: 'Nunito_700Bold',
}}
>
{showProTrialBanner
? 'Du bekommst 1 Woche Pro geschenkt'
: 'Vervollständige dein Profil — 1 Woche Pro geschenkt'}
</Text>
<Text
style={{
marginTop: 4,
fontSize: 11,
color: '#9a3412',
fontFamily: 'Nunito_400Regular',
lineHeight: 15,
}}
>
Mit deinen anonymen Daten machen wir rebreak zur ersten DiGA-zertifizierten
Spielsucht-App. Als Dankeschön: 1 Woche Pro.
</Text>
</View>
</View>
) : null}
{/* Birth Year — Number-Input */}
<FieldRow
label="Geburtsjahr"
why={FIELD_WHY.birthYear}
>
<TextInput
value={local.birthYear !== null ? String(local.birthYear) : ''}
onChangeText={(raw) => {
const cleaned = raw.replace(/[^0-9]/g, '').slice(0, 4);
if (cleaned === '') {
persist({ ...local, birthYear: null });
return;
}
const n = parseInt(cleaned, 10);
// Erlaube tippen — Validierung beim Blur
persist({ ...local, birthYear: Number.isNaN(n) ? null : n });
}}
onBlur={() => {
const n = local.birthYear;
if (n !== null && (n < 1920 || n > 2010)) {
// ungültig — auf null zurücksetzen
flushSave({ ...local, birthYear: null });
}
}}
keyboardType="number-pad"
maxLength={4}
placeholder="z.B. 1989"
placeholderTextColor={colors.textMuted}
style={inputStyle}
/>
</FieldRow>
{/* Gender — Select */}
<FieldRow label="Geschlecht" why={FIELD_WHY.gender}>
<SelectButton
value={lookupLabel(GENDER_OPTIONS, local.gender)}
onPress={() => setPickerField('gender')}
/>
</FieldRow>
{/* Profession — TextInput */}
<FieldRow label="Beruf" why={FIELD_WHY.profession}>
<TextInput
value={local.profession ?? ''}
onChangeText={(t) => persist({ ...local, profession: t })}
onBlur={() => {
const trimmed = (local.profession ?? '').trim();
flushSave({ ...local, profession: trimmed === '' ? null : trimmed });
}}
maxLength={80}
placeholder="z.B. Pflege, IT, Schichtarbeit"
placeholderTextColor={colors.textMuted}
style={inputStyle}
/>
</FieldRow>
{/* Marital — Select */}
<FieldRow label="Familienstand" why={FIELD_WHY.maritalStatus}>
<SelectButton
value={lookupLabel(MARITAL_OPTIONS, local.maritalStatus)}
onPress={() => setPickerField('maritalStatus')}
/>
</FieldRow>
{/* Bundesland — Select */}
<FieldRow label="Bundesland" why={FIELD_WHY.bundesland}>
<SelectButton
value={lookupLabel(BUNDESLAND_OPTIONS, local.bundesland)}
onPress={() => setPickerField('bundesland')}
/>
</FieldRow>
{/* City — TextInput */}
<FieldRow label="Stadt" why={FIELD_WHY.city} isLast>
<TextInput
value={local.city ?? ''}
onChangeText={(t) => persist({ ...local, city: t })}
onBlur={() => {
const trimmed = (local.city ?? '').trim();
flushSave({ ...local, city: trimmed === '' ? null : trimmed });
}}
maxLength={60}
placeholder="z.B. München"
placeholderTextColor={colors.textMuted}
style={inputStyle}
/>
</FieldRow>
{/* Revoke Consent */}
<Pressable
onPress={onRevokeConsent}
style={({ pressed }) => ({
opacity: pressed ? 0.7 : 1,
marginTop: 4,
paddingHorizontal: 14,
paddingVertical: 12,
borderTopWidth: 1,
borderTopColor: 'rgba(0,0,0,0.06)',
})}
>
<Text
style={{
fontSize: 12,
color: colors.error,
fontFamily: 'Nunito_600SemiBold',
textAlign: 'center',
}}
>
Einwilligung widerrufen
</Text>
</Pressable>
</View>
) : null}
<SelectSheet
visible={pickerField === 'gender'}
title="Geschlecht"
options={GENDER_OPTIONS}
selectedValue={local.gender}
onClose={() => setPickerField(null)}
onSelect={(v) => {
flushSave({ ...local, gender: v });
setPickerField(null);
}}
/>
<SelectSheet
visible={pickerField === 'maritalStatus'}
title="Familienstand"
options={MARITAL_OPTIONS}
selectedValue={local.maritalStatus}
onClose={() => setPickerField(null)}
onSelect={(v) => {
flushSave({ ...local, maritalStatus: v });
setPickerField(null);
}}
/>
<SelectSheet
visible={pickerField === 'bundesland'}
title="Bundesland"
options={BUNDESLAND_OPTIONS}
selectedValue={local.bundesland}
onClose={() => setPickerField(null)}
onSelect={(v) => {
flushSave({ ...local, bundesland: v });
setPickerField(null);
}}
/>
</View>
);
}
const inputStyle = {
fontSize: 14,
color: colors.text,
fontFamily: 'Nunito_600SemiBold',
paddingVertical: 8,
paddingHorizontal: 10,
backgroundColor: '#fafafa',
borderRadius: 8,
borderWidth: 1,
borderColor: '#ececec',
minWidth: 140,
textAlign: 'right' as const,
};
function FieldRow({
label,
why,
isLast,
children,
}: {
label: string;
why: string;
isLast?: boolean;
children: React.ReactNode;
}) {
return (
<View
style={{
paddingHorizontal: 14,
paddingVertical: 12,
borderBottomWidth: isLast ? 0 : 1,
borderBottomColor: 'rgba(0,0,0,0.06)',
}}
>
<View
style={{
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
gap: 12,
}}
>
<Text
style={{
fontSize: 13,
color: colors.text,
fontFamily: 'Nunito_600SemiBold',
}}
>
{label}
</Text>
<View style={{ flexShrink: 1 }}>{children}</View>
</View>
<Text
style={{
marginTop: 6,
fontSize: 11,
color: colors.textMuted,
fontFamily: 'Nunito_400Regular',
lineHeight: 15,
}}
>
{why}
</Text>
</View>
);
}
function SelectButton({ value, onPress }: { value: string | null; onPress: () => void }) {
return (
<Pressable
onPress={onPress}
style={({ pressed }) => ({
opacity: pressed ? 0.6 : 1,
flexDirection: 'row',
alignItems: 'center',
gap: 6,
paddingVertical: 8,
paddingHorizontal: 10,
backgroundColor: '#fafafa',
borderRadius: 8,
borderWidth: 1,
borderColor: '#ececec',
minWidth: 140,
justifyContent: 'flex-end',
})}
>
<Text
style={{
fontSize: 14,
color: value ? colors.text : colors.textMuted,
fontFamily: value ? 'Nunito_600SemiBold' : 'Nunito_400Regular',
textAlign: 'right',
}}
>
{value ?? 'auswählen'}
</Text>
<Ionicons name="chevron-down" size={14} color={colors.textMuted} />
</Pressable>
);
}
function SelectSheet({
visible,
title,
options,
selectedValue,
onClose,
onSelect,
}: {
visible: boolean;
title: string;
options: Array<{ label: string; value: string }>;
selectedValue: string | null;
onClose: () => void;
onSelect: (v: string) => void;
}) {
const sortedOptions = useMemo(() => options, [options]);
return (
<Modal
visible={visible}
transparent
animationType="slide"
onRequestClose={onClose}
>
<Pressable
onPress={onClose}
style={{
flex: 1,
backgroundColor: 'rgba(0,0,0,0.4)',
justifyContent: 'flex-end',
}}
>
<Pressable
onPress={() => {
/* swallow */
}}
style={{
backgroundColor: '#ffffff',
borderTopLeftRadius: 18,
borderTopRightRadius: 18,
paddingHorizontal: 8,
paddingTop: 12,
paddingBottom: 24,
maxHeight: '70%',
}}
>
<View
style={{
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
paddingHorizontal: 12,
paddingBottom: 8,
}}
>
<Text
style={{
fontSize: 15,
color: colors.text,
fontFamily: 'Nunito_700Bold',
}}
>
{title}
</Text>
<Pressable onPress={onClose} hitSlop={10}>
<Ionicons name="close" size={22} color={colors.textMuted} />
</Pressable>
</View>
<ScrollView style={{ maxHeight: 380 }}>
{sortedOptions.map((opt) => {
const isSelected = opt.value === selectedValue;
return (
<Pressable
key={opt.value}
onPress={() => onSelect(opt.value)}
style={({ pressed }) => ({
opacity: pressed ? 0.6 : 1,
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
paddingHorizontal: 14,
paddingVertical: 14,
borderRadius: 10,
backgroundColor: isSelected ? '#f5f8ff' : 'transparent',
})}
>
<Text
style={{
fontSize: 14,
color: colors.text,
fontFamily: isSelected ? 'Nunito_700Bold' : 'Nunito_400Regular',
}}
>
{opt.label}
</Text>
{isSelected ? (
<Ionicons name="checkmark" size={18} color={colors.brandOrange} />
) : null}
</Pressable>
);
})}
</ScrollView>
</Pressable>
</Pressable>
</Modal>
);
}

View File

@ -0,0 +1,113 @@
import { View, Text, Pressable } from 'react-native';
import { Ionicons } from '@expo/vector-icons';
import { colors } from '../../lib/theme';
type Props = {
onDismiss?: () => void;
onContribute?: () => void;
};
export function DigaMissionBanner({ onDismiss, onContribute }: Props) {
return (
<View
style={{
marginHorizontal: 16,
marginTop: 16,
backgroundColor: '#fffbeb',
borderWidth: 1,
borderColor: '#fde68a',
borderRadius: 14,
padding: 16,
}}
>
<View style={{ flexDirection: 'row', alignItems: 'flex-start', gap: 10 }}>
<View
style={{
width: 28,
height: 28,
borderRadius: 14,
backgroundColor: '#fef3c7',
alignItems: 'center',
justifyContent: 'center',
marginTop: 2,
}}
>
<Ionicons name="medkit-outline" size={14} color="#854d0e" />
</View>
<View style={{ flex: 1 }}>
<Text
style={{
fontSize: 13,
color: '#854d0e',
fontFamily: 'Nunito_700Bold',
}}
>
30 Tage geschützt danke
</Text>
<Text
style={{
marginTop: 4,
fontSize: 12,
color: '#92400e',
fontFamily: 'Nunito_400Regular',
lineHeight: 17,
}}
>
Rebreak strebt die Anerkennung als DiGA an. Mit ein paar anonymen
Angaben hilfst du, die Wirksamkeit zu belegen damit Krankenkassen
die App künftig erstatten können.
</Text>
<View style={{ flexDirection: 'row', gap: 8, marginTop: 12 }}>
<Pressable
onPress={onContribute}
style={({ pressed }) => ({
opacity: pressed ? 0.7 : 1,
paddingHorizontal: 12,
paddingVertical: 7,
backgroundColor: '#854d0e',
borderRadius: 8,
})}
>
<Text
style={{
fontSize: 12,
color: '#ffffff',
fontFamily: 'Nunito_600SemiBold',
}}
>
Beitragen
</Text>
</Pressable>
<Pressable
onPress={onDismiss}
style={({ pressed }) => ({
opacity: pressed ? 0.7 : 1,
paddingHorizontal: 12,
paddingVertical: 7,
borderRadius: 8,
})}
>
<Text
style={{
fontSize: 12,
color: '#92400e',
fontFamily: 'Nunito_600SemiBold',
}}
>
Später
</Text>
</Pressable>
</View>
</View>
<Pressable
onPress={onDismiss}
hitSlop={8}
style={({ pressed }) => ({ opacity: pressed ? 0.5 : 1 })}
>
<Ionicons name="close" size={16} color={colors.textMuted} />
</Pressable>
</View>
</View>
);
}

View File

@ -0,0 +1,241 @@
import { useState } from 'react';
import { View, Text, Pressable, Image } from 'react-native';
import { Ionicons } from '@expo/vector-icons';
import { colors } from '../../lib/theme';
import { resolveAvatar } from '../../lib/resolveAvatar';
import type { Plan } from '../../hooks/useUserPlan';
export type AuthProvider = 'apple' | 'google' | 'email';
type Props = {
nickname: string;
email: string;
avatar: string | null;
plan: Plan;
memberSince: string;
provider: AuthProvider;
showDemographicsHint?: boolean;
onEditAvatar?: () => void;
onEditNickname?: () => void;
onDemographicsHintPress?: () => void;
};
const planLabel: Record<Plan, string> = {
free: 'Free',
pro: 'Pro',
legend: 'Legend',
};
const planColors: Record<Plan, { bg: string; text: string; border: string }> = {
free: { bg: '#f5f5f5', text: '#525252', border: '#e5e5e5' },
pro: { bg: '#fff7ed', text: '#c2410c', border: '#fed7aa' },
legend: { bg: '#fef9c3', text: '#854d0e', border: '#fde68a' },
};
export function ProfileHeader({
nickname,
email,
avatar,
plan,
memberSince,
provider,
showDemographicsHint,
onEditAvatar,
onEditNickname,
onDemographicsHintPress,
}: Props) {
const [imageFailed, setImageFailed] = useState(false);
const avatarUrl = resolveAvatar(avatar, nickname);
const initials = nickname.slice(0, 2).toUpperCase();
const showImage = !!avatar && !imageFailed;
const planStyle = planColors[plan];
const providerPillLabel =
provider === 'apple' ? 'via Apple Sign-In'
: provider === 'google' ? 'via Google Sign-In'
: null;
return (
<View style={{ alignItems: 'center', paddingVertical: 24, paddingHorizontal: 20 }}>
{/* Avatar — Pressable; Camera-Badge ist eigene Pressable (vorher nur dekoratives View) */}
<Pressable
onPress={onEditAvatar}
style={({ pressed }) => ({
position: 'relative',
opacity: pressed ? 0.85 : 1,
})}
>
<View
style={{
width: 96,
height: 96,
borderRadius: 48,
borderWidth: 2,
borderColor: planStyle.border,
alignItems: 'center',
justifyContent: 'center',
overflow: 'hidden',
backgroundColor: showImage ? '#fafafa' : colors.brandOrange,
}}
>
{showImage ? (
<Image
source={{ uri: avatarUrl }}
onError={() => setImageFailed(true)}
style={{ width: 92, height: 92, borderRadius: 46 }}
/>
) : (
<Text style={{ color: '#fff', fontSize: 32, fontFamily: 'Nunito_700Bold' }}>
{initials}
</Text>
)}
</View>
{/* Camera-Badge — iOS-Photos-Pattern: blauer Kreis, weißes Icon */}
<View
pointerEvents="none"
style={{
position: 'absolute',
right: -2,
bottom: -2,
width: 30,
height: 30,
borderRadius: 15,
backgroundColor: '#0a0a0a',
borderWidth: 2,
borderColor: '#ffffff',
alignItems: 'center',
justifyContent: 'center',
}}
>
<Ionicons name="camera" size={14} color="#ffffff" />
</View>
</Pressable>
{/* Nickname — ganze Zeile Pressable (iOS-Settings-Pattern), kein hässliches Pencil */}
<Pressable
onPress={onEditNickname}
hitSlop={8}
style={({ pressed }) => ({
flexDirection: 'row',
alignItems: 'center',
marginTop: 16,
gap: 6,
opacity: pressed ? 0.5 : 1,
})}
>
<Text
style={{
fontSize: 22,
color: colors.text,
fontFamily: 'Nunito_700Bold',
}}
>
{nickname}
</Text>
<Ionicons name="chevron-forward" size={16} color={colors.textMuted} />
</Pressable>
{providerPillLabel ? (
<View
style={{
marginTop: 4,
flexDirection: 'row',
alignItems: 'center',
gap: 6,
paddingHorizontal: 10,
paddingVertical: 4,
borderRadius: 999,
backgroundColor: '#f5f5f5',
borderWidth: 1,
borderColor: '#e5e5e5',
}}
>
<Ionicons
name={provider === 'apple' ? 'logo-apple' : 'logo-google'}
size={11}
color={colors.textMuted}
/>
<Text style={{ fontSize: 11, color: colors.textMuted, fontFamily: 'Nunito_600SemiBold' }}>
{providerPillLabel}
</Text>
</View>
) : (
<Text
style={{
marginTop: 4,
fontSize: 12,
color: colors.textMuted,
fontFamily: 'Nunito_400Regular',
}}
>
{email}
</Text>
)}
<View style={{ flexDirection: 'row', alignItems: 'center', gap: 10, marginTop: 12 }}>
<View
style={{
paddingHorizontal: 10,
paddingVertical: 3,
borderRadius: 999,
backgroundColor: planStyle.bg,
borderWidth: 1,
borderColor: planStyle.border,
}}
>
<Text
style={{
fontSize: 11,
color: planStyle.text,
fontFamily: 'Nunito_700Bold',
letterSpacing: 0.4,
}}
>
{planLabel[plan].toUpperCase()}
</Text>
</View>
<Text style={{ fontSize: 12, color: colors.textMuted, fontFamily: 'Nunito_400Regular' }}>
Mitglied seit {memberSince}
</Text>
</View>
{/* Freundlicher Hint statt Progress-Bar — nur sichtbar wenn Demographics unvollständig */}
{showDemographicsHint ? (
<Pressable
onPress={onDemographicsHintPress}
hitSlop={6}
style={({ pressed }) => ({
alignSelf: 'stretch',
marginTop: 14,
paddingHorizontal: 14,
paddingVertical: 10,
borderRadius: 12,
backgroundColor: '#f5f8ff',
borderWidth: 1,
borderColor: '#dbe5ff',
flexDirection: 'row',
alignItems: 'center',
gap: 10,
opacity: pressed ? 0.7 : 1,
})}
>
<Ionicons name="heart-outline" size={16} color={colors.brandOrange} />
<Text
style={{
flex: 1,
fontSize: 12,
color: colors.text,
fontFamily: 'Nunito_600SemiBold',
lineHeight: 17,
}}
>
Hilf uns rebreak besser zu machen fülle deine anonymen Daten aus.
</Text>
<Ionicons name="chevron-forward" size={14} color={colors.textMuted} />
</Pressable>
) : null}
</View>
);
}

View File

@ -0,0 +1,116 @@
import { View, Text, Pressable } from 'react-native';
import { Ionicons } from '@expo/vector-icons';
import { colors } from '../../lib/theme';
type Props = {
postsCount: number;
followersCount: number;
approvedDomainsCount: number;
onPostsPress?: () => void;
onFollowersPress?: () => void;
onApprovedDomainsPress?: () => void;
};
type CardProps = {
value: string;
label: string;
icon?: React.ComponentProps<typeof Ionicons>['name'];
onPress?: () => void;
};
function StatCard({ value, label, icon, onPress }: CardProps) {
return (
<Pressable
onPress={onPress}
style={({ pressed }) => ({
flex: 1,
opacity: pressed ? 0.6 : 1,
alignItems: 'center',
justifyContent: 'center',
paddingVertical: 16,
paddingHorizontal: 8,
})}
>
<View style={{ flexDirection: 'row', alignItems: 'center', gap: 6 }}>
<Text
style={{
fontSize: 30,
color: colors.text,
fontFamily: 'Nunito_700Bold',
letterSpacing: -0.5,
}}
>
{value}
</Text>
{icon ? (
<Ionicons name={icon} size={16} color={colors.textMuted} style={{ marginTop: 4 }} />
) : null}
</View>
<Text
style={{
marginTop: 4,
fontSize: 12,
color: colors.textMuted,
fontFamily: 'Nunito_600SemiBold',
textAlign: 'center',
}}
>
{label}
</Text>
</Pressable>
);
}
function Divider() {
return (
<View
style={{
width: 1,
backgroundColor: 'rgba(0,0,0,0.08)',
marginVertical: 14,
}}
/>
);
}
/**
* Community-Stats: 3 prominente Cards in einer zentrierten Reihe.
* - Posts / Follower / Approved Domains
* - Approved Domains: PLAIN INTEGER (kein Cap), Trophy-Icon als Community-Beitrag-Hint
*/
export function StatsBar({
postsCount,
followersCount,
approvedDomainsCount,
onPostsPress,
onFollowersPress,
onApprovedDomainsPress,
}: Props) {
return (
<View style={{ paddingHorizontal: 16, alignItems: 'center' }}>
<View
style={{
flexDirection: 'row',
alignItems: 'stretch',
backgroundColor: '#fafafa',
borderRadius: 18,
borderWidth: 1,
borderColor: '#ececec',
width: '100%',
maxWidth: 420,
}}
>
<StatCard value={String(postsCount)} label="Posts" onPress={onPostsPress} />
<Divider />
<StatCard value={String(followersCount)} label="Follower" onPress={onFollowersPress} />
<Divider />
<StatCard
value={String(approvedDomainsCount)}
label="Approved Domains"
icon="trophy-outline"
onPress={onApprovedDomainsPress}
/>
</View>
</View>
);
}

View File

@ -0,0 +1,232 @@
import { View, Text } from 'react-native';
import { Ionicons } from '@expo/vector-icons';
import { colors } from '../../lib/theme';
export type CooldownEntry = {
id: string;
startedAt: string;
durationLabel: string;
status: 'active' | 'resolved' | 'cancelled';
reason: string | null;
};
type Props = {
currentDays: number;
longestDays: number;
startDate: string;
cooldowns: CooldownEntry[];
};
const statusLabel: Record<CooldownEntry['status'], string> = {
active: 'aktiv',
resolved: 'beendet',
cancelled: 'abgebrochen',
};
const statusColor: Record<CooldownEntry['status'], { bg: string; text: string }> = {
active: { bg: '#fff7ed', text: '#c2410c' },
resolved: { bg: '#f0fdf4', text: '#15803d' },
cancelled: { bg: '#f5f5f5', text: '#737373' },
};
export function StreakSection({ currentDays, longestDays, startDate, cooldowns }: Props) {
return (
<View style={{ marginHorizontal: 16, marginTop: 24 }}>
<View
style={{
flexDirection: 'row',
alignItems: 'center',
gap: 8,
marginBottom: 10,
}}
>
<Ionicons name="flame-outline" size={14} color={colors.textMuted} />
<Text
style={{
fontSize: 11,
color: colors.textMuted,
fontFamily: 'Nunito_700Bold',
letterSpacing: 0.8,
}}
>
STREAK
</Text>
</View>
<View
style={{
backgroundColor: '#ffffff',
borderWidth: 1,
borderColor: '#e5e5e5',
borderRadius: 14,
padding: 16,
}}
>
<View style={{ flexDirection: 'row', alignItems: 'baseline', gap: 8 }}>
<Text
style={{
fontSize: 36,
color: colors.text,
fontFamily: 'Nunito_800ExtraBold',
}}
>
{currentDays}
</Text>
<Text
style={{
fontSize: 14,
color: colors.text,
fontFamily: 'Nunito_600SemiBold',
}}
>
Tage geschützt
</Text>
</View>
<Text
style={{
marginTop: 2,
fontSize: 12,
color: colors.textMuted,
fontFamily: 'Nunito_400Regular',
}}
>
seit {startDate}
</Text>
<Text
style={{
marginTop: 8,
fontSize: 12,
color: colors.textMuted,
fontFamily: 'Nunito_400Regular',
}}
>
Längste Streak: {longestDays} Tage
</Text>
</View>
{cooldowns.length > 0 ? (
<View style={{ marginTop: 16 }}>
<Text
style={{
fontSize: 11,
color: colors.textMuted,
fontFamily: 'Nunito_700Bold',
letterSpacing: 0.8,
marginBottom: 8,
paddingHorizontal: 2,
}}
>
COOLDOWN-VERLAUF
</Text>
<View
style={{
backgroundColor: '#ffffff',
borderWidth: 1,
borderColor: '#e5e5e5',
borderRadius: 14,
padding: 14,
}}
>
{cooldowns.map((c, idx) => {
const isLast = idx === cooldowns.length - 1;
const colorPair = statusColor[c.status];
return (
<View key={c.id} style={{ flexDirection: 'row' }}>
<View style={{ width: 16, alignItems: 'center' }}>
<View
style={{
width: 8,
height: 8,
borderRadius: 4,
backgroundColor:
c.status === 'active'
? colors.brandOrange
: c.status === 'resolved'
? '#15803d'
: '#a3a3a3',
marginTop: 4,
}}
/>
{!isLast ? (
<View
style={{
width: 1,
flex: 1,
backgroundColor: 'rgba(0,0,0,0.06)',
marginTop: 2,
}}
/>
) : null}
</View>
<View style={{ flex: 1, paddingLeft: 12, paddingBottom: isLast ? 0 : 14 }}>
<View
style={{
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
}}
>
<View style={{ flexDirection: 'row', alignItems: 'center', gap: 8 }}>
<Text
style={{
fontSize: 13,
color: colors.text,
fontFamily: 'Nunito_600SemiBold',
}}
>
{c.startedAt}
</Text>
<Text
style={{
fontSize: 12,
color: colors.textMuted,
fontFamily: 'Nunito_400Regular',
}}
>
{c.durationLabel}
</Text>
</View>
<View
style={{
paddingHorizontal: 8,
paddingVertical: 2,
borderRadius: 999,
backgroundColor: colorPair.bg,
}}
>
<Text
style={{
fontSize: 10,
color: colorPair.text,
fontFamily: 'Nunito_700Bold',
letterSpacing: 0.4,
}}
>
{statusLabel[c.status].toUpperCase()}
</Text>
</View>
</View>
{c.reason ? (
<Text
style={{
marginTop: 2,
fontSize: 12,
color: colors.textMuted,
fontFamily: 'Nunito_400Regular',
}}
>
{c.reason}
</Text>
) : null}
</View>
</View>
);
})}
</View>
</View>
) : null}
</View>
);
}

View File

@ -0,0 +1,209 @@
import { View, Text } from 'react-native';
import { Ionicons } from '@expo/vector-icons';
import { colors } from '../../lib/theme';
export type HelpedByEntry = {
key: 'breathing' | 'game' | 'talk' | 'other';
label: string;
count: number;
};
type Props = {
sessions: number;
overcome: number;
helpedBy: HelpedByEntry[];
topEmotion: string | null;
};
export function UrgeStatsCard({ sessions, overcome, helpedBy, topEmotion }: Props) {
const overcomePct = sessions > 0 ? Math.round((overcome / sessions) * 100) : 0;
const totalHelped = helpedBy.reduce((sum, h) => sum + h.count, 0);
return (
<View style={{ marginHorizontal: 16, marginTop: 24 }}>
<View
style={{
flexDirection: 'row',
alignItems: 'center',
gap: 8,
marginBottom: 10,
}}
>
<Ionicons name="sparkles-outline" size={14} color={colors.textMuted} />
<Text
style={{
fontSize: 11,
color: colors.textMuted,
fontFamily: 'Nunito_700Bold',
letterSpacing: 0.8,
}}
>
LYRA INSIGHTS
</Text>
</View>
<View
style={{
backgroundColor: '#ffffff',
borderWidth: 1,
borderColor: '#e5e5e5',
borderRadius: 14,
padding: 16,
}}
>
<Text
style={{
fontSize: 12,
color: colors.textMuted,
fontFamily: 'Nunito_400Regular',
}}
>
Letzte 30 Tage
</Text>
{sessions === 0 ? (
<Text
style={{
marginTop: 8,
fontSize: 13,
color: colors.text,
fontFamily: 'Nunito_600SemiBold',
}}
>
Noch keine SOS-Session. Lyra ist da, wenn du sie brauchst.
</Text>
) : (
<>
<View style={{ marginTop: 6, flexDirection: 'row', alignItems: 'baseline', gap: 6 }}>
<Text
style={{
fontSize: 22,
color: colors.text,
fontFamily: 'Nunito_700Bold',
}}
>
{sessions}
</Text>
<Text
style={{
fontSize: 13,
color: colors.text,
fontFamily: 'Nunito_600SemiBold',
}}
>
SOS-Sessions, {overcome} bewältigt
</Text>
</View>
<View
style={{
marginTop: 8,
height: 6,
backgroundColor: '#f5f5f5',
borderRadius: 999,
overflow: 'hidden',
}}
>
<View
style={{
height: '100%',
width: `${overcomePct}%`,
backgroundColor: colors.success,
borderRadius: 999,
}}
/>
</View>
<Text
style={{
marginTop: 4,
fontSize: 11,
color: colors.textMuted,
fontFamily: 'Nunito_400Regular',
}}
>
{overcomePct}% bewältigt
</Text>
{totalHelped > 0 ? (
<View style={{ marginTop: 16 }}>
<Text
style={{
fontSize: 12,
color: colors.text,
fontFamily: 'Nunito_600SemiBold',
marginBottom: 8,
}}
>
Was hat geholfen
</Text>
{helpedBy.map((h) => {
const pct = totalHelped > 0 ? (h.count / totalHelped) * 100 : 0;
return (
<View key={h.key} style={{ marginBottom: 8 }}>
<View
style={{
flexDirection: 'row',
justifyContent: 'space-between',
marginBottom: 3,
}}
>
<Text
style={{
fontSize: 12,
color: colors.text,
fontFamily: 'Nunito_600SemiBold',
}}
>
{h.label}
</Text>
<Text
style={{
fontSize: 11,
color: colors.textMuted,
fontFamily: 'Nunito_400Regular',
}}
>
{h.count} {h.count === 1 ? 'Session' : 'Sessions'}
</Text>
</View>
<View
style={{
height: 4,
backgroundColor: '#f5f5f5',
borderRadius: 999,
overflow: 'hidden',
}}
>
<View
style={{
height: '100%',
width: `${pct}%`,
backgroundColor: colors.brandOrange,
borderRadius: 999,
}}
/>
</View>
</View>
);
})}
</View>
) : null}
{topEmotion ? (
<Text
style={{
marginTop: 8,
fontSize: 12,
color: colors.textMuted,
fontFamily: 'Nunito_400Regular',
}}
>
Häufigste Emotion: {topEmotion}
</Text>
) : null}
</>
)}
</View>
</View>
);
}

View File

@ -121,13 +121,16 @@ export function SnakeGame({
{ row: 10, col: 7 }, { row: 10, col: 6 }, { row: 10, col: 5 },
]);
const [food, setFood] = useState<Pos>({ row: 3, col: 10 });
const snakeRef = useRef<Pos[]>(snake);
const foodRef = useRef<Pos>(food);
useEffect(() => { snakeRef.current = snake; }, [snake]);
useEffect(() => { foodRef.current = food; }, [food]);
const dirRef = useRef<Dir>('right');
const nextDirRef = useRef<Dir>('right');
const [score, setScore] = useState(0);
const [highScore, setHighScore] = useState(0);
const [gameOver, setGameOver] = useState(false);
const [activeDPad, setActiveDPad] = useState<Dir>('right');
const [, forceRender] = useState(0);
const intervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
// Load high score
@ -164,43 +167,47 @@ export function SnakeGame({
setTimeout(() => onComplete(finalScore), 500);
}
// Game tick loop
// Game tick loop — single setInterval, side-effects driven via refs (NOT inside reducers).
// Reading snake/food from refs avoids stale closures and prevents duplicate setFood calls
// when React (StrictMode) invokes a state-updater twice.
useEffect(() => {
if (gameOver) return;
intervalRef.current = setInterval(() => {
dirRef.current = nextDirRef.current;
setSnake((prev) => {
const prev = snakeRef.current;
const head = prev[0];
if (!head) return prev;
if (!head) return;
const next: Pos = { row: head.row, col: head.col };
if (dirRef.current === 'up') next.row--;
else if (dirRef.current === 'down') next.row++;
else if (dirRef.current === 'left') next.col--;
else if (dirRef.current === 'right') next.col++;
if (next.row < 0 || next.row >= SNAKE_ROWS || next.col < 0 || next.col >= SNAKE_COLS) {
setTimeout(() => endGame(score), 0);
return prev;
endGame(score);
return;
}
if (prev.some((s) => s.row === next.row && s.col === next.col)) {
setTimeout(() => endGame(score), 0);
return prev;
endGame(score);
return;
}
const ate = next.row === food.row && next.col === food.col;
const currentFood = foodRef.current;
const ate = next.row === currentFood.row && next.col === currentFood.col;
const newSnake = [next, ...prev];
if (!ate) newSnake.pop();
else {
snakeRef.current = newSnake;
setSnake(newSnake);
if (ate) {
const newFood = randomFood(newSnake);
foodRef.current = newFood;
setFood(newFood);
setScore((s) => s + 1);
setFood(randomFood(newSnake));
}
return newSnake;
});
forceRender((x) => x + 1);
}, SNAKE_TICK_MS);
return () => {
if (intervalRef.current) clearInterval(intervalRef.current);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [gameOver, food, score, highScore]);
}, [gameOver, score, highScore]);
// Swipe gestures
const panResponder = useMemo(
@ -269,9 +276,9 @@ export function SnakeGame({
: null;
return (
<View style={{ paddingHorizontal: 8, paddingBottom: Math.max(insets.bottom, 16) }}>
<View style={{ paddingHorizontal: 16, paddingTop: 4, paddingBottom: Math.max(insets.bottom, 16) }}>
{/* Header */}
<View style={{ flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', paddingHorizontal: 4, marginBottom: 8 }}>
<View style={{ flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', marginBottom: 12 }}>
<Text style={{ fontSize: 11, color: '#6b7280', flex: 1, marginRight: 8 }} numberOfLines={2}>{lyraMessage}</Text>
<View style={{ flexDirection: 'row', alignItems: 'center', gap: 14 }}>
<View style={{ alignItems: 'center' }}>
@ -322,8 +329,8 @@ export function SnakeGame({
<DPadBtn dir="up" active={activeDPad === 'up'} onPress={() => onDPad('up')} />
<View style={{ flexDirection: 'row', gap: 14, alignItems: 'center' }}>
<DPadBtn dir="left" active={activeDPad === 'left'} onPress={() => onDPad('left')} />
<View style={{ width: 64, height: 64, borderRadius: 32, backgroundColor: '#f3f4f6', alignItems: 'center', justifyContent: 'center' }}>
<View style={{ width: 14, height: 14, borderRadius: 7, backgroundColor: '#d1d5db' }} />
<View style={{ width: 60, height: 60, borderRadius: 30, backgroundColor: 'transparent', alignItems: 'center', justifyContent: 'center' }}>
<View style={{ width: 8, height: 8, borderRadius: 4, backgroundColor: '#d1d5db' }} />
</View>
<DPadBtn dir="right" active={activeDPad === 'right'} onPress={() => onDPad('right')} />
</View>
@ -341,40 +348,44 @@ export function SnakeGame({
);
}
// Platform-native D-Pad button: iOS uses system-blue tinted circle (SF-symbol look),
// Android uses Material elevated surface with ripple.
function DPadBtn({ dir, active, onPress }: { dir: Dir; active: boolean; onPress: () => void }) {
const icons: Record<Dir, 'chevron-up' | 'chevron-down' | 'chevron-back' | 'chevron-forward'> = {
up: 'chevron-up', down: 'chevron-down', left: 'chevron-back', right: 'chevron-forward',
};
// FIX 1 (prev agent): icon color follows pressed-OR-active so it stays visible against dark pressed-bg.
// FIX 2 (this agent): idle button was #ffffff on a #ffffff screen → invisible. Idle is now light-gray
// with stronger border, pressed becomes mid-gray, active stays dark. Guarantees ≥ 3:1 contrast in all states.
const isHighlighted = active;
const isIOS = Platform.OS === 'ios';
const tint = '#007aff';
return (
<Pressable
onPress={() => { tapHaptic(); onPress(); }}
hitSlop={12}
android_ripple={{ color: 'rgba(31,41,55,0.18)', borderless: true, radius: 36 }}
style={({ pressed }) => ({
width: 64, height: 64, borderRadius: 32,
backgroundColor: isHighlighted ? '#1f2937' : (pressed ? '#d1d5db' : '#f3f4f6'),
borderWidth: 1.5,
borderColor: isHighlighted ? '#1f2937' : (pressed ? '#6b7280' : '#9ca3af'),
android_ripple={{ color: 'rgba(0,122,255,0.22)', borderless: true, radius: 32 }}
style={({ pressed }) => {
const bgIdle = isIOS ? 'rgba(0,122,255,0.10)' : '#ffffff';
const bgPressed = isIOS ? 'rgba(0,122,255,0.22)' : '#f5f5f5';
const bgActive = tint;
const bg = active ? bgActive : (pressed && isIOS ? bgPressed : bgIdle);
return {
width: 60, height: 60, borderRadius: 30,
backgroundColor: bg,
alignItems: 'center', justifyContent: 'center',
...(isIOS ? {} : {
elevation: active ? 4 : 2,
shadowColor: '#000',
shadowOffset: { width: 0, height: 2 },
shadowOpacity: pressed ? 0.06 : 0.12,
shadowRadius: 4,
elevation: pressed ? 1 : 3,
transform: [{ scale: pressed ? 0.94 : 1 }],
})}
shadowOffset: { width: 0, height: 1 },
shadowOpacity: 0.15,
shadowRadius: 2,
}),
transform: [{ scale: pressed && isIOS ? 0.96 : 1 }],
};
}}
>
{({ pressed }) => (
<Ionicons
name={icons[dir]}
size={30}
color={isHighlighted ? '#ffffff' : (pressed ? '#111827' : '#1f2937')}
size={28}
color={active ? '#ffffff' : tint}
/>
)}
</Pressable>
);
}
@ -952,9 +963,9 @@ export function TetrisGame({
const speedColors = ['#22c55e', '#84cc16', '#eab308', '#f97316', '#ef4444'];
return (
<View style={{ paddingHorizontal: 8, paddingBottom: Math.max(insets.bottom, 16) }}>
<View style={{ paddingHorizontal: 16, paddingTop: 4, paddingBottom: Math.max(insets.bottom, 16) }}>
{/* Header */}
<View style={{ flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', paddingHorizontal: 4, marginBottom: 8 }}>
<View style={{ flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', marginBottom: 12 }}>
<Text style={{ fontSize: 11, color: '#6b7280', flex: 1, marginRight: 8 }} numberOfLines={2}>{lyraMessage}</Text>
<View style={{ flexDirection: 'row', alignItems: 'center', gap: 10 }}>
<Stat label="Score" value={score} color="#111827" />
@ -966,7 +977,7 @@ export function TetrisGame({
</View>
{/* Board */}
<View style={{ alignItems: 'center' }}>
<View style={{ alignItems: 'center', marginVertical: 4 }}>
<View style={{ width: TETRIS_COLS * CELL, height: TETRIS_ROWS * CELL, borderRadius: 12, overflow: 'hidden', backgroundColor: '#0d1117', borderWidth: 1, borderColor: '#1f2937' }}>
{displayBoard.map((row, y) => (
<View key={y} style={{ flexDirection: 'row', height: CELL }}>
@ -993,7 +1004,7 @@ export function TetrisGame({
</View>
{/* Speed — native rendered slider (UISlider on iOS, SeekBar on Android) */}
<View style={{ marginTop: 14, paddingHorizontal: 16 }}>
<View style={{ marginTop: 14 }}>
<View style={{ flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', marginBottom: 4 }}>
<View style={{ flexDirection: 'row', alignItems: 'center', gap: 6 }}>
<Ionicons name="flash" size={14} color={speedColors[speedLevel - 1]} />
@ -1023,7 +1034,7 @@ export function TetrisGame({
</View>
{/* Controls — Move Pad (links) + Action Pad (rechts) */}
<View style={{ flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', paddingHorizontal: 16, marginTop: 18 }}>
<View style={{ flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', marginTop: 18 }}>
{/* Move Pad */}
<View style={{ flexDirection: 'row', gap: 14 }}>
<DPadBtn dir="left" active={false} onPress={moveLeft} />

View File

@ -44,13 +44,15 @@ type MarkerEntry = {
export class BenchSession {
readonly t0: number;
readonly provider: string;
readonly llm: string;
readonly label: string;
private entries: MarkerEntry[] = [];
private printed = false;
constructor(opts: { provider: string; label?: string }) {
constructor(opts: { provider: string; llm?: string; label?: string }) {
this.t0 = Date.now();
this.provider = opts.provider;
this.llm = opts.llm ?? 'unknown';
this.label = opts.label ?? 'sos-turn';
}
@ -73,7 +75,8 @@ export class BenchSession {
};
const stages = {
provider: this.provider,
tts: this.provider,
llm: this.llm,
label: this.label,
'req→session': fmt(get('session-post-done')),
'lyra-ttfb': fmt(get('sse-first-chunk')),
@ -91,7 +94,7 @@ export class BenchSession {
// console.table mit allen Markern (für strukturierte Inspektion).
// eslint-disable-next-line no-console
console.log(
`[bench] ${this.provider} (${this.label})${extraNote ? ' ' + extraNote : ''}`,
`[bench] LLM=${this.llm} TTS=${this.provider} (${this.label})${extraNote ? ' ' + extraNote : ''}`,
stages,
);
// eslint-disable-next-line no-console
@ -99,7 +102,7 @@ export class BenchSession {
}
/** Snapshot für UI-Overlays (Debug-Drawer etc.). */
snapshot(): { provider: string; label: string; entries: MarkerEntry[] } {
return { provider: this.provider, label: this.label, entries: [...this.entries] };
snapshot(): { provider: string; llm: string; label: string; entries: MarkerEntry[] } {
return { provider: this.provider, llm: this.llm, label: this.label, entries: [...this.entries] };
}
}

View File

@ -15,7 +15,7 @@ import { Platform, type ImageSourcePropType } from 'react-native';
export type TabKey = 'home' | 'chat' | 'coach' | 'blocker' | 'mail';
const ANDROID_ICONS: Record<TabKey, ImageSourcePropType> = {
const ANDROID_ICONS: Partial<Record<TabKey, ImageSourcePropType>> = {
home: require('../assets/tabs/home.png'),
chat: require('../assets/tabs/chatbubble.png'),
coach: require('../assets/tabs/sparkles.png'),

View File

@ -91,18 +91,34 @@
},
"appHeader": {
"appName": "ReBreak",
"sosLabel": "SOS — Atemübung",
"sosSubtitle": "Sofort-Hilfe bei Druck",
"sosLabel": "SOS",
"sosTagline": "wir sind für dich da",
"sosSubtitle": "Hier lang wenn du Hilfe brauchst",
"editProfile": "Profil bearbeiten",
"settings": "Einstellungen",
"signOut": "Abmelden"
},
"headerMenu": {
"profile": "Profil",
"settings": "Einstellungen",
"games": "ReBreak Games",
"debug": "Debug",
"logout": "Abmelden"
},
"tabs": {
"home": "Home",
"chat": "Chat",
"coach": "Coach",
"blocker": "Blocker",
"mail": "Mail"
"mail": "Mail",
"profile": "Profil"
},
"games": {
"title": "ReBreak Games",
"subtitle": "Casual spielen ohne SOS — Memory, Snake, Tetris und Tic-Tac-Toe.",
"back_to_picker": "Spiele",
"last_score": "Score: {{score}}",
"skeleton_footer": "Skeleton — Highscore-Leaderboard kommt in Phase C"
},
"home": {
"tagline": "Du gehst nicht allein.",
@ -401,15 +417,43 @@
"devices": "Geräte",
"devices_desc": "Registrierte Geräte verwalten",
"subscription": "Abonnement",
"subscription_desc": "Plan & Upgrade-Pfad",
"plan_free": "Free",
"push_notifications": "Push-Benachrichtigungen",
"streak_reminders": "Streak-Erinnerungen",
"language": "Sprache",
"language_desc": "Deutsch / Englisch",
"language_current": "Deutsch",
"upgrade_cta": "Auf Pro upgraden — 29 €/Jahr",
"delete_account": "Konto löschen",
"delete_desc": "Alle Daten werden unwiderruflich gelöscht.",
"sign_out": "Abmelden"
"sign_out": "Abmelden",
"coming_soon_title": "Coming soon",
"coming_soon_desc": "Settings werden in Phase 3 wired-up. Aktuell nur Skeleton.",
"soon_badge": "Soon",
"skeleton_footer": "Settings-Skeleton — siehe ops/UI_MIGRATION_PLAN.md",
"section_profile": "Profil",
"profile_edit": "Nickname & Avatar",
"profile_edit_desc": "Nickname, Avatar-Bild, persönliche Daten",
"profile_avatar": "Avatar wählen",
"profile_avatar_desc": "Preset-Library oder eigenes Foto",
"section_theme": "Theme & Sprache",
"theme": "Theme",
"theme_desc": "Hell / Dunkel / System",
"section_notifications": "Benachrichtigungen",
"notifications_push": "Push-Benachrichtigungen",
"notifications_push_desc": "Einzelne Kategorien an/aus",
"notifications_streak": "Streak-Erinnerungen",
"notifications_streak_desc": "Tägliche Anstöße zum Dranbleiben",
"section_devices": "Geräte & Abo",
"section_lyra": "Lyra (Legend)",
"lyra_voice": "Lyra-Stimme",
"lyra_voice_desc": "Voice-Picker — verfügbar im Legend-Plan",
"section_debug": "Debug",
"debug_llm": "LLM-Provider",
"debug_llm_desc": "Modell & Prompt-Tuning (DEV)",
"debug_tts": "TTS-Provider",
"debug_tts_desc": "Cartesia / ElevenLabs / Gemini (DEV)"
},
"urge": {
"title": "SOS — Atemübung",

View File

@ -91,18 +91,34 @@
},
"appHeader": {
"appName": "ReBreak",
"sosLabel": "SOS — Breathing exercise",
"sosSubtitle": "Instant help under pressure",
"sosLabel": "SOS",
"sosTagline": "we're here for you",
"sosSubtitle": "Tap if you need help",
"editProfile": "Edit profile",
"settings": "Settings",
"signOut": "Sign out"
},
"headerMenu": {
"profile": "Profile",
"settings": "Settings",
"games": "ReBreak Games",
"debug": "Debug",
"logout": "Sign out"
},
"tabs": {
"home": "Home",
"chat": "Chat",
"coach": "Coach",
"blocker": "Blocker",
"mail": "Mail"
"mail": "Mail",
"profile": "Profile"
},
"games": {
"title": "ReBreak Games",
"subtitle": "Casual play outside SOS — Memory, Snake, Tetris and Tic-Tac-Toe.",
"back_to_picker": "Games",
"last_score": "Score: {{score}}",
"skeleton_footer": "Skeleton — Highscore leaderboard coming in Phase C"
},
"home": {
"tagline": "You're not walking alone.",
@ -401,15 +417,43 @@
"devices": "Devices",
"devices_desc": "Manage registered devices",
"subscription": "Subscription",
"subscription_desc": "Plan & upgrade path",
"plan_free": "Free",
"push_notifications": "Push notifications",
"streak_reminders": "Streak reminders",
"language": "Language",
"language_desc": "German / English",
"language_current": "English",
"upgrade_cta": "Upgrade to Pro — €29/year",
"delete_account": "Delete account",
"delete_desc": "All data will be permanently deleted.",
"sign_out": "Sign out"
"sign_out": "Sign out",
"coming_soon_title": "Coming soon",
"coming_soon_desc": "Settings will be wired up in Phase 3. Currently skeleton only.",
"soon_badge": "Soon",
"skeleton_footer": "Settings skeleton — see ops/UI_MIGRATION_PLAN.md",
"section_profile": "Profile",
"profile_edit": "Nickname & avatar",
"profile_edit_desc": "Nickname, avatar image, personal data",
"profile_avatar": "Choose avatar",
"profile_avatar_desc": "Preset library or your own photo",
"section_theme": "Theme & language",
"theme": "Theme",
"theme_desc": "Light / Dark / System",
"section_notifications": "Notifications",
"notifications_push": "Push notifications",
"notifications_push_desc": "Toggle individual categories",
"notifications_streak": "Streak reminders",
"notifications_streak_desc": "Daily nudges to stay on track",
"section_devices": "Devices & subscription",
"section_lyra": "Lyra (Legend)",
"lyra_voice": "Lyra voice",
"lyra_voice_desc": "Voice picker — Legend-plan exclusive",
"section_debug": "Debug",
"debug_llm": "LLM provider",
"debug_llm_desc": "Model & prompt tuning (DEV)",
"debug_tts": "TTS provider",
"debug_tts_desc": "Cartesia / ElevenLabs / Gemini (DEV)"
},
"urge": {
"title": "SOS — Breathing exercise",

View File

@ -0,0 +1,259 @@
# 1v1 Games Migration Plan (Nuxt → rebreak-native)
Status: Recon abgeschlossen 2026-05-07. Read-only Analyse, kein Code-Touch.
Author scope: Migration der bestehenden Nuxt-1v1-Implementierung (TicTacToe + Memory) aus `~/mono/trucko-monorepo/apps/rebreak/` in die neue React-Native-App `~/mono/rebreak-monorepo/apps/rebreak-native/`. Letzter Schritt vor finalem Nuxt-Cutover (DiGA).
---
## 1. Status quo Nuxt-Implementierung
### 1.1 Frontend (Vue/Nuxt)
| Datei | Zweck |
|---|---|
| `~/mono/trucko-monorepo/apps/rebreak/app/pages/app/game/[challengeId].vue` (802 LOC) | Haupt-Game-Page. Lobby (Waiting), Live-Board für TicTacToe + Memory, Status, Lyra-Bubble, Tabs (History + Ranking), Rematch, Live-Share-Toggle. Subscribed Supabase-Realtime auf `rebreak.game_challenges`-Row. |
| `~/mono/trucko-monorepo/apps/rebreak/app/components/sos/GameTicTacToe.vue` | Solo-Modus mit Lyra-AI. Enthält "Gegen echten Spieler"-Button (Z. 73-76) — POST `/api/games/challenge` + Redirect. |
| `~/mono/trucko-monorepo/apps/rebreak/app/components/sos/GameMemory.vue` | Solo-Memory mit "Gegen echten Spieler"-Button (Z. 48-52) — POST `/api/games/challenge-memory` + Redirect. |
| `~/mono/trucko-monorepo/apps/rebreak/app/components/CommunityPostCard.vue` | Rendert "Challenge annehmen"-Button für Community-Posts mit `category="challenge"` (Z. 288, 469-479). |
| `~/mono/trucko-monorepo/apps/rebreak/app/stores/community.ts` | Pinia-Store, hält `challengeId` an Posts (Z. 8, 250). |
**File-Count Frontend: 5 relevante Vue-Files (1 Page + 2 Solo-Game-Components mit 1v1-Hook + 1 PostCard + 1 Store).**
### 1.2 Backend (Nuxt-Server, Nitro)
Backend liegt **nicht** in einem separaten trucko-backend-Service, sondern im selben Nuxt-Projekt unter `apps/rebreak/server/`. Endpoints:
| Endpoint | File |
|---|---|
| `POST /api/games/challenge` | `server/api/games/challenge.post.ts` (38 LOC) — TicTacToe-Challenge erzeugen + Community-Post |
| `POST /api/games/challenge-memory` | `server/api/games/challenge-memory.post.ts` (62 LOC) — Memory-Challenge erzeugen (16 Karten, shuffled) |
| `GET /api/games/challenge/[id]` | `server/api/games/challenge/[id].get.ts` (16 LOC) — Lade Challenge-State |
| `POST /api/games/challenge/[id]/accept` | `server/api/games/challenge/[id]/accept.post.ts` (35 LOC) — Gegner tritt bei, Status: OPEN → ACTIVE |
| `POST /api/games/challenge/[id]/move` | `server/api/games/challenge/[id]/move.post.ts` (109 LOC) — TicTacToe-Move; Win-Check, Score-Update, Post-Cleanup |
| `POST /api/games/challenge/[id]/memory-move` | `server/api/games/challenge/[id]/memory-move.post.ts` (152 LOC) — Memory-Move (Flip/Match/Mismatch) |
| `POST /api/games/challenge/[id]/rematch` | `server/api/games/challenge/[id]/rematch.post.ts` (64 LOC) — Neue Challenge mit Gegner pre-set, status=ACTIVE |
| `POST /api/games/challenge/[id]/live-toggle` | `server/api/games/challenge/[id]/live-toggle.post.ts` (35 LOC) — `isLive`-Flag für Spectators |
| `GET /api/games/history` | `server/api/games/history.get.ts` (44 LOC) — Spielhistorie (alle, oder vs Gegner) |
| `GET /api/games/ranking` | `server/api/games/ranking.get.ts` (15 LOC) — Top-Spieler-Liste |
**File-Count Backend: 10 Endpoints, ~570 LOC.**
### 1.3 DB-Schema
Aus `~/mono/trucko-monorepo/apps/rebreak/prisma/schema.prisma`:
- `enum GameChallengeStatus` (Z. 424): `OPEN | ACTIVE | FINISHED | CANCELLED`
- `model GameChallenge` (Z. 433-452, Tabelle `rebreak.game_challenges`): id, challengerId, challengerName, opponentId, opponentName, status, board (TEXT, default `---------`), currentTurn, winner, postId, gameType (default "tictactoe"), isLive, memoryState (Json), timestamps.
- `model GameScore` (Z. 470, Tabelle `rebreak.game_scores`): userId PK, playerName, wins, losses, draws, points (3 für Sieg, 1 für Unentschieden).
- `model GameRating` (Z. 483) und `GameHighScore` (Z. 496) — gehören zum Solo-Mode, irrelevant für 1v1, aber bereits portiert.
Migrations-SQL:
- `~/mono/trucko-monorepo/apps/rebreak/prisma/migrations/add_game_challenges.sql` — Enum, Table, Indexes, `ALTER PUBLICATION supabase_realtime ADD TABLE rebreak.game_challenges`
- `~/mono/trucko-monorepo/apps/rebreak/prisma/migrations/add_game_challenges_rls.sql` — RLS-Policies (read/insert/update für challenger + opponent via `auth.uid()`)
- Spätere Patches haben `gameType`, `isLive`, `memoryState` hinzugefügt (in den live-DB Tabelle vorhanden, kein eigenes Migration-File gefunden — Schema-Drift-Verdacht in Nuxt).
### 1.4 State-Sync-Mechanismus (1 Satz)
**Server-authoritative State in Postgres (`rebreak.game_challenges`-Row); Frontend mutiert via REST-POST und subscribed parallel auf Supabase-Realtime `postgres_changes` UPDATE-Events der eigenen Row → keine Polling, keine WebSocket-Eigenbau.**
### 1.5 Datenflussdiagramm (ASCII)
```
Spieler A (Challenger) Spieler B (Opponent)
───────────────────── ─────────────────────
│ │
POST /api/games/challenge │
│ │
▼ │
┌──────────────────────┐ │
│ game_challenges │ communityPost.challengeId │
│ status=OPEN │◀────────────────────────────────┐│
│ board=--------- │ ││
└──────────┬───────────┘ ││
│ ▼
│ GET /api/community/posts
│ (sees challenge card)
│ │
│ POST /api/games/challenge/[id]/accept
│ │
▼ ▼
┌────────────────────────────────────────────────────────────┐
│ game_challenges status=ACTIVE opponent_id=B │
└──────────────────────────┬─────────────────────────────────┘
│ Supabase Realtime (postgres_changes)
│ channel = `game:<id>:<ts>`
│ filter = id=eq.<challengeId>
┌──────────────────────────────┐
│ both clients update UI │
└──────────┬───────────────────┘
loop until FINISHED:
POST /api/games/challenge/[id]/move (or memory-move)
┌────────────────────────────────────────────────────────────┐
│ Server validates turn + writes new board / memoryState │
│ on win/draw → upsert game_scores, delete community post │
└──────────────────────────┬─────────────────────────────────┘
│ Realtime UPDATE → both clients
┌──────────────────────────────┐
│ FINISHED screen + Rematch │
└──────────────────────────────┘
```
---
## 2. Migration-Plan
### Phase A — Backend-Endpoints in rebreak-monorepo
**Status: BEREITS PORTIERT.** Verifiziert per `diff`:
- `~/mono/rebreak-monorepo/backend/server/api/games/challenge.post.ts` ist byte-identisch mit Nuxt.
- `~/mono/rebreak-monorepo/backend/server/api/games/challenge/[id]/move.post.ts` ist byte-identisch.
- Alle 10 Endpoints existieren bereits unter `~/mono/rebreak-monorepo/backend/server/api/games/`.
**Aufwand Phase A: 0 h.** Nur ein leichter Smoke-Test (curl Request mit Bearer-Token gegen den staging-Nitro) zur Bestätigung dass die Endpoints im Nitro-Prod-Build aktiv sind.
### Phase B — DB-Migrations für game_sessions
**Status: BEREITS PORTIERT.** Schema verifiziert:
- `enum GameChallengeStatus` in `~/mono/rebreak-monorepo/backend/prisma/schema.prisma` Z. 424 vorhanden.
- `model GameChallenge`, `GameScore`, `GameRating`, `GameHighScore` alle vorhanden.
**Offene Punkte (klein):**
1. SQL-Migration unter `backend/prisma/migrations/` muss verifiziert werden — sind `gameType`, `isLive`, `memoryState`-Spalten in einer eigenen Migration angelegt? Falls nein: ein konsolidiertes `add_game_challenges.sql` nachziehen.
2. RLS-Policies und `ALTER PUBLICATION supabase_realtime ADD TABLE rebreak.game_challenges` müssen am Staging-DB-Cluster bestätigt werden (gleicher DB für Nuxt + RN-Backend, also vermutlich schon aktiv).
**Aufwand Phase B: 1-2 h** (SQL-Audit + ggf. ein Catch-up-Migration-File).
### Phase C — RN-UI-Komponenten
**Status: KOMPLETT NEU.** RN-App hat aktuell:
- `apps/rebreak-native/components/urge/UrgeGames.tsx` (1067 LOC) — Solo-Mode für Memory/TicTacToe/Snake/Tetris.
- `apps/rebreak-native/app/games.tsx` — Standalone-Games-Page (Solo).
- KEIN Community-Komponent, KEIN Game-Page für 1v1.
**Zu erstellen:**
1. `apps/rebreak-native/app/(app)/game/[challengeId].tsx` — Pendant zu `pages/app/game/[challengeId].vue`. RN-Expo-Router-File. Ports:
- Loading + Lobby (`OPEN`-Status, Waiting-Screen mit Cancel-Button)
- TicTacToe-Board (3x3 Grid, X/O-Marker, WinLine-Highlight) — `Pressable`-Cells statt `<button>`
- Memory-Board (4x4 Grid, Score-Header, Mismatch-Reveal, Progress-Bar)
- Lyra-Bubble (Avatar + animierter Phrase-Text, optional TTS-Toggle — bereits vorhandene `lib/sosTtsQueue.ts`-Infra wiederverwendbar)
- Status/Result-Section + Rematch-Button
- History-Tab + Ranking-Tab
2. `apps/rebreak-native/components/games/Game1v1Board.tsx` (optional, falls zu monolithisch) — Sub-Component für Board-Rendering.
3. **1v1-Entry-Buttons in `UrgeGames.tsx`** — analog Vue, pro TicTacToe und Memory Solo-Mode einen "Gegen echten Spieler"-Button hinzu, der `POST /api/games/challenge[-memory]` callt und auf `/game/[id]` navigiert.
4. **Community-Listing-View** — Aktuell hat RN-App keine Community-Tab. Entweder:
- **Option a:** Existing community-Page aus Nuxt nach RN portieren (separater großer Task).
- **Option b:** Erstmal nur eine **"Open Challenges"-Liste** unter `/game/index.tsx`, die alle `OPEN`-Challenges (eigener Endpoint nötig: `GET /api/games/challenges?status=OPEN`) listet.
- **Option c (empfohlen):** Direkter Invite-Flow per Share-Link `/game/[id]` (Deep-Link funktioniert bereits in Expo) — kein Community-Browsing nötig für DiGA-Cutover.
**Aufwand Phase C: 12-20 h** (1 Page mit 2 Game-Modes + Realtime + Lyra + History-Tab + Ranking-Tab + Lobby-Flow). Größte Position.
### Phase D — Realtime-Wiring
**Status: INFRA VORHANDEN.** `apps/rebreak-native/lib/supabase.ts` hat bereits `realtime`-Konfig.
**Zu tun:**
- Im neuen `[challengeId].tsx` analog zu Vue: `supabase.channel(...).on('postgres_changes', { schema: 'rebreak', table: 'game_challenges', filter: `id=eq.${id}` })`.
- React-Native-Spezifika: AppState-Listener für Reconnect bei Background → Foreground (Vue-Variante hat nur passive Reconnect bei `CHANNEL_ERROR`).
- Auth-Token via `supabase.realtime.setAuth(session.access_token)` — identisch zu Vue.
**Aufwand Phase D: 2-3 h** (im Rahmen Phase C).
### Phase E — Testing + Deploy
- **Manueller 2-Device-Test** (iOS-Simulator + Android-Emulator simultan): Challenge erstellen, accepten, abwechselnd Züge, Win/Draw, Rematch.
- **Disconnect-Resilience**: Airplane-Mode toggle während ACTIVE — Realtime muss reconnecten.
- **Deep-Link-Test**: `rebreaknative://game/<id>` aus geteiltem Link.
- **EAS-Preview-Build** für Tester.
**Aufwand Phase E: 4-6 h** (inkl. Bugfixes).
**Gesamtaufwand: ~20-30 h Net-Coding**.
---
## 3. Architektur-Empfehlung — was wir besser machen
Die Nuxt-Implementierung ist solide (server-authoritative + Supabase-Realtime ist die richtige Wahl). Drei Verbesserungen für die RN-Variante:
1. **Kein Community-Post-Coupling.** Die Nuxt-Variante erstellt für jede Challenge automatisch einen Community-Post (`category="challenge"`) und löscht ihn beim Spielende. Das verschmiert Game-Lifecycle und Community-Layer. Empfehlung: 1v1-Challenges leben in einer eigenen Tabelle / einem eigenen "Open-Challenges"-Endpoint, ohne Cross-Coupling. Macht Phase-C-Cleanup einfacher und entkoppelt RN-Cutover von Community-Migration.
2. **Optimistic UI mit Rollback.** Aktuell wartet Vue auf den POST-Response, bevor das Board updated → 100-300 ms Lag pro Move. RN-Variante: lokal sofort renderen (gleiche Validierungslogik clientseitig spiegeln) und auf Realtime-UPDATE reconcilen. Bei Validation-Error vom Server: rollback + Toast. Macht das Spiel "snappier" auf flackrigen Mobilfunk-Verbindungen.
3. **Heartbeat / Idle-Cancel.** Nuxt hat keinen Cleanup für tote `OPEN`-Challenges. RN-Variante: Cron-Job im Backend (`backend/server/api/cron/`-Pattern existiert bereits) markiert OPEN-Challenges nach 30 min als CANCELLED, und ACTIVE-Challenges ohne Move seit 10 min ebenfalls. Entlastet die DB und verhindert Geister-Posts.
4. **Bonus**: `gameType` zum Enum machen statt `String @default("tictactoe")` — kleine Schema-Hygiene.
---
## 4. Risk-Assessment
| Risk | Severity | Mitigation |
|---|---|---|
| Realtime-Latenz auf Mobilfunk | mittel | Optimistic UI (siehe oben). 100-500 ms ist für TicTacToe/Memory ok (Async-Style). |
| Anti-Cheat / Move-Spoofing | niedrig | Server-authoritative ist bereits implementiert (Server prüft Turn + überprüft Board-State). RLS-Policies erlauben Updates nur für Teilnehmer — aber Updates passieren ohnehin via Service-Role-Backend, RLS ist nur für Realtime-Read. |
| Disconnect mid-game | mittel | Aktuell: Spiel "hängt" bis Spieler zurückkommt; kein Auto-Forfeit. Risk: Spieler quittet → Gegner stuck. Mitigation: Heartbeat + Auto-Cancel nach 10 min Inaktivität (Phase E Verbesserung) + UI-"Gegner offline"-Badge. |
| Plan-Tier-Gate | niedrig | Aktuell **kein** Plan-Check in den Endpoints — alle Auth-User können challengen. Falls Pro-Only gewünscht: in `challenge.post.ts` und `challenge-memory.post.ts` ein `requirePlan('pro')` einfügen (User-Decision). |
| Schema-Drift Nuxt vs Rebreak-Monorepo | mittel | Beide Projekte teilen physisch dieselbe DB (Schema `rebreak`). Solange beide Schemas synchron sind, kein Problem. Beim Cutover: Nuxt komplett deaktivieren, sonst race conditions auf `game_challenges`. |
| Realtime-Quota-Kosten | niedrig | Supabase Realtime hat ~200 concurrent connections im Pro-Plan. Bei 1v1 = 2 Subscriber/Match → erst ab 100 parallelen Matches problematisch. Monitoring per Supabase-Dashboard. |
| Memory-Game state.cards Größe | niedrig | 16 Cards JSON-Blob ~1 KB pro UPDATE → vernachlässigbar. |
| iOS Background WebSocket | mittel | iOS killed Realtime-Channels nach ~30 s im Background. Reconnect-on-resume zwingend (AppState-Listener). |
**Top-3 Risks:** 1) Disconnect mid-game ohne Auto-Forfeit; 2) iOS Background-WebSocket-Drop; 3) Schema-Drift während Übergangsphase Nuxt+RN parallel.
---
## 5. Open Questions an User
1. **Live oder Async?**
Aktuelle Nuxt-Implementierung ist **Live** (Realtime + Same-Session). Empfehlung für RN: **Live beibehalten**, weil Netcode steht. Async-Wordle-Style würde komplette Re-Architektur erfordern (Push-Notifications, Move-Queue) und 2-3x Aufwand bedeuten.
2. **Random-Matchmaking vs Friends-only vs Community-Post?**
Nuxt nutzt Community-Post (jeder kann annehmen, kein Friend-Graph). Optionen für RN:
- **a)** Community-Post-Style portieren (braucht Community-View in RN — großer separater Task).
- **b)** Public "Open Challenges"-Liste auf `/games`-Page (klein, schnell).
- **c)** Share-Link-Invite (`rebreaknative://game/<id>` per Native-Share-Sheet — kein Browse nötig).
- **d)** Random-Pool: Server-Side-Matchmaking (setzt 2 OPEN-Challenges paarweise zusammen — kein UI-Touch).
**User muss entscheiden.** Empfehlung: c + b kombiniert für MVP.
3. **Public Leaderboard mit Win-Rate-Stats?**
`GameScore`-Tabelle existiert + `/api/games/ranking`-Endpoint. Frage: soll Leaderboard
- **a)** in jedes Game integriert (aktueller Nuxt-Stand: Tab im `[challengeId].vue`),
- **b)** als globale Page `/games/leaderboard`,
- **c)** beides?
4. **Plan-Tier-Gate?** 1v1 für Free-Tier verfügbar oder Pro-Only? (DiGA-Relevanz unklar.)
5. **Anonymität:** Sollen Gegnernamen anonymisiert sein? Aktuell wird `nickname || username || "Anonym"` benutzt. DiGA-Datenschutz?
6. **User-Quit-Verhalten:** Wenn ein Spieler die App schließt mid-game — Forfeit nach X min, oder Spiel hängt offen? Empfehlung: Auto-Cancel nach 10 min Inaktivität, kein Forfeit (= kein Punkteabzug).
---
## 6. Migration-Aufwand-Summary
| Phase | Aufwand | Status |
|---|---|---|
| A — Backend-Endpoints | 0 h | bereits portiert |
| B — DB-Migrations | 1-2 h | Schema da, SQL-Audit nötig |
| C — RN-UI Game-Page + Lobby | 12-20 h | komplett neu |
| D — Realtime-Wiring | 2-3 h | im Rahmen C |
| E — Testing + Deploy | 4-6 h | manuelle 2-Device-Tests |
| **Gesamt** | **~20-30 h** | |
Plus optional: Community-View-Migration (separater Plan) für Community-Post-Style-Matchmaking.
---
*Doc-Version 1.0 — 2026-05-07 — `~/mono/rebreak-monorepo/ops/GAMES_1V1_MIGRATION_PLAN.md`*

View File

@ -0,0 +1,223 @@
# Maestro Hosting — Decision Memo
Author: Backyard (Infrastruktur) · Stand: 2026-05-07 · Status: Recon-Empfehlung, READ-ONLY
User-Frage (2026-05-08): „lohnt sich maestro für uns oder reicht self hosted auf hetzner neue server mit backyard checken"
Dieses Doc ist die Antwort. Kurzfassung in einem Satz:
**Maestro Cloud (Starter ~10 EUR/Monat oder Free-Tier zuerst) nehmen — Self-Host
auf Hetzner lohnt sich für unsere Team-Größe + Stack nicht, vor allem weil iOS
auf Linux prinzipiell nicht testbar ist und unser CX23-Server bereits ausgelastet
ist.**
---
## 1. Hard-Constraint: iOS lässt sich auf Linux nicht testen
Apple-Lizenz-Restriction: **iOS-Simulator läuft ausschließlich auf macOS.** Hetzner
liefert (im EU-Standard-Tier) keine Mac-Hardware. Linux-VMs auf Hetzner können
nur Android-Emulators hosten. Das heißt:
- **Self-Host auf einem Hetzner-Linux-Server kann maximal die Android-Hälfte
unserer E2E-Suite abdecken.**
- iOS-Tests müssten parallel auf einem anderen Pfad laufen:
- (a) lokaler Mac-Build (User-Hardware) — nicht CI-fähig
- (b) Mac-mini bei MacStadium o.ä. — ~60-120 EUR/Monat
- (c) GitHub-Actions macOS-Runner — ~0.08 USD/Min, pay-per-use
- (d) Maestro-Cloud nur für iOS, self-host für Android — Hybrid
Jede dieser Optionen treibt entweder Kosten oder Wartungs-Komplexität.
**Konsequenz:** „Self-Hosted-Maestro auf Hetzner" ist immer ein Hybrid-Setup,
nie eine vollständige Lösung. Wer „self-hosted" sagt, meint praktisch entweder
*nur Android* oder *Hybrid mit zweitem Pfad für iOS*.
---
## 2. Performance-Risk auf existierendem CX23
Der dedicated rebreak-server ist eine **CX23: 2 vCPU x86, 4 GB RAM, 40 GB SSD,
2 GB swap** (siehe `reference_rebreak_server.md`). Aktuelle Last:
- `rebreak-staging` (Nuxt-Server, Nitro)
- `rebreak-imap-staging`, `rebreak-idle-staging` (Mo's IMAP-Stack)
- `rebreak-webhook` (Auto-Deploy-Listener)
- `pnpm build` während Deploy (peakt RAM)
Ein Android-Emulator braucht **2-4 GB RAM** + 1-2 vCPU für akzeptable Boot-Zeit
und stabile Test-Runs (bei `-no-window -no-audio -gpu swiftshader_indirect` am
unteren Ende). Das **kollidiert direkt** mit dem Build-Peak. OOM-Risk auf 4 GB
RAM ist real und wird in `feedback_deploy_workflow.md` schon ohne Maestro
notiert.
**Heißt:** wir KÖNNEN den CX23 nicht zusätzlich mit Android-Emulator beladen,
ohne Production-Risiko. Wir bräuchten zwingend einen **zweiten dedicated
CI-Server** (mindestens CX22 ~5 EUR/Monat netto, eher CX32 mit 8 GB RAM
~9-11 EUR/Monat um Reserve zu haben, weil Emulator + Maestro-CLI + Test-Runner
+ ggf. paralleles Android-Build sich gerne überlappen).
Damit ist der „self-hosted ist gratis"-Mythos schon entzaubert: wir reden
realistisch über **zusätzlich ~10 EUR/Monat Hetzner-Server** plus mehrere
Stunden Setup plus laufende Wartung.
---
## 3. Cost-Comparison
Annahmen: ~10 Test-Runs/Monat (TestFlight-Sprint-Phase, später ggf. mehr), je
Run ~5 Minuten Maestro-Time pro Plattform, iOS+Android parallel.
| Variante | Direkte Kosten/Monat | Direkte Kosten/Jahr | Setup-Aufwand | Laufende Wartung |
|----------|----------------------|---------------------|---------------|------------------|
| **(A) Maestro Cloud Starter** | ~10 EUR (laut Ahmed-Quote, plus Free-Tier-Monatsminuten) | ~120 EUR | 30-60 min (CLI install + cloud-token) | quasi 0 — Maestro betreibt Devices |
| **(B) Self-Host Android-only auf Hetzner (neuer CX22/32)** | 5-11 EUR (+ Strom-Pool inklusive) | 60-130 EUR | 4-8 h (Server provision + Android-SDK + Emulator-image + KVM-acceleration check + Maestro-CLI + Webhook/Trigger) | ~30 min/Monat (image-updates, Emulator-state-Drift) |
| **(B+) Self-Host Android + GitHub-Actions-iOS** | 5-11 EUR Hetzner + ~4 EUR GitHub macOS-Minutes (10×5 min) | 110-180 EUR | 6-10 h (zwei Pfade synchron halten) | ~1 h/Monat (zwei Pipelines pflegen) |
| **(C) Pure self-host iOS + Android (eigener Mac-mini bei MacStadium)** | 60-120 EUR Mac + 5-11 EUR Hetzner | 780-1570 EUR | 8-16 h | ~2 h/Monat (zwei Hosts) |
| **(D) GitHub Actions Matrix-Build (macOS-runner für beide)** | ~8 EUR (10×5 min iOS + Android Linux gratis) | ~100 EUR | 2-3 h Workflow-yml | ~30 min/Monat |
**Beobachtung:** Die nominellen Kosten von (A), (B), (B+) und (D) liegen alle
im Bereich 60-180 EUR/Jahr. Der echte Tradeoff sind **Setup-Aufwand und
Wartungs-Komplexität**, nicht Cash.
---
## 4. Aufwand-Schätzung pro Variante
### (A) Maestro Cloud
- **Setup:** Account anlegen, `MAESTRO_CLOUD_API_KEY` als GitHub-Secret, ein
einziger CLI-Aufruf in der Action. **30-60 Min.**
- **Wartung:** Maestro pflegt Device-Pool, Updates, OS-Versionen. **Nahe 0.**
- **Risk:** External Vendor — wenn Maestro down, blockiert PR-Test. Akzeptabel
weil Tests nicht Production-blocking sind, nur PR-Gate.
### (B) Self-Host Android-only
- **Setup:** Hetzner-Server provisionieren (CX22/32), Ubuntu, Android-SDK +
cmdline-tools + emulator-image installieren, KVM/HAXM-Acceleration testen
(auf Hetzner-Cloud bedingt verfügbar — `/dev/kvm` muss exposed sein, je
nach Hetzner-Tier OK), Maestro-CLI installieren, Webhook-Endpoint bauen
oder GitHub-Actions self-hosted-runner registrieren. **4-8 h.**
- **Wartung:** Android-System-Image-Updates (alle 2-3 Monate), Emulator-state
kann korrupt werden, Maestro-CLI-Updates, Disk-cleanup für AVD-snapshots.
**~30 Min/Monat im stabilen Zustand, gelegentlich mehrere Stunden bei Bruch.**
- **Risk:** Wenn Server down, Test-Pipeline down. Plus: ein zweiter Server
vergrößert die Hetzner-Sprawl, gegen die wir gerade in
`project_rebreak_dedicated_hetzner.md` fokussiert haben.
### (B+) Hybrid Self-Host Android + Cloud-iOS oder GH-Actions-iOS
- **Setup:** wie (B) **plus** zweiter Pfad für iOS. **6-10 h.**
- **Wartung:** Zwei Pipelines, zwei Auth-Token-Pflege, zwei verschiedene
Reporting-Outputs. **~1 h/Monat.**
- **Risk:** Pipeline-Drift — Android-Test-Setup divergiert von iOS-Test-Setup,
Bugs werden auf einer Plattform gefangen, auf der anderen nicht.
### (C) Pure Self-Host inkl. Mac
- **Setup:** Mac-mini-Provider auswählen (MacStadium, Scaleway-Apple-M1, Mac
in Cloud), Maestro auf macOS installieren, iOS-Sim einrichten, plus Hetzner
für Android. **8-16 h.**
- **Wartung:** Zwei OS, zwei Toolchains, Apple-Xcode-Updates. **~2 h/Monat.**
- **Risk:** Hoch. Aufwand steht in keinem Verhältnis zum Output für unsere
Team-Größe (User + ein paar Sub-Agents).
### (D) GitHub Actions Matrix
- **Setup:** Eine `.yml` mit zwei Jobs (`maestro-ios` auf macos-latest,
`maestro-android` auf ubuntu-latest mit `reactivecircus/android-emulator-runner`).
**2-3 h.**
- **Wartung:** Action-Updates, ggf. Cache-Invalidierung. **~30 Min/Monat.**
- **Risk:** macOS-Minutes können bei vielen Test-Runs schnell teuer werden
(z.B. 100 Runs/Monat × 5 Min = 40 USD). Bei aktueller Run-Frequenz aber OK.
---
## 5. Empfehlung — Lieblings-Pfad
### Lieblings-Pfad: **Maestro Cloud Starter (Variante A)**
**Begründung:**
1. **iOS-Hard-Constraint** löst sich elegant — Cloud betreibt Apple-Devices.
2. **Setup-Aufwand minimal** — eine Stunde max, keine Hetzner-Sprawl.
3. **Hetzner-Strategie respektiert** — der CX23 bleibt fokussiert auf
`rebreak-staging`/`rebreak-prod`-Workload, kein OOM-Risk durch Emulator-Last.
4. **Ahmed-Empfehlung deckt sich**`TESTING_STATE.md §5.2` nennt Maestro mit
Cloud-Run als bevorzugten Pfad genau aus diesem Grund.
5. **Cash-Risk niedrig** — 120 EUR/Jahr ist Rauschen verglichen mit
DiGA-Pen-Test (5-15k EUR), Apple-Developer-Program (99 USD) etc.
6. **Reversibilität hoch** — wenn Cloud-Limits gesprengt werden, lässt sich
später auf (B+) oder (D) wechseln. Maestro-yml-Flows sind portable.
**Plan-Form:**
- Anfang: **Free-Tier prüfen** (Maestro hat ein gratis Kontingent für kleine
Teams — verifizieren beim Account-Anlegen). Wenn Free-Tier ausreicht für
unsere ~10 Runs/Monat, kostet's 0.
- Wenn Free-Tier nicht reicht: Starter-Plan (~10 EUR/Monat laut Ahmed-Quote).
- Maestro-CLI lokal für Dev-Iteration, Cloud für CI-Gate auf `main`.
### Plan B: **GitHub Actions Matrix (Variante D)**
Wenn Maestro Cloud aus irgendeinem Grund nicht passt (z.B. EU-Datenschutz-Bedenken
weil Test-Recordings auf US-Servern liegen, oder Pricing-Änderung):
- GitHub-Actions-Matrix mit `macos-latest` für iOS und `ubuntu-latest` +
`reactivecircus/android-emulator-runner` für Android.
- Test-Runs nur auf `main`-Push und `workflow_dispatch`, nicht auf jedem PR
→ hält macOS-Minutes-Verbrauch klein.
- Aufwand: 2-3 h Setup, ~30 Min/Monat Wartung, ~100 EUR/Jahr.
### Anti-Empfehlung: **Self-Host auf Hetzner (B oder B+)**
Würde ich nur empfehlen wenn:
- DSGVO/DiGA-Audit verlangt explizit, dass Test-Pipelines auf EU-eigener
Infrastruktur laufen (unwahrscheinlich, weil Test-Recordings keine
Produktiv-User-Daten enthalten).
- Wir mehr als ~50 Test-Runs/Tag haben (sind wir nicht).
- Wir bereits dedicated CI-Infrastruktur betreiben (haben wir nicht — der
rebreak-server ist Production).
In allen anderen Fällen ist Self-Host für unsere Größe **Over-Engineering**
und kollidiert mit der Hetzner-Konsolidierungs-Linie aus den letzten
Backyard-Sessions.
---
## 6. Setup-Estimate für die Empfehlung (Pfad A)
| Schritt | Owner | Aufwand |
|---------|-------|---------|
| Maestro-Cloud-Account anlegen + Free-Tier-Limits checken | User | 15 Min |
| `MAESTRO_CLOUD_API_KEY` GitHub-Secret hinterlegen | User | 5 Min |
| `.github/workflows/rebreak-test.yml` Job `maestro-cloud` schreiben (`maestro cloud --apiKey $MAESTRO_CLOUD_API_KEY .maestro/`) | Ahmed/Backyard | 30 Min |
| Erster Test-Run gegen Staging-Build von TestFlight-IPA | Ahmed | 30 Min |
| Smoke-Flow `auth/sign-in.yaml` entstehen lassen + via Cloud verifizieren | Ahmed | bereits in TESTING_STATE Roadmap (1 h) |
| **Total bis erste grüne Cloud-Run** | | **~2 h** zusätzlich zur Maestro-Flow-Entwicklung selbst |
Vergleich Self-Host: 4-8 h Setup + Wartungs-Last + zweiter Hetzner-Server. Cloud
gewinnt deutlich.
---
## 7. Offene Punkte (User-Decision)
1. **Maestro-Cloud-Pricing-Verifikation:** „~10 EUR/Monat" ist Schätzung aus
Ahmed's Notiz. User sollte beim Account-Anlegen den aktuellen Starter-Plan-Preis
und das Free-Tier-Kontingent verifizieren. Backyard hat bewusst nicht selbst
den Maestro-Pricing-Endpoint gefetcht (kein Web-Tool im Recon-Scope).
2. **Cloud-Datenschutz vs DSGVO:** Maestro-Cloud-Server-Region prüfen (US vs EU).
Test-Flows enthalten keine echten User-Daten, sondern Test-Account-Daten —
Risk niedrig, aber Hans-Müller (DSFA) sollte das im Datenfluss-Diagramm
notieren.
3. **Wenn (D) als Plan B aktiv wird:** Run-Frequenz kapeln (max 1× pro PR auf
main, nicht auf jedem Branch-Push), sonst eskalieren macOS-Minutes.
---
## 8. TL;DR für User
- **Maestro Cloud Starter (~10 EUR/Monat, ggf. Free-Tier am Anfang) nehmen.**
- iOS lässt sich nicht auf Linux testen — Self-Host auf Hetzner ist immer
Hybrid und kostet mehr Stunden als es spart.
- Unser CX23 ist schon nah an OOM bei Build-Peaks; Android-Emulator dazu wäre
Production-Risk.
- Plan B falls Cloud nicht passt: GitHub-Actions-Matrix mit macOS-runner für
iOS, ubuntu-runner für Android. ~100 EUR/Jahr, kein eigener Server.
- Setup für Pfad A: ~2 h gesamt bis zur ersten grünen Cloud-Run.
Ende. — Backyard

417
ops/PROFILE_PAGE_DESIGN.md Normal file
View File

@ -0,0 +1,417 @@
# Profile Page — Detail Design Spec
Stand: 2026-05-07
Owner: rebreak-native-ui
Phase: 1 (Detail-Plan, kein Code).
Quellen: `memory/project_profile_page_design.md`, `memory/feedback_anonymity_nickname.md`, `memory/project_llm_per_plan.md`, `ops/UI_MIGRATION_PLAN.md`.
Profile-Page ist das UI-Showpiece. Streak-Tab entfällt komplett und wandert dezent in die Profile-Page. Settings ist parallel und funktional, aber kein Showpiece.
---
## 0. Routing & Datenmodell-Übersicht
Zwei Views, scharf getrennt:
- `/(app)/profile` (eigenes Profil, Hero-Tab in Tab-Bar). Volle Sicht.
- `/profile/[userId]` (fremdes Profil). Anonymisiert: nur nickname, avatar, plan-tier (keine Email, keine Demographics, keine Cooldowns, keine SOS-Stats, keine Liste der blockierten Domains).
Beide laden via `apiFetch` aus eigenen Endpoints — kein client-side Filtern.
---
## 1. Visual Mock (ASCII-Wireframe)
Eigenes Profil — vertikal scrollbar, ein Screen, sechs Sektionen:
```
+------------------------------------------------------------+
| [<-- back] Profil [icon: cog] | <- minimal top bar
+------------------------------------------------------------+
| |
| +------------------+ |
| | | <- Avatar 96x96, runder |
| | avatar | Rahmen, plan-Akzent |
| | | (free=gray, pro=orange, |
| +------------------+ legend=gold) |
| [icon: camera-edit] |
| |
| Jonas_42 [icon: pencil] | <- nickname, inline-edit
| chahinebrini@gmail.com | <- email read-only,
| | subdued grau, klein
| [pill: legend] Mitglied seit 12.04.2026 |
| |
+------------------------------------------------------------+
STATS [icon: info]
+------------------------------------------------------------+
| +-------+ +-------+ +-------+ +-----------+ |
| | 12 | | 47 | | 134 | | 8 | |
| | Posts | | Folg- | | gebl. | | Approved | |
| | | | ower | | Dom. | | Domains > | |
| +-------+ +-------+ +-------+ +-----------+ |
| tap tap tap tap (highlight) |
+------------------------------------------------------------+
STREAK [icon: chevron] <- collapsible
+------------------------------------------------------------+
| 23 Tage geschützt |
| seit 14. April 2026 |
| |
| longest streak: 41 Tage |
| |
| COOLDOWN-VERLAUF |
| ----------------------------------- |
| | timeline-rail (1px line, vertical) | |
| | o 18.04. 16h Cooldown beendet | |
| | | "Stress nach Arbeit" | |
| | o 02.05. 4h Cooldown abgebr. | |
| | | ohne Reason | |
| | o 06.05. 24h aktiv | [pill: aktiv] |
| ----------------------------------- |
| [load more — last 30 days] |
+------------------------------------------------------------+
LYRA INSIGHTS <- SOS stats, dezent
+------------------------------------------------------------+
| Letzte 30 Tage |
| |
| 5 SOS-Sessions, 4 davon bewältigt [80% bar] |
| |
| Was hat am meisten geholfen? |
| [#] Atemübung ......... 3 Sessions |
| [#] Spiel ......... 1 Session |
| [#] Reden ......... 1 Session |
| |
| Häufigste Emotion: Stress |
+------------------------------------------------------------+
ANONYMER BEITRAG ZUR FORSCHUNG [icon: chevron] <- collapsed default
+------------------------------------------------------------+
| Optional. Hilft DiGA-Wirksamkeit zu belegen. |
| Nur aggregiert, nie personenbezogen. |
| [link: Mehr erfahren] |
| |
| ----- expanded state ----- |
| Geburtsjahr 1989 [icon: pencil] |
| Geschlecht divers [icon: pencil] |
| Familienstand ledig [icon: pencil] |
| Beruf Angestellt [icon: pencil] |
| Bundesland Bayern [icon: pencil] |
| Stadt (nicht angeg) [icon: pencil] |
| |
| [button: Einwilligung widerrufen] |
+------------------------------------------------------------+
```
Fremdes Profil — drastisch reduziert:
```
+------------------------------------------------------------+
| [avatar 96px] |
| Jonas_42 [pill: legend] |
| Mitglied seit April 2026 |
| [button: Folgen] [button: DM senden] |
+------------------------------------------------------------+
| +-------+ +-------+ +-----------+ |
| | 12 | | 47 | | 8 | |
| | Posts | | Folg- | | Approved | |
| | | | ower | | Domains | |
| +-------+ +-------+ +-----------+ |
+------------------------------------------------------------+
| Letzte Posts (5) ... |
+------------------------------------------------------------+
```
UX-Notizen:
- Edit-Icons stehen rechts neben dem editierbaren Wert, nicht in einem zentralen "Edit-Mode". Inline-Tap öffnet Bottom-Sheet mit Input.
- Collapse-Chevron rechts oben in Section-Header, animated 180-Grad-Rotation.
- Keine Tier/Score-Kacheln (alte Nuxt-Logik), nur "Mitglied seit"-Datum + Plan-Pill.
- Plan-Pill nutzt Plan-Akzentfarbe (free=neutral-300, pro=brandOrange, legend=Goldverlauf).
---
## 2. Component-Tree
Routen (neu):
- `app/(app)/profile.tsx` → eigenes Profil. Wird Tab-Bar-Eintrag, ersetzt Streak-Tab.
- `app/profile/[userId].tsx` → fremdes Profil. Modal/Stack-Push.
Komponenten (neu):
```
<ProfileScreen> (top-level page, owns scroll + section refs)
<ProfileHeader> (avatar + nickname + email + plan-pill + member-since)
<AvatarPicker> (preset-grid + custom-upload trigger)
<AvatarCropSheet> (Bottom-Sheet mit Crop-UI für Custom-Photo)
<NicknameEditSheet> (Bottom-Sheet, valibot-validiert)
<StatsBar> (4 stat-cards horizontal, tappable)
<StatCard> (number + label, optional onPress)
<ApprovedDomainsSheet> (Bottom-Sheet mit Liste der approved domains)
<BlockedDomainsSheet> (analog, custom + global summary)
<StreakSection> (collapsible, owns streak + cooldown queries)
<StreakHero> (currentDays + startDate + longest)
<CooldownTimeline> (vertikale Liste, virtualisiert, paginiert)
<CooldownTimelineItem>
<LyraInsightsCard> (SOS-Stats: 30-Tage-Trend + helped-by-Bar)
<HelpedByBar> (atemuebung/spiel/reden mit Anteils-Balken)
<DemographicsAccordion>
<DemographicsConsentNotice>
<DemographicsField> (label + value + edit-icon)
<DemographicsEditSheet>
```
Komponenten (shared, reuse):
- `<Card>` — vorhanden in components/Card.tsx
- `<Button>` — vorhanden
- `<EmptyState>` — vorhanden, für "noch keine approved domains"
- Bottom-Sheet-Pattern aus `<PostCommentsSheet>` (animated, Backdrop)
- `<StreakBadge>` (existiert) — innerhalb `<StreakHero>` als "longest"-Indikator nutzbar
State-Ownership:
- `<ProfileScreen>` lädt einmalig `/api/profile/me/full` (oder kombiniert me + stats + streak + cooldowns + sos in einem Aufruf — siehe Sektion 3).
- `<NicknameEditSheet>` und `<AvatarPicker>` rufen `PATCH /api/auth/me` auf, invalidieren `useMe()` (cachedMe-Flush) + reload.
- `<ApprovedDomainsSheet>` lazy-loadet on-open `/api/profile/me/approved-domains` (separat, nicht im Initial-Bundle — Liste kann groß werden).
- `<DemographicsEditSheet>` ruft `PATCH /api/profile/demographics` auf.
- `<CooldownTimeline>` paginiert: erste 10 vom Initial-Aufruf, "load more" lädt `/api/profile/me/cooldown-history?cursor=...`.
---
## 3. API-Endpoint-Liste
Existiert bereits:
- `GET /api/auth/me` — eigene basis. Liefert id, email, username, nickname, avatar, plan, streak (current days), created_at. KEINE Stats. KEINE Demographics.
- `PATCH /api/auth/me` — username, nickname, avatar. Muss um demographic-Felder erweitert werden ODER separater Endpoint (siehe unten).
- `GET /api/streak` — current Streak-Row.
- `GET /api/streak/events` — Streak-History (max 50). Enthält "started" / "reset" / "milestone" / "relapse". Cooldown ist DAVON GETRENNT.
- `GET /api/cooldown/status` — nur aktiver Cooldown (single).
- `GET /api/community/posts?userId=...` — vorhanden indirekt (über filter-Params).
- `GET /api/social/profile/[userId]` — fremdes Profil. Returnt nickname, avatar, followersCount, postsCount, tier, recentPosts, isFollowing. Approved-Domains nicht enthalten — muss erweitert werden.
- `GET /api/custom-domains` — eigene custom domains (active + submitted + approved + rejected).
Neu nötig:
| Endpoint | Methode | Zweck | Shape |
|---|---|---|---|
| `/api/profile/me` | GET | Aggregat: alles für eigenes Profil in einem Roundtrip | `{ profile, stats, streak, recentCooldowns[], demographics, sosInsights }` |
| `/api/profile/me/cooldown-history` | GET | Paginated cooldown-Liste | cursor-paginated, je 20, ältere ans Ende |
| `/api/profile/me/approved-domains` | GET | Liste approved domains für expanded sheet | `[{ domain, approvedAt }]` |
| `/api/profile/me/sos-insights` | GET | Aggregierte SOS-Stats (Trends, helped-by-Counts) | siehe shape unten |
| `/api/profile/me/demographics` | PATCH | Demographic-Felder setzen, audit-trail | body: subset; setzt `demographics_consent_at` automatisch auf `now()` wenn null |
| `/api/profile/me/demographics` | DELETE | Einwilligung widerrufen | nullt alle Demographic-Felder + `demographics_consent_at` |
| `/api/social/profile/[userId]` | GET | bereits da, ERWEITERN um `approvedDomainsCount` und `blockedCustomCount` (privacy: NICHT die Liste, nur Anzahl) | additive change |
Aggregat-Endpoint `/api/profile/me` Shape (Vorschlag):
```ts
{
profile: { id, email, nickname, username, avatar, plan, createdAt },
stats: {
postsCount: number,
followersCount: number,
followingCount: number,
blockedCustomCount: number, // user-eigene custom domains aktiv
blockedGlobalCount: number, // size der globalen Blocklist (Kontext)
approvedDomainsCount: number, // submitted by user und approved
},
streak: { currentDays, longestDays, startDate, isActive, avgMonthlySavings },
recentCooldowns: Array<{
id, cooldownStartedAt, cooldownEndsAt, resolvedAt, cancelledAt, reason,
status: 'active' | 'resolved' | 'cancelled',
durationHours: number, // computed
}>, // erste 10
hasMoreCooldowns: boolean,
demographics: {
consentAt: string | null,
birthYear: number | null,
gender: string | null,
maritalStatus: string | null,
profession: string | null,
bundesland: string | null,
city: string | null,
},
sosInsights: {
last30Days: { sessions: number, overcome: number, overcomeRate: number },
helpedBy: { breathing: number, game: number, talk: number, other: number }, // counts
topEmotion: string | null,
},
}
```
SOS-Insights-Aggregat: aus `sos_sessions` (existiert) + `urge_logs` (existiert). `helpedBy` durch Heuristik aus `gamesPlayed`-Json + `breathingCount` + `messages`-Length. Edge-Case: 0 Sessions → null-State, UI zeigt EmptyState "noch keine SOS-Session".
Approved-Domains-Count: count der `domain_submissions` mit `status='approved'` und `userId=user.id`. Nicht aus `user_custom_domains` — da steht der approved-Status ebenfalls, aber `domain_submissions` ist source of truth für "von dir submitted und approved".
---
## 4. DB-Schema-Änderungen
Neue Spalten auf `Profile` (alle nullable, opt-in):
```prisma
model Profile {
// ...bestehend...
birthYear Int? @map("birth_year") // nur Jahr (1900-2024), keine vollen Geburtsdaten
gender String? // 'male' | 'female' | 'divers' | 'no_answer'
maritalStatus String? @map("marital_status") // 'single' | 'partnered' | 'married' | 'divorced' | 'widowed' | 'no_answer'
profession String? // freitext, max 80 chars
bundesland String? // ISO-3166-2:DE Code (z.B. 'DE-BY')
city String? // freitext, max 80 chars
demographicsConsentAt DateTime? @map("demographics_consent_at") @db.Timestamptz(6)
lyraVoiceId String? @map("lyra_voice_id") // siehe UI_MIGRATION_PLAN §5 — gleiche Migration mitnehmen
}
```
Migration-File: `backend/prisma/migrations/20260507_add_profile_demographics_and_lyra/migration.sql`
```sql
ALTER TABLE "rebreak"."profiles"
ADD COLUMN "birth_year" INTEGER,
ADD COLUMN "gender" TEXT,
ADD COLUMN "marital_status" TEXT,
ADD COLUMN "profession" TEXT,
ADD COLUMN "bundesland" TEXT,
ADD COLUMN "city" TEXT,
ADD COLUMN "demographics_consent_at" TIMESTAMPTZ,
ADD COLUMN "lyra_voice_id" TEXT;
-- Index nur wenn wirklich für Aggregations-Queries gebraucht (DiGA-Reports auf
-- bundesland/birthYear). Initial: keine Indizes — kann später additiv kommen.
```
Validierungen (server-side, in `me/demographics.patch.ts`):
- `birthYear`: integer, 1900..currentYear-13 (DSGVO Mindestalter 13).
- `gender`: enum-Liste oben.
- `maritalStatus`: enum-Liste oben.
- `bundesland`: regex `^DE-(BW|BY|BE|BB|HB|HH|HE|MV|NI|NW|RP|SL|SN|ST|SH|TH)$`.
- Freitext-Felder: trim, max-Length, kein HTML, kein URL-Pattern (anti-spam).
DSGVO-Audit-Trail: Setzen eines beliebigen Demographic-Feldes setzt `demographics_consent_at = now()` falls null. Widerruf (DELETE) nullt alle Felder PLUS `demographics_consent_at`. Optional zusätzlich Append-only-Log-Tabelle `demographics_consent_log` (created_at, action='granted'|'revoked', user_id) — Empfehlung: jetzt parken, reicht später nachzurüsten falls BfArM/DiGA-Audit das fordert.
---
## 5. UI-Differential-Logik (eigenes vs fremdes Profil)
Nur auf eigenem Profil (`/(app)/profile`):
- Email-Anzeige (klein, subdued, unter nickname)
- Avatar-Edit + Nickname-Edit
- Streak-Sektion komplett
- Cooldown-Timeline
- Lyra-Insights / SOS-Stats
- Demographics-Sektion (mit Edit + Widerruf)
- Liste der eigenen blockierten Custom-Domains (in Sheet)
- Liste der eigenen approved domains (in Sheet)
Auf fremdem Profil (`/profile/[userId]`):
- Avatar (resolveAvatar), nickname (Fallback username), Plan-Pill, Mitglied-seit
- Stats: Posts, Follower, Approved-Domains-Count (motivational signal — siehe Sektion 6)
- Letzte 5 Posts
- Follow-Button + DM-Button
- KEIN: email, demographics, cooldowns, sos-insights, blocked-domains-Liste
Backend-Enforcement:
- `/api/profile/me` und `/api/profile/me/*` benutzen ausschließlich `requireUser(event).id`. Kein `userId`-Param. Kein `?as=...`.
- `/api/social/profile/[userId]` returnt nie email, nie demographics, nie cooldowns, nie sos-insights — auch wenn der Caller selbst der gleiche User ist (eigener Self-View geht zwingend über `/api/profile/me`).
- Frontend hat zwei Routen, jede bindet sich an einen Endpoint. Kein gemeinsamer Component-Tree mit "isOwn"-Flag — separate Components, lehrt die Trennung im Code.
---
## 6. Risiken + Open Questions
### 6.1 Image-Cropper-Library für RN (Expo SDK 53, New Architecture)
| Library | Pro | Contra | Native-Module | Expo-kompat. |
|---|---|---|---|---|
| `react-native-image-crop-picker` | Mature, native UIs (UIImagePickerController + UCrop), Cropper-Quality top | Native-Module → eject/prebuild nötig (haben wir schon, dev-client läuft), Expo-Plugin existiert nicht offiziell, Maintenance-Drift | ja | mit prebuild + manuell config-plugin |
| `expo-image-picker` + `expo-image-manipulator` | Pure Expo, keine native-Coordination | Kein interaktiver Crop — manipulator macht nur fest definierte crop-Boxes ohne UI; eigener Crop-UI = Eigenbau auf `react-native-gesture-handler` + `reanimated` | nein | ja |
| `react-native-image-cropper` (custom) | Flexibel, JS-only | Maintenance fragwürdig (unmaintained), keine native-Performance | nein | ja |
| `@react-native-community/image-editor` | offiziell genug, paired mit `expo-image-picker` | wieder kein interaktiver Crop, nur api-crop | nein | ja |
**Empfehlung:** `expo-image-picker` (existiert bereits in package.json) für Pick + ein eigenes leichtes Crop-Sheet auf `react-native-reanimated` + `react-native-gesture-handler` (existieren). Square-only-Crop reicht für Avatar-Use-Case. Vermeidet Native-Module-Coordination mit zied/backyard und hält die Expo-only-Constraint. `react-native-image-crop-picker` als Phase-5-Followup, falls Square-Crop dem User zu wenig ist und z.B. Zoom-Pan-Pinch-Quality nicht reicht.
### 6.2 Cooldown-Timeline — Liste vs Chart vs Heatmap
Optionen:
- **Liste (vertikales Timeline-Rail):** zeigt Datum, Dauer, Reason, Status pro Eintrag. Detailreich, gut lesbar, scrollbar, paginierbar.
- **Bar-Chart (horizontale x-Achse Datum, y-Achse Dauer-h):** Übersichtlich, aber Reason geht verloren, Tap-to-detail wäre extra.
- **Heatmap (calendar-grid):** Schick, aber Cooldowns sind selten (vielleicht 1-3/Monat). Heatmap mit hauptsächlich leeren Zellen wirkt leer.
**Empfehlung: Liste mit minimalem vertikalem Timeline-Rail (1px-Linie + Punkten).** Begründung: Cooldowns sind sparse Events mit narrativem Wert (Reason ist informativ — "Stress nach Arbeit"), nicht numerisch-aggregierbar. Ein Chart würde das Personal/Reflexive verlieren. Das Rail gibt visuelle Hierarchie ("damals — jetzt") ohne Charting-Overhead. Status-Pills (aktiv/beendet/abgebrochen) farb-codiert. Detail-Tap für extended Info. Das ist die "Liebe zum Detail"-Lösung: Lesefluss > Datendichte.
### 6.3 Demographic-Felder — Pflicht oder optional, wann fragen?
Hard rule: optional, opt-in, jederzeit widerrufbar (DSGVO Art-9 + DiGA).
Wann fragen:
- **Nicht im Onboarding.** Das schreckt ab und kollidiert mit dem "Du gehst nicht allein"-Brand. Onboarding bleibt schlank.
- **Nicht via aufdringliches Modal.** Kein "Hey willst du nicht…" Pop-up beim Profile-Open.
- **Genau eine Stelle:** collapsible Sektion am unteren Ende der Profile-Page mit klarem Title "Anonymer Beitrag zur Forschung" + erklärendem Subtext + Link "Mehr erfahren" (Modal mit DSGVO/DiGA-Erklärung). Komplett collapsed by default. User entdeckt es organisch.
- **Sanfter Nudge nach 30+ Tagen Streak:** EINMAL eine dezente in-app-Banner-Karte (in der Home-Feed, nicht als Modal): "Hilf der Forschung — anonyme Demographics tragen zur DiGA-Wirksamkeitsstudie bei". Tap führt zur Profile-Demographics-Section. Banner danach gedismissed via AsyncStorage.
### 6.4 Anti-Vanity-Metric — was ist motivierend?
Insta-like Stats Reihenfolge fließt psychologisch in den User ein. Vorschlag:
- **Posts** und **Follower** sind klassische Vanity-Metrics — bei Glücksspiel-Recovery können sie schädlich sein (Druck, Vergleich, "echter Aktiver"-Performance).
- **Approved-Domains** ist die einzige Metric, die den Beitrag des Users zur kollektiven Sicherheit misst — direkt rebreak-Mission-aligned.
- **Blockierte Domains** (eigene custom + globale Anzahl als Kontext) zeigt persönlichen Schutz-Stand.
**Empfehlung-Reihenfolge in Stats-Bar (links→rechts):** Posts, Follower, Geblockt, **Approved Domains** (rechts, mit dezenter Akzentfarbe brandOrange-tinted bg statt neutral, gleiche Größe — kein "biggest is best" aber visuell hervorgehoben).
Open Question für User: Sollen wir Follower-Count komplett weglassen und durch was Sinnvolleres ersetzen (z.B. "Tage geschützt" als Zahl)? "Follower" kann in Recovery-Context die falsche Dynamik triggern. Alternative: 4 Cards = Posts / Tage-geschützt / Geblockt / Approved-Domains.
### 6.5 Weitere Open Questions
1. **Plan-Pill Design** — Free=neutral, Pro=orange, Legend=gold sichtbar oder zu plakativ? Soll Legend einen subtilen Goldverlauf bekommen oder nur ein Icon?
2. **Bundesland-Erfassung** — Reicht ISO-3166-2:DE Code (16 Bundesländer)? Oder brauchen wir Stadt+PLZ für DiGA-Reporting? PLZ ist DSGVO-sensibler (3-Stellen-PLZ ist Pseudonym-grenzwertig).
3. **Email auf eigenem Profil zeigen?** Spec sagt ja. Aber subtil + read-only. Falls Sign-Up via Apple/Google: Display "via Apple Sign-In" — wir haben aktuell keinen Provider-Marker im `/api/auth/me`. Brauchen wir `provider`-Feld in der me-response? (Quick win — aus `user.app_metadata.provider`.)
4. **Member-Since Datum-Format** — "12. April 2026" oder "April 2026"? Letzteres ist privacy-friendlier auf fremden Profilen (UserId-Lookup wenn jemand das Datum kombiniert).
---
## 7. Implementation-Reihenfolge (Phase-2/3 Vorschlag)
Phase 2 (Skeleton, dummy-Daten):
1. Tab-Bar `_layout.tsx` ergänzen um `<NativeTabs.Screen name="profile">`. Rest unverändert.
2. `app/(app)/profile.tsx` Skeleton mit 6 Sektionen, alle Hardcoded-Demo-Daten.
3. Components-Stubs anlegen (ProfileHeader, StatsBar, StreakSection, LyraInsightsCard, DemographicsAccordion, CooldownTimeline) in `components/profile/`.
Phase 3 (Wire-up):
4. `/api/profile/me` aggregat-Endpoint backend-side bauen.
5. Migration `20260507_add_profile_demographics_and_lyra` schreiben + auf staging deployen.
6. `useProfileMe`-Hook + Komponente connecten.
7. `/api/profile/me/cooldown-history` + Pagination im Frontend.
8. `/api/profile/me/sos-insights` + LyraInsightsCard.
9. `/api/profile/me/demographics` PATCH/DELETE + Edit-Sheets.
10. AvatarPicker mit expo-image-picker + Custom-Square-Crop-Sheet.
Phase 4 (Polish):
11. `/profile/[userId]` Route mit reduzierter View.
12. `/api/social/profile/[userId]` erweitern um approvedDomainsCount.
13. Skeleton-Loading-State, Empty-States, Animated-Collapse.
---
## 8. Files (relevant für spätere Phasen)
Backend:
- `backend/prisma/schema.prisma` (Profile-Model erweitern)
- `backend/prisma/migrations/20260507_add_profile_demographics_and_lyra/migration.sql` (neu)
- `backend/server/api/profile/me.get.ts` (neu)
- `backend/server/api/profile/me/cooldown-history.get.ts` (neu)
- `backend/server/api/profile/me/sos-insights.get.ts` (neu)
- `backend/server/api/profile/me/approved-domains.get.ts` (neu)
- `backend/server/api/profile/me/demographics.patch.ts` (neu)
- `backend/server/api/profile/me/demographics.delete.ts` (neu)
- `backend/server/api/social/profile/[userId].get.ts` (extend)
- `backend/server/db/profile.ts` (extend für demographic-fields + audit-stamp)
Frontend:
- `apps/rebreak-native/app/(app)/profile.tsx` (neu)
- `apps/rebreak-native/app/(app)/_layout.tsx` (Tab hinzufügen)
- `apps/rebreak-native/app/profile/[userId].tsx` (neu)
- `apps/rebreak-native/components/profile/*` (neue Komponenten-Sammlung)
- `apps/rebreak-native/hooks/useProfileMe.ts` (neu)
- `apps/rebreak-native/lib/demographics.ts` (Enum-Listen + Validierung, neu)
- `apps/rebreak-native/locales/{de,en}.json` (neue String-Namespaces `profile.*`)
- `apps/rebreak-native/components/AppHeader.tsx` (Profile-Item route → `/(app)/profile`)

216
ops/RELEASE_READINESS.md Normal file
View File

@ -0,0 +1,216 @@
# RELEASE_READINESS — TestFlight Internal + Play Internal Testing
**Owner:** zied (Release-/Rollout-Engineer)
**Stand:** 2026-05-07 (Donnerstag)
**Deadline:** Wochenende 2026-05-09/10 — iOS auf TestFlight Internal, Android auf Play Console Internal Testing
**Stack:** Expo SDK 53 + React Native 0.79.6 (NICHT Capacitor — alte zied.md ist veraltet)
**Repo-Pfad:** `/Users/chahinebrini/mono/rebreak-monorepo/apps/rebreak-native/`
> Ziel "Internal Only": kein Apple-Review, kein Google-Review nötig. Apple TestFlight Internal Tester (≤100, App-Store-Connect-User) sind direkt installierbar. Play Console Internal Testing Track lässt bis zu 100 Tester per Email-Liste sofort builden.
---
## Status-Quo-Tabelle
Legende: ✅ ready / 🟡 vorhanden aber Anpassung nötig / 🔴 fehlt blockierend / ⚪ User-Bestätigung nötig
### iOS
| Item | Status | Detail |
|---|---|---|
| Bundle-ID | ✅ | `org.rebreak.app` (app.config.ts:22 + Info.plist) |
| App Store Connect entry | ⚪ | Existiert? Apple-Login nötig — User-Bestätigung |
| Apple Developer Account aktiv | ⚪ | TestFlight setzt aktiven $99/Jahr-Account voraus |
| Provisioning Profile + Cert | ⚪ | EAS managed credentials kann das, aber Account muss connected sein |
| `eas.json` | 🔴 | **fehlt komplett** — muss generiert werden für `eas build` |
| iOS Native-Folder (`ios/`) | ✅ | Existiert via expo prebuild, Workspace + Pods sind drin |
| `Info.plist` Versions | 🟡 | `CFBundleShortVersionString=0.1.0`, `CFBundleVersion=1` — für TF muss CFBundleVersion bei jedem Re-Upload steigen |
| `PrivacyInfo.xcprivacy` | ✅ | vorhanden (Pflicht seit April 2024); declared APIs FileTimestamp/UserDefaults/DiskSpace/SystemBootTime + `NSPrivacyTracking=false` |
| Entitlements | 🟡 | `aps-environment=development` — für TestFlight production-Build muss auf `production` (oder via EAS-Profile gesetzt). Family-Controls + Network-Extension + App-Group sind drin |
| Apple Sign-In | 🟡 | Code-Stub in `stores/auth.ts:82-87` mit TODO. UI zeigt "via Apple Sign-In"-Pill (`components/profile/ProfileHeader.tsx:55`). Capability fehlt im entitlements file. **Apple-Review-Risk:** Apple verlangt Sign-In-with-Apple wenn andere 3rd-Party-Logins (Google) vorhanden. Für TestFlight Internal nicht zwingend, aber für External + Production blocker |
| App-Icon (1024×1024) | ✅ | `ios/Rebreak/Images.xcassets/AppIcon.appiconset/App-Icon-1024x1024@1x.png` + Contents.json |
| Splash | ✅ | `SplashScreen.storyboard` + Image-Assets im Xcasset |
| Stripe-IAP-Konflikt | 🟡 | Code prüft Pro/Legend (`stores/auth.ts`, `app/(app)/blocker.tsx:268`). UI hat KEINEN Upgrade-Button in App — alle Upgrade-Pfade sind `Alert.alert(...)` ohne Stripe-Link → für TestFlight Internal OK, aber bei External/Prod muss klar dokumentiert werden: Subscription nur via rebreak.org/web (nicht in App). Apple-Reviewer-Notes nötig |
### Android
| Item | Status | Detail |
|---|---|---|
| Package-ID | ✅ | `org.rebreak.app` (app.config.ts:38 + AndroidManifest.xml + build.gradle:90/92) |
| Google Play Developer Account | ✅ | Validiert 2026-05-05 (siehe trucko-memory `project_google_dev_account.md`) |
| Play Console App-Entry | ⚪ | User-Bestätigung: existiert "ReBreak"-App im Play Console schon mit Internal-Track? |
| Android Native-Folder (`android/`) | ✅ | Existiert, gradle-Setup ok |
| Keystore (Production) | 🔴 | `android/app/build.gradle:113` signed Release-Build mit **debug.keystore** — Play Store akzeptiert das nicht. Production-Keystore muss erzeugt werden. **Alternativ (empfohlen):** EAS managed Android Credentials → EAS hält Keystore, kein User-Custody-Risk |
| versionCode/versionName | 🟡 | `versionCode=1, versionName="0.1.0"` — versionCode muss bei jedem Play-Upload steigen |
| AAB-Build-Konfig | 🟡 | Keine custom AAB-Targets, default `bundleRelease` würde laufen — aber mit debug-keystore. EAS erzeugt automatisch AAB bei production-Profile |
| `eas.json` | 🔴 | fehlt — siehe iOS |
| Adaptive Icon (foreground/background) | ✅ | `adaptive-icon-android.png` (foreground only, ohne Schrift wegen Mask) + `adaptiveIcon.backgroundColor=#0a0a0a`. mipmap-xxxhdpi etc. via prebuild generiert |
| Notification-Icon (24×24 monochrome) | 🟡 | nicht explizit gesetzt — Expo nutzt fallback. Für expo-notifications im Push-Use-Case sollte ein dedicated white-icon hinterlegt werden |
| Manifest Permissions | ✅ | INTERNET, BIND_VPN_SERVICE, BIND_ACCESSIBILITY_SERVICE, FOREGROUND_SERVICE, POST_NOTIFICATIONS, RECORD_AUDIO etc. drin |
| VpnService + AccessibilityService | ✅ | im Manifest deklariert (Zeile 38-48). **Risk:** Google Play prüft AccessibilityService-Apps streng (Use-Disclosure-Form pflicht). Empfehlung unten |
| Data-Safety-Form | 🔴 | Pflicht-Form in Play Console — User muss DSGVO-Selbstauskunft ausfüllen |
| Content-Rating | 🔴 | Erwartet PEGI 12+ wegen Glücksspiel-Thematik; Form muss vor Internal-Submit ausgefüllt werden |
### Cross-Platform
| Item | Status | Detail |
|---|---|---|
| Versionierung sync (app.config.ts ↔ ios ↔ android) | 🟡 | `app.config.ts:7 version="0.1.0"` ↔ Info.plist `CFBundleShortVersionString=0.1.0` ↔ android `versionName="0.1.0"`. Synchron, aber mehrfache Locations → Sync-Skript fehlt |
| Privacy-Policy URL (öffentlich erreichbar) | 🔴 | NICHT gefunden im App-Code. **Pflicht** für App Store + Play Store. URL z.B. `https://rebreak.org/privacy` muss live sein |
| Imprint/Impressum URL | 🔴 | dito |
| Test-Account-Credentials für Reviewer | 🔴 | Erst External-Review-relevant, für Internal not blocking |
| `apiUrl`-Default im Build | 🟡 | `https://staging.rebreak.org` als fallback in app.config.ts:91 — für TF/Play-Internal sollte ein Production-API gewählt werden ODER explizit dokumentiert werden, dass Tester Staging-Backend nutzen |
| Supabase-Anon-Key hardcoded | 🟡 | app.config.ts:103 hat hardcoded staging anon-key (Comment sagt: temp bis BFF-Migration in Phase 5). Anon-Key ist designed für Client-Ship, RLS protected — aber für Production-Build sollte besser via env-Var injected werden |
---
## Internal-Only-Pfad (3 Tage — realistisch für Wochenende)
**Nötiger Minimalumfang für TestFlight Internal + Play Console Internal Testing:**
1. **`eas.json` schreiben** mit 2-3 Profilen (development, preview, production-internal)
2. **Apple Developer Account** aktiv + ASC-App "ReBreak" erstellen (User-Action)
3. **Play Console** App-Entry für `org.rebreak.app` erstellen, Internal Testing Track aktiv (User-Action)
4. **EAS Login** + projekt initialisieren (`eas init` → projectId in app.config.ts speichern)
5. **EAS managed credentials**: `eas credentials` für iOS (cert + provisioning) und Android (Keystore generieren lassen ODER existing hochladen)
6. **iOS APS-Environment** → für production-Build via EAS-Profile auf `production` setzen
7. **Build triggern:** `eas build --platform ios --profile production` + `eas build --platform android --profile production` (kostet je 1 Build-Quota — User-Approval)
8. **Submit:** `eas submit --platform ios` (lädt automatisch zu TestFlight) + `eas submit --platform android --track internal`
9. **TestFlight Internal Tester** in ASC einladen (User per Email-Liste)
10. **Play Console Internal Tester** in Play Console einladen (Email-Liste oder Google-Group)
**Was Internal NICHT braucht (im Gegensatz zu External/Production):**
- Apple-Review (nur External Beta + Public)
- Google-Review (nur Closed/Open Testing + Production)
- Privacy-Policy-URL hardcoded sichtbar (nur für Listing)
- Screenshots, Feature-Graphic, Beschreibung
- Data-Safety-Form (technisch ja, aber für Internal nicht streng durchgesetzt — sollte trotzdem ausgefüllt werden)
- Content-Rating
---
## External-Beta-Pfad (zusätzlich, ~1 Woche)
Wenn ihr nach Internal-Test auf External (TestFlight Public Link / Play Closed-Test) gehen wollt:
- ✅ Privacy-Policy-URL live unter `https://rebreak.org/privacy` (DSGVO-konform, mit Lyra-Memory + Auto-Extract-Klauseln, Hans-Müller-DSB review)
- ✅ Imprint live unter `https://rebreak.org/imprint`
- ✅ App Store Listing: 8+ Screenshots (iPhone 6.5", 5.5"), App-Beschreibung DE/EN
- ✅ Play Store Listing: 8+ Screenshots Phone, Feature-Graphic 1024×500, Beschreibung DE/EN
- ✅ Data-Safety-Form Play Console
- ✅ App-Content-Rating Play Console (~PEGI 12+)
- ✅ Apple-Review Notes: Demo-Account-Credentials, Hinweis dass Subscription über Web läuft (nicht IAP)
- ✅ Apple Sign-In wirklich implementiert (nicht TODO) — wenn Google-OAuth bleibt, Apple-Sign-In-Pflicht
- ✅ AccessibilityService Disclosure-Form Play Console (warum BIND_ACCESSIBILITY_SERVICE nötig — App-Schutz vor Glücksspiel-Apps)
---
## Risk-Assessment
### R1 — Stripe-IAP-Konflikt (HOCH für External, MITTEL für Internal)
Apple lehnt Apps für "digital goods" ab wenn nicht via IAP gekauft. Rebreak hat Pro/Legend-Subscription via Stripe. **Aktueller App-Code zeigt KEINEN Upgrade-Button** — nur `Alert.alert("Upgrade nötig")` ohne Stripe-Link. Das ist Apple-konformer Workaround:
- App informiert User dass Pro nötig ist
- Kein Link zur Web-Bezahlseite (Apple verbietet das im App-Body)
- User abonniert auf rebreak.org/web (extern)
- App nutzt aktiviertes Abo
**Empfehlung:** für Internal-Build OK lassen. Vor External-Submit Apple-Reviewer-Note schreiben: *"Subscriptions are sold exclusively on rebreak.org. The app does not contain purchase flows. Pro features unlock automatically when the user's web account holds an active subscription."*
### R2 — VPN-Filter-Inclusion im Build (MITTEL)
Native Module `rebreak-protection` ist via `app.config.ts plugins`-Block aktiv. iOS NEFilter Extension + Android VpnService + AccessibilityService sind im Build mit drin. Für **erstes Internal-TestFlight** ist das Risiko:
- iOS: NEFilterDataProvider braucht spezielle Entitlement-Approval von Apple (Network Extensions). Falls Apple Account das nicht hat → Build schlägt beim Codesign fehl.
- Android: AccessibilityService braucht in Play Console eine Use-Disclosure beim Submit; ohne Form lehnt Google ab.
**User-Decision nötig:** Soll VPN-Filter im ersten TestFlight/Play-Internal-Build sein, oder erst V2? **Empfehlung:** für Internal Test OK behalten (kein Review), für External/Prod muss Disclosure-Form + Apple-Entitlement-Antrag laufen.
### R3 — Apple Sign-In TODO (MITTEL für External)
`stores/auth.ts:82-87` ist TODO. UI zeigt aber Apple-Pill. Bei External-Beta verlangt Apple Sign-In-with-Apple wenn andere 3rd-Party-Logins (Google) da sind — Pflicht-Guideline 4.8. Für Internal ist das egal (kein Review).
### R4 — Production-Keystore-Custody (HOCH)
Aktuell ist nur Debug-Keystore eingerichtet. Bei eigenem Keystore: User hält Passwort, Backup-Pflicht (verloren = Update-Path tot). **Empfehlung:** EAS managed Credentials (EAS hält Keystore, ist Industry-Standard, kein User-Custody-Stress).
### R5 — Hardcoded Supabase Anon-Key in app.config.ts (NIEDRIG)
`app.config.ts:103` hat hardcoded staging anon key. Anon-keys sind designed for client-ship (RLS protected DB). Aber für Production sollte env-injection. Internal-Build OK.
### R6 — Backend-API-Pointer für Tester (MITTEL)
`apiUrl` default ist `https://staging.rebreak.org`. Tester nutzen also Staging-DB. Wenn Prod-DB getrennt: Datenstand divergent. **User-Decision:** Internal-Tester gegen Staging oder schon Prod-Backend?
---
## TODO-Liste sortiert nach Tag
### Donnerstag 2026-05-07 (HEUTE — Recon + Doc)
- [x] `RELEASE_READINESS.md` schreiben
- [ ] **User:** Antworten auf Open Questions (siehe unten)
- [ ] **User-Bestätigung:** TestFlight & Play-Internal-Targeting (Internal-only oder gleich External?)
### Freitag 2026-05-08 (Setup + Configs, alles ohne Build-Trigger)
- [ ] zied: `eas.json` mit production-Profile schreiben (User reviewt vor Commit)
- [ ] zied: `app.config.ts` cleanup wenn nötig (env-vars für apiUrl)
- [ ] zied: Privacy-Policy + Imprint Stub-Pages (oder im rebreak-backend webroot deployen — Koordination mit rebreak-ops)
- [ ] **User:** Apple Developer Account-Status verifizieren ($99/Jahr aktiv?)
- [ ] **User:** ASC-App "ReBreak" erstellen (Bundle org.rebreak.app)
- [ ] **User:** Play Console App-Entry erstellen + Internal-Test-Track + Tester-Liste
- [ ] **User:** `eas login` + `eas init` (nur User kann sich einloggen)
- [ ] **User:** Decision EAS-managed Credentials vs eigener Keystore
- [ ] zied: VersionCode-Bump-Pattern dokumentieren
### Samstag 2026-05-09 (Build + Submit, alles mit User-Approval)
- [ ] **User-Approval + Trigger:** `eas build --platform ios --profile production-internal`
- [ ] **User-Approval + Trigger:** `eas build --platform android --profile production-internal`
- [ ] zied: Build-Output validieren (size, sha)
- [ ] **User-Approval + Trigger:** `eas submit --platform ios` (TestFlight) + `eas submit --platform android --track internal`
- [ ] **User:** TestFlight Tester-Emails einladen via ASC
- [ ] **User:** Play Console Internal-Tester-Emails einladen
- [ ] zied: Smoke-Test-Plan auf User-Device (Install, Login, Streak, Lyra-Chat 1 Roundtrip)
### Sonntag 2026-05-10 (Buffer + Iteration)
- [ ] Bugfix-Round 1 falls Tester-Feedback (rebreak-native-ui handled UI-Bugs)
- [ ] Falls neue Build nötig: versionCode++ → re-upload
---
## Top-5 User-Actions die nicht verschoben werden können
1. **Apple Developer Account login + ASC-App-Entry erstellen** (org.rebreak.app, Bundle-ID, App-Name "ReBreak"). Ohne ASC-Entry kein TestFlight.
2. **Play Console App-Entry + Internal-Track aktivieren** für `org.rebreak.app`. Ohne Track kein Internal-Upload.
3. **`eas login` + `eas init`** im `apps/rebreak-native/` ausführen — registriert Projekt + speichert projectId.
4. **Decision EAS-managed-Credentials vs eigener Keystore** + ggf. Keystore-Passwort generieren/sichern (User-Custody, NIEMALS commit).
5. **Privacy-Policy + Imprint live unter rebreak.org-URLs** — auch für Internal empfohlen (Vermeidet Hick-Up beim späteren External-Switch). Für External strikt Pflicht.
---
## Realistischer Wochenend-Plan — Machbarkeit
**Ja, Internal-TestFlight + Play-Internal sind machbar bis Sonntag 2026-05-10**, sofern:
- Apple Dev Account heute/morgen früh reaktiviert/bestätigt
- User Freitag Mittag verfügbar für `eas login` + ASC/Play-Console-Setup
- Keine Apple-Entitlement-Hick-Ups beim ersten iOS-Codesign (NEFilter braucht Entitlement-Approval — falls Apple Account das nicht hat, müssen wir die Plugin-Aktivierung temporär entfernen für Internal Build → V2 mit VPN-Filter)
- EAS-Build-Quota verfügbar (Free-Tier hat ~30 Builds/Monat — sollte reichen)
**Realistischer Risk-Pfad:** Falls iOS-NEFilter-Entitlement nicht da ist, Plan B = Internal-TestFlight ohne VPN-Filter-Plugin (1-Line plugin-comment-out) → User-Test der Core-Loop (Streak/Lyra/Posts/Games) ohne Blocker-Feature. VPN-Filter dann V2.
**External-Beta (TestFlight Public Link / Play Closed-Test) Wochenend-Ziel = NICHT realistisch.** Privacy-Policy-Live + Apple-Review-Process + Sign-In-with-Apple-Implementierung würden mind. 1 weitere Woche brauchen.
---
## 3 wichtigste Open Questions an User
### Q1 — VPN-Filter im ersten Build dabei oder rauslassen?
Plugins `with-rebreak-protection-ios` (NEFilter Extension) + `with-rebreak-protection-android` (VpnService + AccessibilityService) sind aktiv. Risk bei NEFilter: braucht spezielle Apple-Entitlement (Network Extensions Capability), die nicht im Standard-Dev-Account drin ist — muss von Apple separat genehmigt werden (1-2 Wochen Wartezeit historisch).
**Optionen:**
- (A) NEFilter+VpnService dabei → erstmaliger Build kann an Codesign scheitern, falls Entitlement fehlt
- (B) Plugins temporär raus (1-Line Comment in app.config.ts), Core-App testflighten, VPN-Filter V2 mit eigenem Build-Cycle
- (C) Erst Apple-Entitlement-Status prüfen (User-Login ASC), dann entscheiden
### Q2 — Backend-API-Pointer für Internal-Tester: Staging oder Prod?
Default `apiUrl=https://staging.rebreak.org`. Tester schreiben dann in Staging-DB. Wenn Prod-DB schon läuft: Daten-Inkonsistenz. Wenn Test-DB OK: weiter mit Staging.
**Meine Empfehlung:** Internal-Tester auf Staging (klarer Test-Modus, keine Vermischung mit Production-User). Externe Beta dann auf Prod.
### Q3 — Keystore-Strategie Android: EAS managed oder eigener Custody?
EAS managed = EAS speichert keystore verschlüsselt, kein User-Custody, automatisch im Build verwendet. Industry-Standard. Recovery via Expo-Account.
Eigener = User generiert keystore lokal, hält Passwort, lädt zu EAS hoch via `eas credentials`. Mehr Kontrolle, mehr Custody-Risk.
**Meine Empfehlung:** EAS managed für Wochenend-Speed, später kann User keystore mit `eas credentials --platform android` exportieren wenn Custody gewünscht.

320
ops/TESTING_STATE.md Normal file
View File

@ -0,0 +1,320 @@
# Rebreak — Testing State Audit & Roadmap
Author: Ahmed (QA) · Stand: 2026-05-07 · Status: Initial Audit, READ-ONLY
Dieses Dokument ist eine ehrliche Bestandsaufnahme. Antwort auf User-Frage:
„was und wie testen wir, welche Abdeckungsrate, wo drückt der Schuh".
Kurzfassung in einem Satz: Im neuen `rebreak-monorepo` existiert **null automatisierte
Test-Coverage**. Nicht „wenig" — **Null**. Die alte trucko-Suite (114 Vitest-Tests +
Postman + Playwright) deckt eine andere Architektur (Nuxt + Capacitor) ab und ist
nicht 1:1 portierbar.
---
## 1. Status quo (rebreak-monorepo)
### 1.1 Backend (`backend/`, Nitro standalone, Prisma + Supabase)
| Aspekt | Stand |
| ------------------- | ------------------------------------------------------------ |
| Test-Verzeichnis | nicht vorhanden (`tests/`, `__tests__/`, `*.test.ts` = leer) |
| Test-Framework | nicht installiert |
| Coverage-Tool | keins |
| `package.json` test | kein `test`-Script vorhanden |
| API-Endpoints | 122 Files in `server/api/**` — 0 abgedeckt |
| DB-Layer | 16 Files in `server/db/**` — 0 abgedeckt |
| Utils | 11 Files in `server/utils/**` — 0 abgedeckt |
| Middleware | `cors.ts` — 0 abgedeckt |
**Coverage-Schätzung Backend: 0 %.**
### 1.2 Frontend (`apps/rebreak-native/`, Expo SDK 53 + RN 0.79)
| Aspekt | Stand |
| ------------------- | ---------------------------------------------------------------- |
| Test-Verzeichnis | nicht vorhanden |
| `.maestro/` | nicht vorhanden |
| Test-Framework | weder `jest-expo` noch `@testing-library/react-native` installed |
| `package.json` test | nur `lint` + `typecheck`, kein `test` |
| Components | 58 Files — 0 abgedeckt |
| Hooks | 13 Hooks (`useMe`, `useUserPlan`, …) — 0 abgedeckt |
| Stores | 4 Zustand-Stores (`auth`, `coach`, `community`, `notifications`) — 0 abgedeckt |
| Lib (sosStream, llmProvider, ttsProvider, lyraResponse) | 17 Files — 0 abgedeckt |
| App-Routes | 11 Top-Level (`urge.tsx`, `lyra.tsx`, `auth/*`, `(app)/*`, `profile/*`) — 0 abgedeckt |
**Coverage-Schätzung Frontend: 0 %.**
Native-Module-Pfad `apps/rebreak-native/modules/rebreak-protection/android/src/test`
existiert als leerer Gradle-Skeleton, ohne reale Test-Klassen — Backyard's Scope.
### 1.3 CI / CD
- `.github/workflows/` existiert nicht im rebreak-monorepo.
- Es gibt **keinen** `rebreak-unit`-Job (existierte in trucko-monorepo, wurde nicht migriert).
- `xgit`-Deploy-Pipeline buildet ohne Tests durchzulaufen.
- Pre-commit-Hooks: keine.
- TypeScript ist die einzige stille Schutzlinie (`pnpm typecheck` in beiden Paketen).
### 1.4 Zusammenfassung
> 0 Tests, 0 CI-Gate, 0 Coverage. Die Auth-Middleware-500-Cascade dieser Woche wäre
> mit einem 8-Zeilen-Vitest-Test für `requireUser()` verhindert worden.
---
## 2. Heritage aus trucko-monorepo (was portierbar / inspirierend ist)
Pfad: `~/mono/trucko-monorepo/apps/rebreak/tests/`
### 2.1 Vitest Unit-Suite — gold-wert, ~70 % portierbar
`tests/unit/` — 6 Dateien, 114 Tests, alle grün (geprüft 2026-04-28):
| Datei | Was getestet | Portierbar? |
| --------------------------- | ------------------------------------------------------------- | ------------------------ |
| `cooldown.test.ts` | `signCooldownToken`, `verifyCooldownToken`, DB-Layer | Ja, 1:1 |
| `domain-blocklist.test.ts` | `getBlocklistedDomainsSet` Subdomain-Matching | Ja, 1:1 |
| `domain-limits.test.ts` | Plan-Limit-Enforcement bei Custom-Domains | Ja, 1:1 |
| `domain-validation.test.ts` | Domain-Regex + Sanitisierung | Ja, 1:1 |
| `domain-vote.test.ts` | `castDomainVote` Auto-Approve-Logik | Ja, 1:1 |
| `plan-features.test.ts` | `PLAN_LIMITS`, `getPlanLimits` für free/pro/legend | Ja, mit Tier-Update |
Alle nutzen `vi.mock('../../server/utils/prisma', …)` — keine echte DB nötig, läuft
in Sekunden, kein Secret-Setup. **Quick win:** copy + adjust import-paths
(`apps/rebreak/server/...``backend/server/...`).
### 2.2 Postman-Collection — sofort wiederverwendbar
`tests/postman/rebreak.postman_collection.json` deckt 12 Domains (Auth, Profile,
Streaks, Posts, Chat, Lyra, Notifications, Cooldown, Domain-Submissions,
Blocklist, Scores, Sicherheit-401-Sammlung).
Anpassungs-Aufwand:
- `{{baseUrl}}` ist URL-only, funktioniert für Nitro-standalone identisch.
- Auth-Pfad bleibt `POST /api/auth/login` — selbe Route in neuem Backend.
- Token-Auto-Extract-Skript funktioniert, da Backend Bearer-Token zurückgibt.
- Eventuell veraltete Endpoints (`/api/blocklist/*`, `/api/scores/*`) gegen neue
routes der Standalone-API verifizieren.
### 2.3 Playwright Smoke (`tests/e2e/smoke/`)
**Nicht portierbar** für Native-App. Nuxt-DOM-Selektoren, Browser-only, irrelevant
für Expo. `fixtures/auth.ts` (`loginAs(page, …)`) ist konzeptuell wiederverwendbar
für Maestro `JS-onFlowStart` — aber neu schreiben.
### 2.4 Cypress-Suite (`apps/rebreak/cypress/e2e/`)
`01-smoke.cy.ts`, `02-a11y.cy.ts` — Capacitor-Webview, irrelevant für RN. Skip.
---
## 3. Risk-Matrix — Top 10 ungetestete Critical Paths
Bewertung: **Risk = max(DSGVO-Risk, User-Trust-Risk, DiGA-Risk, Recovery-Risk)**.
| # | Pfad | Risk | Why | Empf. Test-Form |
| -- | ------------------------------------------------------ | -------- | ------------------------------------------------------------------------------ | -------------------------------- |
| 1 | `server/utils/auth.ts``requireUser()` | KRITISCH | Diese Woche 500-Cascade durch Bug. Jeder Endpoint hängt dran. | Unit (vitest) + supertest |
| 2 | `coach/sos-stream.get.ts` (Tier-LLM-Switch) | KRITISCH | Free/Pro=Groq, Legend=Haiku. Falscher LLM für falschen User = User-Trust-Bruch + Brand-Voice-Bruch. | Integration mit Plan-Mock |
| 3 | `coach/sos-stream.get.ts` (SSE-Stream + Chip-JSON) | KRITISCH | SOS ist Recovery-Schutz. Stream-Bruch oder Chip-Parse-Fail = User in Krise allein. | Integration + Stream-Snapshot |
| 4 | `community/post.post.ts` + `posts.get.ts` (Anonymität) | KRITISCH | DSGVO Art. 9 + Glücksspiel-Stigma. Wenn UI plötzlich `firstName`/`username` statt `nickname` liefert = Disaster. | Unit (DB-select-shape) + e2e |
| 5 | `cooldown/{request,status,cancel}.post/get.ts` | HOCH | DiGA-relevant Recovery-Schutz. Token-Sign/Verify war in trucko getestet — muss in neue Codebase portiert. | Unit (vitest, gemockt) |
| 6 | `urge/index.post.ts` + `lib/sosTtsQueue.ts` | HOCH | Urge-Logging ist Recovery-Kern-Feature. TTS-Queue = aktuell offen-modifiziert (siehe `git status`). | Unit + Component-Test |
| 7 | `auth/login.post.ts` + `auth/me.get.ts`/`me.patch.ts` | HOCH | Profile-Update = Demographics = DiGA-Daten. Bug = falsche Kohorten-Daten in BfArM-Antrag. | Unit (Zod-validate) + Postman |
| 8 | `stripe/webhook.post.ts` + Pro-Trial-Reward-Trigger | HOCH | Pro-Trial-Logik (Phase C, kommt). Missing Test = Free-User bekommen versehentlich Lifetime-Pro oder umgekehrt. | Unit + Integration mit Stripe-mock |
| 9 | `lib/llmProvider.ts` + `lib/sosStream.ts` (Frontend) | HOCH | Resolved-Provider-Logik passt zum Backend. Wenn Drift entsteht, sehen User falschen LLM-Pill (UX-Lüge). | Unit (RN-jest) |
| 10 | `mail/scan.post.ts` + `mail/connect.post.ts` | MITTEL | Mo's Scope, aber Recovery-relevant. Mail-Scan-Bug = User bekommt Casino-Newsletter durchgelassen. | Integration |
Nicht in Top 10 aber erwähnenswert:
- `protection/*` (URL-Filter-Blocklist) — DiGA-relevant, aber binär/idempotent
- `scores/*` (Leaderboard) — Anonymität-Risk via `feedback_anonymity_nickname.md`
- `notifications/*` — Push-Anrede mit `firstName` statt `nickname` = Anonymitäts-Bruch
---
## 4. Roadmap
### 4.1 Pre-TestFlight (Wochenende, Mai 2026) — absolutes Minimum
Ziel: App stürzt im TestFlight nicht beim Launch ab + die zwei kritischsten User-Journeys (Login + SOS-Trigger) funktionieren auf einem echten iPhone-Build.
| Task | Form | Aufwand |
| ----------------------------------------------------------------- | ------------- | ------- |
| Maestro CLI installieren + `apps/rebreak-native/.maestro/` anlegen | Setup | 30 min |
| Smoke-Flow `auth/sign-in.yaml` (Username + Pwd → App-Shell) | Maestro | 1 h |
| Smoke-Flow `sos/start.yaml` (Login → SOS-Button → Chat-Stream sieht 1+ Token) | Maestro | 1.5 h |
| Smoke-Flow `community/feed-loads.yaml` (Login → Feed → 1+ Post sichtbar) | Maestro | 1 h |
| Manueller Postman-Run der 12-Domain-Collection gegen Staging | manuell | 1 h |
| Backend-Sanity: `pnpm build:backend` + curl-Smoke gegen 5 wichtigste Endpoints (`/api/auth/me`, `/api/coach/sos-stream`, `/api/community/posts`, `/api/cooldown/status`, `/api/urge`) | manuell | 30 min |
**Wichtig:** das ist NUR Smoke. Keine Plan-Tier-Permutationen, keine Edge-Cases.
„Stürzt nicht ab + Happy-Path geht durch" ist das einzige Bestehen-Kriterium.
### 4.2 Post-TestFlight (Woche 1-2 nach Wochenend-Cutover)
| Task | Form | Aufwand |
| ------------------------------------------------------------------------------------- | ------------ | ------- |
| Vitest setup im `backend/` (vitest.config.ts, `pnpm add -D vitest @vitest/coverage-v8`) | Setup | 30 min |
| Port `cooldown.test.ts` von trucko nach `backend/tests/unit/` | Port + adjust| 1 h |
| Port `plan-features.test.ts` (mit Tier-Update legend-Pfad) | Port + adjust| 1 h |
| Port `domain-{blocklist,limits,validation,vote}.test.ts` | Port + adjust| 2 h |
| **NEU** `auth-requireUser.test.ts` — Bug-Replikation (Bearer fehlt, Bearer invalid, Plan-Lookup fail) | Unit | 2 h |
| **NEU** `lyraMemoryExtract.test.ts` — Klarnamen-Replacement-Pipeline | Unit | 1 h |
| Supertest-Setup für Integration (Nitro `.output/server` startable im Test) | Setup | 2 h |
| Integration: `auth/login` + `auth/me` Happy + 401-Pfad | Integration | 2 h |
| GitHub Action `rebreak-test.yml` — Job `backend-unit` (vitest run + coverage) | CI | 1 h |
| Postman-Collection nach `backend/tests/postman/` portieren + `{{baseUrl}}` auf staging.rebreak.org | Port | 2 h |
Coverage-Ziel nach Phase 2: **40 % Backend-Statements, 100 % der `utils/`-Files,
100 % `requireUser`-Branches.**
### 4.3 Phase C (Demographics + Pro-Trial) — TDD parallel
User-Auftrag steht: Demographics-Form + 1-Woche-Pro-Trial-Reward. Hier MUSS Test
parallel zur Implementation laufen, weil:
- Reward-Trigger-Bug = Free-Lifetime-Pro oder Trial-nie-revoked (Stripe-revenue-leak)
- DSGVO-Wording-Pflicht („nicht Daten-gegen-Geld")
- DiGA-Daten-Integrität
| Test | Form |
| ----------------------------------------------------------------------- | ----------- |
| `profile/me/demographics.patch.test.ts` — alle 5 Felder gesetzt → Trial | Unit |
| `profile/me/demographics.patch.test.ts` — Free + bereits Trial-used → kein Re-Trial | Unit |
| `profile/me/demographics.patch.test.ts` — Pro/Legend → kein Trial-Trigger | Unit |
| Cron-Test: `pro-trial-revoke.cron.test.ts` — nach 7d zurück auf free, sofern kein Stripe-Abo | Unit |
| Maestro `profile/demographics-fill.yaml` — Free-User füllt alle Felder, sieht Pro-Reward-Banner | E2E |
### 4.4 DiGA-Pathway — Hans-Müller-Frage
BfArM erwartet für DiGA-Approval (laut `hans-mueller.md` Scope):
- **Penetrationstest-Bericht** (jährlich) — externer Anbieter, nicht Ahmed's Scope
- **DSFA-Dokument** (Datenschutz-Folgenabschätzung) — Hans-Müller's Scope
- **ISO 27001 oder BSI-Grundschutz** — DevOps/Backyard's Scope
Was BfArM **nicht direkt** verlangt aber bei Audit fragt:
- „Ist Ihre Software automatisiert getestet? Welche Coverage?"
- „Wie verhindern Sie Regressionen bei Updates?"
- „Wie testen Sie Crisis-Detection-Logik?"
**Ahmed-Empfehlung:** für DiGA-Antrag streben wir an: **70 % Backend-Coverage,
60 % Frontend-Coverage, 100 % Crisis-Detection-Recall (Lyra-Eval-Suite),
dokumentierter Test-Plan + CI-Pipeline-Gate.** Das ist nicht Pflicht aber
glaubwürdigkeits-prägend. **Frage an User: soll Hans-Müller das im DSFA-Dokument
explizit machen?**
---
## 5. Tooling-Empfehlungen
### 5.1 Backend
- **Test-Framework: vitest 2.x.** Schnell (esbuild), TS-nativ, `vi.mock`-Pattern
matched 1:1 unsere trucko-Heritage. Kein Jest (langsamer, schlechtere TS-DX in
Nitro-Welt). Bun-test ist verlockend aber nitro+prisma stack ist node-validiert.
- **Coverage: `@vitest/coverage-v8`.** Built-in, schnell, accurate.
- **Integration-Tests: supertest** gegen ein in-process Nitro-`.output/server`
bundle. Alternativ: `pnpm dev` als Sidecar in CI starten und gegen
`localhost:3001` fetchen.
- **Mocking: Prisma via `vi.mock('../../server/utils/prisma', …)`** wie in trucko.
Supabase-Client per `vi.mock('@supabase/supabase-js', …)`.
- **Test-DB: NICHT setzen** in Phase 1+2. Mock reicht. Wenn später
Migration-Tests gebraucht: Postgres-Container in CI.
### 5.2 Frontend (Native)
- **Component-Tests: `jest-expo` + `@testing-library/react-native`.** Standard
Expo-Stack, gut dokumentiert. Snapshot-Tests sparsam (nur für stabile Strings,
kein i18n-Output).
- **E2E: Maestro.** Bereits in `ahmed.md` als Standard definiert. iOS+Android
YAML-Flows, kein Detox-Maintenance-Hell. Cloud-Run möglich für CI.
**NICHT Detox** — Setup-Aufwand zu hoch für unsere Team-Größe.
- **`testID`-Konvention:** `<area>-<element>-<action>`, z.B. `auth-username-input`,
`urge-sos-trigger-btn`. Component-Edits dafür sind erlaubt (in ahmed.md geregelt).
### 5.3 CI
- **GitHub Actions** `.github/workflows/rebreak-test.yml`:
- Job `backend-unit`: `pnpm install``pnpm --filter rebreak-backend test`
Coverage-Upload als Artifact. Läuft auf jedem PR.
- Job `frontend-unit`: `pnpm --filter rebreak-native test`. PR-only.
- Job `maestro-cloud`: nur main-Branch + manuell via `workflow_dispatch`,
Maestro-Cloud-Token aus Secrets, gegen Staging-Build.
- **Coverage-Threshold (vitest):** Phase 2 weich (40/40), Phase C streng
(70 % critical paths in `server/utils/`, `server/api/coach/`, `server/api/auth/`).
---
## 6. Konkrete TODOs nach Priorität
### Hoch (vor TestFlight, Wochenende)
| # | TODO | Aufwand | Owner |
| - | --------------------------------------------------------------------- | ------- | -------------- |
| 1 | Maestro CLI auf Mac + Android-Emulator + iOS-Sim installieren | 30 min | User |
| 2 | `apps/rebreak-native/.maestro/auth/sign-in.yaml` schreiben | 1 h | Ahmed |
| 3 | `.maestro/sos/start.yaml` schreiben (testID's am SOS-Button + LLM-Pill nötig — Edit-Approval) | 1.5 h | Ahmed + UI |
| 4 | `.maestro/community/feed-loads.yaml` schreiben | 1 h | Ahmed |
| 5 | Postman-Collection nach `backend/tests/postman/` kopieren + Staging-Env | 1 h | Ahmed |
| 6 | Manueller Curl-Smoke-Sheet der Top-5-Endpoints + Erwartungs-Status-Codes | 30 min | Ahmed |
### Mittel (Woche 1-2 nach TestFlight)
| # | TODO | Aufwand |
| -- | ------------------------------------------------------------------- | ------- |
| 7 | Vitest setup `backend/` + erste Test `plan-features.test.ts` portiert | 1.5 h |
| 8 | Port der 5 weiteren trucko-unit-Tests | 4 h |
| 9 | Neuer Test `auth-requireUser.test.ts` (Bug-Replikation diese Woche) | 2 h |
| 10 | GitHub Action `rebreak-test.yml` Job `backend-unit` | 1 h |
| 11 | Supertest + Integration-Setup, dann `auth/login.integration.test.ts` | 2 h |
| 12 | jest-expo + `@testing-library/react-native` setup im `apps/rebreak-native` | 1 h |
| 13 | Erster RN-Test: `lib/llmProvider.test.ts` (Plan-Tier-Resolve-Logik) | 1.5 h |
| 14 | Erster Component-Test: `components/profile/ProfileHeader.test.tsx` | 1.5 h |
### Niedrig (Woche 3+)
| # | TODO | Aufwand |
| -- | ------------------------------------------------------------------- | ------- |
| 15 | Lyra-Eval-Suite (Crisis-Detection Golden-Prompts) aus trucko porten | 4 h |
| 16 | Maestro-Flow für Phase-C Demographics + Pro-Trial-Reward | 2 h |
| 17 | Stripe-Webhook-Test mit Stripe-Mock + Test-Mode | 3 h |
| 18 | DSFA-Anhang: Test-Coverage-Statement mit Hans-Müller abstimmen | 1 h |
| 19 | Penetrationstest-Provider auswählen (DiGA-Pflicht, jährlich) | User-Decision |
| 20 | Coverage-Threshold in CI-Gate hochziehen auf 70 % critical paths | 1 h |
---
## 7. Wo der Schuh am stärksten drückt — TL;DR
1. **Auth-Middleware ungeschützt.** Der 500-Cascade dieser Woche war ein Lehrgeld-Bug.
Ein 50-Zeilen-Vitest-File hätte ihn vor Deploy gefangen. Höchste Priorität.
2. **SOS-Stream + Tier-LLM-Routing ungeschützt.** Wenn ein Free-User den falschen
LLM bekommt oder der Stream bricht ab in der Krise — wir verlieren Vertrauen
plus das ist ein DiGA-Recovery-Risk.
3. **Anonymität-Pattern ungeschützt.** Wenn jemand bei einem Refactor versehentlich
`select: { firstName: true }` schreibt statt `{ nickname: true }`, sehen plötzlich
alle User echte Namen. DSGVO Art. 9 + Stigma + DiGA = sofortige Krise.
4. **Pro-Trial-Reward kommt ohne Tests** — Stripe-Revenue-Leak-Risiko, Phase C ist
Pflicht-TDD-Zone.
5. **Kein CI-Gate.** Jeder Push kann grün durchdeployen, auch wenn die Codebase
tot ist. Das muss als erstes nach TestFlight kommen.
---
## 8. Open Questions an User
1. **Maestro Cloud Account oder lokale Runs?** Cloud kostet ~10 EUR/Monat, gibt
parallele iOS+Android-Runs in CI. Lokal ist gratis aber blockt Mac-CPU.
2. **Pen-Test-Provider für DiGA-Pflicht?** Jährlich, ca. 5-15k EUR. Ahmed kann
Vorschläge sammeln (Cure53, SektionEins, CrowdStrike-Partner) — User-Decision.
3. **Demographics + Pro-Trial: TDD oder Test-after?** Empfehlung TDD, weil
Reward-Logik in Stripe + DSGVO-relevante Pfade. Bestätigung gewünscht?
4. **Coverage-Threshold-Strategie:** soft (warning, kein Block) oder hard
(PR-Block bei Drop)? Empfehlung: erste 6 Wochen soft, danach hard auf 60 %.
5. **Hans-Müller-Frage:** Soll die DSFA explizit eine Test-Coverage-Quote für
DiGA-Antrag committen? Wenn ja — dann müssen wir vor BfArM-Submission
beweisbar sein.
---
Ende. — Ahmed

265
ops/UI_MIGRATION_PLAN.md Normal file
View File

@ -0,0 +1,265 @@
# UI Migration Plan — Settings + Profile (Nuxt → rebreak-native)
Stand: 2026-05-07
Scope: rebreak-native (Expo / RN). Owner: rebreak-native-ui.
Phase 1 = NUR Plan (dieses Dokument). Keine Code-Änderungen in `apps/rebreak-native/`.
---
## 1. Status Quo
### Nuxt-App (`~/mono/trucko-monorepo/apps/rebreak/app/pages/`)
Bereits gebaute Pages, die in rebreak-native fehlen oder nur als Stub existieren:
- `app/settings.vue` (~520 LOC) — 3 UTabs: **Streak**, **Profil**, **Einstellungen**
- Streak: `<StreakTab />` (separate Component)
- Profil: nickname-Edit + Avatar (HERO_AVATARS preset ODER Foto-Upload mit Cropper)
- Einstellungen-Tab enthält:
- Hilfe & FAQ (Link)
- Appearance: system / light / dark (UColorMode)
- Language: i18n locale-switch
- Dev-Tools: Keyboard-Resize-Test, Family-Controls Spike
- **Devices** (`useDeviceStore`) — Liste mit Limit-Progressbar, current-device-Badge
- **Community-Domains** (approved + rejected) aus `/api/custom-domains`
- **Subscription** (Stripe Portal — `/api/stripe/portal`) — paid only
- Logout
- `app/profile/[userId].vue` — fremdes Profil ansehen (nickname, tier, posts, follow, recent posts, DM-link)
### rebreak-native — was schon existiert
- `app/settings.tsx` (223 LOC) — Stub-Scaffold. Sections vorhanden, aber Handlers leer (`onPress: () => {}`).
- `components/AppHeader.tsx` — Dropdown-Menu funktioniert, hat schon `editProfile` + `settings` Items, beide routen auf `/settings`. SOS-Button im Dropdown ist auch da.
- `app/urge.tsx` — SOS-Page mit `KeyboardAvoidingView` + `<TtsProviderToggle />` + `<LlmProviderToggle />` als Floating-Bar (`st.ttsToggleBar`, line 11281131).
- `lib/ttsProvider.ts` — AsyncStorage-Persist, 5 provider (`openai`, `gemini`, `google-cloud`, `elevenlabs`, `cartesia`).
- `lib/llmProvider.ts` — analog für LLM (`auto`, `openrouter-sonnet`, …).
### Was komplett fehlt im rebreak-native
- Profile-Edit-Logik (nickname-Form, Avatar-Picker mit HERO_AVATARS + Photo-Upload)
- StreakTab-Equivalent als RN-Component
- Devices-Section (read aus `/api/devices`)
- Community-Domains-Section
- Stripe-Portal-Button
- Theme/Language-Switcher mit echtem State (i18n + ColorMode)
- Lyra-Voice-Picker (existiert nirgends)
- Debug-Section (TtsProviderToggle + LlmProviderToggle aktuell hardcoded auf urge.tsx)
- Profile-View für fremde User (`/app/profile/[userId]` Nuxt → `/profile/[userId].tsx` RN)
---
## 2. Header-Dropdown-Menu Architektur
Bereits im File: `apps/rebreak-native/components/AppHeader.tsx:6576`.
Aktuell:
```
[SOS — heart, prominent]
─────
person-outline "Profil bearbeiten" → /settings
settings-outline "Einstellungen" → /settings
─────
log-out-outline "Abmelden"
```
**Empfohlene Items nach Migration:**
- SOS (bleibt prominent)
- Profil bearbeiten → `/settings?tab=profile`
- Einstellungen → `/settings?tab=settings`
- Streak → `/settings?tab=streak`
- (Legend-only) Lyra-Voice → `/settings?tab=settings#lyra-voice`
- (Dev-Builds only / `__DEV__`) Debug → `/settings?tab=debug`
- Logout
→ Settings-Page übernimmt die alte Nuxt-3-Tab-Struktur **plus** Debug-Tab (gated auf `__DEV__` oder Internal-User).
Kein neues Header-Element nötig — Dropdown ist da, nur die `tab`-Query-Param-Logik in `settings.tsx` ergänzen + Items ergänzen.
---
## 3. Settings-Page Struktur (Migration-Ziel)
`app/settings.tsx` umbauen zu Tab-Layout (z.B. via simple Tab-Bar — kein UTabs in RN, custom mit drei `Pressable`-Buttons reicht).
### Tab 1 — Streak (MVP)
- StreakBadge + currentDays/longestDays (read aus `/api/streak`)
- Heatmap der letzten Wochen (StreakEvent-Liste)
- Reset-Button mit Confirm-Modal
### Tab 2 — Profil (MVP)
- Avatar (HERO_AVATARS preset grid + Photo-Upload via `expo-image-picker`)
- Nickname-Input (Save → `PATCH /api/auth/me { nickname }`)
- Username (read-only display, `@username`)
- Member-Since-Date
### Tab 3 — Einstellungen (MVP)
- **Appearance** — system/light/dark (AsyncStorage + theme-Provider) — *requires theme-store create*
- **Language** — de/en (i18n.changeLanguage + AsyncStorage persist)
- **Push-Notifications** Toggle (existiert schon im Stub)
- **Streak-Reminders** Toggle
- **Devices** (read aus `/api/devices`) — Liste, kein Delete (cooldown noch nicht implementiert)
- **Subscription** — Plan-Status + "Manage" → opens Stripe-Portal-URL via `Linking.openURL(url)`
- **Logout**
- **Delete Account** (Danger-Zone)
### Tab 4 — Lyra (Legend-only)
- Voice-Picker (default: Alexandra ElevenLabs / Sonic Cartesia)
- Speed-Slider? (ElevenLabs `voice_settings.style` o.ä.)
- Preview-Button (sample-text TTS)
### Tab 5 — Debug (gated `__DEV__` || internal-user)
- TtsProviderToggle (verschoben aus urge.tsx)
- LlmProviderToggle (verschoben aus urge.tsx)
- Bench-Anzeige (letzte BenchSession-Werte aus `lib/sosTtsBenchmark`)
- Reset-AsyncStorage-Button
### Followup (NICHT MVP)
- Community-Domains-Liste (kann zur Blocker-Page wandern)
- Hilfe & FAQ — Webview oder externe URL erstmal
- Family-Controls Spike — Native-only, kein UI nötig
- Profile-View `/profile/[userId].tsx` — Community-Section, kommt mit Community-Migration
---
## 4. iOS-Keyboard-Fix für SOS-Page
### Pattern aus `components/PostCommentsSheet.tsx`
Funktionierende Bestandteile (line 126139, 240, 367):
1. **Listener** auf `keyboardWillShow`/`keyboardWillHide` (iOS) bzw. `keyboardDidShow`/`keyboardDidHide` (Android) → `keyboardHeight`-State.
2. **Container-Padding bottom = `Platform.OS === 'ios' ? keyboardHeight : 0`** auf der gesamten inneren Inhalts-View. (Android nutzt `windowSoftInputMode=adjustResize` und braucht kein Padding.)
3. **Input-Bar paddingBottom = `keyboardHeight > 0 ? 8 : Math.max(12, insets.bottom)`** — schlankes Padding bei offener Tastatur, sonst Safe-Area.
4. `keyboardShouldPersistTaps="handled"` auf der scrollbaren Liste.
### Was urge.tsx aktuell macht (line 1144)
```tsx
<KeyboardAvoidingView style={{ flex: 1 }} behavior={Platform.OS === 'ios' ? 'padding' : 'height'} keyboardVerticalOffset={0}>
```
→ funktioniert in Modals manchmal, aber bei Full-Screen-Pages wie urge.tsx (kein Modal!) overlapped der Input bei iOS, weil `behavior="padding"` kämpft mit der SafeAreaView und dem festen `paddingTop: insets.top`. User-Bug ist reproduziert.
### Vorgeschlagener Fix für `app/urge.tsx`
**Option A (empfohlen, 1:1 PostCommentsSheet-Pattern):**
- `KeyboardAvoidingView` **entfernen**.
- `keyboardHeight`-State (existiert schon ab line 92, line 198199 listener → behalten).
- Auf den äußeren Container (oder den Wrapper um `FlatList + chips + inputBar`) `paddingBottom: Platform.OS === 'ios' ? keyboardHeight : 0` anwenden.
- Input-Bar (line 1230): `paddingBottom: keyboardHeight > 0 ? 8 : Math.max(12, insets.bottom)` ist schon korrekt — bleibt.
- `FlatList`: `keyboardShouldPersistTaps="handled"` hinzufügen (verhindert dass tap-on-chip die Tastatur wegklickt).
**Option B (minimaler Eingriff):**
- `KeyboardAvoidingView`-`keyboardVerticalOffset` auf `insets.top + topBarHeight` setzen statt `0` — hilft bei vielen Layouts, aber fragiler.
**A** ist konsistent mit dem bewährten Pattern und schon benutzt für PostComment. Soll der Default sein.
### Files anzupassen
- `apps/rebreak-native/app/urge.tsx` (lines 1144, 1249, ggf. um den top-bar-Container herum)
---
## 5. Lyra-Voice-Feature
### Status backend
`speak-elevenlabs.post.ts:28` und `speak-cartesia.post.ts:23` lesen voiceId NUR aus `runtimeConfig.elevenlabsVoiceId` / `cartesiaVoiceId` oder aus `process.env`. **Kein body-param `voiceId` aktuell.**
Aktuell ist die Voice systemweit fix.
### Frage: DB-Schema-Change nötig?
**JA** — aber minimal. Variante (a) ist **empfohlen**:
**(a) Profile-Field erweitern (1 Spalte):**
```prisma
model Profile {
...
lyraVoiceId String? @map("lyra_voice_id") // null = system-default
...
}
```
+ Migration: `ALTER TABLE rebreak.profiles ADD COLUMN lyra_voice_id text NULL;`
+ `me.patch.ts` um `lyraVoiceId` erweitern (gated: nur Legend-User dürfen setzen).
+ `me.get.ts` returnt `lyraVoiceId` mit.
**(b) Separates `UserPreference`-Modell (overkill für jetzt):**
Nur lohnenswert wenn weitere Prefs (theme, language-override, push-prefs) bald hinzu kommen. Dann lieber zentral.
→ Empfehlung: **(a)** für Phase 2, **(b)** parken bis 3+ Prefs gleichzeitig nötig sind.
### API-Changes
`speak-elevenlabs.post.ts` + `speak-cartesia.post.ts`:
- Body um optional `voiceId?: string` erweitern.
- VoiceId-Resolve-Order: body-param → user.lyraVoiceId (DB) → runtimeConfig → env → FALLBACK.
- Tier-check: wenn body-param gesetzt aber user nicht Legend → 403 oder silent-ignore + use default (silent-ignore safer).
### Frontend-Flow
1. `useMe()` returned bereits Profile incl. zukünftiges `lyraVoiceId`.
2. Settings-Tab (Lyra) — Voice-Liste hardcoded zur First-Iteration:
- ElevenLabs: Alexandra (`kdmDKE6EkgrWrrykO9Qt`), Rachel, …
- Cartesia: Default-DE (`b9de4a89-2257-424b-94c2-db18ba68c81a`), …
3. Save → `PATCH /api/auth/me { lyraVoiceId }`.
4. `lib/sosTtsQueue.ts:208` — Body um `voiceId: useMe().lyraVoiceId` erweitern.
### Voices initial (nice-to-have hardcoded)
- ElevenLabs: 35 deutsche female voices auswählen, in `lib/lyraVoices.ts` als statische Liste.
- Cartesia: 23 deutsche stimmen.
- Per Provider (TTS-Provider != Voice-Provider), Voice-Picker zeigt nur Voices passend zum gewählten TTS-Provider — oder "auto" mit per-Provider-Default-Map.
→ Erste Iteration: nur ElevenLabs-Voices anbieten (häufigster Provider), andere Provider ignorieren `lyraVoiceId`.
---
## 6. Migration-Reihenfolge (Phase 2/3/4)
### Phase 2 — Quick Wins (12 days)
1. **iOS-Keyboard-Fix urge.tsx** — kein Server-Change, isolierter UI-Fix → Smoke-Test auf iOS-Device.
2. **Debug-Tab in settings.tsx** — TtsProviderToggle + LlmProviderToggle dort einbauen, aus urge.tsx entfernen.
3. **Header-Dropdown** — Items "Streak" + "Debug" (gated `__DEV__`) ergänzen, settings.tsx Tab-Routing implementieren.
### Phase 3 — MVP-Cutover (35 days)
4. **Profil-Tab** — nickname + Avatar-Picker (preset). Photo-Upload via `expo-image-picker` + Crop später (sehr großes File mit `vue-advanced-cropper`-Equivalent in RN: `react-native-image-crop-picker` wäre Native-Module — später).
5. **Streak-Tab** — useStreak-Hook + Badge.
6. **Einstellungen-Tab** — Devices-Liste, Theme/Language-Picker, Notification-Toggles (real persisten), Logout (existiert).
7. **Subscription-Section** — Stripe-Portal-Link via `Linking.openURL`.
### Phase 4 — Legend Features (23 days)
8. **DB-Migration** `lyra_voice_id` (Backend-team).
9. **API-Update** `speak-*.post.ts` body-param + tier-check.
10. **Voice-Picker UI** im Lyra-Tab.
11. **sosTtsQueue.ts** body voiceId.
### Phase 5 — Followup
12. Community-Domains-Section (mit Blocker-Migration zusammen).
13. Profile-View `/profile/[userId].tsx`.
14. Hilfe/FAQ-Section.
15. Photo-Upload mit Crop (native module).
---
## 7. Top-3 Risiken
1. **Avatar-Photo-Upload** — Nuxt nutzt `vue-advanced-cropper` (HTML5-Canvas). RN-Alternative: `react-native-image-crop-picker` ist ein Native-Module (Expo-Plugin nötig) → Coordination mit `zied`/`backyard`. **Mitigation:** MVP nur HERO_AVATARS preset, photo-upload als Phase-5.
2. **Theme-Switch / ColorMode** — Aktuell nutzt rebreak-native NICHT `useColorScheme`-getriebenes Theme. Komplette Color-Token-Refactor wäre nötig (`lib/theme.ts` aktuell hardcoded light). **Mitigation:** MVP "system" disabled, nur Lock auf light. Dark-Mode Phase-5.
3. **DB-Migration `lyra_voice_id`** — Schema-Change auf production via Prisma migration → wenn Cutover-Blocker noch nicht resolved (siehe `feedback_backend_runtime_config.md`), hängt das. **Mitigation:** Voice-Picker erst bauen wenn Cutover stabil; bis dahin client-side AsyncStorage als Mock-Persist (nur lokal, regelt sich nach Login auf neuem Gerät).
---
## 8. Empfehlung — erster Schritt in Phase 2
**Start: iOS-Keyboard-Fix in `app/urge.tsx`.**
Warum:
- Isoliert (1 File, kein Backend, keine Coordination)
- Direkt user-sichtbar als Win
- Pattern (PostCommentsSheet) bereits validiert
- Kein Risiko für andere Features (kein Schema, kein API-Change)
- Sets up den Workflow: small PR, smoke-test, merge — vor den größeren Settings-Migrations.
Danach: Debug-Tab + TtsProviderToggle/LlmProviderToggle aus urge.tsx in settings.tsx wandern (entfernt visuelle Bench-Bar aus Production-Build, optional gated auf `__DEV__`).

View File

@ -0,0 +1,177 @@
# Webhook-Migration: Standalone Listener → Nitro-Endpoint (Trucko-Pattern)
**Owner:** Backyard
**Erstellt:** 2026-05-07
**Status:** PLAN — keine Implementierung in dieser Session. User-Direktive: „langsam starten".
**Ziel:** Migration des GitHub-Webhook-Receivers vom standalone `scripts/deploy-webhook/server.mjs` zu einem Nitro-Endpoint im Backend (`backend/server/api/webhook/github.post.ts`) — analog zum Trucko-Monorepo.
---
## 1. Status quo (Stand 2026-05-07)
### 1.1 Aktuelle Pipeline
```
GitHub push → nginx (staging.rebreak.org/webhook)
→ 127.0.0.1:9000 (pm2: rebreak-webhook)
→ scripts/deploy-webhook/server.mjs (HMAC-Verify + Queue)
→ spawn bash scripts/deploy.sh
→ git fetch + reset --hard origin/main
→ pnpm install --frozen-lockfile (workspace-root)
→ cd backend && pnpm --filter rebreak-backend build
→ .output → .output-staging (atomisch via tmp)
→ pm2 restart rebreak-staging --update-env
→ pm2 restart rebreak-imap-staging / rebreak-idle-staging / dns-* (best-effort)
```
### 1.2 Beteiligte Dateien
| Datei | Rolle |
|------|-------|
| `/srv/rebreak/scripts/deploy-webhook/server.mjs` | Standalone Node-HTTP-Server auf Port 9000, HMAC-Verify, spawn deploy.sh |
| `/srv/rebreak/scripts/deploy.sh` | Pull → install → build → atomic-deploy → pm2 restart |
| `/srv/rebreak/ecosystem.config.js` | pm2-Service `rebreak-webhook` (cluster mode, 1 instance) |
| `/etc/nginx/sites-enabled/staging.rebreak.org` | `location /webhook``proxy_pass http://127.0.0.1:9000/webhook` |
| `/etc/environment` | `GITHUB_WEBHOOK_SECRET` (via Infisical-bootstrap), `INFISICAL_CLIENT_ID/SECRET` |
### 1.3 Beobachtete Probleme
1. **Push-Detection unzuverlässig:** Manche Pushes triggern Deploy nicht (Listener empfängt Event nicht oder schweigt) — Symptom unklar (timing/network/hängender vorheriger spawn?).
2. **deploy.sh exit code 1:** Vergangene Logs zeigen `cd apps/rebreak: No such file or directory` (alter Pre-Cutover-Pfad in alten Logs; aktueller deploy.sh ist auf `backend/` korrekt umgestellt — Symptom nicht mehr reproduzierbar, aber Vertrauensverlust).
3. **Kein Healthcheck:** `/webhook` antwortet nur auf POST mit valider Sig. Kein `GET /webhook/health` o.ä. — manuelles Debug nur via `pm2 logs rebreak-webhook`.
4. **Kein Retry:** Wenn `git fetch` netzwerkbedingt failt, bleibt Deploy hängen / aborts ohne Rescheduling.
5. **Logs verstreut:** Webhook-Logs in `pm2 logs rebreak-webhook`, Deploy-Output in dieselben Logs gestreamt aber mit `[deploy] /[deploy:err]`-Prefixen — kein strukturiertes Logging, kein File-Persistence über pm2-Restart hinweg.
6. **Single-Point-of-Failure:** Wenn `rebreak-webhook` pm2-Prozess crashed, gehen Pushes ins Leere — keine GitHub-Redelivery-Automatik (manuell via GitHub UI nötig).
---
## 2. Trucko-Pattern (das Vorbild)
**Quelldatei:** `~/mono/trucko-monorepo/backend/server/api/webhook/github.post.ts` (957 Zeilen)
**Test-Coverage:** `~/mono/trucko-monorepo/backend/tests/unit/webhook-hmac.test.ts` (HMAC-Verify isoliert testbar)
### 2.1 Architektur-Eigenschaften
- **Webhook ist ein Nitro-API-Endpoint im Backend selbst** — kein separater Listener.
Pfad: `backend/server/api/webhook/github.post.ts` → wird über das Backend-pm2 (`rebreak-staging`) bedient.
- **HMAC-Sig-Verify** identisch wie unsere `server.mjs` (sha256, `timingSafeEqual`).
- **Reads `useRuntimeConfig()`** für `GITHUB_WEBHOOK_SECRET`, `GITHUB_USERNAME`, `GITHUB_TOKEN` — Secrets kommen via Infisical → Nuxt/Nitro runtimeConfig (kein extra `/etc/environment`-Parsing).
- **Affected-Detection:** Liest `payload.commits[].added/modified/removed`, normalisiert Pfade, mapped auf eine Tabelle `affected = { rebreak: bool, backend: bool, nginx: bool, ... }`. Nur betroffene Apps werden gebaut.
- **Queue-Mechanismus mit Merge:** `queueDeployment()` mergt `affected`-Flags wenn ein zweiter Push während eines laufenden Builds reinkommt → kein Doppel-Build, aber kein Verlust.
- **Inline-Build-Steps via `executeCommand()`-Helper:** Jeder Step (git fetch / install / build / pm2 restart) ist ein einzelner spawned bash mit Timeout + structured stdout/stderr-Capture. Kein externes deploy.sh.
- **Token-basierter Pull:** Nutzt `GITHUB_USERNAME` + `GITHUB_TOKEN` für `https://...@github.com/...` — kein SSH-Deploy-Key-Handling im Webhook.
- **Atomisches Output-Swap** für rebreak: `cp -r .output .output-staging-new && rm -rf .output-staging && mv .output-staging-new .output-staging` (identisch zu unserem deploy.sh).
- **Logs in pm2-stream:** Alles via `console.log("[Webhook] ...")` — landet in `pm2 logs backend`, zentral.
- **Healthcheck implizit:** Wenn das Backend lebt, lebt der Webhook. pm2 zeigt `backend online` → Webhook funktioniert.
### 2.2 Was das Trucko-Pattern besser macht
| Aspekt | Standalone-Listener (Status quo) | Nitro-Endpoint (Trucko) |
|--------|-----------------------------------|-------------------------|
| Single Source of Truth | 2 pm2-Services (webhook + app) | 1 pm2-Service (Backend allein) |
| Secret-Handling | `/etc/environment` parsing manuell | `useRuntimeConfig()` via Infisical |
| Affected-Logik | Nicht vorhanden, baut immer alles | Granular, nur betroffene Apps |
| Logs | 2 verschiedene pm2-Streams | 1 zentraler Stream |
| Test-Coverage | Keine Tests | `webhook-hmac.test.ts` (vitest) |
| Code-Lokation | `scripts/` (separater Code-Pfad) | `backend/server/api/` (gleiche TS-Codebase) |
| Crash-Resilience | Eigener Prozess kann sterben ohne app | Wenn Backend lebt, lebt Webhook |
---
## 3. Migrations-Schritte (für nächste Session — NICHT jetzt)
### Phase 1: Endpoint anlegen (Mac, kein Server-Change)
1. Datei `backend/server/api/webhook/github.post.ts` erstellen — kopiere Trucko-Vorlage, passe an:
- Repo-Pfad: `/srv/rebreak` (statt `/srv/trucko-monorepo`)
- `affected`-Map: nur `rebreak-native`, `backend`, `nginx`, `dns`, `imap` (kein `pizzabox`/`driver`/etc.)
- Build-Steps: nur Backend-Build (`pnpm --filter rebreak-backend build`) + atomic-output-swap + pm2 restart `rebreak-staging`
- Entferne Trucko-spezifische nginx-conf-Listen (die unsere ops/nginx-Files haben andere Namen)
2. `nitro.config.ts`: `runtimeConfig` um `githubWebhookSecret`, `githubUsername`, `githubToken` ergänzen (analog Trucko).
3. Test-File `backend/tests/unit/webhook-hmac.test.ts` mitmigrieren (vitest setup falls nötig).
4. Lokal `pnpm --filter rebreak-backend build` → checken dass Endpoint im `.output/server/chunks/routes/api/webhook/github.post.mjs` landet.
### Phase 2: Infisical-Secrets setzen (User-Eskalation)
5. Infisical Project rebreak-staging:
- `GITHUB_WEBHOOK_SECRET` (bereits vorhanden in /etc/environment, in Infisical spiegeln)
- `GITHUB_USERNAME` (z.B. `chahinebrini`)
- `GITHUB_TOKEN` (PAT mit `repo`-Scope für git-pull über HTTPS)
→ diese Secrets werden via `start-staging.sh` als `NUXT_*` env vars dem Backend bereitgestellt.
### Phase 3: Parallel-Betrieb (Failsafe)
6. **Beide Endpoints aktiv halten:**
- GitHub-Webhook bleibt auf `https://staging.rebreak.org/webhook` (alter Listener auf Port 9000).
- Neuer Endpoint zusätzlich erreichbar unter `https://staging.rebreak.org/api/webhook/github` (Backend-Routing).
7. Auf GitHub: **zweite Webhook-Konfig** anlegen, beide aktiv. Beide verifizieren mit demselben Secret.
8. Logs beide Streams beobachten — wenn neuer Endpoint zuverlässig deployt, alter wird redundant.
### Phase 4: Cutover
9. GitHub-Webhook umstellen: alten URL-Webhook deaktivieren, nur neuer `/api/webhook/github` aktiv.
10. nginx: `location /webhook``proxy_pass http://127.0.0.1:9000` ENTFERNEN (oder auf Backend umrouten).
11. `pm2 stop rebreak-webhook && pm2 delete rebreak-webhook` (NUR auf User-Approval — destruktiv).
12. `scripts/deploy-webhook/` archivieren (nicht löschen — Reference).
13. `scripts/deploy.sh` entweder:
- **a)** behalten als Fallback-Tool für manuelles `bash deploy.sh` auf SSH (empfohlen), oder
- **b)** Code-Logik ins Backend-Endpoint inline-spawnen wie Trucko es tut (mehr Code, aber 100% Single-Source).
### Phase 5: Hardening
14. Healthcheck-Endpoint hinzufügen: `backend/server/api/webhook/health.get.ts` → returns `{ ok, lastDeploy, queueDepth }`.
15. Persistent Deploy-Log: in `/var/log/rebreak/deploys.jsonl` schreiben (statt nur pm2-stream).
16. Slack/Discord-Notifier on deploy-fail (optional).
---
## 4. Risk-Assessment
### Top 3 Risks bei Migration
1. **Self-Deploy-Loop / Race-Condition beim Restart:**
Der Webhook lebt im Backend-Prozess. Wenn der Webhook `pm2 restart rebreak-staging` aufruft, killt der Prozess sich selbst, BEVOR die HTTP-Response gesendet ist. → GitHub bekommt timeout, retry-storm. **Mitigation:** Trucko macht's auch, und es funktioniert dort. Schlüssel: pm2 restart ist async + `setTimeout(2000)` vor dem restart, response wurde schon vor Build-Start gesendet (`return { ok }` BEVOR `queueDeployment()` läuft im Background). Muss bei uns **gleich** strukturiert werden.
2. **Build-Failure crashed Webhook-Capability:**
Wenn ein Bad-Push das Backend-Build kaputt macht, hat der nächste Push keinen Webhook-Endpoint mehr (Backend offline). → Stuck-State, manuelles SSH nötig zum Recovery.
**Mitigation:** Atomic-Output-Swap (.output → .output-staging via mv) bleibt. Alter Build überlebt im `.output-staging`, Backend bleibt online auch wenn neuer Build failed. **PLUS:** Listener als Failsafe parallel erstmal behalten (Phase 3).
3. **Infisical-Secret-Loading-Lücke:**
Wenn `useRuntimeConfig()` zur Build-Zeit aufgerufen wird (statt Runtime), landen die Secrets nicht im Endpoint. Trucko nutzt `runtimeConfig` → wird per `NUXT_*`-env bei Start aufgelöst. Unsere `start-staging.sh` muss `NUXT_GITHUB_WEBHOOK_SECRET`, `NUXT_GITHUB_USERNAME`, `NUXT_GITHUB_TOKEN` setzen — sonst 401 für jeden Push.
**Mitigation:** Phase 2 explizit verifizieren via `curl https://staging.rebreak.org/api/webhook/github -X POST -H "x-hub-signature-256: sha256=..." -d '...'` mit Test-Payload BEVOR GitHub-Webhook umgestellt wird.
### Weitere Risiken (lower-priority)
- **GitHub-Token-Lifetime:** PAT läuft ab → silent fail. Mitigation: Token mit langer Lifetime, ggf. Rotation-Reminder.
- **Body-Parsing-Reihenfolge:** Nitro liest body normal als JSON, aber HMAC braucht raw-body. Trucko liest manuell `event.req.on("data")` — Nitro-Middleware darf body nicht vorher konsumiert haben. Ggf. Plugin-Order checken.
- **OOM während Build:** 4 GB RAM, `NODE_OPTIONS=--max-old-space-size=1536` reicht heute. Wenn Backend-Build wächst → OOM-kill mid-build → nicht-atomar.
---
## 5. Fallback-Plan
Wenn Migration scheitert:
1. **Listener bleibt aktiv** während Migration (Phase 3 ist parallel-Betrieb).
2. **Rollback-Path:**
- GitHub-Webhook URL zurück auf `/webhook` (alter Listener).
- `pm2 restart rebreak-webhook` (falls gestoppt).
- Neuer Endpoint im Backend bleibt vorhanden, antwortet aber nicht mehr aus GitHub-Pushes — kein Schaden.
3. **Total-Rollback (nur falls Backend-Endpoint Backend-Builds blockiert):**
- File `backend/server/api/webhook/github.post.ts` löschen, neu builden, deployen.
- Cleanup: `runtimeConfig`-Einträge in `nitro.config.ts` zurückrollen.
---
## 6. Empfohlener erster Schritt (für nächste Session)
**Read-only Investigation:** Prüfen welche Infisical-Secrets aktuell im Backend-Runtime verfügbar sind (`pm2 logs rebreak-staging | grep -i webhook` + Infisical-CLI `infisical secrets --env=staging | grep GITHUB`). Damit ist klar ob Phase 2 trivial ist (Secrets bereits da) oder neue Secrets nötig sind. **Erst danach** Phase 1 (Endpoint anlegen) auf Mac, ohne deployen.
---
## 7. Was NICHT in Scope
- Code-Änderungen am App-Code (Nitro-Routes außerhalb `/webhook/`).
- Mail-Stack-Touchups (Mo's Scope).
- nginx-Routing-Änderung jetzt — erst nach Cutover-Phase 4.
- Force-Push / git history-rewrite.