From 1dfb0c647cad53ed03fd6d698d779240c7843de7 Mon Sep 17 00:00:00 2001 From: chahinebrini Date: Wed, 13 May 2026 23:23:45 +0200 Subject: [PATCH] feat(mail-page): polish v3 + shared HalfDonut + status-dot heartbeat-aware MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit User-Feedback nach Live-Test: Frontend (mail page): - HalfDonut als shared component in components/common/HalfDonut.tsx extrahiert (vorher local in ProtectionDetailsSheet). Mail-Page nutzt jetzt dieselbe SVG-Math, Animation und Stroke-Style wie der Blocker-Schutz-Details-Sheet — visuelle Konsistenz auf einen Blick. Mail-Donut: width=168 (kompakter als die 220 in Blocker, weil Legend rechts daneben sitzt). - Donut zeigt Total in der Mitte mit kompaktem Format: < 1000 → "999", >=1000 → "1.2k+" / "12k+" / "27k+" Headline-Zahl oben links entfällt — Total ist im Donut-Center. - "Mehr Infos" + "Kürzlich blockiert" zu EINER Top-Level-Collapsible zusammengefasst. Beim Aufklappen: Bar-Chart direkt sichtbar, nested Collapsible "Kürzlich blockiert" darunter (default zu). - Account-Card Expanded: per-Connection-Bar-Chart mit adaptive Granularität nach Connection-Age: · <24h → Empty-State "Daten werden gesammelt, Auswertung nach 24h" · 1-14d → Day-Buckets (echte Daten via /api/mail/stats/blocked-by-day ?connectionId=) · 15-90d → Week-Buckets (client-aggregiert) · >90d → Month-Buckets (client-aggregiert) - Settings-Sheet komplett refactored: State-Machine `mode: 'list' | 'edit-title' | 'edit-email' | 'edit-password'` mit Back-Pfeil. Inline- Edit im selben Sheet statt Sub-Sheet öffnen (FormSheet-Pattern). Email-Edit-Row vorbereitet (Backend-PATCH-Endpoint kommt separat). - Pen-Icons app-weit entfernt: SheetFieldStack-Row, alle Settings-Rows auf chevron-forward (Memory-Konvention). Frontend (MailAccountCard status fix): - resolveStatusDot nutzt jetzt heartbeat-as-fallback. Vorher: "waiting" wenn lastScannedAt=null, egal ob Daemon längst connected war. Jetzt: "waiting" nur wenn weder lebendiger Heartbeat noch vergangener Scan existiert → frisch verbundene Connections (z.B. OAuth-Outlook 5s nach Connect) zeigen direkt "live". - Behebt User-Beobachtung: "wartet auf erste verbindung" bei Outlook obwohl Daemon-Log "connected, auth=xoauth2" zeigt. Backend (imap-idle daemon): - getMailboxLock("INBOX") jetzt mit 30s Promise.race-Timeout gewrappt. - Outlook/XOAUTH2 hat den Edge-Case, dass der Mailbox-Lock lautlos hängt nach erfolgreichem connect — die Session bleibt offen ohne Fortschritt bis der Renew-Timer (10min) ein imap.close() schickt. Mit Timeout wird das Failure-Mode explizit → Auth-Retry-Loop greift sauber + last_connect_error mit klarem Text (statt stiller Hänger). - Root-Cause "warum hängt es" noch nicht behoben — Diagnose nach Deploy in Logs (mo). Co-Authored-By: Claude Opus 4.7 (1M context) --- apps/rebreak-native/app/(app)/mail.tsx | 94 ++- .../components/SheetFieldStack.tsx | 2 +- .../blocker/ProtectionDetailsSheet.tsx | 111 +--- .../components/common/HalfDonut.tsx | 120 ++++ .../components/mail/MailAccountCard.tsx | 81 +-- .../mail/MailAccountSettingsSheet.tsx | 573 +++++++++++++----- .../components/mail/MailActivityLog.tsx | 203 ++++--- .../components/mail/MailBlockedByDayChart.tsx | 11 +- .../components/mail/MailDistributionChart.tsx | 135 ++--- apps/rebreak-native/hooks/useMailStats.ts | 102 +++- apps/rebreak-native/hooks/useMailStatus.ts | 2 + apps/rebreak-native/locales/de.json | 8 +- apps/rebreak-native/locales/en.json | 8 +- backend/imap-idle/index.mjs | 12 +- 14 files changed, 959 insertions(+), 503 deletions(-) create mode 100644 apps/rebreak-native/components/common/HalfDonut.tsx diff --git a/apps/rebreak-native/app/(app)/mail.tsx b/apps/rebreak-native/app/(app)/mail.tsx index 9b67655..15ec366 100644 --- a/apps/rebreak-native/app/(app)/mail.tsx +++ b/apps/rebreak-native/app/(app)/mail.tsx @@ -16,7 +16,7 @@ import { Ionicons } from '@expo/vector-icons'; import { AppHeader } from '../../components/AppHeader'; import { MailAccountCard } from '../../components/mail/MailAccountCard'; import { MailEmptyState } from '../../components/mail/MailEmptyState'; -import { MailActivityLog } from '../../components/mail/MailActivityLog'; +import { MailActivityLogBody } from '../../components/mail/MailActivityLog'; import { MailBlockedByDayChart } from '../../components/mail/MailBlockedByDayChart'; import { MailDistributionChart } from '../../components/mail/MailDistributionChart'; import { ConnectMailSheet } from '../../components/mail/ConnectMailSheet'; @@ -91,20 +91,28 @@ function MoreInfosSection({ expanded, onToggle, blockedByDay, + providers, colors, }: { expanded: boolean; onToggle: () => void; blockedByDay: import('../../hooks/useMailStats').BlockedByDayEntry[]; + providers: string[]; colors: import('../../lib/theme').ColorScheme; }) { const { t } = useTranslation(); + const [activityExpanded, setActivityExpanded] = useState(false); function handleToggle() { LayoutAnimation.configureNext(LayoutAnimation.Presets.easeInEaseOut); onToggle(); } + function handleActivityToggle() { + LayoutAnimation.configureNext(LayoutAnimation.Presets.easeInEaseOut); + setActivityExpanded((p) => !p); + } + return ( + {/* Bar-Chart direkt sichtbar */} + + {/* Nested: Kürzlich blockiert — default collapsed */} + + + + + + + + + {t('mail.activity_log_title')} + + + {t('mail.activity_log_subtitle')} + + + + + + + {activityExpanded && ( + + )} + )} @@ -197,7 +272,6 @@ export default function MailScreen() { const [successVisible, setSuccessVisible] = useState(false); const [disconnectingId, setDisconnectingId] = useState(null); const [expandedAccount, setExpandedAccount] = useState(null); - const [activityLogExpanded, setActivityLogExpanded] = useState(false); const [moreInfosExpanded, setMoreInfosExpanded] = useState(false); const [oauthTitleSheetConnectionId, setOauthTitleSheetConnectionId] = useState(null); @@ -443,25 +517,15 @@ export default function MailScreen() { )} - {/* 3. COLLAPSIBLE "MEHR INFOS" — Bar-Chart letzte 30 Tage */} + {/* 3. COLLAPSIBLE "MEHR INFOS" — Bar-Chart + nested Kürzlich blockiert */} {hasAccounts && ( setMoreInfosExpanded((p) => !p)} blockedByDay={blockedByDay} - colors={colors} - /> - - )} - - {/* 4. ACTIVITY LOG */} - {hasAccounts && ( - - setActivityLogExpanded((p) => !p)} providers={distinctProviders} + colors={colors} /> )} diff --git a/apps/rebreak-native/components/SheetFieldStack.tsx b/apps/rebreak-native/components/SheetFieldStack.tsx index 5185efa..e028c62 100644 --- a/apps/rebreak-native/components/SheetFieldStack.tsx +++ b/apps/rebreak-native/components/SheetFieldStack.tsx @@ -142,7 +142,7 @@ export function SheetFieldStack({ fields, intro, children, onComplete }: Props) {field.secureTextEntry ? '••••••••' : field.value} - + ))} diff --git a/apps/rebreak-native/components/blocker/ProtectionDetailsSheet.tsx b/apps/rebreak-native/components/blocker/ProtectionDetailsSheet.tsx index 3410dd4..d606eb8 100644 --- a/apps/rebreak-native/components/blocker/ProtectionDetailsSheet.tsx +++ b/apps/rebreak-native/components/blocker/ProtectionDetailsSheet.tsx @@ -11,11 +11,11 @@ import { import { useSafeAreaInsets } from 'react-native-safe-area-context'; import { Ionicons } from '@expo/vector-icons'; import { useTranslation } from 'react-i18next'; -import Svg, { Path, Circle } from 'react-native-svg'; import type { ProtectionState } from '../../lib/protection'; import { apiFetch } from '../../lib/api'; import { useColors } from '../../lib/theme'; import { FormSheet } from '../FormSheet'; +import { HalfDonut } from '../common/HalfDonut'; type Props = { visible: boolean; @@ -411,115 +411,6 @@ function LegendItem({ ); } -// ─── Half Donut (multi-segment) ──────────────────────────────────────────── -function HalfDonut({ - segments, - centerValue, - centerLabel, -}: { - segments: { value: number; color: string }[]; - centerValue: number; - centerLabel: string; -}) { - const colors = useColors(); - const total = Math.max(1, segments.reduce((s, x) => s + x.value, 0)); - - const W = 220; - const H = 130; - const cx = W / 2; - const cy = H - 8; - const r = 90; - const stroke = 18; - - // Compute cumulative angles in [180, 360] - let cumAngle = 180; - const arcs = segments.map((seg) => { - const startAngle = cumAngle; - const endAngle = cumAngle + 180 * (seg.value / total); - cumAngle = endAngle; - return { ...seg, startAngle, endAngle }; - }); - - const animProgress = useRef(new Animated.Value(0)).current; - const [progress, setProgress] = useState(0); - - useEffect(() => { - animProgress.setValue(0); - const l = animProgress.addListener(({ value }) => setProgress(value)); - Animated.timing(animProgress, { - toValue: 1, - duration: 1100, - easing: Easing.out(Easing.cubic), - useNativeDriver: false, - }).start(); - return () => animProgress.removeListener(l); - }, [centerValue, animProgress]); - - return ( - - - {/* Background track */} - - {arcs.map((a, i) => { - const animatedEnd = - a.startAngle + (a.endAngle - a.startAngle) * progress; - if (animatedEnd <= a.startAngle + 0.5) return null; - return ( - - ); - })} - {centerValue === 0 && ( - - )} - - - {/* Center number — exactly centered horizontally + vertically inside semicircle */} - - - {centerValue} - - - {centerLabel} - - - - ); -} - -function arcPath(cx: number, cy: number, r: number, startDeg: number, endDeg: number) { - const start = polar(cx, cy, r, startDeg); - const end = polar(cx, cy, r, endDeg); - const largeArc = endDeg - startDeg > 180 ? 1 : 0; - return `M ${start.x} ${start.y} A ${r} ${r} 0 ${largeArc} 1 ${end.x} ${end.y}`; -} - -function polar(cx: number, cy: number, r: number, angleDeg: number) { - const rad = (angleDeg * Math.PI) / 180; - return { x: cx + r * Math.cos(rad), y: cy + r * Math.sin(rad) }; -} - // ─── FAQ Item (chevron AT END of header row, on right) ───────────────────── function FaqItem({ question, answer }: { question: string; answer: string }) { const colors = useColors(); diff --git a/apps/rebreak-native/components/common/HalfDonut.tsx b/apps/rebreak-native/components/common/HalfDonut.tsx new file mode 100644 index 0000000..a8d933a --- /dev/null +++ b/apps/rebreak-native/components/common/HalfDonut.tsx @@ -0,0 +1,120 @@ +import { useEffect, useRef, useState } from 'react'; +import { View, Text, Animated, Easing } from 'react-native'; +import Svg, { Path, Circle } from 'react-native-svg'; +import { useColors } from '../../lib/theme'; + +export type HalfDonutSegment = { value: number; color: string }; + +type Props = { + segments: HalfDonutSegment[]; + centerValue: number | string; + centerLabel: string; + width?: number; +}; + +const W_DEFAULT = 220; +const H_DEFAULT = 130; +const R = 90; +const STROKE = 18; + +function polar(cx: number, cy: number, r: number, angleDeg: number) { + const rad = (angleDeg * Math.PI) / 180; + return { x: cx + r * Math.cos(rad), y: cy + r * Math.sin(rad) }; +} + +function arcPath(cx: number, cy: number, r: number, startDeg: number, endDeg: number) { + const start = polar(cx, cy, r, startDeg); + const end = polar(cx, cy, r, endDeg); + const largeArc = endDeg - startDeg > 180 ? 1 : 0; + return `M ${start.x} ${start.y} A ${r} ${r} 0 ${largeArc} 1 ${end.x} ${end.y}`; +} + +export function HalfDonut({ segments, centerValue, centerLabel, width = W_DEFAULT }: Props) { + const colors = useColors(); + + const scale = width / W_DEFAULT; + const W = width; + const H = Math.round(H_DEFAULT * scale); + const cx = W / 2; + const cy = H - Math.round(8 * scale); + const r = Math.round(R * scale); + const stroke = Math.round(STROKE * scale); + + const total = Math.max(1, segments.reduce((s, x) => s + x.value, 0)); + + let cumAngle = 180; + const arcs = segments.map((seg) => { + const startAngle = cumAngle; + const endAngle = cumAngle + 180 * (seg.value / total); + cumAngle = endAngle; + return { ...seg, startAngle, endAngle }; + }); + + const animProgress = useRef(new Animated.Value(0)).current; + const [progress, setProgress] = useState(0); + + const animKey = typeof centerValue === 'number' ? centerValue : centerValue; + + useEffect(() => { + animProgress.setValue(0); + const l = animProgress.addListener(({ value }) => setProgress(value)); + Animated.timing(animProgress, { + toValue: 1, + duration: 1100, + easing: Easing.out(Easing.cubic), + useNativeDriver: false, + }).start(); + return () => animProgress.removeListener(l); + }, [animKey, animProgress]); + + const isEmpty = typeof centerValue === 'number' ? centerValue === 0 : centerValue === '0'; + + return ( + + + + {arcs.map((a, i) => { + const animatedEnd = a.startAngle + (a.endAngle - a.startAngle) * progress; + if (animatedEnd <= a.startAngle + 0.5) return null; + return ( + + ); + })} + {isEmpty && ( + + )} + + + + + {centerValue} + + + {centerLabel} + + + + ); +} diff --git a/apps/rebreak-native/components/mail/MailAccountCard.tsx b/apps/rebreak-native/components/mail/MailAccountCard.tsx index 0360dc1..ccb60b3 100644 --- a/apps/rebreak-native/components/mail/MailAccountCard.tsx +++ b/apps/rebreak-native/components/mail/MailAccountCard.tsx @@ -12,12 +12,10 @@ import { import { Ionicons } from '@expo/vector-icons'; import { useTranslation } from 'react-i18next'; import { ConfirmAlert } from '../ConfirmAlert'; -import { EditMailAccountSheet } from './EditMailAccountSheet'; -import { EditMailTitleSheet } from './EditMailTitleSheet'; import { MailAccountSettingsSheet } from './MailAccountSettingsSheet'; import { MailBlockedByDayChart } from './MailBlockedByDayChart'; +import { useMailConnectionStats } from '../../hooks/useMailStats'; import type { MailAccount } from '../../hooks/useMailStatus'; -import type { BlockedByDayEntry } from '../../hooks/useMailStats'; if (Platform.OS === 'android' && UIManager.setLayoutAnimationEnabledExperimental) { UIManager.setLayoutAnimationEnabledExperimental(true); @@ -33,7 +31,6 @@ type Props = { onEditSuccess: () => void; disconnecting?: boolean; blockedLast30d?: number; - connectionBlockedByDay?: BlockedByDayEntry[]; }; function OAuthDisconnectHintModal({ @@ -186,10 +183,17 @@ type StatusDot = 'live' | 'stale' | 'error' | 'waiting'; function resolveStatusDot(account: MailAccount): StatusDot { if (account.lastConnectError) return 'error'; - if (!account.lastScannedAt) return 'waiting'; + // 'waiting' nur wenn weder ein lebendiger Heartbeat noch ein vergangener Scan + // existiert. Bei frisch verbundenen Connections (z.B. OAuth-Outlook nach + // ersten 5-30s) hat der Daemon schon einen Heartbeat geschrieben, aber + // lastScannedAt bleibt NULL bis die erste Gambling-Mail trifft. Wir wollen + // dann 'live' anzeigen, nicht 'waiting'. const heartbeatAlive = idleHeartbeatAlive(account.lastIdleHeartbeatAt); - const scannedAgo = Date.now() - new Date(account.lastScannedAt).getTime(); - if (!heartbeatAlive && scannedAgo > STALE_THRESHOLD_MS) return 'stale'; + if (!account.lastScannedAt && !heartbeatAlive) return 'waiting'; + if (account.lastScannedAt) { + const scannedAgo = Date.now() - new Date(account.lastScannedAt).getTime(); + if (!heartbeatAlive && scannedAgo > STALE_THRESHOLD_MS) return 'stale'; + } return 'live'; } @@ -252,16 +256,18 @@ export function MailAccountCard({ onEditSuccess, disconnecting, blockedLast30d, - connectionBlockedByDay, }: Props) { const { t } = useTranslation(); const [settingsVisible, setSettingsVisible] = useState(false); - const [editPasswordVisible, setEditPasswordVisible] = useState(false); - const [editTitleVisible, setEditTitleVisible] = useState(false); const [confirmVisible, setConfirmVisible] = useState(false); const [oauthDisconnectHintVisible, setOauthDisconnectHintVisible] = useState(false); const [localTitle, setLocalTitle] = useState(account.title ?? null); const { icon, color } = resolveProviderIcon(account.provider); + const { data: connStats, granularity, loading: statsLoading } = useMailConnectionStats( + account.id, + account.createdAt ?? null, + expanded, + ); const isOAuth = isOAuthProvider(account.provider); const isLegend = plan === 'legend'; @@ -271,10 +277,6 @@ export function MailAccountCard({ const displayTitle = localTitle ?? domainFromEmail(account.email); function handleToggle() { - if (hasError) { - setEditPasswordVisible(true); - return; - } LayoutAnimation.configureNext(LayoutAnimation.Presets.easeInEaseOut); onToggle(); } @@ -361,9 +363,27 @@ export function MailAccountCard({ {/* Per-connection bar chart */} - {connectionBlockedByDay && connectionBlockedByDay.length > 0 ? ( - - ) : ( + {granularity === 'too-new' ? ( + + + {t('mail.account_chart_collecting_title')} + + + {t('mail.account_chart_collecting_body')} + + + ) : statsLoading && connStats.length === 0 ? ( - {t('mail.account_chart_unavailable')} + {t('mail.loading')} + ) : ( + )} @@ -412,7 +434,7 @@ export function MailAccountCard({ )} - {/* Settings sub-sheet */} + {/* Settings sub-sheet — inline edit, no nested sheets */} setSettingsVisible(false)} - onEditTitle={() => { setSettingsVisible(false); setEditTitleVisible(true); }} - onEditPassword={() => { setSettingsVisible(false); setEditPasswordVisible(true); }} + onTitleSaved={handleTitleSaved} + onPasswordSaved={onEditSuccess} onDisconnectRequest={() => { setSettingsVisible(false); setConfirmVisible(true); }} onIntervalChanged={onIntervalChanged} /> @@ -448,23 +470,6 @@ export function MailAccountCard({ onClose={() => setOauthDisconnectHintVisible(false)} t={t} /> - - {!isOAuth && ( - setEditPasswordVisible(false)} - onSuccess={onEditSuccess} - /> - )} - - setEditTitleVisible(false)} - onSuccess={handleTitleSaved} - /> ); } diff --git a/apps/rebreak-native/components/mail/MailAccountSettingsSheet.tsx b/apps/rebreak-native/components/mail/MailAccountSettingsSheet.tsx index de00829..84025b1 100644 --- a/apps/rebreak-native/components/mail/MailAccountSettingsSheet.tsx +++ b/apps/rebreak-native/components/mail/MailAccountSettingsSheet.tsx @@ -1,10 +1,22 @@ -import { Text, TouchableOpacity, View } from 'react-native'; +import { useState } from 'react'; +import { + ActivityIndicator, + Text, + TextInput, + TouchableOpacity, + View, +} from 'react-native'; import { Ionicons } from '@expo/vector-icons'; import { useTranslation } from 'react-i18next'; import { FormSheet } from '../FormSheet'; import { useMailInterval } from '../../hooks/useMailInterval'; +import { useMailTitleEdit } from '../../hooks/useMailTitleEdit'; +import { useMailConnect } from '../../hooks/useMailConnect'; +import { useColors } from '../../lib/theme'; import type { MailAccount } from '../../hooks/useMailStatus'; +type EditMode = 'list' | 'edit-title' | 'edit-email' | 'edit-password'; + type Props = { visible: boolean; account: MailAccount; @@ -13,8 +25,8 @@ type Props = { plan: 'free' | 'pro' | 'legend'; disconnecting?: boolean; onClose: () => void; - onEditTitle: () => void; - onEditPassword: () => void; + onTitleSaved: (newTitle: string | null) => void; + onPasswordSaved: () => void; onDisconnectRequest: () => void; onIntervalChanged: () => void; }; @@ -30,13 +42,11 @@ function domainFromEmail(email: string): string { } function SettingsRow({ - icon, label, value, onPress, destructive, }: { - icon: React.ComponentProps['name']; label: string; value?: string; onPress?: () => void; @@ -58,12 +68,6 @@ function SettingsRow({ borderBottomColor: '#f5f5f5', }} > - @@ -90,6 +94,118 @@ function SettingsRow({ ); } +function EditView({ + label, + value, + onChangeText, + onSave, + onBack, + saving, + error, + secureTextEntry, + keyboardType, + autoCapitalize, + placeholder, +}: { + label: string; + value: string; + onChangeText: (v: string) => void; + onSave: () => void; + onBack: () => void; + saving: boolean; + error: string | null; + secureTextEntry?: boolean; + keyboardType?: TextInput['props']['keyboardType']; + autoCapitalize?: TextInput['props']['autoCapitalize']; + placeholder?: string; +}) { + const { t } = useTranslation(); + const colors = useColors(); + + return ( + + {/* Back row */} + + + + {label} + + + + {/* Input */} + + + + + {error && ( + + {error} + + )} + + {/* Save button */} + + + {saving ? ( + + ) : ( + + {t('mail.title_save')} + + )} + + + + ); +} + export function MailAccountSettingsSheet({ visible, account, @@ -98,177 +214,352 @@ export function MailAccountSettingsSheet({ plan, disconnecting, onClose, - onEditTitle, - onEditPassword, + onTitleSaved, + onPasswordSaved, onDisconnectRequest, onIntervalChanged, }: Props) { const { t } = useTranslation(); const { setInterval, updating } = useMailInterval(); + const { saveTitle, saving: savingTitle, error: titleError } = useMailTitleEdit(); + const { connect, connecting: connectingPassword, error: connectError } = useMailConnect(); + const isLegend = plan === 'legend'; const intervalOptions = INTERVAL_OPTIONS_BY_PLAN[plan]; const displayTitle = localTitle ?? domainFromEmail(account.email); + const [mode, setMode] = useState('list'); + const [titleDraft, setTitleDraft] = useState(localTitle ?? ''); + const [passwordDraft, setPasswordDraft] = useState(''); + const [passwordVisible, setPasswordVisible] = useState(false); + const [localError, setLocalError] = useState(null); + + function handleClose() { + setMode('list'); + setTitleDraft(localTitle ?? ''); + setPasswordDraft(''); + setPasswordVisible(false); + setLocalError(null); + onClose(); + } + + function goBack() { + setMode('list'); + setLocalError(null); + } + async function handleSetInterval(value: number) { const res = await setInterval(account.id, value); if (res.ok) onIntervalChanged(); } + async function handleSaveTitle() { + const ok = await saveTitle(account.id, titleDraft); + if (ok) { + onTitleSaved(titleDraft.trim() || null); + setMode('list'); + } + } + + async function handleSavePassword() { + setLocalError(null); + const result = await connect({ email: account.email, password: passwordDraft }); + if (result.ok) { + setPasswordDraft(''); + setPasswordVisible(false); + onPasswordSaved(); + setMode('list'); + } else { + setLocalError(result.error ?? t('mail.connect_failed')); + } + } + + const sheetTitle = mode === 'list' + ? displayTitle + : mode === 'edit-title' + ? t('mail.row_title') + : mode === 'edit-email' + ? t('mail.row_email') + : t('mail.row_password'); + return ( - - {/* Bezeichnung */} - - - {/* E-Mail (read-only) */} - - - {/* Passwort — nur für IMAP-Accounts */} - {!isOAuth && ( + {mode === 'list' && ( + + {/* Bezeichnung */} { setTitleDraft(localTitle ?? ''); setMode('edit-title'); }} /> - )} - {/* Scan-Intervall */} - {!isLegend ? ( - - setMode('edit-email') : undefined} + /> + + {/* Passwort — nur IMAP */} + {!isOAuth && ( + { setPasswordDraft(''); setPasswordVisible(false); setMode('edit-password'); }} + /> + )} + + {/* Scan-Intervall */} + {!isLegend ? ( + - {t('mail.scan_interval_label')} - - - {intervalOptions.map((opt) => { - const active = account.scanInterval === opt; - const disabled = plan === 'free' || updating === account.id; - return ( - handleSetInterval(opt)} - style={{ - flex: 1, - paddingVertical: 9, - borderRadius: 10, - alignItems: 'center', - backgroundColor: active ? '#007AFF' : '#f5f5f5', - opacity: disabled && !active ? 0.5 : 1, - }} - > - - {opt}h - - - ); - })} - - {plan === 'free' && ( - {t('mail.free_scan_interval_hint')} + {t('mail.scan_interval_label')} - )} - - ) : ( + + {intervalOptions.map((opt) => { + const active = account.scanInterval === opt; + const disabled = plan === 'free' || updating === account.id; + return ( + handleSetInterval(opt)} + style={{ + flex: 1, + paddingVertical: 9, + borderRadius: 10, + alignItems: 'center', + backgroundColor: active ? '#007AFF' : '#f5f5f5', + opacity: disabled && !active ? 0.5 : 1, + }} + > + + {opt}h + + + ); + })} + + {plan === 'free' && ( + + {t('mail.free_scan_interval_hint')} + + )} + + ) : ( + + + + {t('mail.realtime_desc')} + + + )} + + + + {/* Verbindung trennen */} + + + + {t('mail.row_disconnect')} + + + + )} + + {mode === 'edit-title' && ( + + )} + + {mode === 'edit-email' && ( + {}} + onSave={() => {}} + onBack={goBack} + saving={false} + error={t('mail.email_change_not_supported')} + keyboardType="email-address" + autoCapitalize="none" + placeholder={account.email} + /> + )} + + {mode === 'edit-password' && ( + + {/* Back row */} + + + + {t('mail.row_password')} + + + + {/* Password input with visibility toggle */} - - { setPasswordDraft(v); setLocalError(null); }} + placeholder={t('mail.app_password_placeholder')} + placeholderTextColor="#a3a3a3" + secureTextEntry={!passwordVisible} + autoCapitalize="none" + autoCorrect={false} + returnKeyType="done" + onSubmitEditing={handleSavePassword} + style={{ + flex: 1, + paddingVertical: 13, + fontSize: 15, + fontFamily: 'Nunito_400Regular', + color: '#0a0a0a', + }} + /> + setPasswordVisible((p) => !p)} + hitSlop={8} > - {t('mail.realtime_desc')} - + + - )} - {/* Separator */} - + {(localError ?? connectError) && ( + + {localError ?? connectError} + + )} - {/* Verbindung trennen */} - - - - {t('mail.row_disconnect')} - - - + + {connectingPassword ? ( + + ) : ( + + {t('mail.title_save')} + + )} + + + + )} ); } diff --git a/apps/rebreak-native/components/mail/MailActivityLog.tsx b/apps/rebreak-native/components/mail/MailActivityLog.tsx index 4e1493c..40df865 100644 --- a/apps/rebreak-native/components/mail/MailActivityLog.tsx +++ b/apps/rebreak-native/components/mail/MailActivityLog.tsx @@ -54,21 +54,12 @@ function providerDisplayName(provider: string): string { export function MailActivityLog({ expanded, onToggle, providers = [] }: Props) { const { t } = useTranslation(); const colors = useColors(); - const [activeProvider, setActiveProvider] = useState('all'); - - const { results, total, loading, refresh } = useMailResults(expanded, activeProvider); - - const filterOptions = ['all', ...providers]; function handleToggle() { LayoutAnimation.configureNext(LayoutAnimation.Presets.easeInEaseOut); onToggle(); } - function handleProviderFilter(p: string) { - setActiveProvider(p); - } - return ( - {/* Provider filter chips */} - {filterOptions.length > 1 && ( - - {filterOptions.map((p) => { - const active = activeProvider === p; - return ( - handleProviderFilter(p)} - style={{ - paddingHorizontal: 12, - paddingVertical: 5, - borderRadius: 999, - backgroundColor: active ? '#007AFF' : colors.surfaceElevated, - borderWidth: active ? 0 : 1, - borderColor: colors.border, - }} - > - - {p === 'all' ? t('mail.filter.all') : providerDisplayName(p)} - - - ); - })} - - )} - - {loading && results.length === 0 ? ( - - - {t('mail.loading')} - - - ) : results.length === 0 ? ( - - - - {t('mail.activity_log_empty')} - - - ) : ( - <> - {results.slice(0, 10).map((item) => ( - - ))} - - - {total > 10 - ? t('mail.activity_log_more', { count: total - 10 }) - : t('mail.activity_log_count', { count: total })} - - - - - - - )} + )} ); } +/** + * Only the expandable body — used standalone by MoreInfosSection as nested collapsible. + */ +export function MailActivityLogBody({ + providers = [], + colors, +}: { + providers?: string[]; + colors: ReturnType; +}) { + const { t } = useTranslation(); + const [activeProvider, setActiveProvider] = useState('all'); + + const { results, total, loading, refresh } = useMailResults(true, activeProvider); + + const filterOptions = ['all', ...providers]; + + return ( + + {/* Provider filter chips */} + {filterOptions.length > 1 && ( + + {filterOptions.map((p) => { + const active = activeProvider === p; + return ( + setActiveProvider(p)} + style={{ + paddingHorizontal: 12, + paddingVertical: 5, + borderRadius: 999, + backgroundColor: active ? '#007AFF' : colors.surfaceElevated, + borderWidth: active ? 0 : 1, + borderColor: colors.border, + }} + > + + {p === 'all' ? t('mail.filter.all') : providerDisplayName(p)} + + + ); + })} + + )} + + {loading && results.length === 0 ? ( + + + {t('mail.loading')} + + + ) : results.length === 0 ? ( + + + + {t('mail.activity_log_empty')} + + + ) : ( + <> + {results.slice(0, 10).map((item) => ( + + ))} + + + {total > 10 + ? t('mail.activity_log_more', { count: total - 10 }) + : t('mail.activity_log_count', { count: total })} + + + + + + + )} + + ); +} + function ActivityItem({ item, t, diff --git a/apps/rebreak-native/components/mail/MailBlockedByDayChart.tsx b/apps/rebreak-native/components/mail/MailBlockedByDayChart.tsx index e8cae7c..dc1a24c 100644 --- a/apps/rebreak-native/components/mail/MailBlockedByDayChart.tsx +++ b/apps/rebreak-native/components/mail/MailBlockedByDayChart.tsx @@ -6,6 +6,7 @@ import type { BlockedByDayEntry } from '../../hooks/useMailStats'; type Props = { data: BlockedByDayEntry[]; + granularity?: 'day' | 'week' | 'month'; }; const BAR_AREA_HEIGHT = 64; @@ -16,7 +17,13 @@ function formatAxisLabel(dateStr: string): string { return `${d.getDate()}.${d.getMonth() + 1}.`; } -export function MailBlockedByDayChart({ data }: Props) { +function headingKey(granularity: 'day' | 'week' | 'month'): string { + if (granularity === 'week') return 'mail.stats.blocked_per_week_heading'; + if (granularity === 'month') return 'mail.stats.blocked_per_month_heading'; + return 'mail.stats.blocked_per_day_heading'; +} + +export function MailBlockedByDayChart({ data, granularity = 'day' }: Props) { const { t } = useTranslation(); const colors = useColors(); @@ -58,7 +65,7 @@ export function MailBlockedByDayChart({ data }: Props) { marginBottom: 10, }} > - {t('mail.stats.blocked_per_day_heading')} + {t(headingKey(granularity))} {allZero ? ( diff --git a/apps/rebreak-native/components/mail/MailDistributionChart.tsx b/apps/rebreak-native/components/mail/MailDistributionChart.tsx index 11939a7..2ef5be6 100644 --- a/apps/rebreak-native/components/mail/MailDistributionChart.tsx +++ b/apps/rebreak-native/components/mail/MailDistributionChart.tsx @@ -1,13 +1,12 @@ import { useMemo } from 'react'; import { Text, View } from 'react-native'; -import Svg, { Path, Circle } from 'react-native-svg'; import { useTranslation } from 'react-i18next'; import { useColors } from '../../lib/theme'; +import { HalfDonut } from '../common/HalfDonut'; import type { BlockedByConnectionEntry } from '../../hooks/useMailStats'; type Props = { data: BlockedByConnectionEntry[]; - /** When true: renders as full-width hero card with integrated title row */ hero?: boolean; totalBlocked?: number; accountCount?: number; @@ -17,17 +16,16 @@ type Props = { const SLICE_COLORS = ['#ef4444', '#3b82f6', '#f59e0b', '#8b5cf6']; const OTHER_COLOR = '#a3a3a3'; -// Legend cap: show max 3 named entries + optional "others" row const MAX_LEGEND_ENTRIES = 3; -const R_OUTER = 54; -const R_INNER = 34; -const CX = 64; -const CY = 64; +const DONUT_WIDTH = 168; -// Half-donut: upper semicircle, flat edge at bottom. -// Slices sweep from -90° (left) to +90° (right) = 180° total. -const HALF_DONUT_START_DEG = -90; +function formatCompact(n: number): string { + if (n < 1000) return n.toLocaleString(); + const k = n / 1000; + if (k < 10) return `${Math.floor(k * 10) / 10}k+`; + return `${Math.floor(k)}k+`; +} function domainFromEmail(email: string): string { return email.split('@')[1] ?? email; @@ -37,45 +35,12 @@ function displayLabel(entry: BlockedByConnectionEntry): string { return entry.title ?? domainFromEmail(entry.email); } -function polarToXY(cx: number, cy: number, r: number, angleDeg: number) { - const rad = ((angleDeg - 90) * Math.PI) / 180; - return { - x: cx + r * Math.cos(rad), - y: cy + r * Math.sin(rad), - }; -} - -function arcPath( - cx: number, - cy: number, - rOuter: number, - rInner: number, - startDeg: number, - endDeg: number, -): string { - const outerStart = polarToXY(cx, cy, rOuter, startDeg); - const outerEnd = polarToXY(cx, cy, rOuter, endDeg); - const innerEnd = polarToXY(cx, cy, rInner, endDeg); - const innerStart = polarToXY(cx, cy, rInner, startDeg); - const large = endDeg - startDeg > 180 ? 1 : 0; - - return [ - `M ${outerStart.x} ${outerStart.y}`, - `A ${rOuter} ${rOuter} 0 ${large} 1 ${outerEnd.x} ${outerEnd.y}`, - `L ${innerEnd.x} ${innerEnd.y}`, - `A ${rInner} ${rInner} 0 ${large} 0 ${innerStart.x} ${innerStart.y}`, - 'Z', - ].join(' '); -} - -export function MailDistributionChart({ data, hero, totalBlocked, accountCount, isLegend }: Props) { +export function MailDistributionChart({ data, hero, totalBlocked, isLegend }: Props) { const { t } = useTranslation(); const colors = useColors(); const total = data.reduce((s, d) => s + d.count, 0); - // Build donut slices: hard-cap at 3 named entries + "Sonstige" bucket. - // ≤3 accounts → show all (no grouping). 4+ → Top-3 + Sonstige. const slices = useMemo(() => { if (data.length === 0 || total === 0) return []; @@ -87,11 +52,9 @@ export function MailDistributionChart({ data, hero, totalBlocked, accountCount, count: e.count, color: SLICE_COLORS[i] ?? OTHER_COLOR, isOther: false, - hiddenCount: 0, })); } - // 4+ connections: Top-3 + Sonstige bucket const top3 = sorted.slice(0, MAX_LEGEND_ENTRIES); const rest = sorted.slice(MAX_LEGEND_ENTRIES); const restCount = rest.reduce((s, e) => s + e.count, 0); @@ -102,7 +65,6 @@ export function MailDistributionChart({ data, hero, totalBlocked, accountCount, count: e.count, color: SLICE_COLORS[i], isOther: false, - hiddenCount: 0, })); items.push({ @@ -110,7 +72,6 @@ export function MailDistributionChart({ data, hero, totalBlocked, accountCount, count: restCount, color: OTHER_COLOR, isOther: true, - hiddenCount: restConnectionCount, }); return items; @@ -118,9 +79,11 @@ export function MailDistributionChart({ data, hero, totalBlocked, accountCount, if (data.length <= 1 || total === 0) return null; - let cursor = HALF_DONUT_START_DEG; - const displayTotal = totalBlocked ?? total; + const centerValue = formatCompact(displayTotal); + const centerLabel = t('mail.stats.distribution_center_label'); + + const segments = slices.map((s) => ({ value: s.count, color: s.color })); if (hero) { return ( @@ -135,7 +98,6 @@ export function MailDistributionChart({ data, hero, totalBlocked, accountCount, paddingBottom: 16, }} > - {/* Integrated title row */} - - - {displayTotal.toLocaleString()} - - + + {t('mail.stats.distribution_heading')} + - {/* Live / Scheduled pill */} - {/* Donut + Legend */} - - {slices.map((slice) => { - const sweep = (slice.count / total) * 180; - const startDeg = cursor; - cursor += sweep; - return ( - - ); - })} - - - + {slices.map((slice) => ( @@ -216,7 +166,6 @@ export function MailDistributionChart({ data, hero, totalBlocked, accountCount, ); } - // Standard (non-hero) card — kept for potential reuse return ( - - {slices.map((slice) => { - const sweep = (slice.count / total) * 180; - const startDeg = cursor; - cursor += sweep; - return ( - - ); - })} - - - + {slices.map((slice) => ( diff --git a/apps/rebreak-native/hooks/useMailStats.ts b/apps/rebreak-native/hooks/useMailStats.ts index ab36de3..9cc8c71 100644 --- a/apps/rebreak-native/hooks/useMailStats.ts +++ b/apps/rebreak-native/hooks/useMailStats.ts @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useState } from 'react'; +import { useCallback, useEffect, useRef, useState } from 'react'; import { apiFetch } from '../lib/api'; export type BlockedByDayEntry = { @@ -20,6 +20,106 @@ type MailStatsState = { loading: boolean; }; +type ConnectionAgeGranularity = 'too-new' | 'day' | 'week' | 'month'; + +function resolveGranularity(createdAt: string | null | undefined): ConnectionAgeGranularity { + if (!createdAt) return 'day'; + const ageMs = Date.now() - new Date(createdAt).getTime(); + const ageH = ageMs / 3_600_000; + if (ageH < 24) return 'too-new'; + const ageD = ageH / 24; + if (ageD <= 14) return 'day'; + if (ageD <= 90) return 'week'; + return 'month'; +} + +type ConnectionStatsState = { + data: BlockedByDayEntry[]; + granularity: ConnectionAgeGranularity; + loading: boolean; +}; + +/** + * Fetches per-connection blocked-by-day stats lazily (only when `enabled` flips true). + * Adaptive heading granularity based on connection age: + * < 24h → 'too-new' (empty-state, data collection in progress) + * 1-14d → 'day' (30-day bars) + * 15-90d → 'week' (client-aggregated into week buckets) + * > 90d → 'month' (client-aggregated into month buckets) + */ +export function useMailConnectionStats( + connectionId: string, + createdAt: string | null | undefined, + enabled: boolean, +) { + const fetchedRef = useRef(false); + const [state, setState] = useState({ + data: [], + granularity: resolveGranularity(createdAt), + loading: false, + }); + + const granularity = resolveGranularity(createdAt); + + const fetch = useCallback(async () => { + if (!enabled || !connectionId) return; + setState((s) => ({ ...s, loading: true })); + try { + const raw = await apiFetch( + `/api/mail/stats/blocked-by-day?days=30&connectionId=${connectionId}`, + ); + + let data = raw; + + if (granularity === 'week') { + data = aggregateToWeeks(raw); + } else if (granularity === 'month') { + data = aggregateToMonths(raw); + } + + setState({ data, granularity, loading: false }); + } catch { + setState((s) => ({ ...s, loading: false })); + } + }, [enabled, connectionId, granularity]); + + useEffect(() => { + if (!enabled) return; + if (fetchedRef.current) return; + fetchedRef.current = true; + fetch(); + }, [enabled, fetch]); + + return { ...state, refresh: fetch }; +} + +function aggregateToWeeks(entries: BlockedByDayEntry[]): BlockedByDayEntry[] { + const buckets: Map = new Map(); + for (const e of entries) { + const d = new Date(e.date + 'T00:00:00'); + const day = d.getDay(); + const diff = d.getDate() - day + (day === 0 ? -6 : 1); + const monday = new Date(d); + monday.setDate(diff); + const key = monday.toISOString().slice(0, 10); + buckets.set(key, (buckets.get(key) ?? 0) + e.count); + } + return [...buckets.entries()] + .sort(([a], [b]) => a.localeCompare(b)) + .map(([date, count]) => ({ date, count })); +} + +function aggregateToMonths(entries: BlockedByDayEntry[]): BlockedByDayEntry[] { + const buckets: Map = new Map(); + for (const e of entries) { + const key = e.date.slice(0, 7); + buckets.set(key, (buckets.get(key) ?? 0) + e.count); + } + return [...buckets.entries()] + .sort(([a], [b]) => a.localeCompare(b)) + .map(([date, count]) => ({ date, count })); +} + export function useMailStats(enabled: boolean) { const [state, setState] = useState({ blockedByDay: [], diff --git a/apps/rebreak-native/hooks/useMailStatus.ts b/apps/rebreak-native/hooks/useMailStatus.ts index 5316e98..dd24b7d 100644 --- a/apps/rebreak-native/hooks/useMailStatus.ts +++ b/apps/rebreak-native/hooks/useMailStatus.ts @@ -18,6 +18,8 @@ export type MailAccount = { lastConnectError?: string | null; lastConnectErrorAt?: string | null; lastIdleHeartbeatAt?: string | null; + /** ISO-date — present when backend includes it. Used for adaptive chart granularity. */ + createdAt?: string | null; }; export type DailyStat = { diff --git a/apps/rebreak-native/locales/de.json b/apps/rebreak-native/locales/de.json index 69e2ec5..c3b0bc4 100644 --- a/apps/rebreak-native/locales/de.json +++ b/apps/rebreak-native/locales/de.json @@ -457,7 +457,10 @@ "stats": { "blocked_per_day_heading": "Blockiert — letzte 30 Tage", "blocked_per_day_sublabel": "%{total} Mails blockiert · %{avg} letzte Woche", + "blocked_per_week_heading": "Blockiert — letzte Wochen", + "blocked_per_month_heading": "Blockiert — letzte Monate", "distribution_heading": "Verteilung nach Postfach", + "distribution_center_label": "insgesamt", "distribution_other": "Sonstige", "distribution_other_n": "+%{n} weitere", "empty_title": "Noch keine Mails blockiert", @@ -479,7 +482,10 @@ "disconnect_hint_title": "Verbindung getrennt", "disconnect_hint_body": "Die Tokens wurden aus unserer Datenbank gelöscht. Microsoft unterstützt leider keinen serverseitigen Widerruf durch Drittanbieter-Apps. Für eine vollständige Entfernung der Rebreak-Berechtigung in deinem Microsoft-Konto: account.microsoft.com → Sicherheit → Berechtigungen für Apps → Rebreak suchen → Entfernen.", "disconnect_hint_open_ms": "Microsoft öffnen" - } + }, + "account_chart_collecting_title": "Daten werden gesammelt", + "account_chart_collecting_body": "Auswertung verfügbar nach 24h", + "email_change_not_supported": "E-Mail-Änderung kommt bald" }, "settings": { "title": "Einstellungen", diff --git a/apps/rebreak-native/locales/en.json b/apps/rebreak-native/locales/en.json index 03d7871..89652e1 100644 --- a/apps/rebreak-native/locales/en.json +++ b/apps/rebreak-native/locales/en.json @@ -457,7 +457,10 @@ "stats": { "blocked_per_day_heading": "Blocked — last 30 days", "blocked_per_day_sublabel": "%{total} mails blocked · %{avg} last week", + "blocked_per_week_heading": "Blocked — recent weeks", + "blocked_per_month_heading": "Blocked — recent months", "distribution_heading": "Distribution by mailbox", + "distribution_center_label": "total", "distribution_other": "Others", "distribution_other_n": "+%{n} more", "empty_title": "No mails blocked yet", @@ -479,7 +482,10 @@ "disconnect_hint_title": "Connection removed", "disconnect_hint_body": "The tokens have been deleted from our database. Unfortunately Microsoft does not support server-side revocation by third-party apps. To fully remove Rebreak's permission from your Microsoft account: account.microsoft.com → Security → App permissions → find Rebreak → Remove.", "disconnect_hint_open_ms": "Open Microsoft" - } + }, + "account_chart_collecting_title": "Collecting data", + "account_chart_collecting_body": "Analysis available after 24h", + "email_change_not_supported": "Email change coming soon" }, "settings": { "title": "Settings", diff --git a/backend/imap-idle/index.mjs b/backend/imap-idle/index.mjs index 043af0c..82ce72f 100644 --- a/backend/imap-idle/index.mjs +++ b/backend/imap-idle/index.mjs @@ -480,7 +480,17 @@ async function runSession(conn) { clearConnectionError(conn.id).catch(() => {}), ]); - await imap.getMailboxLock("INBOX"); + // Outlook/XOAUTH2 hat den Edge-Case dass getMailboxLock lautlos hängt + // wenn der Server in einen ungültigen Zustand kommt — die Session + // bleibt offen ohne Fortschritt bis der Renew-Timer (10min) ein + // imap.close() schickt. Timeout-wrap macht das Failure-Mode explizit + // → Auth-Retry-Loop greift sauber. + await Promise.race([ + imap.getMailboxLock("INBOX"), + new Promise((_, reject) => + setTimeout(() => reject(new Error("getMailboxLock timeout (30s)")), 30_000), + ), + ]); // Consent-Gate-Log: einmalig beim Connect — nur wenn consentAt fehlt if (!conn.consentAt) {