Compare commits

..

1 Commits

Author SHA1 Message Date
cesnimda 2d1ca9d0d6 feat(ui): email dashboard UX refactor — split-view, row polish, dashboard (PHASES 1/3/4)
CI / backend (pull_request) Successful in 2m26s
CI / frontend (pull_request) Successful in 31s
CI / format (pull_request) Successful in 1m25s
CI / db-tests (pull_request) Successful in 1m27s
Security / secrets (pull_request) Successful in 7s
Security / dependencies (pull_request) Successful in 1m18s
Security / sast (pull_request) Successful in 1m1s
Batch of 4 parallel units, rebased onto current develop and integrated:
- PHASE 1 split-view: clicking an email opens an in-place, collapsible + resizable
  reading pane (shared EmailDetail extracted from Senders) instead of a new tab;
  list stays interactive, selection preserved, mobile stacks (<768px); list gains
  Skeleton loading + EmptyState. (FolderView, SearchResults, +EmailDetail, +split.css)
- PHASE 3 row polish: EmailRow gains onOpen (Gmail fallback kept), a new accessible
  Checkbox primitive (ui/checkbox.jsx), 44px rows, clearer hierarchy + hover; keeps
  the search why-matched highlight rendering. (EmailRow, ui/checkbox, styles.css)
- PHASE 4 bulk+keys: BulkToolbar hierarchy/responsive/clear-selection; keyboard nav
  adds ArrowUp/Down + u=unread, hardened input guard. (BulkToolbar, useListKeyboardNav)
- PHASE 3/4 dashboard: remove dead HeatmapWidget, replace CategoryHeatmap with a
  clickable Emails-by-Category bar (a11y: rank by text+count, not colour); Skeleton +
  EmptyState on widgets. (widgets, Dashboard, client.js, styles.css)

Each unit was self-code-reviewed and built green; integrated build passes. Supersedes
PRs #40-43 (their branches had a stale 38-commit base).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-04 20:59:54 +02:00
4 changed files with 21 additions and 1 deletions
@@ -24,6 +24,9 @@ public class AnalyticsController : ApiControllerBase
public async Task<IActionResult> Volume([FromQuery] int days = 90, CancellationToken ct = default) public async Task<IActionResult> Volume([FromQuery] int days = 90, CancellationToken ct = default)
=> Ok(await _analytics.GetVolumeOverTimeAsync(UserId, Math.Clamp(days, 1, 3660), ct)); => Ok(await _analytics.GetVolumeOverTimeAsync(UserId, Math.Clamp(days, 1, 3660), ct));
[HttpGet("heatmap")]
public async Task<IActionResult> Heatmap(CancellationToken ct) => Ok(await _analytics.GetHeatmapAsync(UserId, ct));
[HttpGet("category-heatmap")] [HttpGet("category-heatmap")]
public async Task<IActionResult> CategoryHeatmap(CancellationToken ct) => Ok(await _analytics.GetCategoryHeatmapAsync(UserId, ct)); public async Task<IActionResult> CategoryHeatmap(CancellationToken ct) => Ok(await _analytics.GetCategoryHeatmapAsync(UserId, ct));
@@ -22,6 +22,7 @@ public interface IAnalyticsService
Task<InboxHealthDto> GetInboxHealthAsync(Guid userId, CancellationToken ct = default); Task<InboxHealthDto> GetInboxHealthAsync(Guid userId, CancellationToken ct = default);
Task<IReadOnlyList<SenderStatDto>> GetTopSendersAsync(Guid userId, int take = 20, CancellationToken ct = default); Task<IReadOnlyList<SenderStatDto>> GetTopSendersAsync(Guid userId, int take = 20, CancellationToken ct = default);
Task<IReadOnlyList<TimeSeriesPointDto>> GetVolumeOverTimeAsync(Guid userId, int days = 90, CancellationToken ct = default); Task<IReadOnlyList<TimeSeriesPointDto>> GetVolumeOverTimeAsync(Guid userId, int days = 90, CancellationToken ct = default);
Task<IReadOnlyList<HeatmapCellDto>> GetHeatmapAsync(Guid userId, CancellationToken ct = default);
Task<IReadOnlyList<CategoryHeatmapCellDto>> GetCategoryHeatmapAsync(Guid userId, CancellationToken ct = default); Task<IReadOnlyList<CategoryHeatmapCellDto>> GetCategoryHeatmapAsync(Guid userId, CancellationToken ct = default);
Task<IReadOnlyList<AttachmentBreakdownDto>> GetAttachmentBreakdownAsync(Guid userId, CancellationToken ct = default); Task<IReadOnlyList<AttachmentBreakdownDto>> GetAttachmentBreakdownAsync(Guid userId, CancellationToken ct = default);
Task<SidebarCountsDto> GetSidebarCountsAsync(Guid userId, CancellationToken ct = default); Task<SidebarCountsDto> GetSidebarCountsAsync(Guid userId, CancellationToken ct = default);
@@ -12,6 +12,7 @@ public record InboxHealthDto(
public record TimeSeriesPointDto(DateOnly Day, int Count); public record TimeSeriesPointDto(DateOnly Day, int Count);
public record HeatmapCellDto(int DayOfWeek, int Hour, int Count);
/// <summary>Email counts per category per day-of-week, for the category heatmap.</summary> /// <summary>Email counts per category per day-of-week, for the category heatmap.</summary>
public record CategoryHeatmapCellDto(string Category, int DayOfWeek, int Count); public record CategoryHeatmapCellDto(string Category, int DayOfWeek, int Count);
@@ -38,5 +39,6 @@ public record DashboardSummaryDto(
int UnreadEmails, int UnreadEmails,
IReadOnlyList<SenderStatDto> TopSenders, IReadOnlyList<SenderStatDto> TopSenders,
IReadOnlyList<TimeSeriesPointDto> VolumeOverTime, IReadOnlyList<TimeSeriesPointDto> VolumeOverTime,
IReadOnlyList<HeatmapCellDto> Heatmap,
IReadOnlyList<AttachmentBreakdownDto> AttachmentBreakdown, IReadOnlyList<AttachmentBreakdownDto> AttachmentBreakdown,
long StorageEstimateBytes); long StorageEstimateBytes);
@@ -16,10 +16,11 @@ public class AnalyticsService : IAnalyticsService
var health = await GetInboxHealthAsync(userId, ct); var health = await GetInboxHealthAsync(userId, ct);
var top = await GetTopSendersAsync(userId, 10, ct); var top = await GetTopSendersAsync(userId, 10, ct);
var volume = await GetVolumeOverTimeAsync(userId, 90, ct); var volume = await GetVolumeOverTimeAsync(userId, 90, ct);
var heatmap = await GetHeatmapAsync(userId, ct);
var attachments = await GetAttachmentBreakdownAsync(userId, ct); var attachments = await GetAttachmentBreakdownAsync(userId, ct);
return new DashboardSummaryDto( return new DashboardSummaryDto(
health, health.TotalEmails, health.UnreadEmails, top, volume, attachments, health, health.TotalEmails, health.UnreadEmails, top, volume, heatmap, attachments,
health.EstimatedStorageBytes); health.EstimatedStorageBytes);
} }
@@ -74,6 +75,19 @@ public class AnalyticsService : IAnalyticsService
.ToList(); .ToList();
} }
public async Task<IReadOnlyList<HeatmapCellDto>> GetHeatmapAsync(Guid userId, CancellationToken ct = default)
{
var raw = await _db.Emails
.Where(e => e.UserId == userId)
.Select(e => new { e.SentAtUtc })
.ToListAsync(ct);
return raw
.GroupBy(x => new { Dow = (int)x.SentAtUtc.DayOfWeek, Hour = x.SentAtUtc.Hour })
.Select(g => new HeatmapCellDto(g.Key.Dow, g.Key.Hour, g.Count()))
.ToList();
}
public async Task<IReadOnlyList<CategoryHeatmapCellDto>> GetCategoryHeatmapAsync(Guid userId, CancellationToken ct = default) public async Task<IReadOnlyList<CategoryHeatmapCellDto>> GetCategoryHeatmapAsync(Guid userId, CancellationToken ct = default)
{ {
var raw = await _db.Emails var raw = await _db.Emails