feat(mail-page): hero-donut + FAB + collapsible bar-chart + legend truncation

UX-Welle nach User-Feedback aus dem ersten Live-Test der Mail-Page:

Page-Hierarchie neu (top → bottom):

1. HALF-DONUT als HERO-Karte — bisherige "BLOCKIERT XX über N Postfächer Live"-
   Banner-Card weg, Inhalt ist jetzt Title-Zeile innerhalb der Donut-Karte
   (rendert nur ab ≥2 Connections; Fallback-Stats-Row für 0-1 Connections)
2. Postfach-Liste (Account-Cards aus letztem Refactor — schlanker Header)
3. NEU: "Mehr Infos"-Collapsible — Bar-Chart "Blockiert letzte 30 Tage"
   liegt jetzt versteckt drin (default collapsed)
4. Activity-Log "Kürzlich blockiert" (unverändert)
5. NEU: FAB unten rechts — 56pt brandOrange Kreis mit "+"-Icon,
   öffnet ConnectMailSheet. Section-Header-Plus-Button entfällt.

Half-Donut Legend-Truncation:
- ≤3 Connections → alle anzeigen
- =4 Connections → alle anzeigen
- ≥5 Connections → Top-3 by blocked-count + "Sonstige"-Bucket
  · Donut: 4 Segmente (Top-3 + OTHER_COLOR grau)
  · Legend: 4 Zeilen (Top-3 fett, "weitere"-Zeile in regular grau)

Backend: GET /api/mail/stats/blocked-by-day?connectionId=<uuid> als
optionaler Filter (für per-Connection-Bar-Chart in expanded Account-Card,
in dieser Welle noch nicht im UI verdrahtet — Erweiterung kommt wenn
gewünscht).

FAB-Details (iOS-diskreter Shadow statt Material-Glow):
- position absolute, right 24, bottom = tabBarHeight + insets.bottom + 16
- 56pt, borderRadius 28, brandOrange BG, weißes Plus-Icon
- ScrollView paddingBottom angehoben damit kein Content unter dem FAB clipped

Edge-Cases:
- 0 Accounts → FAB sichtbar, Donut/Stats/Charts/Log versteckt + EmptyState
- 1 Account → Donut hidden (nur mit ≥2 Connections sinnvoll), Fallback-Stats-Row
- limitReached + FAB-Tap → bestehender Plan-Alert (FAB ist visuell nicht disabled)

Memory: Pull-to-refresh + bestehendes 30s-Status-Polling reichen für "wartet
auf erste verbindung"→"aktiv"-Übergang nach OAuth-Connect (Daemon-Heartbeat
braucht initial 2-9min, mo-Befund). UX-Polish-Option für später: in der
Initial-Phase einen freundlicheren "Verbinde gerade…"-Status anzeigen.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
chahinebrini 2026-05-13 22:39:45 +02:00
parent cb5c193980
commit 432d9d27a3
6 changed files with 453 additions and 132 deletions

View File

