chahinebrini e76be7ee78 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).
2026-05-07 18:22:58 +02:00

109 lines
4.0 KiB
TypeScript

// SOS+TTS Latenz-Benchmark.
//
// Eine BenchSession pro sendToLyra-Call. Aggregiert Timing-Marker aus
// sosStream + sosTtsQueue und druckt am Ende eine Tabelle ins Dev-Console.
//
// Marker-Reihenfolge im typischen Flow:
// t0 request-fired (sendToLyra start)
// t1 session-post-done (POST /sos-session resolved → sessionId da)
// t2 sse-first-chunk ("Lyra denkt fertig" — erstes Token)
// t3 sse-done (full text fertig)
// t4 tts-fetch-start (POST an /api/coach/speak-* fired)
// t5 tts-fetch-headers (response headers da, body kommt noch)
// t6 tts-body-done (kompletter Audio-Body geladen — DAS ist der Bottleneck)
// t7 tts-file-written (base64 → File geschrieben)
// t8 audio-loaded (Audio.Sound.createAsync resolved)
// t9 first-audio (erstes onPlaybackStatusUpdate mit isPlaying)
//
// Bottleneck-Diagnose:
// - (t6 - t5) groß → Body-Download dominiert. Cartesia's TTFB-Vorteil
// verpufft hier weil wir auf alles warten statt zu streamen.
// - (t9 - t8) groß → expo-av lädt langsam (file-codec-detect etc.)
export type BenchMarker =
| 'session-post-start'
| 'session-post-done'
| 'sse-first-chunk'
| 'sse-done'
| 'tts-fetch-start'
| 'tts-fetch-headers'
| 'tts-body-done'
| 'tts-file-written'
| 'audio-loaded'
| 'first-audio';
export type BenchOnMetric = (marker: BenchMarker, meta?: Record<string, unknown>) => void;
type MarkerEntry = {
marker: BenchMarker;
/** ms relativ zu t0 */
tRel: number;
meta?: Record<string, unknown>;
};
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; llm?: string; label?: string }) {
this.t0 = Date.now();
this.provider = opts.provider;
this.llm = opts.llm ?? 'unknown';
this.label = opts.label ?? 'sos-turn';
}
/** Bound version — kann direkt als onMetric weitergegeben werden. */
readonly mark: BenchOnMetric = (marker, meta) => {
if (this.printed) return;
this.entries.push({ marker, tRel: Date.now() - this.t0, meta });
};
/** Druckt eine kompakte Tabelle. Idempotent (nur 1x pro Session). */
print(extraNote?: string): void {
if (this.printed) return;
this.printed = true;
const get = (m: BenchMarker) => this.entries.find((e) => e.marker === m)?.tRel;
const fmt = (v: number | undefined) => (v == null ? '—' : `${v}ms`);
const diff = (a: BenchMarker, b: BenchMarker) => {
const va = get(a), vb = get(b);
return va != null && vb != null ? `${vb - va}ms` : '—';
};
const stages = {
tts: this.provider,
llm: this.llm,
label: this.label,
'req→session': fmt(get('session-post-done')),
'lyra-ttfb': fmt(get('sse-first-chunk')),
'lyra-done': fmt(get('sse-done')),
'tts-fired': fmt(get('tts-fetch-start')),
'tts-ttfb (rel)': diff('tts-fetch-start', 'tts-fetch-headers'),
'tts-body (rel)': diff('tts-fetch-headers', 'tts-body-done'),
'tts-file (rel)': diff('tts-body-done', 'tts-file-written'),
'audio-load (rel)': diff('tts-file-written', 'audio-loaded'),
'first-audio': fmt(get('first-audio')),
'TOTAL → speak': fmt(get('first-audio')),
};
// Eine kompakte Zeile als console.log (für Logbox-Lesbarkeit) +
// console.table mit allen Markern (für strukturierte Inspektion).
// eslint-disable-next-line no-console
console.log(
`[bench] LLM=${this.llm} TTS=${this.provider} (${this.label})${extraNote ? ' ' + extraNote : ''}`,
stages,
);
// eslint-disable-next-line no-console
console.table(this.entries.map((e) => ({ marker: e.marker, tRel: e.tRel })));
}
/** Snapshot für UI-Overlays (Debug-Drawer etc.). */
snapshot(): { provider: string; llm: string; label: string; entries: MarkerEntry[] } {
return { provider: this.provider, llm: this.llm, label: this.label, entries: [...this.entries] };
}
}