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 (
-
-
-
- {/* 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 (
+
+
+
+
+
+ {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) => (
@@ -216,7 +166,6 @@ export function MailDistributionChart({ data, hero, totalBlocked, accountCount,
);
}
- // Standard (non-hero) card — kept for potential reuse
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) {