@ -2,16 +2,19 @@ import { useState } from 'react';
import { import {
ActivityIndicator, ActivityIndicator,
Alert, Alert,
LayoutAnimation,
Platform,
ScrollView, ScrollView,
Text, Text,
TouchableOpacity, TouchableOpacity,
UIManager,
View, View,
} from 'react-native'; } from 'react-native';
import { useBottomTabBarHeight } from 'react-native-bottom-tabs'; import { useBottomTabBarHeight } from 'react-native-bottom-tabs';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { Ionicons } from '@expo/vector-icons'; import { Ionicons } from '@expo/vector-icons';
import { AppHeader } from '../../components/AppHeader'; import { AppHeader } from '../../components/AppHeader';
import { MailStatsRow } from '../../components/mail/MailStatsRow';
import { MailAccountCard } from '../../components/mail/MailAccountCard'; import { MailAccountCard } from '../../components/mail/MailAccountCard';
import { MailEmptyState } from '../../components/mail/MailEmptyState'; import { MailEmptyState } from '../../components/mail/MailEmptyState';
import { MailActivityLog } from '../../components/mail/MailActivityLog'; import { MailActivityLog } from '../../components/mail/MailActivityLog';
@ -27,6 +30,10 @@ import { useUserPlan } from '../../hooks/useUserPlan';
import { useColors } from '../../lib/theme'; import { useColors } from '../../lib/theme';
import { useMailConnectDraft } from '../../stores/mailConnectDraft'; import { useMailConnectDraft } from '../../stores/mailConnectDraft';
if (Platform.OS === 'android' && UIManager.setLayoutAnimationEnabledExperimental) {
UIManager.setLayoutAnimationEnabledExperimental(true);
}
const PLAN_LABEL: Record<string, string> = { free: 'Free', pro: 'Pro', legend: 'Legend' }; const PLAN_LABEL: Record<string, string> = { free: 'Free', pro: 'Pro', legend: 'Legend' };
function MailOverLimitBanner({ function MailOverLimitBanner({
@ -81,14 +88,108 @@ function MailOverLimitBanner({
); );
} }
function MoreInfosSection({
expanded,
onToggle,
blockedByDay,
colors,
}: {
expanded: boolean;
onToggle: () => void;
blockedByDay: import('../../hooks/useMailStats').BlockedByDayEntry[];
colors: import('../../lib/theme').ColorScheme;
}) {
const { t } = useTranslation();
function handleToggle() {
LayoutAnimation.configureNext(LayoutAnimation.Presets.easeInEaseOut);
onToggle();
}
return (
<View
style={{
backgroundColor: colors.surface,
borderRadius: 16,
borderWidth: 1,
borderColor: colors.border,
overflow: 'hidden',
}}
>
<TouchableOpacity onPress={handleToggle} activeOpacity={0.85}>
<View
style={{
flexDirection: 'row',
alignItems: 'center',
paddingHorizontal: 14,
paddingVertical: 14,
}}
>
<View
style={{
width: 32,
height: 32,
borderRadius: 8,
backgroundColor: '#eff6ff',
alignItems: 'center',
justifyContent: 'center',
marginRight: 12,
}}
>
<Ionicons name="bar-chart-outline" size={15} color="#2563eb" />
</View>
<View style={{ flex: 1, minWidth: 0, marginRight: 8 }}>
<Text
style={{ fontSize: 14, fontFamily: 'Nunito_700Bold', color: colors.text }}
numberOfLines={1}
>
{t('mail.more_infos_title')}
</Text>
<Text
style={{
fontSize: 11,
fontFamily: 'Nunito_400Regular',
color: colors.textMuted,
marginTop: 2,
}}
numberOfLines={1}
>
{t('mail.more_infos_subtitle')}
</Text>
</View>
<Ionicons
name={expanded ? 'chevron-up' : 'chevron-down'}
size={18}
color={colors.textMuted}
/>
</View>
</TouchableOpacity>
{expanded && (
<View
style={{
borderTopWidth: 1,
borderTopColor: colors.border,
paddingHorizontal: 12,
paddingVertical: 12,
}}
>
<MailBlockedByDayChart data={blockedByDay} />
</View>
)}
</View>
);
}
export default function MailScreen() { export default function MailScreen() {
const { t } = useTranslation(); const { t } = useTranslation();
const tabBarHeight = useBottomTabBarHeight(); const tabBarHeight = useBottomTabBarHeight();
const insets = useSafeAreaInsets();
const colors = useColors(); const colors = useColors();
const { plan } = useUserPlan(); const { plan } = useUserPlan();
const { connected, accounts, totalBlocked, maxAccounts, loading, refresh } = const { accounts, totalBlocked, maxAccounts, loading, refresh } =
useMailStatus(plan); useMailStatus(plan);
const { disconnect, disconnecting } = useMailDisconnect(); const { disconnect, disconnecting } = useMailDisconnect();
const hasAccounts = accounts.length > 0; const hasAccounts = accounts.length > 0;
@ -99,16 +200,11 @@ export default function MailScreen() {
const [disconnectingId, setDisconnectingId] = useState<string | null>(null); const [disconnectingId, setDisconnectingId] = useState<string | null>(null);
const [expandedAccount, setExpandedAccount] = useState<string | null>(null); const [expandedAccount, setExpandedAccount] = useState<string | null>(null);
const [activityLogExpanded, setActivityLogExpanded] = useState(false); const [activityLogExpanded, setActivityLogExpanded] = useState(false);
const [moreInfosExpanded, setMoreInfosExpanded] = useState(false);
const [oauthTitleSheetConnectionId, setOauthTitleSheetConnectionId] = useState<string | null>(null); const [oauthTitleSheetConnectionId, setOauthTitleSheetConnectionId] = useState<string | null>(null);
const { pendingOAuthConnectionId, setPendingOAuthConnectionId } = useMailConnectDraft(); const { pendingOAuthConnectionId, setPendingOAuthConnectionId } = useMailConnectDraft();
const nextScanAt =
accounts
.map((a) => a.nextScanAt)
.filter((v): v is string => v !== null)
.sort()[0] ?? null;
const pausedAccounts = accounts.filter((a) => a.paused === true); const pausedAccounts = accounts.filter((a) => a.paused === true);
const overLimit = maxAccounts !== Infinity && accounts.length > maxAccounts; const overLimit = maxAccounts !== Infinity && accounts.length > maxAccounts;
const limitReached = maxAccounts !== Infinity && accounts.length >= maxAccounts; const limitReached = maxAccounts !== Infinity && accounts.length >= maxAccounts;
@ -117,6 +213,9 @@ export default function MailScreen() {
...new Set(accounts.map((a) => a.provider.toLowerCase())), ...new Set(accounts.map((a) => a.provider.toLowerCase())),
]; ];
// Show distribution chart only when ≥2 accounts have data
const showDistributionHero = blockedByConnection.length >= 2;
function handleAddPress() { function handleAddPress() {
if (limitReached) { if (limitReached) {
Alert.alert(t('mail.upgrade_alert_title'), t('mail.upgrade_alert_desc')); Alert.alert(t('mail.upgrade_alert_title'), t('mail.upgrade_alert_desc'));
@ -166,11 +265,11 @@ export default function MailScreen() {
contentContainerStyle={{ contentContainerStyle={{
paddingHorizontal: 16, paddingHorizontal: 16,
paddingTop: 16, paddingTop: 16,
paddingBottom: tabBarHeight + 24, paddingBottom: tabBarHeight + 88,
}} }}
showsVerticalScrollIndicator={false} showsVerticalScrollIndicator={false}
> >
{/* Über-Limit-Banner */} {/* Over-limit banner */}
{overLimit && pausedAccounts.length > 0 && ( {overLimit && pausedAccounts.length > 0 && (
<MailOverLimitBanner <MailOverLimitBanner
usedCount={accounts.length} usedCount={accounts.length}
@ -181,10 +280,12 @@ export default function MailScreen() {
/> />
)} )}
{/* Stats card */} {/* 1. HERO — Half-Donut with integrated title row */}
{hasAccounts && ( {hasAccounts && showDistributionHero && (
<View style={{ marginBottom: 14 }}> <View style={{ marginBottom: 14 }}>
<MailStatsRow <MailDistributionChart
data={blockedByConnection}
hero
totalBlocked={totalBlocked} totalBlocked={totalBlocked}
accountCount={accounts.length} accountCount={accounts.length}
isLegend={plan === 'legend'} isLegend={plan === 'legend'}
@ -192,119 +293,144 @@ export default function MailScreen() {
</View> </View>
)} )}
{/* Section header + add button */} {/* Fallback stats row when donut is not shown (0-1 accounts with data) */}
{hasAccounts && ( {hasAccounts && !showDistributionHero && (
<View <View
style={{ style={{
backgroundColor: colors.surface,
borderRadius: 16,
borderWidth: 1,
borderColor: colors.border,
paddingHorizontal: 16,
paddingVertical: 16,
marginBottom: 14,
flexDirection: 'row', flexDirection: 'row',
alignItems: 'center', alignItems: 'center',
marginBottom: 10,
paddingHorizontal: 2,
}} }}
> >
<View style={{ flex: 1, marginRight: 10 }}> <View style={{ flex: 1 }}>
<Text <Text
style={{ style={{
fontSize: 11, fontSize: 22,
fontFamily: 'Nunito_700Bold', fontFamily: 'Nunito_800ExtraBold',
color: colors.textMuted, color: colors.error,
textTransform: 'uppercase', lineHeight: 26,
letterSpacing: 0.8,
}} }}
> >
{t('mail.section_accounts')} {totalBlocked.toLocaleString()}
</Text> </Text>
<Text <Text
style={{ style={{
fontSize: 11, fontSize: 12,
fontFamily: 'Nunito_400Regular', fontFamily: 'Nunito_400Regular',
color: colors.textMuted, color: colors.textMuted,
marginTop: 2, marginTop: 2,
}} }}
> >
{maxAccounts === Infinity {t('mail.stats_account_summary', { count: accounts.length })}
? t('mail.section_accounts_count_unlimited', { used: accounts.length })
: t('mail.section_accounts_count', {
used: accounts.length,
max: maxAccounts,
})}
</Text> </Text>
</View> </View>
<View
<TouchableOpacity
onPress={handleAddPress}
disabled={limitReached}
activeOpacity={limitReached ? 1 : 0.8}
accessibilityLabel={t('mail.add_account_a11y')}
style={{ style={{
backgroundColor: limitReached ? colors.surfaceElevated : '#007AFF',
borderRadius: 12,
opacity: limitReached ? 0.7 : 1,
shadowColor: '#007AFF',
shadowOffset: { width: 0, height: 4 },
shadowOpacity: limitReached ? 0 : 0.25,
shadowRadius: 8,
elevation: limitReached ? 0 : 4,
flexDirection: 'row', flexDirection: 'row',
alignItems: 'center', alignItems: 'center',
paddingHorizontal: 14, paddingHorizontal: 10,
paddingVertical: 10, paddingVertical: 5,
borderRadius: 999,
backgroundColor: plan === 'legend' ? '#f0fdf4' : '#eff6ff',
}} }}
> >
<Ionicons <View
name="add" style={{
size={18} width: 6,
color={limitReached ? colors.textMuted : '#fff'} height: 6,
style={{ marginRight: 6 }} borderRadius: 3,
backgroundColor: plan === 'legend' ? '#16a34a' : '#2563eb',
marginRight: 6,
}}
/> />
<Text <Text
style={{ style={{
fontSize: 13, fontSize: 12,
fontFamily: 'Nunito_700Bold', fontFamily: 'Nunito_700Bold',
color: limitReached ? colors.textMuted : '#fff', color: plan === 'legend' ? '#16a34a' : '#2563eb',
}} }}
> >
{t('mail.add_account')} {plan === 'legend' ? t('mail.live') : t('mail.scheduled')}
</Text> </Text>
</TouchableOpacity> </View>
</View>
)}
{/* 2. ACCOUNT LIST */}
{hasAccounts && (
<View style={{ marginBottom: 10, paddingHorizontal: 2 }}>
<Text
style={{
fontSize: 11,
fontFamily: 'Nunito_700Bold',
color: colors.textMuted,
textTransform: 'uppercase',
letterSpacing: 0.8,
}}
>
{t('mail.section_accounts')}
</Text>
<Text
style={{
fontSize: 11,
fontFamily: 'Nunito_400Regular',
color: colors.textMuted,
marginTop: 2,
}}
>
{maxAccounts === Infinity
? t('mail.section_accounts_count_unlimited', { used: accounts.length })
: t('mail.section_accounts_count', {
used: accounts.length,
max: maxAccounts,
})}
</Text>
</View> </View>
)} )}
{/* Account cards or empty */}
{accounts.length === 0 ? ( {accounts.length === 0 ? (
<MailEmptyState onConnectPress={handleAddPress} /> <MailEmptyState onConnectPress={handleAddPress} />
) : ( ) : (
<View> <View style={{ gap: 10 }}>
{accounts.map((account, idx) => { {accounts.map((account) => {
const connStat = blockedByConnection.find((c) => c.connectionId === account.id); const connStat = blockedByConnection.find((c) => c.connectionId === account.id);
return ( return (
<View key={account.id} style={{ marginTop: idx === 0 ? 0 : 10 }}> <MailAccountCard
<MailAccountCard key={account.id}
account={account} account={account}
plan={plan} plan={plan}
expanded={expandedAccount === account.id} expanded={expandedAccount === account.id}
onToggle={() => toggleAccount(account.id)} onToggle={() => toggleAccount(account.id)}
onDisconnect={handleDisconnect} onDisconnect={handleDisconnect}
onIntervalChanged={refresh} onIntervalChanged={refresh}
onEditSuccess={handleConnectSuccess} onEditSuccess={handleConnectSuccess}
disconnecting={disconnectingId === account.id && disconnecting} disconnecting={disconnectingId === account.id && disconnecting}
blockedLast30d={connStat?.count} blockedLast30d={connStat?.count}
/> />
</View>
); );
})} })}
</View> </View>
)} )}
{/* Charts — nur wenn Accounts vorhanden */} {/* 3. COLLAPSIBLE "MEHR INFOS" — Bar-Chart letzte 30 Tage */}
{hasAccounts && ( {hasAccounts && (
<View style={{ gap: 12, marginTop: 14 }}> <View style={{ marginTop: 14 }}>
<MailBlockedByDayChart data={blockedByDay} /> <MoreInfosSection
<MailDistributionChart data={blockedByConnection} /> expanded={moreInfosExpanded}
onToggle={() => setMoreInfosExpanded((p) => !p)}
blockedByDay={blockedByDay}
colors={colors}
/>
</View> </View>
)} )}
{/* Activity log */} {/* 4. ACTIVITY LOG */}
{hasAccounts && ( {hasAccounts && (
<View style={{ marginTop: 14 }}> <View style={{ marginTop: 14 }}>
<MailActivityLog <MailActivityLog
@ -316,6 +442,31 @@ export default function MailScreen() {
)} )}
</ScrollView> </ScrollView>
{/* 5. FAB — Floating Action Button */}
<TouchableOpacity
onPress={handleAddPress}
activeOpacity={0.85}
accessibilityLabel={t('mail.add_account_a11y')}
style={{
position: 'absolute',
right: 24,
bottom: tabBarHeight + Math.max(insets.bottom, 16) + 16,
width: 56,
height: 56,
borderRadius: 28,
backgroundColor: colors.brandOrange,
alignItems: 'center',
justifyContent: 'center',
shadowColor: '#000',
shadowOffset: { width: 0, height: 2 },
shadowOpacity: 0.18,
shadowRadius: 6,
elevation: 6,
}}
>
<Ionicons name="add" size={28} color="#fff" />
</TouchableOpacity>
<ConnectMailSheet <ConnectMailSheet
visible={sheetVisible} visible={sheetVisible}
onClose={() => setSheetVisible(false)} onClose={() => setSheetVisible(false)}

View File

@ -7,20 +7,25 @@ import type { BlockedByConnectionEntry } from '../../hooks/useMailStats';
type Props = { type Props = {
data: BlockedByConnectionEntry[]; data: BlockedByConnectionEntry[];
/** When true: renders as full-width hero card with integrated title row */
hero?: boolean;
totalBlocked?: number;
accountCount?: number;
isLegend?: boolean;
}; };
const SLICE_COLORS = ['#ef4444', '#3b82f6', '#f59e0b', '#8b5cf6', '#10b981']; const SLICE_COLORS = ['#ef4444', '#3b82f6', '#f59e0b', '#8b5cf6'];
const OTHER_COLOR = '#a3a3a3'; const OTHER_COLOR = '#a3a3a3';
const MAX_SLICES = 5;
// Legend cap: show max 3 named entries + optional "others" row
const MAX_LEGEND_ENTRIES = 3;
const R_OUTER = 54; const R_OUTER = 54;
const R_INNER = 34; const R_INNER = 34;
const CX = 64; const CX = 64;
const CY = 64; const CY = 64;
// Half-donut renders the UPPER semicircle (flat edge at bottom). // Half-donut: upper semicircle, flat edge at bottom.
// CY=64 places the center at the bottom of the 68px-tall viewBox.
// angleDeg=0 → top (12 o'clock), angleDeg=-90 → left, angleDeg=90 → right.
// Slices sweep from -90° (left) to +90° (right) = 180° total. // Slices sweep from -90° (left) to +90° (right) = 180° total.
const HALF_DONUT_START_DEG = -90; const HALF_DONUT_START_DEG = -90;
@ -63,32 +68,51 @@ function arcPath(
].join(' '); ].join(' ');
} }
export function MailDistributionChart({ data }: Props) { export function MailDistributionChart({ data, hero, totalBlocked, accountCount, isLegend }: Props) {
const { t } = useTranslation(); const { t } = useTranslation();
const colors = useColors(); const colors = useColors();
const total = data.reduce((s, d) => s + d.count, 0); const total = data.reduce((s, d) => s + d.count, 0);
// Build donut slices: always cap at 4 visible segments (Top-3 + Sonstige).
// Edge-case: ≤3 → no grouping. Exactly 4 → show all 4 (no grouping).
// 5+ → Top-3 + Sonstige.
const slices = useMemo(() => { const slices = useMemo(() => {
if (data.length === 0 || total === 0) return []; if (data.length === 0 || total === 0) return [];
const sorted = [...data].sort((a, b) => b.count - a.count); const sorted = [...data].sort((a, b) => b.count - a.count);
const top = sorted.slice(0, MAX_SLICES);
const rest = sorted.slice(MAX_SLICES);
const items: { label: string; count: number; color: string }[] = top.map((e, i) => ({ if (sorted.length <= 4) {
return sorted.map((e, i) => ({
label: displayLabel(e),
count: e.count,
color: SLICE_COLORS[i] ?? OTHER_COLOR,
isOther: false,
hiddenCount: 0,
}));
}
// 5+ 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);
const restConnectionCount = rest.length;
const items = top3.map((e, i) => ({
label: displayLabel(e), label: displayLabel(e),
count: e.count, count: e.count,
color: SLICE_COLORS[i], color: SLICE_COLORS[i],
isOther: false,
hiddenCount: 0,
})); }));
if (rest.length > 0) { items.push({
items.push({ label: t('mail.stats.distribution_other_n', { n: restConnectionCount }),
label: t('mail.stats.distribution_other'), count: restCount,
count: rest.reduce((s, e) => s + e.count, 0), color: OTHER_COLOR,
color: OTHER_COLOR, isOther: true,
}); hiddenCount: restConnectionCount,
} });
return items; return items;
}, [data, total, t]); }, [data, total, t]);
@ -97,6 +121,113 @@ export function MailDistributionChart({ data }: Props) {
let cursor = HALF_DONUT_START_DEG; let cursor = HALF_DONUT_START_DEG;
const displayTotal = totalBlocked ?? total;
if (hero) {
return (
<View
style={{
backgroundColor: colors.surface,
borderRadius: 16,
borderWidth: 1,
borderColor: colors.border,
paddingHorizontal: 16,
paddingTop: 16,
paddingBottom: 16,
}}
>
{/* Integrated title row */}
<View
style={{
flexDirection: 'row',
alignItems: 'center',
marginBottom: 14,
}}
>
<View style={{ flex: 1 }}>
<Text
style={{
fontSize: 22,
fontFamily: 'Nunito_800ExtraBold',
color: colors.error,
lineHeight: 26,
}}
>
{displayTotal.toLocaleString()}
</Text>
<Text
style={{
fontSize: 12,
fontFamily: 'Nunito_400Regular',
color: colors.textMuted,
marginTop: 2,
}}
>
{t('mail.stats_account_summary', { count: accountCount ?? data.length })}
</Text>
</View>
{/* Live / Scheduled pill */}
<View
style={{
flexDirection: 'row',
alignItems: 'center',
paddingHorizontal: 10,
paddingVertical: 5,
borderRadius: 999,
backgroundColor: isLegend ? '#f0fdf4' : '#eff6ff',
}}
>
<View
style={{
width: 6,
height: 6,
borderRadius: 3,
backgroundColor: isLegend ? '#16a34a' : '#2563eb',
marginRight: 6,
}}
/>
<Text
style={{
fontSize: 12,
fontFamily: 'Nunito_700Bold',
color: isLegend ? '#16a34a' : '#2563eb',
}}
>
{isLegend ? t('mail.live') : t('mail.scheduled')}
</Text>
</View>
</View>
{/* Donut + Legend */}
<View style={{ flexDirection: 'row', alignItems: 'center', gap: 16 }}>
<Svg width={128} height={68} viewBox="0 0 128 68">
{slices.map((slice) => {
const sweep = (slice.count / total) * 180;
const startDeg = cursor;
cursor += sweep;
return (
<Path
key={slice.label}
d={arcPath(CX, CY, R_OUTER, R_INNER, startDeg, startDeg + sweep)}
fill={slice.color}
/>
);
})}
<Circle cx={CX} cy={CY} r={R_INNER - 1} fill={colors.surface} />
</Svg>
<View style={{ flex: 1, gap: 5 }}>
{slices.map((slice) => (
<LegendRow key={slice.label} slice={slice} colors={colors} />
))}
</View>
</View>
</View>
);
}
// Standard (non-hero) card — kept for potential reuse
return ( return (
<View <View
style={{ style={{
@ -123,7 +254,6 @@ export function MailDistributionChart({ data }: Props) {
</Text> </Text>
<View style={{ flexDirection: 'row', alignItems: 'center', gap: 16 }}> <View style={{ flexDirection: 'row', alignItems: 'center', gap: 16 }}>
{/* Half-donut — upper semicircle, center pinned at bottom of viewBox */}
<Svg width={128} height={68} viewBox="0 0 128 68"> <Svg width={128} height={68} viewBox="0 0 128 68">
{slices.map((slice) => { {slices.map((slice) => {
const sweep = (slice.count / total) * 180; const sweep = (slice.count / total) * 180;
@ -137,41 +267,56 @@ export function MailDistributionChart({ data }: Props) {
/> />
); );
})} })}
{/* Inner fill to enforce donut shape */}
<Circle cx={CX} cy={CY} r={R_INNER - 1} fill={colors.surface} /> <Circle cx={CX} cy={CY} r={R_INNER - 1} fill={colors.surface} />
</Svg> </Svg>
{/* Legend */}
<View style={{ flex: 1, gap: 6 }}> <View style={{ flex: 1, gap: 6 }}>
{slices.map((slice) => ( {slices.map((slice) => (
<View key={slice.label} style={{ flexDirection: 'row', alignItems: 'center', gap: 6 }}> <LegendRow key={slice.label} slice={slice} colors={colors} />
<View
style={{ width: 8, height: 8, borderRadius: 4, backgroundColor: slice.color }}
/>
<Text
style={{
flex: 1,
fontSize: 12,
fontFamily: 'Nunito_600SemiBold',
color: colors.text,
}}
numberOfLines={1}
>
{slice.label}
</Text>
<Text
style={{
fontSize: 12,
fontFamily: 'Nunito_700Bold',
color: colors.textMuted,
}}
>
{slice.count}
</Text>
</View>
))} ))}
</View> </View>
</View> </View>
</View> </View>
); );
} }
function LegendRow({
slice,
colors,
}: {
slice: { label: string; count: number; color: string; isOther: boolean };
colors: ReturnType<typeof useColors>;
}) {
return (
<View style={{ flexDirection: 'row', alignItems: 'center', gap: 6 }}>
<View
style={{
width: 8,
height: 8,
borderRadius: 4,
backgroundColor: slice.color,
}}
/>
<Text
style={{
flex: 1,
fontSize: 12,
fontFamily: slice.isOther ? 'Nunito_400Regular' : 'Nunito_600SemiBold',
color: slice.isOther ? colors.textMuted : colors.text,
}}
numberOfLines={1}
>
{slice.label}
</Text>
<Text
style={{
fontSize: 12,
fontFamily: 'Nunito_700Bold',
color: colors.textMuted,
}}
>
{slice.count}
</Text>
</View>
);
}

View File

@ -452,11 +452,14 @@
"account_chart_unavailable": "Tages-Verlauf wird geladen …", "account_chart_unavailable": "Tages-Verlauf wird geladen …",
"disconnect_confirm_title": "Verbindung trennen?", "disconnect_confirm_title": "Verbindung trennen?",
"disconnect_confirm_body": "%{email} wird getrennt und alle Scan-Daten gelöscht.", "disconnect_confirm_body": "%{email} wird getrennt und alle Scan-Daten gelöscht.",
"more_infos_title": "Mehr Infos",
"more_infos_subtitle": "Blockiert — letzte 30 Tage",
"stats": { "stats": {
"blocked_per_day_heading": "Blockiert — letzte 30 Tage", "blocked_per_day_heading": "Blockiert — letzte 30 Tage",
"blocked_per_day_sublabel": "%{total} Mails blockiert · %{avg} letzte Woche", "blocked_per_day_sublabel": "%{total} Mails blockiert · %{avg} letzte Woche",
"distribution_heading": "Verteilung nach Postfach", "distribution_heading": "Verteilung nach Postfach",
"distribution_other": "Sonstige", "distribution_other": "Sonstige",
"distribution_other_n": "+%{n} weitere",
"empty_title": "Noch keine Mails blockiert", "empty_title": "Noch keine Mails blockiert",
"empty_body": "Sobald Mails blockiert werden, erscheint hier ein Überblick." "empty_body": "Sobald Mails blockiert werden, erscheint hier ein Überblick."
}, },

View File

@ -452,11 +452,14 @@
"account_chart_unavailable": "Daily chart loading …", "account_chart_unavailable": "Daily chart loading …",
"disconnect_confirm_title": "Disconnect mailbox?", "disconnect_confirm_title": "Disconnect mailbox?",
"disconnect_confirm_body": "%{email} will be disconnected and all scan data deleted.", "disconnect_confirm_body": "%{email} will be disconnected and all scan data deleted.",
"more_infos_title": "More Info",
"more_infos_subtitle": "Blocked — last 30 days",
"stats": { "stats": {
"blocked_per_day_heading": "Blocked — last 30 days", "blocked_per_day_heading": "Blocked — last 30 days",
"blocked_per_day_sublabel": "%{total} mails blocked · %{avg} last week", "blocked_per_day_sublabel": "%{total} mails blocked · %{avg} last week",
"distribution_heading": "Distribution by mailbox", "distribution_heading": "Distribution by mailbox",
"distribution_other": "Others", "distribution_other": "Others",
"distribution_other_n": "+%{n} more",
"empty_title": "No mails blocked yet", "empty_title": "No mails blocked yet",
"empty_body": "Once mails are blocked, an overview will appear here." "empty_body": "Once mails are blocked, an overview will appear here."
}, },

View File

@ -1,12 +1,14 @@
import { getBlockedMailsByDay } from "../../../db/mail"; import { getBlockedMailsByDay } from "../../../db/mail";
/** /**
* GET /api/mail/stats/blocked-by-day?days=30 * GET /api/mail/stats/blocked-by-day?days=30[&connectionId=<uuid>]
* *
* Blockierte Mails pro Tag (UTC) Bar-Chart-Datenquelle. * Blockierte Mails pro Tag (UTC) Bar-Chart-Datenquelle.
* *
* Query params: * Query params:
* days? number Anzahl Tage zurück (default 30, max 90) * days? number Anzahl Tage zurück (default 30, max 90)
* connectionId? uuid Wenn angegeben: nur diese Connection. Gehört die UUID
* einem fremden User, kommen 0-Rows zurück (implizit 404).
* *
* Response: { date: 'YYYY-MM-DD', count: number }[] * Response: { date: 'YYYY-MM-DD', count: number }[]
* Alle N Tage sind enthalten, auch wenn count=0 (Frontend zeichnet flatline statt Lücken). * Alle N Tage sind enthalten, auch wenn count=0 (Frontend zeichnet flatline statt Lücken).
@ -19,7 +21,9 @@ export default defineEventHandler(async (event) => {
const rawDays = parseInt((query.days as string) || "30"); const rawDays = parseInt((query.days as string) || "30");
const days = Math.min(Math.max(1, isNaN(rawDays) ? 30 : rawDays), 90); const days = Math.min(Math.max(1, isNaN(rawDays) ? 30 : rawDays), 90);
const data = await getBlockedMailsByDay(user.id, days); const connectionId = (query.connectionId as string | undefined) || undefined;
const data = await getBlockedMailsByDay(user.id, days, connectionId);
return { success: true, data }; return { success: true, data };
}); });

View File

@ -327,24 +327,39 @@ export async function updateMailConnectionTitle(
* Geblockte Mails pro Tag (UTC) für die letzten N Tage für Bar-Chart. * Geblockte Mails pro Tag (UTC) für die letzten N Tage für Bar-Chart.
* Liest aus mail_blocked_stats (permanent, kein 24h-Cleanup). * Liest aus mail_blocked_stats (permanent, kein 24h-Cleanup).
* Fehlende Tage werden mit count=0 aufgefüllt. * Fehlende Tage werden mit count=0 aufgefüllt.
*
* connectionId (optional): filtert auf eine einzelne MailConnection.
* Gehört die connectionId einem fremden User, liefert die WHERE-Klausel
* schlicht 0 Rows alle Buckets werden mit count=0 aufgefüllt (404-alike).
*/ */
export async function getBlockedMailsByDay( export async function getBlockedMailsByDay(
userId: string, userId: string,
days: number, days: number,
connectionId?: string,
): Promise<{ date: string; count: number }[]> { ): Promise<{ date: string; count: number }[]> {
const db = usePrisma(); const db = usePrisma();
const since = new Date(Date.now() - days * 86_400_000); const since = new Date(Date.now() - days * 86_400_000);
since.setUTCHours(0, 0, 0, 0); since.setUTCHours(0, 0, 0, 0);
// Aggregiere SUM(count) pro Tag aus der permanenten Stats-Tabelle // Aggregiere SUM(count) pro Tag aus der permanenten Stats-Tabelle
const rows = await db.$queryRaw<{ date: string; count: bigint }[]>` const rows = connectionId
SELECT TO_CHAR("date", 'YYYY-MM-DD') AS date, SUM("count")::bigint AS count ? await db.$queryRaw<{ date: string; count: bigint }[]>`
FROM "rebreak"."mail_blocked_stats" SELECT TO_CHAR("date", 'YYYY-MM-DD') AS date, SUM("count")::bigint AS count
WHERE "user_id" = ${userId}::uuid FROM "rebreak"."mail_blocked_stats"
AND "date" >= ${since}::date WHERE "user_id" = ${userId}::uuid
GROUP BY "date" AND "date" >= ${since}::date
ORDER BY "date" ASC AND "mail_connection_id" = ${connectionId}::uuid
`; GROUP BY "date"
ORDER BY "date" ASC
`
: await db.$queryRaw<{ date: string; count: bigint }[]>`
SELECT TO_CHAR("date", 'YYYY-MM-DD') AS date, SUM("count")::bigint AS count
FROM "rebreak"."mail_blocked_stats"
WHERE "user_id" = ${userId}::uuid
AND "date" >= ${since}::date
GROUP BY "date"
ORDER BY "date" ASC
`;
const map: Record<string, number> = {}; const map: Record<string, number> = {};
for (const row of rows) { for (const row of rows) {