Files
Inboxintel/src/InboxIntel.Infrastructure/Analytics/AnalyticsService.cs
T
cesnimda cfcf45cdf3
CI / backend (push) Successful in 1m29s
CI / frontend (push) Successful in 28s
CI / format (push) Successful in 1m17s
CI / db-tests (push) Successful in 1m24s
CI / backend (pull_request) Successful in 1m18s
CI / frontend (pull_request) Successful in 21s
CI / format (pull_request) Successful in 1m5s
CI / db-tests (pull_request) Successful in 1m12s
Deploy Staging / deploy (push) Successful in 43s
Security / secrets (push) Successful in 6s
Security / dependencies (push) Successful in 1m17s
Security / sast (push) Successful in 1m7s
Security / secrets (pull_request) Successful in 6s
Security / dependencies (pull_request) Successful in 1m24s
Security / sast (pull_request) Successful in 1m8s
chore(analytics): remove dead day-hour heatmap (Unit 5) (#45)
2026-07-04 21:43:32 +02:00

209 lines
9.7 KiB
C#

using InboxIntel.Application.Abstractions;
using InboxIntel.Application.DTOs;
using InboxIntel.Domain.Enums;
using InboxIntel.Infrastructure.Persistence;
using Microsoft.EntityFrameworkCore;
namespace InboxIntel.Infrastructure.Analytics;
public class AnalyticsService : IAnalyticsService
{
private readonly AppDbContext _db;
public AnalyticsService(AppDbContext db) => _db = db;
public async Task<DashboardSummaryDto> GetDashboardAsync(Guid userId, CancellationToken ct = default)
{
var health = await GetInboxHealthAsync(userId, ct);
var top = await GetTopSendersAsync(userId, 10, ct);
var volume = await GetVolumeOverTimeAsync(userId, 90, ct);
var attachments = await GetAttachmentBreakdownAsync(userId, ct);
return new DashboardSummaryDto(
health, health.TotalEmails, health.UnreadEmails, top, volume, attachments,
health.EstimatedStorageBytes);
}
public async Task<InboxHealthDto> GetInboxHealthAsync(Guid userId, CancellationToken ct = default)
{
var emails = _db.Emails.Where(e => e.UserId == userId);
var total = await emails.CountAsync(ct);
var unread = await emails.CountAsync(e => e.IsUnread, ct);
var newsletters = await emails.CountAsync(e => e.Category == EmailCategory.Newsletter, ct);
var storage = total == 0 ? 0 : await emails.SumAsync(e => e.SizeEstimateBytes, ct);
var safeToUnsub = await _db.UnsubscribeItems.CountAsync(u => u.UserId == userId, ct);
// Health score: penalise high unread ratio and newsletter clutter.
var unreadRatio = total == 0 ? 0 : (double)unread / total;
var newsletterRatio = total == 0 ? 0 : (double)newsletters / total;
var score = (int)Math.Round(100 * (1 - 0.6 * unreadRatio - 0.4 * newsletterRatio));
score = Math.Clamp(score, 0, 100);
var grade = score switch { >= 90 => "A", >= 80 => "B", >= 70 => "C", >= 60 => "D", _ => "F" };
var recs = new List<string>();
if (unreadRatio > 0.3) recs.Add($"You have {unread:N0} unread emails. Consider bulk-marking older ones as read.");
if (newsletters > 50) recs.Add($"{newsletters:N0} newsletters detected. Review the safe-to-unsubscribe list.");
if (storage > 1_000_000_000) recs.Add("Inbox storage exceeds 1 GB. Clean up large attachments.");
if (recs.Count == 0) recs.Add("Your inbox is in good shape. Keep it up!");
return new InboxHealthDto(score, grade, total, unread, newsletters, safeToUnsub, storage, recs);
}
public async Task<IReadOnlyList<SenderStatDto>> GetTopSendersAsync(Guid userId, int take = 20, CancellationToken ct = default)
{
return await _db.Senders
.Where(s => s.UserId == userId)
.OrderByDescending(s => s.EmailCount)
.Take(take)
.Select(s => new SenderStatDto(
s.Id, s.Address, s.DisplayName, s.Domain!.Name, s.EmailCount, s.UnreadCount,
s.TotalSizeBytes, s.HasUnsubscribe, s.LastReceivedUtc))
.ToListAsync(ct);
}
public async Task<IReadOnlyList<TimeSeriesPointDto>> GetVolumeOverTimeAsync(Guid userId, int days = 90, CancellationToken ct = default)
{
var since = DateTimeOffset.UtcNow.AddDays(-days);
var raw = await _db.Emails
.Where(e => e.UserId == userId && e.SentAtUtc >= since)
.GroupBy(e => e.SentAtUtc.Date)
.Select(g => new { Day = g.Key, Count = g.Count() })
.ToListAsync(ct);
return raw.OrderBy(x => x.Day)
.Select(x => new TimeSeriesPointDto(DateOnly.FromDateTime(x.Day), x.Count))
.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
.Where(a => a.UserId == userId)
.Select(a => new { a.MimeType, a.SizeBytes })
.ToListAsync(ct);
return raw
.GroupBy(a => Bucket(a.MimeType))
.Select(g => new AttachmentBreakdownDto(g.Key, g.Sum(x => x.SizeBytes), g.Count()))
.OrderByDescending(x => x.TotalBytes)
.ToList();
}
private static string Bucket(string? mime) => mime switch
{
null => "other",
var m when m.StartsWith("image/") => "images",
var m when m.StartsWith("video/") => "video",
var m when m.StartsWith("audio/") => "audio",
var m when m.Contains("pdf") => "pdf",
var m when m.Contains("zip") || m.Contains("compressed") => "archives",
var m when m.Contains("spreadsheet") || m.Contains("excel") => "spreadsheets",
var m when m.Contains("word") || m.Contains("document") => "documents",
_ => "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.Shopping) + Cat(EmailCategory.Promotional),
["noreply"] = Cat(EmailCategory.Notification),
["gaming"] = Cat(EmailCategory.Gaming),
["sales"] = Cat(EmailCategory.SeasonalSales),
["ridesharing"] = Cat(EmailCategory.RideSharing),
["food"] = Cat(EmailCategory.FoodDelivery),
["wellness"] = Cat(EmailCategory.Wellness),
// New categories
["travel"] = Cat(EmailCategory.Travel),
["subscriptions"] = Cat(EmailCategory.Subscriptions),
["parcels"] = Cat(EmailCategory.Parcels),
["recruitment"] = Cat(EmailCategory.Recruitment),
["events"] = Cat(EmailCategory.Events),
["security"] = Cat(EmailCategory.SecurityAlerts),
["healthcare"] = Cat(EmailCategory.Healthcare),
["education"] = Cat(EmailCategory.Education),
["news"] = Cat(EmailCategory.NewsMedia),
["property"] = Cat(EmailCategory.PropertyUtilities),
["charity"] = Cat(EmailCategory.Charity),
["government"] = Cat(EmailCategory.Government),
["crypto"] = Cat(EmailCategory.CryptoInvesting),
["family"] = Cat(EmailCategory.FamilySchool),
};
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);
var perDay = await _db.Emails
.Where(e => e.UserId == userId && e.SentAtUtc >= since)
.Select(e => new { e.SentAtUtc, e.IsUnread, e.Category, e.HasAttachments, e.SizeEstimateBytes })
.ToListAsync(ct);
var grouped = perDay.GroupBy(e => DateOnly.FromDateTime(e.SentAtUtc.UtcDateTime.Date));
foreach (var g in grouped)
{
var existing = await _db.AnalyticsAggregates.FirstOrDefaultAsync(a => a.UserId == userId && a.Day == g.Key, ct);
var agg = existing ?? new Domain.Entities.AnalyticsAggregate { UserId = userId, Day = g.Key };
agg.TotalReceived = g.Count();
agg.TotalUnread = g.Count(x => x.IsUnread);
agg.NewsletterCount = g.Count(x => x.Category == EmailCategory.Newsletter);
agg.WithAttachments = g.Count(x => x.HasAttachments);
agg.TotalSizeBytes = g.Sum(x => x.SizeEstimateBytes);
if (existing is null) _db.AnalyticsAggregates.Add(agg);
}
await _db.SaveChangesAsync(ct);
}
}