DSGVO Art. 9 — Compliance-Gap im Mail-Connect-Flow geschlossen (Hans-Müller-DSB
hat den Gap für Gmail/iCloud/GMX identifiziert, schon vor Outlook-OAuth-Pflicht):
- Schema: mail_connections.consent_at + consent_version + consent_ip_address;
neue consent_logs-Tabelle für Audit (grant + revoke append-only)
- Endpoints:
- POST /api/mail-connections/consent (Bulk-Array für Re-Consent, partial-fail
wirft sofort = DSGVO-sicher gegen silent-skip fremder IDs)
- POST /api/mail-connections/:id mit consent-gate (412 wenn consentVersion fehlt)
- DELETE /api/mail-connections/:id mit Widerruf-Log (OAuth-Token-Revoke als
TODO für mo Phase 2)
- GET /api/mail-connections/pending-consent — listet Bestands-Connections
mit consent_at=NULL für Re-Consent-Modal
- Account-Lösch-Bug fix: deleteAllMailConnections() war in user/delete nicht
eingebunden — Verbindungen blieben als Waisen
- Frontend:
- ConnectMailSheet: neuer Consent-Step VOR Provider-Grid (view-Machine
consent → grid → form), exakter Hans-Müller-Wortlaut für Art. 9 Abs. 2
lit. a Einwilligung
- MailConsentReminderSheet: Re-Consent-Modal beim App-Open für Bestands-User
- Stores mailConsent + mailConnectDraft (letzterer fixt Bug: Email/Provider
ging verloren wenn User Browser für App-Pw-Generierung öffnete)
- 12 neue i18n-Keys mail.consent.* in DE + EN
- Versionierter Consent-Text: art9-mail-v1-2026-05-13 (Bump bei Text-Änderung
triggert Re-Consent für alle)
Outlook-OAuth Schema (Phase 0 — additiv, Endpoints kommen später):
- mail_connections: auth_method (default 'app_password' → keine Bestands-
Connection bricht), oauth_access_token, oauth_refresh_token,
oauth_token_expiry, oauth_scope
- Encryption via bestehendes server/utils/crypto.ts (AES-256-GCM, Key aus
Infisical)
- Plan-Doc backend/docs/mail-outlook-oauth-plan.md (mo)
- DSB-Review backend/docs/mail-outlook-oauth-dsgvo-review.md (Hans-Müller):
MS als Sub-AV via DPA Sep 2025, EU Data Boundary seit Feb 2025; 5 Pflicht-
Aufgaben + Anwalts-Klärung zu DPA-Anspruch ohne MS-Lizenz
Profile — Cooldown-Pattern-Analysis als Collapsible:
- CooldownPatternAnalysis: 24h-Uhrzeit-Heatmap, Mo–So-Wochentag-Histogramm,
Top-5-Reason-Wortcloud mit Stop-Words-Filter, Cancel-Rate-Anzeige
- DiGA-relevant: NLP läuft client-side, reason-Texte verlassen das Device
nicht (gut für DSB-Akte)
- useProfileData: useCooldownHistoryFull (limit=100) für Pattern-Analyse
- Neutral formuliert, kein Stigma, alle Headings als Frage
Plan-Docs (kein Code):
- backend/docs/mail-custom-keywords-plan.md — Pro/Legend Custom-Keyword-Filter
(3.25 PT MVP, user-scoped, Body-Match in Phase 2)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
304 lines
8.4 KiB
TypeScript
304 lines
8.4 KiB
TypeScript
import { View, Text } from 'react-native';
|
|
import { Ionicons } from '@expo/vector-icons';
|
|
import { useTranslation } from 'react-i18next';
|
|
import { useColors } from '../../lib/theme';
|
|
import { CooldownPatternAnalysis } from './CooldownPatternAnalysis';
|
|
import type { BackendCooldownEntry } from '../../hooks/useProfileData';
|
|
|
|
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[];
|
|
rawCooldowns: BackendCooldownEntry[] | null;
|
|
};
|
|
|
|
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, rawCooldowns }: 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>
|
|
|
|
<CooldownPatternAnalysis rawCooldowns={rawCooldowns} />
|
|
</View>
|
|
</View>
|
|
);
|
|
}
|