fix(mail-page): UX polish — FAB-revert, legend cap, activity NaNd, instant heartbeat

User-Feedback nach Live-Test:

Frontend:
- FAB raus, Plus-Button zurück in den Account-Liste-Section-Header
  (`add-circle-outline` in brandOrange + Label "Postfach hinzufügen").
  FAB stört am unteren Rand, oben passt zum iOS-NavBar-Pattern.
- Half-Donut Legend strikt max Top-3 + "Sonstige" — Threshold von ≤4
  auf ≤3 gesenkt. Auch bei 4 Connections wird jetzt schon komprimiert.
- Hero-Donut-Subtitle "über N Postfächer" entfernt — Title-Block ist
  jetzt eine Zeile: "XX blockiert · ● Live"
- Activity-Log default-collapsed war schon richtig (kein Change)
- Activity-Item-Redesign: x-Icon-Pille raus, Zeit + Provider als
  Sub-Zeile unter dem Subject ("vor 2h · GMX"), kein Zeit-Label rechts mehr

Bug-Fix — NaNd in Activity-Row:
- Root-Cause: snake_case/camelCase-Mismatch. Backend liefert
  `receivedAt`, `senderEmail`, `senderName`, `connectionId` (camelCase),
  Frontend-Type hatte snake_case → undefined-Werte → `new Date(undefined)`
  → NaN → "NaNd"-Render
- MailBlockedItem-Type auf camelCase umgestellt + nested `connection`-Objekt
  (passt jetzt zum Backend-Response)
- formatDate mit Number.isFinite-Guard — gibt null zurück bei ungültigem
  Datum statt NaN-String zu rendern

Backend (imap-idle daemon):
- Daemon schreibt jetzt unmittelbar nach `client.connect()` einen Heartbeat
  (last_idle_heartbeat_at = NOW()) + clear last_connect_error parallel
- Vorher: User sah 2-9min lang "wartet auf erste verbindung" obwohl
  Connection längst aktiv war (Heartbeat kam erst beim ersten NOOP-Cycle)
- Re-Connect-Pfad nach AUTHENTICATIONFAILED ist automatisch mit
  abgedeckt (geht durch denselben connect-Block)
- ESM-Daemon, kein Build-Step — Pipeline scp + pm2-restart reicht

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
chahinebrini 2026-05-13 22:55:50 +02:00
parent 432d9d27a3
commit 206941e5e1
5 changed files with 100 additions and 140 deletions

View File

