feat: Smart Folders sidebar, category heatmap, sidebar counts API

- Replace plain activity heatmap with CategoryHeatmapWidget on dashboard
- Add collapsible Smart Folders sidebar with Favorites, Mailbox, Smart Folders sections
- Favorites: customisable pinned shortcuts (default: Inbox, Unread, Large, Old, Read Later); pin/unpin smart folders via buttons; persists to localStorage
- Mailbox section: 11 Gmail mailbox items with icons and live count badges
- Smart Folders section: 10 category folders sorted by count desc
- Live count badges via new GET /api/v1/analytics/sidebar-counts endpoint
- Backend: SidebarCountsDto, GetSidebarCountsAsync with label lookups for SENT/DRAFT/SPAM, size/age filters, EmailCategory mapping
- Sidebar collapses to icon-only; section states persist to localStorage

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
cesnimda
2026-06-30 19:40:54 +02:00
parent fe5920919f
commit ca02f40841
17 changed files with 2866 additions and 66 deletions
@@ -88,6 +88,19 @@ public class AnalyticsService : IAnalyticsService
.ToList();
}
public async Task<IReadOnlyList<CategoryHeatmapCellDto>> GetCategoryHeatmapAsync(Guid userId, CancellationToken ct = default)
{
var raw = await _db.Emails
.Where(e => e.UserId == userId)
.Select(e => new { e.SentAtUtc, e.Category })
.ToListAsync(ct);
return raw
.GroupBy(x => new { x.Category, Dow = (int)x.SentAtUtc.DayOfWeek })
.Select(g => new CategoryHeatmapCellDto(g.Key.Category.ToString(), g.Key.Dow, g.Count()))
.ToList();
}
public async Task<IReadOnlyList<AttachmentBreakdownDto>> GetAttachmentBreakdownAsync(Guid userId, CancellationToken ct = default)
{
var raw = await _db.Attachments
@@ -115,6 +128,60 @@ public class AnalyticsService : IAnalyticsService
_ => "other"
};
public async Task<SidebarCountsDto> GetSidebarCountsAsync(Guid userId, CancellationToken ct = default)
{
var emails = _db.Emails.Where(e => e.UserId == userId);
var allMail = await emails.CountAsync(ct);
var inbox = await emails.CountAsync(e => e.IsInInbox, ct);
var unread = await emails.CountAsync(e => e.IsUnread, ct);
var starred = await emails.CountAsync(e => e.IsStarred, ct);
var trash = await emails.CountAsync(e => e.IsTrashed, ct);
var large = await emails.CountAsync(e => e.SizeEstimateBytes > 5_000_000, ct);
var cutoff = DateTimeOffset.UtcNow.AddYears(-1);
var old = await emails.CountAsync(e => e.SentAtUtc < cutoff, ct);
// Label-backed counts (SENT / DRAFT / SPAM are system Gmail labels)
async Task<int> LabelCount(string gmailId)
{
var labelId = await _db.Labels
.Where(l => l.UserId == userId && l.GmailLabelId == gmailId)
.Select(l => (Guid?)l.Id)
.FirstOrDefaultAsync(ct);
return labelId.HasValue
? await _db.EmailLabels.CountAsync(el => el.LabelId == labelId.Value, ct)
: 0;
}
var sent = await LabelCount("SENT");
var drafts = await LabelCount("DRAFT");
var spam = await LabelCount("SPAM");
// Category → smart-folder slug mapping
var catCounts = await emails
.GroupBy(e => e.Category)
.Select(g => new { Cat = g.Key, N = g.Count() })
.ToListAsync(ct);
int Cat(EmailCategory c) => catCounts.FirstOrDefault(x => x.Cat == c)?.N ?? 0;
var smartFolders = new Dictionary<string, int>
{
["automated"] = Cat(EmailCategory.Notification),
["finance"] = Cat(EmailCategory.Finance),
["social"] = Cat(EmailCategory.Social),
["shopping"] = Cat(EmailCategory.Promotional),
["noreply"] = 0,
["gaming"] = 0,
["sales"] = 0,
["ridesharing"] = 0,
["food"] = 0,
["wellness"] = 0,
};
return new SidebarCountsDto(inbox, allMail, unread, starred, sent, drafts, trash, spam, large, old, smartFolders);
}
public async Task RefreshAggregatesAsync(Guid userId, CancellationToken ct = default)
{
var since = DateTimeOffset.UtcNow.AddDays(-365);