brewlog/templates/pages/stats.html
Jon Seager 5820c162f6
fix: address static assets code review findings
- Remove dead CSS color property in .tab-mobile
- Fix design token violations (text-red-500, fallback colors)
- Add disconnectedCallback to chip-scroll, image-upload, searchable-select
- Replace innerHTML with safe DOM APIs in world-map screen reader table
- Add credentials: same-origin to WebAuthn fetch calls
- Move page-specific scripts (donut-chart, location) out of base.html
- Add client-side image resizing (1920px max dimension)
- Resize app-icon-512.png from 2048x2048 to 512x512
- Document Datastar unsafe-eval CSP requirement
- Add static asset serving tests (16 routes)
- Add e2e tests for world-map, donut-chart, chip-scroll presence
- Add cache-busting query params to all static asset URLs
2026-02-13 17:13:02 +00:00

254 lines
9.9 KiB
HTML

{% extends "base.html" %} {% import "partials/icons.html" as icons %}
{% import "partials/histogram.html" as histogram %}
{% block title %}Brewlog · Stats{% endblock %}
{% block og_title %}Stats — Brewlog{% endblock %}
{% block og_description %}
Aggregated coffee data across origins, consumption, and brewing.
{% endblock %}
{% block head %}
<meta property="og:image" content="{{ base_url }}/static/og-image.png" />
<script
defer
src="/static/js/components/donut-chart.js?v={{ version_info.commit }}"
></script>
{% endblock %}
{% block content %}
<header class="flex flex-col gap-2">
<div class="flex items-start justify-between gap-4">
<h1 class="text-3xl font-semibold">Stats</h1>
{% if !cache_age.is_empty() %}
<span class="shrink-0 text-xs text-text-muted pt-2"
>Updated {{ cache_age }}</span
>
{% endif %}
</div>
<p class="max-w-2xl text-sm text-text-secondary">
Aggregated coffee data across origins, consumption, and brewing.
</p>
</header>
{% if has_data %}
<div class="flex flex-col gap-6">
{% include "partials/tab_bar.html" %}
{# Safety: content is always pre-rendered from another Askama template via render_template() #}
<div id="stats-content">{{ content|safe }}</div>
</div>
<section>
<div class="flex items-center justify-between mb-5">
<h2 class="text-lg font-semibold text-text">Roast Stats</h2>
</div>
<div class="grid gap-3 sm:grid-cols-3">
<div
class="flex flex-col items-center gap-1 rounded-lg border bg-surface p-4"
>
{{ icons::map("h-6 w-6 text-text-muted") }}
<span class="text-lg font-bold text-text"
>{{ roast_summary.unique_origins }}</span
>
<span class="text-sm font-medium text-text-muted">Origins</span>
</div>
<div
class="flex flex-col items-center gap-1 rounded-lg border bg-surface p-4"
>
{{ icons::location("h-6 w-6 text-text-muted") }}
<span class="text-lg font-bold text-text truncate max-w-full"
>{% match roast_summary.top_origin %}{% when Some with (v) %}{{ v }}{% when None %}&mdash;{% endmatch %}</span
>
<span class="text-sm font-medium text-text-muted">Top Origin</span>
</div>
<div
class="flex flex-col items-center gap-1 rounded-lg border bg-surface p-4"
>
{{ icons::fire("h-6 w-6 text-text-muted") }}
<span class="text-lg font-bold text-text truncate max-w-full"
>{% match roast_summary.top_roaster %}{% when Some with (v) %}{{ v }}{% when None %}&mdash;{% endmatch %}</span
>
<span class="text-sm font-medium text-text-muted">Top Roaster</span>
</div>
</div>
{% if !roast_summary.origin_counts.is_empty() || !roast_summary.flavour_counts.is_empty() %}
<div class="mt-5 grid gap-5 md:grid-cols-2">
{% if !roast_summary.origin_counts.is_empty() %}
<div>
<h3 class="text-sm font-semibold text-text mb-3">
Top 5 Origins
</h3>
{{ histogram::bar_chart(roast_summary.origin_counts, roast_summary.max_origin_count) }}
</div>
{% endif %}
{% if !roast_summary.flavour_counts.is_empty() %}
<div>
<h3 class="text-sm font-semibold text-text mb-3">
Top 5 Flavours
</h3>
{{ histogram::bar_chart(roast_summary.flavour_counts, roast_summary.max_flavour_count) }}
</div>
{% endif %}
</div>
{% endif %}
</section>
<section>
<div class="flex items-center justify-between mb-5">
<h2 class="text-lg font-semibold text-text">Consumption</h2>
</div>
<div class="grid grid-cols-2 gap-3 sm:grid-cols-4">
<div
class="flex flex-col items-center gap-1 rounded-lg border bg-surface p-4"
>
{{ icons::coffee_bean("h-6 w-6 text-text-muted") }}
<span class="text-lg font-bold text-text"
>{{ consumption_30d_weight }}</span
>
<span class="text-sm font-medium text-text-muted">Last 30 Days</span>
</div>
<div
class="flex flex-col items-center gap-1 rounded-lg border bg-surface p-4"
>
{{ icons::coffee_bean("h-6 w-6 text-text-muted") }}
<span class="text-lg font-bold text-text"
>{{ consumption_all_time_weight }}</span
>
<span class="text-sm font-medium text-text-muted">All Time</span>
</div>
<div
class="flex flex-col items-center gap-1 rounded-lg border bg-surface p-4"
>
{{ icons::beaker("h-6 w-6 text-text-muted") }}
<span class="text-lg font-bold text-text"
>{{ consumption.brews_last_30_days }}</span
>
<span class="text-sm font-medium text-text-muted">Brews (30d)</span>
</div>
<div
class="flex flex-col items-center gap-1 rounded-lg border bg-surface p-4"
>
{{ icons::beaker("h-6 w-6 text-text-muted") }}
<span class="text-lg font-bold text-text"
>{{ consumption.brews_all_time }}</span
>
<span class="text-sm font-medium text-text-muted"
>Brews (All Time)</span
>
</div>
</div>
</section>
<section>
<div class="flex items-center justify-between mb-5">
<h2 class="text-lg font-semibold text-text">Brewing</h2>
</div>
{% if !brewing_summary.brewer_counts.is_empty() || !brewing_summary.grinder_counts.is_empty() %}
<div class="grid gap-5 md:grid-cols-2">
{% if !brewing_summary.brewer_counts.is_empty() %}
<div class="rounded-lg border bg-surface p-5">
<h3 class="text-sm font-semibold text-text mb-4">Brewer Usage</h3>
<donut-chart
data-icon="beaker"
data-items="{% for item in brewing_summary.brewer_counts %}{% if !loop.first %}|{% endif %}{{ item.0 }}:{{ item.1 }}{% endfor %}"
></donut-chart>
</div>
{% endif %}
{% if !brewing_summary.grinder_counts.is_empty() %}
<div class="rounded-lg border bg-surface p-5">
<h3 class="text-sm font-semibold text-text mb-4">
Grinder Usage
</h3>
<donut-chart
data-icon="grinder"
data-items="{% for item in brewing_summary.grinder_counts %}{% if !loop.first %}|{% endif %}{{ item.0 }}:{{ item.1 }}{% endfor %}"
></donut-chart>
</div>
{% endif %}
</div>
{% endif %}
<div class="mt-5 grid gap-5 md:grid-cols-2">
{% if !grinder_weights.is_empty() %}
<div>
<h3 class="text-sm font-semibold text-text mb-3">
Coffee per Grinder
</h3>
{{ histogram::bar_chart_weight(grinder_weights, max_grinder_weight) }}
</div>
{% endif %}
{% if !brewing_summary.brew_time_distribution.is_empty() %}
<div>
<h3 class="text-sm font-semibold text-text mb-3">
Brew Time Distribution
</h3>
{{ histogram::bar_chart(brewing_summary.brew_time_distribution, brewing_summary.max_brew_time_count) }}
</div>
{% endif %}
</div>
</section>
{% else %}
<div class="relative">
<div
class="flex flex-col gap-8 blur-[2px] select-none pointer-events-none"
aria-hidden="true"
>
<!-- Placeholder: Roast Stats -->
<section>
<div class="mb-5">
<div class="h-5 w-28 rounded bg-surface-alt"></div>
</div>
<div class="grid gap-3 sm:grid-cols-3">
{% for _ in 0..3 %}
<div
class="flex flex-col items-center gap-1 rounded-lg border bg-surface p-4"
>
<div class="h-6 w-6 rounded-full bg-surface-alt"></div>
<div class="h-5 w-10 rounded bg-surface-alt"></div>
<div class="h-4 w-16 rounded bg-surface-alt"></div>
</div>
{% endfor %}
</div>
</section>
<!-- Placeholder: Consumption -->
<section>
<div class="mb-5">
<div class="h-5 w-32 rounded bg-surface-alt"></div>
</div>
<div class="grid grid-cols-2 gap-3 sm:grid-cols-4">
{% for _ in 0..4 %}
<div
class="flex flex-col items-center gap-1 rounded-lg border bg-surface p-4"
>
<div class="h-6 w-6 rounded-full bg-surface-alt"></div>
<div class="h-5 w-12 rounded bg-surface-alt"></div>
<div class="h-4 w-20 rounded bg-surface-alt"></div>
</div>
{% endfor %}
</div>
</section>
<!-- Placeholder: Brewing charts -->
<section>
<div class="mb-5">
<div class="h-5 w-24 rounded bg-surface-alt"></div>
</div>
<div class="grid gap-5 md:grid-cols-2">
{% for _ in 0..2 %}
<div class="rounded-lg border bg-surface p-5">
<div class="h-4 w-28 rounded bg-surface-alt mb-4"></div>
<div class="flex flex-col gap-2">
{% for _ in 0..4 %}
<div class="flex items-center gap-3">
<div class="w-28 h-3 rounded bg-surface-alt"></div>
<div class="flex-1 h-5 rounded bg-surface-alt"></div>
<div class="w-6 h-3 rounded bg-surface-alt"></div>
</div>
{% endfor %}
</div>
</div>
{% endfor %}
</div>
</section>
</div>
<div class="absolute inset-0 z-10 flex items-center justify-center">
<span class="text-lg font-semibold text-text-muted">No stats yet</span>
</div>
</div>
{% endif %}
{% endblock %}