heartwood every commit a ring

Replace dashboard PDF report with toner-friendly print template

7ee840fa by Isaac Bythewood · 12 days ago

Replace dashboard PDF report with toner-friendly print template

The PDF was generated by re-rendering the dark dashboard with a
print-tweaked stylesheet, which printed huge black backgrounds. Replace
it with a dedicated property_print.html: light theme, A4-paginated,
SVG line chart with a thin black stroke, three-column tables for
top-lists / breakdowns / UTM, percentage columns instead of doughnuts.

Also detect the chromium binary lazily so installing it after the
server is up no longer requires a restart.
modified analytics/chromium.py
@@ -45,9 +45,6 @@ def _find_chromium() -> Optional[str]:    return NoneCHROMIUM_BINARY = _find_chromium()class ChromiumError(RuntimeError):    pass
@@ -76,12 +73,13 @@ def _html_tempfile(html: str) -> Iterator[str]:def _run(args: list[str], timeout: int) -> None:    if not CHROMIUM_BINARY:    binary = _find_chromium()    if not binary:        raise ChromiumError(            "No chromium binary found on PATH (tried: chromium, "            "chromium-browser, google-chrome)"        )    cmd = [CHROMIUM_BINARY, *BASE_FLAGS, *args]    cmd = [binary, *BASE_FLAGS, *args]    try:        subprocess.run(cmd, check=True, capture_output=True, timeout=timeout)    except subprocess.TimeoutExpired as exc:
modified properties/static_src/index.js
@@ -4,5 +4,3 @@ import "./scripts/property_date_select.js";import "./scripts/property_custom_cards.js";import "./scripts/property_is_public.js";import "./scripts/property_filters.js";import "./styles/print.scss";
deleted properties/static_src/styles/print.scss
@@ -1,49 +0,0 @@@media print {  @page {    size: A4;    margin: 12mm;  }  html, body {    width: fit-content;    height: fit-content;    background: #fff !important;    color: #000 !important;  }  body {    -webkit-print-color-adjust: exact;    print-color-adjust: exact;  }  .container {    max-width: 100%;  }  .chart-panel {    page-break-inside: avoid;  }  #chart-total-events {    height: 300px;    page-break-after: always;  }  #doughnut-graphs {    width: 100%;    display: flex;    flex-wrap: wrap;    margin-bottom: 1.5rem;    page-break-after: always;  }  #doughnut-graphs > div {    width: 50%;    margin-bottom: 0;  }  #top-lists .col-12 {    width: 50%;    page-break-inside: avoid;  }}
modified properties/templates/properties/property.html
@@ -2,11 +2,6 @@{% load static %}{% block extra_css %}<link rel="stylesheet" href="{% static 'properties.css' %}">{% endblock %}{% block extra_js %}{{ total_events_graph|json_script:"chart-total-events-data" }}{{ total_events_by_browser|json_script:"chart-total-events-by-browser-data" }}
added properties/templates/properties/property_print.html
@@ -0,0 +1,470 @@{% load static %}<!doctype html><html lang="en"><head><meta charset="utf-8"><title>{{ property.name }} · Analytics report</title><style>  @page { size: A4; margin: 14mm; }  @page :first { margin-top: 16mm; }  * { box-sizing: border-box; }  html, body {    background: #fff;    color: #000;    font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;    font-size: 9.5pt;    line-height: 1.45;    margin: 0;    padding: 0;    -webkit-print-color-adjust: exact;    print-color-adjust: exact;  }  h1, h2, h3 { color: #000; margin: 0; font-weight: 700; }  h1 { font-size: 22pt; letter-spacing: -0.01em; }  h2 {    font-size: 11pt;    text-transform: uppercase;    letter-spacing: 0.08em;    margin: 16pt 0 6pt;    padding-bottom: 3pt;    border-bottom: 1px solid #000;  }  h3 {    font-size: 8.5pt;    text-transform: uppercase;    letter-spacing: 0.08em;    margin: 8pt 0 3pt;    color: #333;  }  .header {    display: flex;    justify-content: space-between;    align-items: flex-start;    margin-bottom: 4pt;  }  .header .brand {    font-size: 8.5pt;    text-transform: uppercase;    letter-spacing: 0.12em;    color: #555;  }  .header .generated {    font-size: 8pt;    color: #555;    text-align: right;  }  .meta {    font-size: 9pt;    margin-top: 4pt;    border-top: 1px solid #000;    border-bottom: 1px solid #000;    padding: 6pt 0;    display: grid;    grid-template-columns: repeat(2, 1fr);    gap: 2pt 16pt;  }  .meta dt { color: #555; font-size: 8pt; text-transform: uppercase; letter-spacing: 0.05em; margin: 0; }  .meta dd { margin: 0 0 4pt; font-weight: 600; }  .meta dd code { font-family: ui-monospace, "SF Mono", Menlo, Consolas, monospace; font-size: 8.5pt; font-weight: 400; }  .metrics {    display: grid;    grid-template-columns: repeat(4, 1fr);    gap: 4pt;  }  .metric {    border: 1px solid #000;    padding: 5pt 7pt;    page-break-inside: avoid;  }  .metric .label {    font-size: 7.5pt;    text-transform: uppercase;    letter-spacing: 0.05em;    color: #555;    line-height: 1.2;  }  .metric .value {    font-size: 14pt;    font-weight: 700;    margin-top: 2pt;    font-variant-numeric: tabular-nums;  }  .metric .delta {    font-size: 7.5pt;    color: #333;    margin-top: 1pt;    font-variant-numeric: tabular-nums;  }  .metric .delta.up::before { content: "▲ "; }  .metric .delta.down::before { content: "▼ "; }  .metric .delta.flat::before { content: "· "; }  .chart-wrap {    margin: 4pt 0 0;    page-break-inside: avoid;  }  .chart {    width: 100%;    height: 90pt;    display: block;  }  .chart-axis {    display: flex;    justify-content: space-between;    font-size: 7.5pt;    color: #555;    margin-top: 2pt;    font-variant-numeric: tabular-nums;  }  .grid-2 { display: grid; grid-template-columns: 1fr 1fr; gap: 10pt 14pt; }  .grid-2 > section { page-break-inside: avoid; }  .grid-3 { display: grid; grid-template-columns: 1fr 1fr 1fr; gap: 8pt 10pt; }  .grid-3 > section { page-break-inside: avoid; min-width: 0; }  table { width: 100%; border-collapse: collapse; font-size: 7.5pt; table-layout: fixed; }  th, td {    text-align: left;    padding: 2pt 3pt;    border-bottom: 1px solid #ddd;    vertical-align: top;    overflow-wrap: break-word;  }  th {    font-weight: 700;    border-bottom: 1px solid #000;    text-transform: uppercase;    letter-spacing: 0.04em;    font-size: 6.5pt;    color: #555;  }  td.num, th.num { text-align: right; font-variant-numeric: tabular-nums; white-space: nowrap; width: 22%; }  td.pct, th.pct { text-align: right; font-variant-numeric: tabular-nums; white-space: nowrap; color: #555; width: 18%; }  td.code, td .code {    font-family: ui-monospace, "SF Mono", Menlo, Consolas, monospace;    font-size: 7pt;    word-break: break-all;  }  tr:last-child td { border-bottom: none; }  .empty { color: #888; font-style: italic; font-size: 8pt; }  footer.report-footer {    margin-top: 18pt;    padding-top: 6pt;    border-top: 1px solid #000;    font-size: 7.5pt;    color: #555;    display: flex;    justify-content: space-between;  }  .filter-banner {    display: inline-block;    border: 1px solid #000;    padding: 1pt 6pt;    font-size: 8pt;    margin-top: 4pt;  }  .filter-banner strong { font-weight: 700; }</style></head><body><div class="header">  <div class="brand">// Analytics · property report</div>  <div class="generated">Generated {% now "Y-m-d H:i" %}</div></div><h1>{{ property.name }}</h1><dl class="meta">  <div><dt>Property ID</dt><dd><code>{{ property.id }}</code></dd></div>  <div><dt>Operator</dt><dd>{{ property.user.username }}</dd></div>  <div><dt>Date range</dt><dd>{{ date_start }} → {{ date_end }} <span style="color:#555;font-weight:400">({{ date_range }} days)</span></dd></div>  <div><dt>Live users · last 30m</dt><dd>{{ total_live_users }}</dd></div></dl>{% if filter_url %}<div class="filter-banner"><strong>Filter · url:</strong> <code>{{ filter_url }}</code></div>{% endif %}<h2>Metrics · period vs previous</h2><div class="metrics">  {% for card in event_cards %}  <div class="metric">    <div class="label">{{ card.name }}</div>    <div class="value">{{ card.value }}</div>    {% with pc=card.percent_change|stringformat:"s" %}    {% if card.percent_change == 0 %}      <div class="delta flat">no change</div>    {% elif pc|slice:":1" == "-" %}      <div class="delta down">{{ card.percent_change }}% vs previous</div>    {% else %}      <div class="delta up">+{{ card.percent_change }}% vs previous</div>    {% endif %}    {% endwith %}  </div>  {% endfor %}</div><h2>Events over time</h2><div class="chart-wrap">  {% if chart_polyline %}  <svg class="chart" viewBox="0 0 600 100" preserveAspectRatio="none" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="Events over time">    <line x1="0" y1="99.5" x2="600" y2="99.5" stroke="#000" stroke-width="0.4" />    <polyline fill="none" stroke="#000" stroke-width="0.9" stroke-linejoin="round" stroke-linecap="round" points="{{ chart_polyline }}" />  </svg>  <div class="chart-axis">    <span>{{ chart_label_start }}</span>    <span>peak {{ chart_peak_count }} · {{ chart_peak_label }}</span>    <span>{{ chart_label_end }}</span>  </div>  {% else %}  <div class="empty">No events in this range.</div>  {% endif %}</div><div class="grid-3">  {% if total_page_views_by_page_url %}  <section>    <h3>Top pages · page views</h3>    <table>      <thead><tr><th>URL</th><th class="num">Views</th></tr></thead>      <tbody>        {% for item in total_page_views_by_page_url %}        <tr><td class="code">{{ item.label }}</td><td class="num">{{ item.count }}</td></tr>        {% endfor %}      </tbody>    </table>  </section>  {% endif %}  {% if total_events_by_page_url %}  <section>    <h3>Top pages · all events</h3>    <table>      <thead><tr><th>URL</th><th class="num">Events</th></tr></thead>      <tbody>        {% for item in total_events_by_page_url %}        <tr><td class="code">{{ item.label }}</td><td class="num">{{ item.count }}</td></tr>        {% endfor %}      </tbody>    </table>  </section>  {% endif %}  {% if total_session_starts_by_referrer %}  <section>    <h3>Top referrers</h3>    <table>      <thead><tr><th>Referrer</th><th class="num">Sessions</th></tr></thead>      <tbody>        {% for item in total_session_starts_by_referrer %}        <tr><td class="code">{{ item.label }}</td><td class="num">{{ item.count }}</td></tr>        {% endfor %}      </tbody>    </table>  </section>  {% endif %}  {% if total_events_by_custom_event %}  <section>    <h3>Top custom events</h3>    <table>      <thead><tr><th>Event</th><th class="num">Count</th></tr></thead>      <tbody>        {% for item in total_events_by_custom_event %}        <tr><td>{{ item.label }}</td><td class="num">{{ item.count }}</td></tr>        {% endfor %}      </tbody>    </table>  </section>  {% endif %}</div><h2>Visitor breakdown</h2><div class="grid-3">  {% if total_events_by_device %}  <section>    <h3>Device</h3>    <table>      <thead><tr><th>Type</th><th class="num">Events</th><th class="pct">%</th></tr></thead>      <tbody>        {% for item in total_events_by_device %}        <tr>          <td>{{ item.label }}</td>          <td class="num">{{ item.count }}</td>          <td class="pct">{% widthratio item.count breakdown_totals.device 100 %}%</td>        </tr>        {% endfor %}      </tbody>    </table>  </section>  {% endif %}  {% if total_events_by_browser %}  <section>    <h3>Browser</h3>    <table>      <thead><tr><th>Name</th><th class="num">Events</th><th class="pct">%</th></tr></thead>      <tbody>        {% for item in total_events_by_browser %}        <tr>          <td>{{ item.label }}</td>          <td class="num">{{ item.count }}</td>          <td class="pct">{% widthratio item.count breakdown_totals.browser 100 %}%</td>        </tr>        {% endfor %}      </tbody>    </table>  </section>  {% endif %}  {% if total_events_by_platform %}  <section>    <h3>Platform</h3>    <table>      <thead><tr><th>Name</th><th class="num">Events</th><th class="pct">%</th></tr></thead>      <tbody>        {% for item in total_events_by_platform %}        <tr>          <td>{{ item.label }}</td>          <td class="num">{{ item.count }}</td>          <td class="pct">{% widthratio item.count breakdown_totals.platform 100 %}%</td>        </tr>        {% endfor %}      </tbody>    </table>  </section>  {% endif %}  {% if total_events_by_screen_size %}  <section>    <h3>Screen size</h3>    <table>      <thead><tr><th>Size</th><th class="num">Events</th><th class="pct">%</th></tr></thead>      <tbody>        {% for item in total_events_by_screen_size %}        <tr>          <td>{{ item.label }}</td>          <td class="num">{{ item.count }}</td>          <td class="pct">{% widthratio item.count breakdown_totals.screen_size 100 %}%</td>        </tr>        {% endfor %}      </tbody>    </table>  </section>  {% endif %}</div>{% if top_countries %}<h2>Geography</h2><div class="grid-2">  <section>    <h3>Top countries</h3>    <table>      <thead><tr><th>Country</th><th class="num">Sessions</th></tr></thead>      <tbody>        {% for item in top_countries %}        <tr><td>{{ item.label }}</td><td class="num">{{ item.count }}</td></tr>        {% endfor %}      </tbody>    </table>  </section></div>{% endif %}{% if total_page_views_by_utm_source or total_page_views_by_utm_medium or total_page_views_by_utm_campaign %}<h2>UTM attribution</h2><div class="grid-3">  {% if total_page_views_by_utm_source %}  <section>    <h3>Source</h3>    <table>      <thead><tr><th>Source</th><th class="num">Views</th></tr></thead>      <tbody>        {% for item in total_page_views_by_utm_source %}        <tr><td>{{ item.label }}</td><td class="num">{{ item.count }}</td></tr>        {% endfor %}      </tbody>    </table>  </section>  {% endif %}  {% if total_page_views_by_utm_medium %}  <section>    <h3>Medium</h3>    <table>      <thead><tr><th>Medium</th><th class="num">Views</th></tr></thead>      <tbody>        {% for item in total_page_views_by_utm_medium %}        <tr><td>{{ item.label }}</td><td class="num">{{ item.count }}</td></tr>        {% endfor %}      </tbody>    </table>  </section>  {% endif %}  {% if total_page_views_by_utm_campaign %}  <section>    <h3>Campaign</h3>    <table>      <thead><tr><th>Campaign</th><th class="num">Views</th></tr></thead>      <tbody>        {% for item in total_page_views_by_utm_campaign %}        <tr><td>{{ item.label }}</td><td class="num">{{ item.count }}</td></tr>        {% endfor %}      </tbody>    </table>  </section>  {% endif %}</div>{% endif %}<h2>Bot traffic <span style="font-size:8pt;font-weight:400;text-transform:none;letter-spacing:0;color:#555;">excluded from metrics above</span></h2>{% if bot_traffic.total > 0 %}<div class="grid-2">  <section>    <h3>Top bots</h3>    <table>      <thead><tr><th>Bot</th><th class="num">Events</th></tr></thead>      <tbody>        <tr><td><strong>Total</strong></td><td class="num"><strong>{{ bot_traffic.total }}</strong></td></tr>        {% for item in bot_traffic.top_bots %}        <tr><td>{{ item.label }}</td><td class="num">{{ item.count }}</td></tr>        {% endfor %}      </tbody>    </table>  </section>  {% if bot_traffic.top_pages %}  <section>    <h3>Top pages hit by bots</h3>    <table>      <thead><tr><th>URL</th><th class="num">Events</th></tr></thead>      <tbody>        {% for item in bot_traffic.top_pages %}        <tr><td class="code">{{ item.label }}</td><td class="num">{{ item.count }}</td></tr>        {% endfor %}      </tbody>    </table>  </section>  {% endif %}</div>{% else %}<div class="empty">No bot events in this range.</div>{% endif %}<footer class="report-footer">  <span>Analytics · self-hosted · {{ BASE_URL }}</span>  <span>{{ property.name }} · {{ date_start }} → {{ date_end }}</span></footer></body></html>
modified properties/views.py
@@ -20,6 +20,29 @@ from .models import PropertyDASHBOARD_CACHE_TTL = 300  # secondsdef _chart_polyline(points, width=600, height=100, padding=4):    """SVG polyline points string for the events-over-time line chart on the    printed report. Toner-friendly: thin black stroke, no fill, no gradient."""    if not points:        return ""    counts = [p["count"] for p in points]    max_c = max(counts) or 1    n = len(counts)    usable_h = height - 2 * padding    if n == 1:        x = width / 2        y = height - padding - (counts[0] / max_c) * usable_h        return f"{x:.1f},{y:.1f}"    return " ".join(        f"{(i / (n - 1)) * width:.1f},{height - padding - (c / max_c) * usable_h:.1f}"        for i, c in enumerate(counts)    )def _breakdown_total(rows):    return sum(r["count"] for r in rows) or 1def properties(request):    if not request.user.is_authenticated:        return redirect("/")
@@ -221,8 +244,30 @@ def property(request, property_id):            response["Content-Disposition"] = f'inline; filename="{property_obj.name}.md"'            return response        if fmt == "pdf":            context["print"] = True            html = render_to_string("properties/property.html", context)            graph = context.get("total_events_graph") or []            peak = max(graph, key=lambda p: p["count"]) if graph else None            country_counts = context.get("session_starts_by_country") or {}            top_countries = sorted(                ({"label": code, "count": count} for code, count in country_counts.items()),                key=lambda r: r["count"],                reverse=True,            )[:10]            print_context = {                **context,                "chart_polyline": _chart_polyline(graph),                "chart_label_start": graph[0]["label"] if graph else "",                "chart_label_end": graph[-1]["label"] if graph else "",                "chart_peak_count": peak["count"] if peak else 0,                "chart_peak_label": peak["label"] if peak else "",                "breakdown_totals": {                    "device": _breakdown_total(context.get("total_events_by_device") or []),                    "browser": _breakdown_total(context.get("total_events_by_browser") or []),                    "platform": _breakdown_total(context.get("total_events_by_platform") or []),                    "screen_size": _breakdown_total(context.get("total_events_by_screen_size") or []),                },                "top_countries": top_countries,            }            html = render_to_string("properties/property_print.html", print_context)            filename = f"reports/{uuid.uuid4()}.pdf"            generate_pdf_from_html(html, filename)            with open(default_storage.path(filename), "rb") as pdf: