chahinebrini 01d515d137 feat(rebreak-native): persistent FaceID-sign-in + iOS-grouped UI + Outlook guard + sparkline cooldowns
Auth / FaceID — eingeloggt bleiben funktioniert jetzt:
- AppLock-Init idempotent: late re-init durch router.replace-Re-Mount behält
  locked-State (fixt Endlosschleife: unlock → re-mount → init reset → lock)
- LockScreen-Auto-Prompt nur wenn AppState=active (verhindert silent FaceID-
  Fail wenn LockScreen während background-Event mountet — User sah dann nur
  Fallback-Button)
- index.tsx: wenn Session schon in AsyncStorage liegt → router.replace zu /(app),
  Landing wird übersprungen; early-return nach allen Hooks (Rules of Hooks)
- WebBrowser.dismissAuthSession vor openAuthSessionAsync (verhindert
  "Another web browser is already open" nach abgebrochenen OAuth-Flows)

UI — iOS-Grouped-Look auf Settings + Profile:
- Neue Theme-Tokens groupedBg (#F2F2F7 / #000) + card (#fff / #1c1c1e),
  identisch zu Apples systemGroupedBackground / secondarySystemGroupedBackground
- settings.tsx + profile/index.tsx + profile/[userId].tsx: Page-BG → groupedBg
- StreakSection / UrgeStatsCard / DemographicsAccordion / StatsBar /
  ApprovedDomainsList: Card-BG colors.surface → colors.card

Mail-Connect — Outlook-Tile entschärft:
- Microsoft hat App-Passwords für consumer-Outlook (.com/hotmail/live/msn) im
  September 2024 abgeschaltet, der bisherige Guide-Flow ist seit ~8 Monaten
  wirkungslos → AUTHENTICATIONFAILED
- Tile bleibt sichtbar mit opacity 0.45, "Kommt bald"-Sub-Label, disabled=true
- Provider-Typ um disabled? + disabledLabelKey? erweitert (wiederverwendbar)
- Backend-OAuth-Plan unter backend/docs/mail-outlook-oauth-plan.md (mo)
  → Generisches AuthMethod-Framework (app_password | oauth) geplant

Profile — Cooldown-Verlauf als Sparkline statt Endlos-Liste:
- 8 Wochen-Buckets, Bar-Höhe nach Frequenz (cap 5/Woche), leere Wochen als
  2px-Flatlines
- Sub-Label: "{n} Cooldowns in 8 Wochen · Ø 1 pro {avg} Wochen · zuletzt {date}"
- Neutral formuliert (Sucht-/Stigma-Sensibilität: Cooldown = Schutz-Pause,
  kein Rückfall)
- useProfileData.ts liefert rawStartedAt (ISO) zusätzlich zum formatierten Wert
- i18n-Keys unter profile.cooldown.* in DE + EN

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 16:15:54 +02:00

299 lines
8.2 KiB
TypeScript

import { View, Text } from 'react-native';
import { Ionicons } from '@expo/vector-icons';
import { useTranslation } from 'react-i18next';
import { useColors } from '../../lib/theme';
export type CooldownEntry = {
id: string;
startedAt: string;
rawStartedAt: string;
durationLabel: string;
status: 'active' | 'resolved' | 'cancelled';
reason: string | null;
};
type Props = {
currentDays: number;
longestDays: number;
startDate: string;
cooldowns: CooldownEntry[];
};
const WEEKS = 8;
const MAX_BAR_HEIGHT = 28;
const MIN_BAR_HEIGHT = 2;
function getMondayOfWeek(date: Date): Date {
const d = new Date(date);
const day = d.getDay();
const diff = (day === 0 ? -6 : 1 - day);
d.setDate(d.getDate() + diff);
d.setHours(0, 0, 0, 0);
return d;
}
function buildWeekBuckets(cooldowns: CooldownEntry[]): number[] {
const now = new Date();
const currentWeekMonday = getMondayOfWeek(now);
const buckets: number[] = Array(WEEKS).fill(0);
for (const c of cooldowns) {
if (!c.rawStartedAt) continue;
const started = new Date(c.rawStartedAt);
const weekMonday = getMondayOfWeek(started);
const diffMs = currentWeekMonday.getTime() - weekMonday.getTime();
const diffWeeks = Math.round(diffMs / (7 * 24 * 60 * 60 * 1000));
if (diffWeeks >= 0 && diffWeeks < WEEKS) {
const bucketIndex = WEEKS - 1 - diffWeeks;
buckets[bucketIndex]++;
}
}
return buckets;
}
function formatLastDate(cooldowns: CooldownEntry[], language: string): string {
if (cooldowns.length === 0) return '';
const sorted = [...cooldowns].sort(
(a, b) => new Date(b.rawStartedAt).getTime() - new Date(a.rawStartedAt).getTime(),
);
const latest = new Date(sorted[0].rawStartedAt);
if (language === 'de') {
const day = String(latest.getDate()).padStart(2, '0');
const month = String(latest.getMonth() + 1).padStart(2, '0');
return `${day}.${month}.`;
}
return latest.toLocaleDateString('en-US', { month: 'short', day: 'numeric' });
}
function formatAvg(totalCount: number, language: string): string {
if (totalCount === 0) return '0';
const avg = WEEKS / totalCount;
if (language === 'de') {
return avg.toFixed(1).replace('.', ',');
}
return avg.toFixed(1);
}
export function StreakSection({ currentDays, longestDays, startDate, cooldowns }: Props) {
const colors = useColors();
const { t, i18n } = useTranslation();
const lang = i18n.language ?? 'de';
const buckets = buildWeekBuckets(cooldowns);
const maxCount = Math.max(...buckets, 1);
const totalInWindow = buckets.reduce((s, v) => s + v, 0);
const cooldownsInWindow = totalInWindow;
const lastDate = cooldowns.length > 0 ? formatLastDate(cooldowns, lang) : null;
const avgStr = formatAvg(cooldownsInWindow, lang);
const countLabel =
cooldownsInWindow === 0
? t('profile.cooldown.none')
: cooldownsInWindow === 1
? t('profile.cooldown.count_one', { weeks: WEEKS })
: t('profile.cooldown.count_other', { n: cooldownsInWindow, weeks: WEEKS });
const avgLabel =
cooldownsInWindow > 0 && lastDate
? t('profile.cooldown.avg_last', { avg: avgStr, date: lastDate })
: null;
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,
}}
>
{t('profile.streak_section_label')}
</Text>
</View>
<View
style={{
backgroundColor: colors.card,
borderWidth: 1,
borderColor: colors.border,
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',
}}
>
{t('profile.streak_days_protected')}
</Text>
</View>
<Text
style={{
marginTop: 2,
fontSize: 12,
color: colors.textMuted,
fontFamily: 'Nunito_400Regular',
}}
>
{t('profile.streak_since', { date: startDate })}
</Text>
<Text
style={{
marginTop: 8,
fontSize: 12,
color: colors.textMuted,
fontFamily: 'Nunito_400Regular',
}}
>
{t('profile.streak_longest', { days: longestDays })}
</Text>
</View>
<View style={{ marginTop: 16 }}>
<View
style={{
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
marginBottom: 8,
paddingHorizontal: 2,
}}
>
<Text
style={{
fontSize: 11,
color: colors.textMuted,
fontFamily: 'Nunito_700Bold',
letterSpacing: 0.8,
}}
>
{t('profile.cooldown.heading')}
</Text>
<Text
style={{
fontSize: 11,
color: colors.textMuted,
fontFamily: 'Nunito_400Regular',
}}
>
{t('profile.cooldown.window_label', { weeks: WEEKS })}
</Text>
</View>
<View
style={{
backgroundColor: colors.card,
borderWidth: 1,
borderColor: colors.border,
borderRadius: 14,
padding: 16,
}}
>
<View
style={{
flexDirection: 'row',
alignItems: 'flex-end',
justifyContent: 'space-between',
height: MAX_BAR_HEIGHT + 20,
marginBottom: 6,
}}
>
{buckets.map((count, i) => {
const isEmpty = count === 0;
const barHeight = isEmpty
? MIN_BAR_HEIGHT
: Math.max(
MIN_BAR_HEIGHT,
Math.min(count, 5) / Math.min(maxCount, 5) * MAX_BAR_HEIGHT,
);
return (
<View key={i} style={{ alignItems: 'center', flex: 1 }}>
<View
style={{
width: 10,
height: barHeight,
borderRadius: 3,
backgroundColor: isEmpty ? colors.border : colors.brandOrange,
}}
/>
</View>
);
})}
</View>
<View
style={{
flexDirection: 'row',
justifyContent: 'space-between',
marginBottom: 12,
}}
>
{buckets.map((_, i) => (
<View key={i} style={{ flex: 1, alignItems: 'center' }}>
<Text
style={{
fontSize: 9,
color: colors.textMuted,
fontFamily: 'Nunito_400Regular',
}}
>
{t('profile.cooldown.week_label', { n: i + 1 })}
</Text>
</View>
))}
</View>
<Text
style={{
fontSize: 12,
color: colors.text,
fontFamily: 'Nunito_600SemiBold',
}}
>
{countLabel}
</Text>
{avgLabel ? (
<Text
style={{
marginTop: 2,
fontSize: 12,
color: colors.textMuted,
fontFamily: 'Nunito_400Regular',
}}
>
{avgLabel}
</Text>
) : null}
</View>
</View>
</View>
);
}