diff --git a/backend/libs/handlers/accounts_test.go b/backend/libs/handlers/accounts_test.go index 6ce1b33..f66c991 100644 --- a/backend/libs/handlers/accounts_test.go +++ b/backend/libs/handlers/accounts_test.go @@ -707,24 +707,60 @@ func TestAdminOverviewChartsUseZeroAndFullHeights(t *testing.T) { for i, bar := range overview.UploadDays { want := 0 if i == len(overview.UploadDays)-1 { - want = 100 + want = 150 } - if bar.Height != want { - t.Fatalf("upload bar %d height = %d, want %d", i, bar.Height, want) + if bar.HeightPx != want { + t.Fatalf("upload bar %d height = %d, want %d", i, bar.HeightPx, want) } } for i, bar := range overview.StorageDays { want := 0 if i == len(overview.StorageDays)-1 { - want = 100 + want = 150 } - if bar.Height != want { - t.Fatalf("storage bar %d height = %d, want %d", i, bar.Height, want) + if bar.HeightPx != want { + t.Fatalf("storage bar %d height = %d, want %d", i, bar.HeightPx, want) } } + if overview.StatusBars[0].WidthPercent != 100 { + t.Fatalf("active status width = %d, want 100", overview.StatusBars[0].WidthPercent) + } } -func TestAdminOverviewRendersBarHeightVariables(t *testing.T) { +func TestAdminOverviewChartsScaleRelativeToVisibleRange(t *testing.T) { + now := time.Now().UTC() + today := time.Date(now.Year(), now.Month(), now.Day(), 12, 0, 0, 0, time.UTC) + yesterday := today.AddDate(0, 0, -1) + twoDaysAgo := today.AddDate(0, 0, -2) + boxes := []services.AdminBox{ + {ID: "today-1", CreatedAt: today, TotalSize: 30}, + {ID: "today-2", CreatedAt: today, TotalSize: 30}, + {ID: "today-3", CreatedAt: today, TotalSize: 30}, + {ID: "yesterday-1", CreatedAt: yesterday, TotalSize: 20}, + {ID: "yesterday-2", CreatedAt: yesterday, TotalSize: 20}, + {ID: "two-days-ago", CreatedAt: twoDaysAgo, TotalSize: 10}, + } + overview := buildAdminOverview(boxes, services.AdminStats{TotalBoxes: 6, ExpiredBoxes: 2, ProtectedBoxes: 1}) + + last := len(overview.UploadDays) - 1 + if overview.UploadDays[last].HeightPx != 150 { + t.Fatalf("3-upload day height = %d, want 150", overview.UploadDays[last].HeightPx) + } + if overview.UploadDays[last-1].HeightPx != 100 { + t.Fatalf("2-upload day height = %d, want 100", overview.UploadDays[last-1].HeightPx) + } + if overview.UploadDays[last-2].HeightPx != 50 { + t.Fatalf("1-upload day height = %d, want 50", overview.UploadDays[last-2].HeightPx) + } + if overview.StorageDays[last].HeightPx != 150 || overview.StorageDays[last-1].HeightPx != 66 || overview.StorageDays[last-2].HeightPx != 16 { + t.Fatalf("storage heights = %d/%d/%d, want 150/66/16", overview.StorageDays[last].HeightPx, overview.StorageDays[last-1].HeightPx, overview.StorageDays[last-2].HeightPx) + } + if overview.StatusBars[0].WidthPercent != 100 || overview.StatusBars[1].WidthPercent != 50 || overview.StatusBars[2].WidthPercent != 25 { + t.Fatalf("status widths = %d/%d/%d, want 100/50/25", overview.StatusBars[0].WidthPercent, overview.StatusBars[1].WidthPercent, overview.StatusBars[2].WidthPercent) + } +} + +func TestAdminOverviewRendersInlineBarDimensions(t *testing.T) { app, cleanup := newTestApp(t) defer cleanup() adminToken := createAdminSession(t, app) @@ -738,11 +774,26 @@ func TestAdminOverviewRendersBarHeightVariables(t *testing.T) { t.Fatalf("AdminDashboard status = %d, body = %s", response.Code, response.Body.String()) } body := response.Body.String() - if !strings.Contains(body, "--bar-height: 100%") { - t.Fatalf("admin overview did not render a full-height bar: %s", body) + if !strings.Contains(body, `style="height: 150px"`) { + t.Fatalf("admin overview did not render a full-height pixel bar: %s", body) } - if strings.Contains(body, `style="height:`) { - t.Fatalf("admin overview still uses fragile percent height styles: %s", body) + if !strings.Contains(body, `data-height-px="150"`) || !strings.Contains(body, `data-chart-value=`) { + t.Fatalf("admin overview did not render chart fallback data attributes: %s", body) + } + if !strings.Contains(body, `style="height: 0px"`) { + t.Fatalf("admin overview did not render zero pixel bars: %s", body) + } + if !strings.Contains(body, `style="width: 100%"`) { + t.Fatalf("admin overview did not render a full-width status bar: %s", body) + } + if !strings.Contains(body, `data-width-percent="100"`) || !strings.Contains(body, `data-stat-value=`) { + t.Fatalf("admin overview did not render status fallback data attributes: %s", body) + } + if strings.Contains(body, "--bar-height") { + t.Fatalf("admin overview still uses css variable bar heights: %s", body) + } + if !strings.Contains(body, "/static/js/25-admin-charts.js?version=test") { + t.Fatalf("admin overview did not load chart fallback script: %s", body) } } diff --git a/backend/libs/handlers/admin.go b/backend/libs/handlers/admin.go index 2683bf0..cec35a2 100644 --- a/backend/libs/handlers/admin.go +++ b/backend/libs/handlers/admin.go @@ -159,15 +159,17 @@ type adminOverview struct { } type adminChartBar struct { - Label string - Value string - Height int // 0-100, percent of the tallest bar + Label string + Value string + HeightPx int + RawValue int64 } type adminStatBar struct { - Label string - Value string - Percent int + Label string + Value string + RawValue int + WidthPercent int } type adminBoxView struct { @@ -336,6 +338,7 @@ func (a *App) recentBoxViews(boxes []services.AdminBox, limit int) []adminBoxVie // status distributions for the overview dashboard. func buildAdminOverview(boxes []services.AdminBox, stats services.AdminStats) adminOverview { const days = 14 + const chartMaxHeightPx = 150 now := time.Now().UTC() today := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, time.UTC) @@ -374,14 +377,16 @@ func buildAdminOverview(boxes []services.AdminBox, stats services.AdminStats) ad storageDays := make([]adminChartBar, days) for i := 0; i < days; i++ { uploadDays[i] = adminChartBar{ - Label: labels[i], - Value: strconv.Itoa(counts[i]), - Height: scaleHeight(int64(counts[i]), int64(maxCount)), + Label: labels[i], + Value: strconv.Itoa(counts[i]), + HeightPx: scaleHeightPx(int64(counts[i]), int64(maxCount), chartMaxHeightPx), + RawValue: int64(counts[i]), } storageDays[i] = adminChartBar{ - Label: labels[i], - Value: helpers.FormatBytes(bytes[i]), - Height: scaleHeight(bytes[i], maxBytes), + Label: labels[i], + Value: helpers.FormatBytes(bytes[i]), + HeightPx: scaleHeightPx(bytes[i], maxBytes, chartMaxHeightPx), + RawValue: bytes[i], } } @@ -389,10 +394,11 @@ func buildAdminOverview(boxes []services.AdminBox, stats services.AdminStats) ad if activeBoxes < 0 { activeBoxes = 0 } + maxStatusValue := maxInt(activeBoxes, stats.ExpiredBoxes, stats.ProtectedBoxes) statusBars := []adminStatBar{ - {Label: "Active", Value: strconv.Itoa(activeBoxes), Percent: percentOf(activeBoxes, stats.TotalBoxes)}, - {Label: "Expired", Value: strconv.Itoa(stats.ExpiredBoxes), Percent: percentOf(stats.ExpiredBoxes, stats.TotalBoxes)}, - {Label: "Password-protected", Value: strconv.Itoa(stats.ProtectedBoxes), Percent: percentOf(stats.ProtectedBoxes, stats.TotalBoxes)}, + {Label: "Active", Value: strconv.Itoa(activeBoxes), RawValue: activeBoxes, WidthPercent: percentOf(activeBoxes, maxStatusValue)}, + {Label: "Expired", Value: strconv.Itoa(stats.ExpiredBoxes), RawValue: stats.ExpiredBoxes, WidthPercent: percentOf(stats.ExpiredBoxes, maxStatusValue)}, + {Label: "Password-protected", Value: strconv.Itoa(stats.ProtectedBoxes), RawValue: stats.ProtectedBoxes, WidthPercent: percentOf(stats.ProtectedBoxes, maxStatusValue)}, } return adminOverview{ @@ -402,13 +408,16 @@ func buildAdminOverview(boxes []services.AdminBox, stats services.AdminStats) ad } } -func scaleHeight(value, max int64) int { +func scaleHeightPx(value, max int64, maxHeightPx int) int { if max <= 0 || value <= 0 { return 0 } - height := int(value * 100 / max) - if height < 4 { - height = 4 + height := int(value * int64(maxHeightPx) / max) + if height < 8 { + height = 8 + } + if height > maxHeightPx { + return maxHeightPx } return height } @@ -420,6 +429,16 @@ func percentOf(value, total int) int { return value * 100 / total } +func maxInt(values ...int) int { + max := 0 + for _, value := range values { + if value > max { + max = value + } + } + return max +} + func (a *App) AdminUsers(w http.ResponseWriter, r *http.Request) { if !a.requireAdmin(w, r) { return diff --git a/backend/static/css/50-admin.css b/backend/static/css/50-admin.css index 18bf943..6b847ab 100644 --- a/backend/static/css/50-admin.css +++ b/backend/static/css/50-admin.css @@ -198,9 +198,9 @@ .bar-chart { display: grid; grid-template-columns: repeat(14, minmax(0, 1fr)); - align-items: stretch; + align-items: end; gap: 0.4rem; - min-height: 15rem; + min-height: 13rem; margin-top: 1.25rem; padding-top: 0.5rem; } @@ -214,11 +214,13 @@ } .bar-chart-track { - position: relative; + display: flex; + align-items: flex-end; + justify-content: center; flex: 1 1 auto; width: 100%; max-width: 1.8rem; - min-height: 9rem; + height: 150px; margin: 0 auto; border-bottom: 2px solid color-mix(in srgb, var(--primary, #8b5cf6) 75%, transparent); border-radius: 0.45rem 0.45rem 0 0; @@ -228,12 +230,8 @@ .bar-chart-bar { display: block; - position: absolute; - left: 0; - right: 0; - bottom: 0; width: 100%; - height: var(--bar-height, 0%); + min-height: 0; border-radius: 6px 6px 0 0; background: linear-gradient(180deg, var(--primary-hover, #7c3aed), var(--primary, #8b5cf6)); box-shadow: 0 0 18px color-mix(in srgb, var(--primary, #8b5cf6) 35%, transparent); @@ -278,6 +276,8 @@ } .stat-bar-track { + display: block; + width: 100%; margin-top: 0.35rem; height: 0.55rem; border-radius: 999px; @@ -288,6 +288,7 @@ .stat-bar-fill { display: block; height: 100%; + min-width: 0; border-radius: 999px; background: var(--primary, #8b5cf6); } diff --git a/backend/static/js/25-admin-charts.js b/backend/static/js/25-admin-charts.js new file mode 100644 index 0000000..7fd044e --- /dev/null +++ b/backend/static/js/25-admin-charts.js @@ -0,0 +1,57 @@ +(function () { + const maxBarHeight = 150; + + function numberAttr(element, name) { + const value = Number(element.getAttribute(name)); + return Number.isFinite(value) ? value : 0; + } + + function applyChartBars() { + document.querySelectorAll(".bar-chart").forEach((chart) => { + const bars = Array.from(chart.querySelectorAll(".bar-chart-col")); + const maxValue = Math.max(0, ...bars.map((bar) => numberAttr(bar, "data-chart-value"))); + + bars.forEach((bar) => { + const fill = bar.querySelector(".bar-chart-bar"); + if (!fill) { + return; + } + const value = numberAttr(bar, "data-chart-value"); + let height = numberAttr(fill, "data-height-px"); + if (maxValue > 0) { + height = value <= 0 ? 0 : Math.max(8, Math.round((value / maxValue) * maxBarHeight)); + } + fill.style.height = `${Math.min(maxBarHeight, height)}px`; + }); + }); + } + + function applyStatusBars() { + const rows = Array.from(document.querySelectorAll(".stat-bar")); + const maxValue = Math.max(0, ...rows.map((row) => numberAttr(row, "data-stat-value"))); + + rows.forEach((row) => { + const fill = row.querySelector(".stat-bar-fill"); + if (!fill) { + return; + } + const value = numberAttr(row, "data-stat-value"); + let width = numberAttr(fill, "data-width-percent"); + if (maxValue > 0) { + width = value <= 0 ? 0 : Math.round((value / maxValue) * 100); + } + fill.style.width = `${Math.max(0, Math.min(100, width))}%`; + }); + } + + function init() { + applyChartBars(); + applyStatusBars(); + } + + if (document.readyState === "loading") { + document.addEventListener("DOMContentLoaded", init); + } else { + init(); + } +})(); diff --git a/backend/templates/layouts/base.html b/backend/templates/layouts/base.html index 9a3bace..8394a35 100644 --- a/backend/templates/layouts/base.html +++ b/backend/templates/layouts/base.html @@ -32,6 +32,7 @@ + diff --git a/backend/templates/pages/admin.html b/backend/templates/pages/admin.html index 668bb9f..bbf609c 100644 --- a/backend/templates/pages/admin.html +++ b/backend/templates/pages/admin.html @@ -65,9 +65,9 @@
New boxes created over the last 14 days.