@ -11,7 +11,6 @@ import {
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';
@ -184,7 +183,6 @@ function MoreInfosSection({
export default function MailScreen() {
const { t } = useTranslation();
const tabBarHeight = useBottomTabBarHeight();
const insets = useSafeAreaInsets();
const colors = useColors();
const { plan } = useUserPlan();
@ -265,7 +263,7 @@ export default function MailScreen() {
contentContainerStyle={{
paddingHorizontal: 16,
paddingTop: 16,
paddingBottom: tabBarHeight + 88,
paddingBottom: tabBarHeight + 24,
}}
showsVerticalScrollIndicator={false}
>
@ -365,32 +363,59 @@ export default function MailScreen() {
{/* 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 style={{ flexDirection: 'row', alignItems: 'center' }}>
<View style={{ flex: 1 }}>
<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>
<TouchableOpacity
onPress={handleAddPress}
activeOpacity={0.7}
accessibilityLabel={t('mail.add_account_a11y')}
style={{
flexDirection: 'row',
alignItems: 'center',
gap: 4,
paddingVertical: 4,
paddingLeft: 8,
}}
>
<Ionicons name="add-circle-outline" size={18} color={colors.brandOrange} />
<Text
style={{
fontSize: 13,
fontFamily: 'Nunito_600SemiBold',
color: colors.brandOrange,
}}
>
{t('mail.add_account')}
</Text>
</TouchableOpacity>
</View>
</View>
)}
@ -442,31 +467,6 @@ 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

@ -23,14 +23,16 @@ type Props = {
providers?: string[];
};
function formatDate(iso: string, t: (k: string) => string): string {
const diff = Date.now() - new Date(iso).getTime();
function formatDate(iso: string, t: (k: string) => string): string | null {
const ts = new Date(iso).getTime();
if (!Number.isFinite(ts)) return null;
const diff = Date.now() - ts;
const mins = Math.floor(diff / 60_000);
if (mins < 2) return t('mail.account_just_now');
if (mins < 60) return `${mins} min`;
const hours = Math.floor(mins / 60);
if (hours < 24) return `${hours}h`;
return `${Math.floor(hours / 24)}d`;
if (hours < 24) return `vor ${hours}h`;
return `vor ${Math.floor(hours / 24)}d`;
}
function domainFromEmail(email: string): string {
@ -228,87 +230,40 @@ function ActivityItem({
t: (k: string, opts?: any) => string;
colors: ReturnType<typeof useColors>;
}) {
const accountLabel = item.connection_title ?? (
item.sender_email ? domainFromEmail(item.sender_email) : null
const providerLabel = item.connection?.providerLabel ?? (
item.senderEmail ? domainFromEmail(item.senderEmail) : null
);
const timeLabel = formatDate(item.receivedAt, t);
const subLine = [timeLabel, providerLabel].filter(Boolean).join(' · ');
return (
<View
style={{
flexDirection: 'row',
alignItems: 'flex-start',
paddingHorizontal: 14,
paddingVertical: 10,
borderTopWidth: 1,
borderTopColor: colors.border,
}}
>
<View
style={{
width: 22,
height: 22,
borderRadius: 6,
backgroundColor: '#fef2f2',
alignItems: 'center',
justifyContent: 'center',
marginRight: 10,
marginTop: 1,
}}
<Text
style={{ fontSize: 13, fontFamily: 'Nunito_600SemiBold', color: colors.text }}
numberOfLines={1}
>
<Ionicons name="close" size={12} color="#dc2626" />
</View>
<View style={{ flex: 1, minWidth: 0, marginRight: 8 }}>
<View style={{ flexDirection: 'row', alignItems: 'center', gap: 6, flexWrap: 'wrap' }}>
<Text
style={{ fontSize: 13, fontFamily: 'Nunito_600SemiBold', color: colors.text, flexShrink: 1 }}
numberOfLines={1}
>
{item.subject || t('mail.activity_no_subject')}
</Text>
{accountLabel && (
<View
style={{
paddingHorizontal: 6,
paddingVertical: 2,
borderRadius: 4,
backgroundColor: colors.surfaceElevated,
}}
>
<Text
style={{
fontSize: 10,
fontFamily: 'Nunito_600SemiBold',
color: colors.textMuted,
}}
numberOfLines={1}
>
{accountLabel}
</Text>
</View>
)}
</View>
{item.subject || t('mail.activity_no_subject')}
</Text>
{subLine.length > 0 && (
<Text
style={{
fontSize: 11,
fontFamily: 'Nunito_400Regular',
color: colors.textMuted,
marginTop: 1,
marginTop: 2,
}}
numberOfLines={1}
>
{item.sender_name || item.sender_email}
{subLine}
</Text>
</View>
<Text
style={{
fontSize: 10,
fontFamily: 'Nunito_400Regular',
color: colors.textMuted,
marginTop: 2,
}}
>
{formatDate(item.received_at, t)}
</Text>
)}
</View>
);
}

View File

@ -74,15 +74,14 @@ export function MailDistributionChart({ data, hero, totalBlocked, accountCount,
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.
// Build donut slices: hard-cap at 3 named entries + "Sonstige" bucket.
// ≤3 accounts → show all (no grouping). 4+ → Top-3 + Sonstige.
const slices = useMemo(() => {
if (data.length === 0 || total === 0) return [];
const sorted = [...data].sort((a, b) => b.count - a.count);
if (sorted.length <= 4) {
if (sorted.length <= MAX_LEGEND_ENTRIES) {
return sorted.map((e, i) => ({
label: displayLabel(e),
count: e.count,
@ -92,7 +91,7 @@ export function MailDistributionChart({ data, hero, totalBlocked, accountCount,
}));
}
// 5+ connections: Top-3 + Sonstige bucket
// 4+ connections: Top-3 + Sonstige bucket
const top3 = sorted.slice(0, MAX_LEGEND_ENTRIES);
const rest = sorted.slice(MAX_LEGEND_ENTRIES);
const restCount = rest.reduce((s, e) => s + e.count, 0);
@ -155,16 +154,6 @@ export function MailDistributionChart({ data, hero, totalBlocked, accountCount,
>
{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 File

@ -4,12 +4,17 @@ import { apiFetch } from "../lib/api";
export type MailBlockedItem = {
id: string;
subject: string;
sender_email: string;
sender_name: string | null;
received_at: string;
connection_id: string;
connection_title?: string | null;
provider?: string | null;
senderEmail: string;
senderName: string | null;
receivedAt: string;
connectionId: string;
connection?: {
id: string;
email: string;
title: string | null;
provider: string;
providerLabel: string;
} | null;
};
export type MailResultsResponse = {

View File

@ -467,7 +467,18 @@ async function runSession(conn) {
log(conn.email, `connected (${conn.imapHost}:${conn.imapPort}, auth=${creds.type})`);
attempt = 0; // Reset nach erfolgreicher Verbindung
authRetries = 0; // Auth-Retry-Counter ebenfalls reset
await clearConnectionError(conn.id).catch(() => {});
// Initial-Heartbeat: sofort nach erfolgreichem Connect schreiben damit
// das Frontend "aktiv" zeigt statt bis zum ersten NOOP-Cycle zu warten
// (NOOP-Cycle = alle 2min → worst-case 2min+, gemessene delay 2-9min).
// Gilt für alle auth-Methoden (app_password + oauth2_microsoft) und
// auch für den Re-Connect nach AUTHENTICATIONFAILED-Recovery, da beide
// Paths durch diesen Block laufen.
// last_connect_error wird gleichzeitig geclearet: ein zuvor failed-State
// (z.B. abgelaufener Token) ist nach erfolgreichem Connect behoben.
await Promise.all([
updateIdleHeartbeat(conn.id).catch(() => {}),
clearConnectionError(conn.id).catch(() => {}),
]);
await imap.getMailboxLock("INBOX");