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
9 changed files with 27 additions and 75 deletions
+2 -4
View File
@@ -62,12 +62,10 @@ jobs:
- uses: actions/checkout@v4
# The runner image lacks pip, and a semgrep job-container lacks the node that
# actions/checkout needs — so install pip via apt on the standard image.
# Gitea's Act runner has no sudo binary but already runs as root, so call
# apt-get directly; pip install --user needs no pipx.
- name: Install semgrep
run: |
apt-get update -qq && apt-get install -y -qq python3-pip
python3 -m pip install --user --break-system-packages semgrep || python3 -m pip install --user semgrep
sudo apt-get update -qq && sudo apt-get install -y -qq python3-pip pipx
pipx install semgrep
- name: Semgrep scan
run: |
export PATH="$HOME/.local/bin:$PATH"
-4
View File
@@ -1,6 +1,5 @@
services:
postgres:
restart: unless-stopped
# pgvector-enabled Postgres 16 (semantic search). Drop-in for postgres:16 data;
# the 'vector' extension is created by the AddEmbeddingColumn migration.
image: pgvector/pgvector:pg16
@@ -28,7 +27,6 @@ services:
# encrypted disk and be included in your off-machine backup regime (see SECURITY.md).
# Restore: docker compose exec -T postgres psql -U inboxintel -d inboxintel < backups/<file>.sql
backup:
restart: unless-stopped
image: pgvector/pgvector:pg16
entrypoint: /bin/sh
command:
@@ -66,7 +64,6 @@ services:
- keys:/keys
api:
restart: unless-stopped
build:
context: .
dockerfile: src/InboxIntel.Api/Dockerfile
@@ -101,7 +98,6 @@ services:
- "127.0.0.1:8080:8080"
frontend:
restart: unless-stopped
build:
context: ./frontend
dockerfile: Dockerfile
+2 -10
View File
@@ -1,11 +1,3 @@
# Honor a reverse proxy's X-Forwarded-Proto (e.g. traefik terminating TLS) so the API sees
# https and builds https OAuth redirects; fall back to the connection scheme for direct access.
map $http_x_forwarded_proto $fwd_proto {
default $scheme;
https https;
http http;
}
server {
listen 80;
server_name _;
@@ -35,7 +27,7 @@ server {
proxy_pass http://api:8080;
proxy_set_header Host $http_host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $fwd_proto;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-Host $http_host;
proxy_set_header Cookie $http_cookie;
}
@@ -46,7 +38,7 @@ server {
proxy_pass http://api:8080;
proxy_set_header Host $http_host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $fwd_proto;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-Host $http_host;
proxy_set_header Cookie $http_cookie;
}
+2 -2
View File
@@ -1,6 +1,5 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import GridLayoutBase, { WidthProvider } from 'react-grid-layout';
const GridLayout = WidthProvider(GridLayoutBase);
import GridLayout from 'react-grid-layout';
import 'react-grid-layout/css/styles.css';
import 'react-resizable/css/styles.css';
import { AnalyticsApi, LayoutApi, ExportApi, SyncApi } from '../api/client.js';
@@ -138,6 +137,7 @@ export default function Dashboard() {
layout={visibleLayout}
cols={12}
rowHeight={60}
width={1200}
isResizable
isDraggable
resizeHandles={['se']}
@@ -24,6 +24,9 @@ public class AnalyticsController : ApiControllerBase
public async Task<IActionResult> Volume([FromQuery] int days = 90, CancellationToken ct = default)
=> 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")]
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<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<HeatmapCellDto>> GetHeatmapAsync(Guid userId, CancellationToken ct = default);
Task<IReadOnlyList<CategoryHeatmapCellDto>> GetCategoryHeatmapAsync(Guid userId, CancellationToken ct = default);
Task<IReadOnlyList<AttachmentBreakdownDto>> GetAttachmentBreakdownAsync(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 HeatmapCellDto(int DayOfWeek, int Hour, int Count);
/// <summary>Email counts per category per day-of-week, for the category heatmap.</summary>
public record CategoryHeatmapCellDto(string Category, int DayOfWeek, int Count);
@@ -38,5 +39,6 @@ public record DashboardSummaryDto(
int UnreadEmails,
IReadOnlyList<SenderStatDto> TopSenders,
IReadOnlyList<TimeSeriesPointDto> VolumeOverTime,
IReadOnlyList<HeatmapCellDto> Heatmap,
IReadOnlyList<AttachmentBreakdownDto> AttachmentBreakdown,
long StorageEstimateBytes);
@@ -16,10 +16,11 @@ public class AnalyticsService : IAnalyticsService
var health = await GetInboxHealthAsync(userId, ct);
var top = await GetTopSendersAsync(userId, 10, ct);
var volume = await GetVolumeOverTimeAsync(userId, 90, ct);
var heatmap = await GetHeatmapAsync(userId, ct);
var attachments = await GetAttachmentBreakdownAsync(userId, ct);
return new DashboardSummaryDto(
health, health.TotalEmails, health.UnreadEmails, top, volume, attachments,
health, health.TotalEmails, health.UnreadEmails, top, volume, heatmap, attachments,
health.EstimatedStorageBytes);
}
@@ -74,6 +75,19 @@ public class AnalyticsService : IAnalyticsService
.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)
{
var raw = await _db.Emails
-54
View File
@@ -1,54 +0,0 @@
# InboxIntel — TODO / follow-ups
Outstanding items after the 2026-07-04 work session (UI refactor, category fixes, login fix,
Pi deployment, mockups). Nothing here is blocking; these are the known loose ends.
## Sync — Sent / Drafts folders
The PHASE 2 category fix (PR #38) is correct — after a re-sync, **Spam and Trash now populate**
(Trash 1363, Spam 112 on staging). But:
- [ ] **Uncapped re-sync to confirm Sent.** Sent is still empty on staging only because of the
2000-message dev cap (`GmailSync:MaxMessages=2000`) + Gmail's newest-first ordering — the
newest 2000 are dominated by trash/promotions, so Sent/Draft/most Inbox mail falls outside
the window. An uncapped sync (`MAX_MESSAGES=0`, real deployment) should include Sent.
To re-run: `DELETE FROM sync_states` for the user, then reload the dashboard (auto-fires a
full sync). Note: the capped re-sync also left staging's Inbox sparse (~5) — staging-only.
- [ ] **Drafts fix.** User added a drafts scope in Google Cloud console. Code side: `gmail.readonly`
already grants draft *read*, but `messages.list` doesn't return drafts — add a `users.drafts.list`
fetch in `GmailService`/`SyncService` and link the DRAFT label. (Add the new scope to
`GoogleOAuth:Scopes` in appsettings if compose/modify is needed for drafts too.)
- [ ] Optional: with `IncludeSpamTrash=true`, a *capped* sync gets flooded by trash. If caps are
kept, consider prioritising non-trash or making the cap per-folder.
## Pi deployment — real login
InboxIntel is live on the homelab at http://192.168.50.13:8090 (auto-deploys from `develop`).
- [x] Live message cap removed — `MAX_MESSAGES=0` in the Pi `deploy/.env` (takes effect on next sync).
- [ ] **Google login on the Pi.** Subdomain: **inboxintel.cesnimda.uk** (user wiring traefik+TLS manually).
Redirect URI (in the new live Google OAuth client): `https://inboxintel.cesnimda.uk/signin-google`.
Once traefik is up: set `FRONTEND_ORIGIN=https://inboxintel.cesnimda.uk` in the Pi `deploy/.env`,
set `AllowedHosts`, and put the **new live** `GOOGLE_CLIENT_ID`/`GOOGLE_CLIENT_SECRET` in the Pi
`deploy/.env` (see below), then redeploy.
- [ ] **Where the live OAuth ID/secret go: the Pi `/opt/inboxintel/deploy/.env`** (git-ignored) — NOT
Gitea secrets. The Pi auto-deploys via a cron `git pull` + `docker compose`, which reads
`deploy/.env` directly; there's no Gitea Actions runner on the Pi, so Gitea secrets wouldn't be
injected. (Gitea secrets only matter for the staging CD runner.)
## Release / housekeeping
- [ ] Decide on promoting `develop``main` and cutting a release tag (e.g. v0.1.0). `main` is
stale; all current work lives on `develop`. Stale PR #6 (develop→main) can be closed/redone.
- [ ] Deferred by user: "Clean up my inbox" natural-language chat feature (do last).
- [ ] Accessibility pass before launch (green accent must not be colour-only) — noted as nice-to-have.
## Done this session (for reference)
- Login 500 fixed (root-owned DataProtection keys volume) — PR #37
- All 7 smart-category bugs fixed at source — PR #38
- Email dashboard UX: split-view, custom checkbox, row polish, bulk/keyboard, dashboard
heatmap→category-bar, skeletons/empty states — PR #44
- Dead backend heatmap removed — PR #45
- .NET 10 migration, Renovate, DB backups, OpenTelemetry, hybrid semantic search, keyset
pagination, feature-flags foundation, Semgrep SAST (earlier PRs)
- Resume mockups → F:\Pictures\website\inboxintel
- Live Docker deployment + cron auto-deploy on pi@192.168.50.13