From 432d9d27a3fbf657ca4495fe4d0cb754a9df70c1 Mon Sep 17 00:00:00 2001 From: chahinebrini Date: Wed, 13 May 2026 22:39:45 +0200 Subject: [PATCH] feat(mail-page): hero-donut + FAB + collapsible bar-chart + legend truncation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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= 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) --- apps/rebreak-native/app/(app)/mail.tsx | 305 +++++++++++++----- .../components/mail/MailDistributionChart.tsx | 233 ++++++++++--- apps/rebreak-native/locales/de.json | 3 + apps/rebreak-native/locales/en.json | 3 + .../api/mail/stats/blocked-by-day.get.ts | 10 +- backend/server/db/mail.ts | 31 +- 6 files changed, 453 insertions(+), 132 deletions(-) diff --git a/apps/rebreak-native/app/(app)/mail.tsx b/apps/rebreak-native/app/(app)/mail.tsx index 1fb96f8..40d6558 100644 --- a/apps/rebreak-native/app/(app)/mail.tsx +++ b/apps/rebreak-native/app/(app)/mail.tsx @@ -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 = { 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 ( + + + + + + + + + {t('mail.more_infos_title')} + + + {t('mail.more_infos_subtitle')} + + + + + + + {expanded && ( + + + + )} + + ); +} + 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(null); const [expandedAccount, setExpandedAccount] = useState(null); const [activityLogExpanded, setActivityLogExpanded] = useState(false); + const [moreInfosExpanded, setMoreInfosExpanded] = useState(false); const [oauthTitleSheetConnectionId, setOauthTitleSheetConnectionId] = useState(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 && ( )} - {/* Stats card */} - {hasAccounts && ( + {/* 1. HERO — Half-Donut with integrated title row */} + {hasAccounts && showDistributionHero && ( - )} - {/* Section header + add button */} - {hasAccounts && ( + {/* Fallback stats row when donut is not shown (0-1 accounts with data) */} + {hasAccounts && !showDistributionHero && ( - + - {t('mail.section_accounts')} + {totalBlocked.toLocaleString()} - {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 })} - - - - {t('mail.add_account')} + {plan === 'legend' ? t('mail.live') : t('mail.scheduled')} - + + + )} + + {/* 2. ACCOUNT LIST */} + {hasAccounts && ( + + + {t('mail.section_accounts')} + + + {maxAccounts === Infinity + ? t('mail.section_accounts_count_unlimited', { used: accounts.length }) + : t('mail.section_accounts_count', { + used: accounts.length, + max: maxAccounts, + })} + )} - {/* Account cards or empty */} {accounts.length === 0 ? ( ) : ( - - {accounts.map((account, idx) => { + + {accounts.map((account) => { const connStat = blockedByConnection.find((c) => c.connectionId === account.id); return ( - - toggleAccount(account.id)} - onDisconnect={handleDisconnect} - onIntervalChanged={refresh} - onEditSuccess={handleConnectSuccess} - disconnecting={disconnectingId === account.id && disconnecting} - blockedLast30d={connStat?.count} - /> - + toggleAccount(account.id)} + onDisconnect={handleDisconnect} + onIntervalChanged={refresh} + onEditSuccess={handleConnectSuccess} + disconnecting={disconnectingId === account.id && disconnecting} + blockedLast30d={connStat?.count} + /> ); })} )} - {/* Charts — nur wenn Accounts vorhanden */} + {/* 3. COLLAPSIBLE "MEHR INFOS" — Bar-Chart letzte 30 Tage */} {hasAccounts && ( - - - + + setMoreInfosExpanded((p) => !p)} + blockedByDay={blockedByDay} + colors={colors} + /> )} - {/* Activity log */} + {/* 4. ACTIVITY LOG */} {hasAccounts && ( + {/* 5. FAB — Floating Action Button */} + + + + setSheetVisible(false)} diff --git a/apps/rebreak-native/components/mail/MailDistributionChart.tsx b/apps/rebreak-native/components/mail/MailDistributionChart.tsx index ddfc476..5b587e5 100644 --- a/apps/rebreak-native/components/mail/MailDistributionChart.tsx +++ b/apps/rebreak-native/components/mail/MailDistributionChart.tsx @@ -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 ( + + {/* Integrated title row */} + + + + {displayTotal.toLocaleString()} + + + {t('mail.stats_account_summary', { count: accountCount ?? data.length })} + + + + {/* Live / Scheduled pill */} + + + + {isLegend ? t('mail.live') : t('mail.scheduled')} + + + + + {/* Donut + Legend */} + + + {slices.map((slice) => { + const sweep = (slice.count / total) * 180; + const startDeg = cursor; + cursor += sweep; + return ( + + ); + })} + + + + + {slices.map((slice) => ( + + ))} + + + + ); + } + + // Standard (non-hero) card — kept for potential reuse return ( - {/* Half-donut — upper semicircle, center pinned at bottom of viewBox */} {slices.map((slice) => { const sweep = (slice.count / total) * 180; @@ -137,41 +267,56 @@ export function MailDistributionChart({ data }: Props) { /> ); })} - {/* Inner fill to enforce donut shape */} - {/* Legend */} {slices.map((slice) => ( - - - - {slice.label} - - - {slice.count} - - + ))} ); } + +function LegendRow({ + slice, + colors, +}: { + slice: { label: string; count: number; color: string; isOther: boolean }; + colors: ReturnType; +}) { + return ( + + + + {slice.label} + + + {slice.count} + + + ); +} diff --git a/apps/rebreak-native/locales/de.json b/apps/rebreak-native/locales/de.json index e0e877d..69e2ec5 100644 --- a/apps/rebreak-native/locales/de.json +++ b/apps/rebreak-native/locales/de.json @@ -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." }, diff --git a/apps/rebreak-native/locales/en.json b/apps/rebreak-native/locales/en.json index 0503904..03d7871 100644 --- a/apps/rebreak-native/locales/en.json +++ b/apps/rebreak-native/locales/en.json @@ -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." }, diff --git a/backend/server/api/mail/stats/blocked-by-day.get.ts b/backend/server/api/mail/stats/blocked-by-day.get.ts index 00b7cfb..ac8bb34 100644 --- a/backend/server/api/mail/stats/blocked-by-day.get.ts +++ b/backend/server/api/mail/stats/blocked-by-day.get.ts @@ -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=] * * 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 }; }); diff --git a/backend/server/db/mail.ts b/backend/server/db/mail.ts index ff8a762..3b9b3a4 100644 --- a/backend/server/db/mail.ts +++ b/backend/server/db/mail.ts @@ -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 = {}; for (const row of rows) {