Three operator decisions change v1's premises, so the design is now two tiers with separate flags: Tier A (THERMOGRAPH_EVENTS) is v1 unchanged -- anonymous hourly aggregates, no identifier, nothing stored on the device, no consent needed, runs for everyone. It stays the primary signal precisely so a poor consent rate cannot take the numbers down with it, and so Tier B's rates can be calibrated against it to measure the consent bias rather than ignoring it. Tier B (THERMOGRAPH_EVENT_SESSIONS) adds an ephemeral per-tab id -- 16 random bytes in sessionStorage, rotating on a 30-minute idle and a 2-hour absolute cap, capped at 200 events, never linked to another session, device, or to the account (api_event does not read the auth cookie and there is no user column). Rows land in a 30-day hypertable with minute-granularity timestamps and an in-session sequence number instead of precise clock times. Storing an identifier engages ePrivacy Art. 5(3) and analytics is not strictly necessary, so it is gated on opt-in consent: an equal-weight banner that mints nothing before "Allow", one-click withdrawal in every footer that drops the live id immediately, and GPC/DNT treated as a refusal already given. THERMOGRAPH_SEARCH_MISS captures zero-result search text server-side in api_suggest only -- normalised, rejected outright on any personal-data smell, stored as a per-day count, and pruned below a three-person floor after a week. The privacy page is rewritten rather than deferred: "no per-visitor identifier" becomes false the moment Tier B ships. Tests assert the load-bearing promises so the copy and the code cannot drift apart silently. Raw-IP truncation is NOT implemented here (the logging pipeline owns it) but UI-EVENTS.md states what the app side must do, and records that Caddy's default JSON log already stores full request URIs -- so every search query is in Loki with the client IP today, which the search-miss mitigations depend on fixing. UI-EVENTS.md carries the v1-to-v2 diff, the consent reasoning including why legitimate interest is not available, and an explicit argument that the session identifier is the wrong trade at this traffic volume.
205 lines
10 KiB
Django/Jinja
205 lines
10 KiB
Django/Jinja
{% extends "base.html.j2" %}
|
|
{# The Weekly view — the product itself — with the distribution surfaces built
|
|
around it. Everything structural is server-rendered so crawlers and no-JS
|
|
readers get a complete page; app.js hydrates the live numbers in place.
|
|
|
|
The interactive controls below (#find-btn, #date-input, #panel, #results, …)
|
|
are app.js's DOM contract, carried over verbatim from the old static
|
|
frontend/index.html. Do not rename these ids.
|
|
|
|
section / brand_tag / main_class come from the route's render context
|
|
(content.home_page), not a child-template {% set %} — the SEO pages already
|
|
pass `section` that way. #}
|
|
|
|
{% block title %}Thermograph: how unusual is your weather?{% endblock %}
|
|
{% block description %}See how today's weather compares to decades of local climate history for any place on Earth. Daily high, low, feels-like, humidity, wind and rain, each graded by percentile.{% endblock %}
|
|
{% block canonical_path %}/{% endblock %}
|
|
|
|
{% block head_extra %}
|
|
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" />
|
|
{% endblock %}
|
|
|
|
{% block jsonld %}
|
|
<script type="application/ld+json">
|
|
{{ jsonld_str }}
|
|
</script>
|
|
{% endblock %}
|
|
|
|
{% block content %}
|
|
{# --- Hero ------------------------------------------------------------
|
|
The grade card is the LCP element: its frame is server-rendered and its
|
|
slots are sized here, so hydrating the live numbers causes no layout
|
|
shift. Band colour is always paired with the band word — colour is never
|
|
the only signal. #}
|
|
<section id="hero" class="hero">
|
|
<div class="hero-copy">
|
|
<h1>How unusual is your weather?</h1>
|
|
<p class="hero-sub">Any day, anywhere on Earth, graded against 45 years of that place's own history.</p>
|
|
</div>
|
|
|
|
<div class="hero-card">
|
|
{# --cat carries the band colour, the same convention .normal-card uses,
|
|
so the band token stays the single source of truth. #}
|
|
<div class="grade-card" id="hero-grade" aria-live="polite"
|
|
{% if unusual %}style="--cat: var(--{{ unusual.cls }})"{% endif %}>
|
|
<p class="grade-where" id="hero-where">
|
|
{%- if unusual -%}
|
|
Today in {{ unusual.display }}
|
|
{%- else -%}
|
|
Today
|
|
{%- endif -%}
|
|
</p>
|
|
<p class="grade-band" id="hero-band">{{ unusual.grade if unusual else '—' }}</p>
|
|
<p class="grade-detail" id="hero-detail">
|
|
{%- if unusual -%}
|
|
{{ unusual.metric_label }} in the {{ unusual.percentile|ordinal }} percentile for {{ unusual.window_label }}
|
|
{%- else -%}
|
|
Pick a place to see how unusual its weather is today.
|
|
{%- endif -%}
|
|
</p>
|
|
{% if unusual and unusual.is_default %}
|
|
<p class="grade-why muted">the most unusual weather we're tracking{% if stale %} · as of {{ unusual.date }}{% endif %}</p>
|
|
{% endif %}
|
|
</div>
|
|
<div class="hero-actions">
|
|
{# app.js hides the locate button outside a secure context, where the
|
|
browser refuses geolocation and it could only ever fail. #}
|
|
<button type="button" id="hero-locate" class="btn-primary">Use my location</button>
|
|
<button type="button" id="hero-pick" class="btn-ghost">Pick on the map</button>
|
|
</div>
|
|
<p class="hero-locate-msg" id="hero-locate-msg" role="status" aria-live="polite" hidden></p>
|
|
<p class="hero-jump"><a href="#panel">See your week ↓</a></p>
|
|
</div>
|
|
</section>
|
|
|
|
<section class="controls">
|
|
<div class="find-bar">
|
|
<button type="button" id="find-btn" class="find-btn"></button>
|
|
<span id="loc-label" class="loc-label" hidden></span>
|
|
</div>
|
|
<div class="date-row">
|
|
<label>Target date
|
|
<input id="date-input" type="date" />
|
|
</label>
|
|
<button type="button" id="today-btn" class="today-chip" title="Reset the target date to today">Today</button>
|
|
<span class="hint"><a href="{{ asset_base }}/legend">What do the grades mean? →</a></span>
|
|
</div>
|
|
</section>
|
|
|
|
<section id="panel" class="panel panel-full">
|
|
<div class="placeholder" id="placeholder">
|
|
<p>Find a location to begin.</p>
|
|
<p class="muted">Thermograph fetches decades of daily highs, lows, and precipitation
|
|
for your ~4 sq mi cell, builds a ±7-day seasonal distribution for each
|
|
day of the year, and grades recent weather by where it falls on that distribution.</p>
|
|
</div>
|
|
<div id="results" hidden></div>
|
|
</section>
|
|
|
|
{# Sits outside #panel on purpose: app.js rewrites results.innerHTML
|
|
wholesale on every grade, so anything inside it would be destroyed. #}
|
|
<p class="stance-line muted">
|
|
Free. No ads. No third-party trackers. No account needed.
|
|
<a href="{{ base }}/about">How we work →</a>
|
|
</p>
|
|
|
|
{# --- Unusual right now ----------------------------------------------
|
|
Scroll-snap row, no JS carousel. Both tails are represented whenever a
|
|
cold-tail city qualifies — two-sided honesty is product identity. #}
|
|
{% if ranked %}
|
|
<section class="unusual-strip" aria-labelledby="records-title">
|
|
<h2 id="records-title">Unusual right now</h2>
|
|
<ul class="unusual-row">
|
|
{% for r in ranked %}
|
|
<li class="unusual-card" style="--cat: var(--{{ r.cls }})">
|
|
<a href="{{ asset_base }}/day#lat={{ r.lat }}&lon={{ r.lon }}"
|
|
data-event="home.records_click">
|
|
<span class="unusual-city">{{ r.display }}</span>
|
|
{# The reading itself, then what it is and what it should be —
|
|
a percentile means little without the value it describes. #}
|
|
<span class="unusual-value">{{ temp(r.value) }}</span>
|
|
<span class="unusual-band">{{ r.grade_label }}</span>
|
|
<span class="unusual-meta">
|
|
{{ r.metric_label }} · {{ r.pct_label }} pct
|
|
{%- if r.normal is not none %} · normal {{ temp(r.normal) }}{% endif %}
|
|
{%- if r.date_label %} · {{ r.date_label }}{% endif %}
|
|
</span>
|
|
</a>
|
|
</li>
|
|
{% endfor %}
|
|
</ul>
|
|
<p class="unusual-more"><a href="{{ base }}/climate">See all records →</a></p>
|
|
</section>
|
|
{% endif %}
|
|
|
|
{# --- How it works ---------------------------------------------------- #}
|
|
<section class="how-it-works" aria-labelledby="how-title">
|
|
<h2 id="how-title">How it works</h2>
|
|
<ol class="how-steps">
|
|
<li>We keep 45 years of climate history (ERA5 reanalysis) for your exact ~2-mile grid square.</li>
|
|
<li>Today gets compared to the same two weeks of the year, every year back to 1980.</li>
|
|
<li>The result is a percentile and a plain-language grade, from Near Record cold to Near Record hot.</li>
|
|
</ol>
|
|
<p class="muted">
|
|
<a href="{{ asset_base }}/legend">Full methodology →</a>
|
|
· Data: ERA5 via Open-Meteo · NASA POWER · MET Norway
|
|
</p>
|
|
</section>
|
|
|
|
{# --- Explore --------------------------------------------------------- #}
|
|
<section class="explore" aria-labelledby="explore-title">
|
|
<h2 id="explore-title">Explore</h2>
|
|
{# Each card takes a colour from the grade ramp rather than a decorative
|
|
palette, so the section reads as part of the product's own scale.
|
|
Calendar carries the ramp itself — it IS the heatmap. #}
|
|
<div class="explore-cards">
|
|
<a class="explore-card" href="{{ asset_base }}/calendar" data-event="home.nav_calendar"
|
|
style="--cat: var(--warm)">
|
|
<span class="explore-name">Calendar</span>
|
|
<span class="explore-ramp" aria-hidden="true">
|
|
{% for t in ('rec-cold','very-cold','cold','cool','normal','warm','hot','very-hot','rec-hot') %}
|
|
<i style="background: var(--{{ t }})"></i>
|
|
{% endfor %}
|
|
</span>
|
|
<span class="explore-desc">Two years of your days, each colored by how unusual it was.</span>
|
|
</a>
|
|
<a class="explore-card" href="{{ asset_base }}/compare" data-event="home.nav_compare"
|
|
style="--cat: var(--normal)">
|
|
<span class="explore-name">Compare</span>
|
|
<span class="explore-desc">Rank any places by comfort for a trip or a move.</span>
|
|
</a>
|
|
<a class="explore-card" href="{{ asset_base }}/day" data-event="home.nav_day"
|
|
style="--cat: var(--cool)">
|
|
<span class="explore-name">Day</span>
|
|
<span class="explore-desc">Any date since 1980, anywhere: what was normal, what happened.</span>
|
|
</a>
|
|
<a class="explore-card" href="{{ asset_base }}/alerts" data-event="home.nav_alerts"
|
|
style="--cat: var(--rec-hot)">
|
|
<span class="explore-name">Alerts</span>
|
|
<span class="explore-desc">Free percentile alerts: get pinged when your weather goes statistically weird.</span>
|
|
</a>
|
|
</div>
|
|
</section>
|
|
|
|
{# --- City hub links --------------------------------------------------
|
|
Real HTML links funnelling homepage authority into the ~1000-page
|
|
/climate surface. #}
|
|
<section class="city-hub" aria-labelledby="cities-title">
|
|
<h2 id="cities-title">Climate context for 1,000 cities</h2>
|
|
<ul class="city-chips">
|
|
{% for c in cities %}
|
|
<li><a href="{{ base }}/climate/{{ c.slug }}" data-event="home.nav_city">{{ c.name }}</a></li>
|
|
{% endfor %}
|
|
</ul>
|
|
<p><a href="{{ base }}/climate" data-event="home.nav_cities">Browse all cities →</a></p>
|
|
</section>
|
|
{% endblock %}
|
|
|
|
{% block body_scripts %}
|
|
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
|
|
<script type="module" src="{{ asset_base }}/app.js"></script>
|
|
{# The records strip renders real temperatures server-side as .temp spans, so
|
|
it needs the same converter the SEO pages use or they'd stay in °F while
|
|
the toggle says °C. Both import units.js, which the module loader dedupes. #}
|
|
<script type="module" src="{{ asset_base }}/climate.js"></script>
|
|
{% endblock %}
|