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:
parent
cb5c193980
commit
432d9d27a3
@ -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)}
|
||||
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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."
|
||||
},
|
||||
|
||||
@ -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."
|
||||
},
|
||||
|
||||
@ -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 };
|
||||
});
|
||||
|
||||
@ -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) {
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user