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 {
ActivityIndicator,
Alert,
LayoutAnimation,
Platform,
ScrollView,
Text,
TouchableOpacity,
UIManager,
View,
} from 'react-native';
import { useBottomTabBarHeight } from 'react-native-bottom-tabs';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { useTranslation } from 'react-i18next';
import { Ionicons } from '@expo/vector-icons';
import { AppHeader } from '../../components/AppHeader';
import { MailStatsRow } from '../../components/mail/MailStatsRow';
import { MailAccountCard } from '../../components/mail/MailAccountCard';
import { MailEmptyState } from '../../components/mail/MailEmptyState';
import { MailActivityLog } from '../../components/mail/MailActivityLog';
@ -27,6 +30,10 @@ import { useUserPlan } from '../../hooks/useUserPlan';
import { useColors } from '../../lib/theme';
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' };
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() {
const { t } = useTranslation();
const tabBarHeight = useBottomTabBarHeight();
const insets = useSafeAreaInsets();
const colors = useColors();
const { plan } = useUserPlan();
const { connected, accounts, totalBlocked, maxAccounts, loading, refresh } =
const { accounts, totalBlocked, maxAccounts, loading, refresh } =
useMailStatus(plan);
const { disconnect, disconnecting } = useMailDisconnect();
const hasAccounts = accounts.length > 0;
@ -99,16 +200,11 @@ export default function MailScreen() {
const [disconnectingId, setDisconnectingId] = useState<string | null>(null);
const [expandedAccount, setExpandedAccount] = useState<string | null>(null);
const [activityLogExpanded, setActivityLogExpanded] = useState(false);
const [moreInfosExpanded, setMoreInfosExpanded] = useState(false);
const [oauthTitleSheetConnectionId, setOauthTitleSheetConnectionId] = useState<string | null>(null);
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 overLimit = 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())),
];
// Show distribution chart only when ≥2 accounts have data
const showDistributionHero = blockedByConnection.length >= 2;
function handleAddPress() {
if (limitReached) {
Alert.alert(t('mail.upgrade_alert_title'), t('mail.upgrade_alert_desc'));
@ -166,11 +265,11 @@ export default function MailScreen() {
contentContainerStyle={{
paddingHorizontal: 16,
paddingTop: 16,
paddingBottom: tabBarHeight + 24,
paddingBottom: tabBarHeight + 88,
}}
showsVerticalScrollIndicator={false}
>
{/* Über-Limit-Banner */}
{/* Over-limit banner */}
{overLimit && pausedAccounts.length > 0 && (
<MailOverLimitBanner
usedCount={accounts.length}
@ -181,10 +280,12 @@ export default function MailScreen() {
/>
)}
{/* Stats card */}
{hasAccounts && (
{/* 1. HERO — Half-Donut with integrated title row */}
{hasAccounts && showDistributionHero && (
<View style={{ marginBottom: 14 }}>
<MailStatsRow
<MailDistributionChart
data={blockedByConnection}
hero
totalBlocked={totalBlocked}
accountCount={accounts.length}
isLegend={plan === 'legend'}
@ -192,119 +293,144 @@ export default function MailScreen() {
</View>
)}
{/* Section header + add button */}
{hasAccounts && (
{/* Fallback stats row when donut is not shown (0-1 accounts with data) */}
{hasAccounts && !showDistributionHero && (
<View
style={{
backgroundColor: colors.surface,
borderRadius: 16,
borderWidth: 1,
borderColor: colors.border,
paddingHorizontal: 16,
paddingVertical: 16,
marginBottom: 14,
flexDirection: 'row',
alignItems: 'center',
marginBottom: 10,
paddingHorizontal: 2,
}}
>
<View style={{ flex: 1, marginRight: 10 }}>
<View style={{ flex: 1 }}>
<Text
style={{
fontSize: 11,
fontFamily: 'Nunito_700Bold',
color: colors.textMuted,
textTransform: 'uppercase',
letterSpacing: 0.8,
fontSize: 22,
fontFamily: 'Nunito_800ExtraBold',
color: colors.error,
lineHeight: 26,
}}
>
{t('mail.section_accounts')}
{totalBlocked.toLocaleString()}
</Text>
<Text
style={{
fontSize: 11,
fontSize: 12,
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,
})}
{t('mail.stats_account_summary', { count: accounts.length })}
</Text>
</View>
<TouchableOpacity
onPress={handleAddPress}
disabled={limitReached}
activeOpacity={limitReached ? 1 : 0.8}
accessibilityLabel={t('mail.add_account_a11y')}
<View
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',
alignItems: 'center',
paddingHorizontal: 14,
paddingVertical: 10,
paddingHorizontal: 10,
paddingVertical: 5,
borderRadius: 999,
backgroundColor: plan === 'legend' ? '#f0fdf4' : '#eff6ff',
}}
>
<Ionicons
name="add"
size={18}
color={limitReached ? colors.textMuted : '#fff'}
style={{ marginRight: 6 }}
<View
style={{
width: 6,
height: 6,
borderRadius: 3,
backgroundColor: plan === 'legend' ? '#16a34a' : '#2563eb',
marginRight: 6,
}}
/>
<Text
style={{
fontSize: 13,
fontSize: 12,
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>
</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>
)}
{/* Account cards or empty */}
{accounts.length === 0 ? (
<MailEmptyState onConnectPress={handleAddPress} />
) : (
<View>
{accounts.map((account, idx) => {
<View style={{ gap: 10 }}>
{accounts.map((account) => {
const connStat = blockedByConnection.find((c) => c.connectionId === account.id);
return (
<View key={account.id} style={{ marginTop: idx === 0 ? 0 : 10 }}>
<MailAccountCard
account={account}
plan={plan}
expanded={expandedAccount === account.id}
onToggle={() => toggleAccount(account.id)}
onDisconnect={handleDisconnect}
onIntervalChanged={refresh}
onEditSuccess={handleConnectSuccess}
disconnecting={disconnectingId === account.id && disconnecting}
blockedLast30d={connStat?.count}
/>
</View>
<MailAccountCard
key={account.id}
account={account}
plan={plan}
expanded={expandedAccount === account.id}
onToggle={() => toggleAccount(account.id)}
onDisconnect={handleDisconnect}
onIntervalChanged={refresh}
onEditSuccess={handleConnectSuccess}
disconnecting={disconnectingId === account.id && disconnecting}
blockedLast30d={connStat?.count}
/>
);
})}
</View>
)}
{/* Charts — nur wenn Accounts vorhanden */}
{/* 3. COLLAPSIBLE "MEHR INFOS" — Bar-Chart letzte 30 Tage */}
{hasAccounts && (
<View style={{ gap: 12, marginTop: 14 }}>
<MailBlockedByDayChart data={blockedByDay} />
<MailDistributionChart data={blockedByConnection} />
<View style={{ marginTop: 14 }}>
<MoreInfosSection
expanded={moreInfosExpanded}
onToggle={() => setMoreInfosExpanded((p) => !p)}
blockedByDay={blockedByDay}
colors={colors}
/>
</View>
)}
{/* Activity log */}
{/* 4. ACTIVITY LOG */}
{hasAccounts && (
<View style={{ marginTop: 14 }}>
<MailActivityLog
@ -316,6 +442,31 @@ export default function MailScreen() {
)}
</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
visible={sheetVisible}
onClose={() => setSheetVisible(false)}

View File

@ -7,20 +7,25 @@ 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;
isLegend?: boolean;
};
const SLICE_COLORS = ['#ef4444', '#3b82f6', '#f59e0b', '#8b5cf6', '#10b981'];
const SLICE_COLORS = ['#ef4444', '#3b82f6', '#f59e0b', '#8b5cf6'];
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_INNER = 34;
const CX = 64;
const CY = 64;
// Half-donut renders the 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.
// Half-donut: upper semicircle, flat edge at bottom.
// Slices sweep from -90° (left) to +90° (right) = 180° total.
const HALF_DONUT_START_DEG = -90;
@ -63,32 +68,51 @@ function arcPath(
].join(' ');
}
export function MailDistributionChart({ data }: Props) {
export function MailDistributionChart({ data, hero, totalBlocked, accountCount, isLegend }: Props) {
const { t } = useTranslation();
const colors = useColors();
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(() => {
if (data.length === 0 || total === 0) return [];
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),
count: e.count,
color: SLICE_COLORS[i],
isOther: false,
hiddenCount: 0,
}));
if (rest.length > 0) {
items.push({
label: t('mail.stats.distribution_other'),
count: rest.reduce((s, e) => s + e.count, 0),
color: OTHER_COLOR,
});
}
items.push({
label: t('mail.stats.distribution_other_n', { n: restConnectionCount }),
count: restCount,
color: OTHER_COLOR,
isOther: true,
hiddenCount: restConnectionCount,
});
return items;
}, [data, total, t]);
@ -97,6 +121,113 @@ export function MailDistributionChart({ data }: Props) {
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 (
<View
style={{
@ -123,7 +254,6 @@ export function MailDistributionChart({ data }: Props) {
</Text>
<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">
{slices.map((slice) => {
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} />
</Svg>
{/* Legend */}
<View style={{ flex: 1, gap: 6 }}>
{slices.map((slice) => (
<View key={slice.label} 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: 'Nunito_600SemiBold',
color: colors.text,
}}
numberOfLines={1}
>
{slice.label}
</Text>
<Text
style={{
fontSize: 12,
fontFamily: 'Nunito_700Bold',
color: colors.textMuted,
}}
>
{slice.count}
</Text>
</View>
<LegendRow key={slice.label} slice={slice} colors={colors} />
))}
</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 …",
"disconnect_confirm_title": "Verbindung trennen?",
"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": {
"blocked_per_day_heading": "Blockiert — letzte 30 Tage",
"blocked_per_day_sublabel": "%{total} Mails blockiert · %{avg} letzte Woche",
"distribution_heading": "Verteilung nach Postfach",
"distribution_other": "Sonstige",
"distribution_other_n": "+%{n} weitere",
"empty_title": "Noch keine Mails blockiert",
"empty_body": "Sobald Mails blockiert werden, erscheint hier ein Überblick."
},

View File

@ -452,11 +452,14 @@
"account_chart_unavailable": "Daily chart loading …",
"disconnect_confirm_title": "Disconnect mailbox?",
"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": {
"blocked_per_day_heading": "Blocked — last 30 days",
"blocked_per_day_sublabel": "%{total} mails blocked · %{avg} last week",
"distribution_heading": "Distribution by mailbox",
"distribution_other": "Others",
"distribution_other_n": "+%{n} more",
"empty_title": "No mails blocked yet",
"empty_body": "Once mails are blocked, an overview will appear here."
},

View File

@ -1,12 +1,14 @@
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.
*
* 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 }[]
* 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 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 };
});

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.
* Liest aus mail_blocked_stats (permanent, kein 24h-Cleanup).
* 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(
userId: string,
days: number,
connectionId?: string,
): Promise<{ date: string; count: number }[]> {
const db = usePrisma();
const since = new Date(Date.now() - days * 86_400_000);
since.setUTCHours(0, 0, 0, 0);
// Aggregiere SUM(count) pro Tag aus der permanenten Stats-Tabelle
const rows = 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 rows = connectionId
? 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
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> = {};
for (const row of rows) {