21606c5f91
New EmailCategory enum values: Travel, Subscriptions, Parcels, Recruitment, Events, SecurityAlerts, Healthcare, Education, NewsMedia, PropertyUtilities, Charity, Government, CryptoInvesting, FamilySchool. HeuristicClassifier: locale-agnostic rules using global brand domains + TLD patterns (.gov.*, .edu, .ac.*) + English subject keywords — works for international users without relying on country-specific domains (e.g. gov.uk). Priority order ensures SecurityAlerts and Government take precedence over Finance, and Travel/RideSharing/FoodDelivery fire before Finance to prevent receipt mis-classification. AnalyticsService: maps all 14 new slugs to category counts. client.js: folderToRequest() handles all 14 new slugs. Layout.jsx: SMART_FOLDERS extended with icons and labels. widgets.jsx: CAT_SLUG updated for dashboard drillthrough. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
200 lines
7.2 KiB
React
200 lines
7.2 KiB
React
import { useEffect, useState } from 'react';
|
|
import { useNavigate } from 'react-router-dom';
|
|
import { Bar, Line, Doughnut } from 'react-chartjs-2';
|
|
import { AnalyticsApi } from '../api/client.js';
|
|
import {
|
|
Chart as ChartJS, CategoryScale, LinearScale, BarElement, PointElement,
|
|
LineElement, ArcElement, Tooltip, Legend
|
|
} from 'chart.js';
|
|
|
|
ChartJS.register(CategoryScale, LinearScale, BarElement, PointElement, LineElement, ArcElement, Tooltip, Legend);
|
|
|
|
const fmtBytes = (b) => {
|
|
if (!b) return '0 B';
|
|
const u = ['B', 'KB', 'MB', 'GB']; let i = 0; let n = b;
|
|
while (n >= 1024 && i < u.length - 1) { n /= 1024; i++; }
|
|
return `${n.toFixed(1)} ${u[i]}`;
|
|
};
|
|
|
|
export function StatCard({ label, value, to }) {
|
|
const navigate = useNavigate();
|
|
return (
|
|
<div className={`widget stat${to ? ' widget--link' : ''}`} onClick={to ? () => navigate(to) : undefined} title={to ? `View ${label}` : undefined}>
|
|
<div className="stat-value">{value}</div>
|
|
<div className="stat-label">{label}</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export function HealthWidget({ health }) {
|
|
if (!health) return <Empty />;
|
|
return (
|
|
<div className="widget">
|
|
<h3>Inbox Health</h3>
|
|
<div className={`health-score grade-${health.grade}`}>{health.score}<span>/100</span></div>
|
|
<div className="grade">Grade {health.grade}</div>
|
|
<ul className="recs">{health.recommendations.map((r, i) => <li key={i}>{r}</li>)}</ul>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export function TopSendersWidget({ senders }) {
|
|
const navigate = useNavigate();
|
|
if (!senders) return <Empty />;
|
|
return (
|
|
<div className="widget">
|
|
<h3>Top Senders</h3>
|
|
<table className="mini">
|
|
<tbody>
|
|
{senders.map((s) => (
|
|
<tr
|
|
key={s.senderId}
|
|
className="mini-row--link"
|
|
onClick={() => navigate(`/app/search?q=${encodeURIComponent(`from:${s.address}`)}`)}
|
|
title={`Search emails from ${s.address}`}
|
|
>
|
|
<td title={s.address}>{s.displayName || s.address}</td>
|
|
<td className="num">{s.emailCount}</td>
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export function VolumeWidget({ volume }) {
|
|
if (!volume) return <Empty />;
|
|
const data = {
|
|
labels: volume.map((p) => p.day),
|
|
datasets: [{ label: 'Emails', data: volume.map((p) => p.count), borderColor: '#4f8cff', tension: 0.3 }]
|
|
};
|
|
return (
|
|
<div className="widget">
|
|
<h3>Email Volume</h3>
|
|
<Line data={data} options={{ plugins: { legend: { display: false } }, maintainAspectRatio: false }} />
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export function HeatmapWidget({ heatmap }) {
|
|
if (!heatmap) return <Empty />;
|
|
const days = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
|
|
const max = Math.max(1, ...heatmap.map((c) => c.count));
|
|
const grid = {};
|
|
heatmap.forEach((c) => { grid[`${c.dayOfWeek}-${c.hour}`] = c.count; });
|
|
return (
|
|
<div className="widget">
|
|
<h3>Activity Heatmap</h3>
|
|
<div className="heatmap">
|
|
{days.map((d, dow) => (
|
|
<div className="hm-row" key={dow}>
|
|
<span className="hm-day">{d}</span>
|
|
{Array.from({ length: 24 }, (_, h) => {
|
|
const v = grid[`${dow}-${h}`] || 0;
|
|
return <span key={h} className="hm-cell" style={{ opacity: 0.1 + 0.9 * (v / max) }} title={`${d} ${h}:00 — ${v}`} />;
|
|
})}
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
const DOW = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
|
|
|
|
// Maps EmailCategory enum name → folder slug or search query
|
|
const CAT_SLUG = {
|
|
Finance: '/app/folder/finance',
|
|
Social: '/app/folder/social',
|
|
Notification: '/app/folder/automated',
|
|
Promotional: '/app/folder/shopping',
|
|
Shopping: '/app/folder/shopping',
|
|
Gaming: '/app/folder/gaming',
|
|
RideSharing: '/app/folder/ridesharing',
|
|
FoodDelivery: '/app/folder/food',
|
|
SeasonalSales: '/app/folder/sales',
|
|
Wellness: '/app/folder/wellness',
|
|
Travel: '/app/folder/travel',
|
|
Subscriptions: '/app/folder/subscriptions',
|
|
Parcels: '/app/folder/parcels',
|
|
Recruitment: '/app/folder/recruitment',
|
|
Events: '/app/folder/events',
|
|
SecurityAlerts: '/app/folder/security',
|
|
Healthcare: '/app/folder/healthcare',
|
|
Education: '/app/folder/education',
|
|
NewsMedia: '/app/folder/news',
|
|
PropertyUtilities:'/app/folder/property',
|
|
Charity: '/app/folder/charity',
|
|
Government: '/app/folder/government',
|
|
CryptoInvesting: '/app/folder/crypto',
|
|
FamilySchool: '/app/folder/family',
|
|
Spam: '/app/folder/spam',
|
|
Newsletter: '/app/search?q=newsletter',
|
|
Personal: '/app/search?q=is:unread',
|
|
Unknown: '/app/folder/allmail',
|
|
};
|
|
|
|
export function CategoryHeatmapWidget() {
|
|
const [cells, setCells] = useState(null);
|
|
const navigate = useNavigate();
|
|
useEffect(() => { AnalyticsApi.categoryHeatmap().then(setCells).catch(() => setCells([])); }, []);
|
|
|
|
if (!cells) return <div className="widget"><div className="muted">Loading…</div></div>;
|
|
if (cells.length === 0) return <div className="widget"><h3>Category Heatmap</h3><div className="muted">No data yet — run a sync.</div></div>;
|
|
|
|
const categories = [...new Set(cells.map((c) => c.category))].sort();
|
|
const max = Math.max(1, ...cells.map((c) => c.count));
|
|
const grid = {};
|
|
cells.forEach((c) => { grid[`${c.category}-${c.dayOfWeek}`] = c.count; });
|
|
|
|
return (
|
|
<div className="widget">
|
|
<h3>Category Heatmap</h3>
|
|
<div className="cat-heatmap">
|
|
<div className="chm-row chm-head">
|
|
<span className="chm-label" />
|
|
{DOW.map((d) => <span key={d} className="chm-col">{d}</span>)}
|
|
</div>
|
|
{categories.map((cat) => {
|
|
const dest = CAT_SLUG[cat];
|
|
return (
|
|
<div className="chm-row" key={cat}>
|
|
<span
|
|
className={`chm-label${dest ? ' chm-label--link' : ''}`}
|
|
title={dest ? `View ${cat} emails` : cat}
|
|
onClick={dest ? () => navigate(dest) : undefined}
|
|
>{cat}</span>
|
|
{DOW.map((_, dow) => {
|
|
const v = grid[`${cat}-${dow}`] || 0;
|
|
return <span key={dow} className="chm-cell" style={{ opacity: 0.12 + 0.88 * (v / max) }} title={`${cat} · ${DOW[dow]} — ${v}`}>{v || ''}</span>;
|
|
})}
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export function AttachmentsWidget({ attachments }) {
|
|
if (!attachments) return <Empty />;
|
|
const data = {
|
|
labels: attachments.map((a) => a.mimeBucket),
|
|
datasets: [{ data: attachments.map((a) => a.totalBytes), backgroundColor: ['#4f8cff', '#6fcf97', '#f2c94c', '#eb5757', '#bb6bd9', '#56ccf2', '#a0a0a0'] }]
|
|
};
|
|
return (
|
|
<div className="widget">
|
|
<h3>Attachments by Type</h3>
|
|
<Doughnut data={data} options={{ plugins: { legend: { position: 'right' } }, maintainAspectRatio: false }} />
|
|
<div className="muted">Total: {fmtBytes(attachments.reduce((s, a) => s + a.totalBytes, 0))}</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export function StorageWidget({ bytes }) {
|
|
return <StatCard label="Estimated Storage" value={fmtBytes(bytes)} />;
|
|
}
|
|
|
|
function Empty() { return <div className="widget"><div className="muted">No data yet — run a sync.</div></div>; }
|