Move static asset + SPA-shell ownership from backend to frontend (repo-split Stage 7a) (#22)

This commit is contained in:
emi 2026-07-21 22:48:59 +00:00
parent 897f413f3c
commit 452788fb18
15 changed files with 29 additions and 928 deletions

View file

@ -1,10 +1,10 @@
"""Canonical filesystem locations, resolved once from the repo root.
Every path the backend needs (the climate cache, the accounts DB, logs, the
Jinja templates, the frontend static dir, the bundled city data) is derived
here from the repository root rather than from each module's own ``__file__``.
That keeps the locations stable no matter how deep in the package tree a module
lives, so moving a module never silently re-points the cache or the accounts DB.
bundled city data) is derived here from the repository root rather than from
each module's own ``__file__``. That keeps the locations stable no matter how
deep in the package tree a module lives, so moving a module never silently
re-points the cache or the accounts DB.
Callers that support an environment override (e.g. ``THERMOGRAPH_ACCOUNTS_DB``)
keep doing so; these are only the defaults.
@ -33,12 +33,6 @@ REPO_DIR = _repo_root()
BACKEND_DIR = os.path.join(REPO_DIR, "backend")
DATA_DIR = os.path.join(REPO_DIR, "data")
LOGS_DIR = os.path.join(REPO_DIR, "logs")
FRONTEND_DIR = os.path.join(REPO_DIR, "frontend")
TEMPLATES_DIR = os.path.join(BACKEND_DIR, "templates")
# Structured SSR copy (glossary, static-page SEO meta) — see
# backend/web/content_loader.py and docs/README.md. A sibling of backend/ and
# frontend/, not under either: it's the seed of a future thermograph-copy repo.
CONTENT_DIR = os.path.join(REPO_DIR, "content")
# Bundled reference data shipped alongside the code (generated by gen_cities /
# gen_flavor), not runtime state — hence under backend/, not data/.

View file

@ -17,10 +17,13 @@ alembic==1.14.0
pywebpush==2.0.0
# Ed25519 verification of Discord interaction webhooks (see backend/discord_interactions.py).
PyNaCl==1.5.0
# Server-rendered SEO content pages (see backend/web/content.py, templates/).
# Not used by backend itself (its own content.py/content_loader.py were
# deleted in repo-split Stage 4) -- installed here because frontend_ssr/
# shares this requirements.txt in the current shared Dockerfile. See
# frontend_ssr/content.py (server-rendered SEO content pages).
jinja2==3.1.6
# Structured SSR copy (glossary, static-page SEO meta) — see
# backend/web/content_loader.py and content/.
# frontend_ssr/content_loader.py and content/.
PyYAML==6.0.3
# Recurring worker-tier jobs — city warming, IndexNow pings (see
# notifications/scheduler.py). In-process; no broker/queue needed at this scale.

View file

@ -1,36 +0,0 @@
{% extends "base.html.j2" %}
{% block title %}{{ page_title }}{% endblock %}
{% block description %}{{ page_description }}{% endblock %}
{% block canonical_path %}{{ canonical_path }}{% endblock %}
{% block content %}
<nav class="breadcrumb" aria-label="Breadcrumb">
{% for label, href in breadcrumb %}{% if href %}<a href="{{ href }}">{{ label }}</a>{% else %}<span>{{ label }}</span>{% endif %}{% if not loop.last %} <span class="sep"></span> {% endif %}{% endfor %}
</nav>
<article class="climate-page">
<h1>About Thermograph</h1>
<p class="lede">Thermograph answers one question for any point on Earth: <b>how unusual is this
weather?</b> Instead of an absolute temperature, it shows where a day falls in that exact
location's own climate history.</p>
<h2>Where the data comes from</h2>
<p>The ~45-year daily record (high, low, feels-like, humidity, wind, gust and precipitation)
comes from the <b>ERA5 reanalysis</b> (ECMWF), served via <a href="https://open-meteo.com/" rel="nofollow">Open-Meteo</a>.
Because reanalysis is a gridded, model-consistent record, Thermograph works even where there's
no weather station, anywhere on the ~4&nbsp;sq&nbsp;mi grid.</p>
<h2>How a day is graded</h2>
<p>For each metric and date, Thermograph builds a reference distribution from every historical day
within <b>&plusmn;7 days of that day-of-year</b>, a 15-day seasonal window across all years. The
observed value is placed on that distribution as an <a href="{{ base }}/glossary/percentile">empirical
percentile</a>, then mapped to a grade from “Below Normal” through “High” to “Near Record”.</p>
<p>Everything is <b>relative to the location</b>: a {{ temp(60) }} day can be “Above Normal” in one place and
“Below Normal” in another. Precipitation is graded only among days that actually rained, so “Heavy”
means heavy for a rainy day <i>here</i>. See the <a href="{{ base }}/legend">grade guide</a> for the full scale.</p>
<h2>Freshness</h2>
<p>The historical archive updates as new days are added; recent and forecast conditions refresh hourly.
City climate pages show the latest recorded day graded against the local normal.</p>
<p><a class="cta" href="{{ base }}/">Open the weather grader →</a></p>
</article>
{% endblock %}

View file

@ -1,113 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
{{ head_verify() }}
<title>{% block title %}Thermograph{% endblock %}</title>
<meta name="description" content="{% block description %}{% endblock %}" />
<meta name="theme-color" content="#f0803c" />
<!-- iOS/Android home-screen install: run standalone (no browser chrome) when
added to the Home Screen. On iOS 16.4+ this is what unlocks Web Push. -->
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
<meta name="apple-mobile-web-app-title" content="Thermograph" />
<link rel="canonical" href="{{ base_url }}{% block canonical_path %}/{% endblock %}" />
<meta property="og:type" content="{% block og_type %}website{% endblock %}" />
<meta property="og:site_name" content="Thermograph" />
<meta property="og:title" content="{{ self.title() }}" />
<meta property="og:description" content="{{ self.description() }}" />
<meta property="og:url" content="{{ base_url }}{{ self.canonical_path() }}" />
<meta property="og:image" content="{{ base_url }}/logo.png?v=3" />
<meta property="og:image:width" content="512" />
<meta property="og:image:height" content="512" />
<meta property="og:image:alt" content="Thermograph logo" />
<meta name="twitter:card" content="summary" />
<link rel="icon" href="{{ base }}/favicon.svg?v=3" type="image/svg+xml" />
<link rel="icon" href="{{ base }}/favicon-48.png?v=3" type="image/png" sizes="48x48" />
<link rel="icon" href="{{ base }}/favicon-32.png?v=3" type="image/png" sizes="32x32" />
<link rel="icon" href="{{ base }}/favicon-16.png?v=3" type="image/png" sizes="16x16" />
<link rel="apple-touch-icon" href="{{ base }}/apple-touch-icon.png?v=3" />
<link rel="manifest" href="{{ base }}/manifest.webmanifest" />
{% block head_extra %}{% endblock %}
<link rel="stylesheet" href="{{ base }}/style.css" />
{% block jsonld %}{% endblock %}
</head>
<body{% if unit_default %} data-unit-default="{{ unit_default }}"{% endif %}>
<header>
<div class="brand">
<div>
{# The brand is the page's h1 everywhere EXCEPT the homepage, where the
hero headline owns the sole h1 and the brand degrades to a <p>. The
.brand h1, .brand .site-name selector pair in style.css keeps the
lockup looking identical either way. #}
<{{ brand_tag|default('h1') }} class="site-name"><a href="{{ base }}/"><span class="logo"><svg viewBox="0 0 512 512" width="28" height="28" aria-hidden="true"><rect x="32" y="32" width="448" height="448" rx="96" fill="#10141B" stroke="#FFFFFF" stroke-opacity="0.08" stroke-width="4"/><rect x="115" y="403" width="42" height="42" rx="10" fill="#111924"/><rect x="163" y="403" width="42" height="42" rx="10" fill="#111924"/><rect x="211" y="403" width="42" height="42" rx="10" fill="#111924"/><rect x="259" y="403" width="42" height="42" rx="10" fill="#111924"/><rect x="307" y="403" width="42" height="42" rx="10" fill="#111924"/><rect x="355" y="403" width="42" height="42" rx="10" fill="#111924"/><rect x="403" y="403" width="42" height="42" rx="10" fill="#111924"/><rect x="163" y="355" width="42" height="42" rx="10" fill="#131C25"/><rect x="211" y="355" width="42" height="42" rx="10" fill="#131C25"/><rect x="259" y="355" width="42" height="42" rx="10" fill="#131C25"/><rect x="307" y="355" width="42" height="42" rx="10" fill="#131C25"/><rect x="355" y="355" width="42" height="42" rx="10" fill="#131C25"/><rect x="403" y="355" width="42" height="42" rx="10" fill="#131C25"/><rect x="67" y="307" width="42" height="42" rx="10" fill="#181F27"/><rect x="163" y="307" width="42" height="42" rx="10" fill="#181F27"/><rect x="211" y="307" width="42" height="42" rx="10" fill="#181F27"/><rect x="259" y="307" width="42" height="42" rx="10" fill="#181F27"/><rect x="307" y="307" width="42" height="42" rx="10" fill="#181F27"/><rect x="355" y="307" width="42" height="42" rx="10" fill="#181F27"/><rect x="403" y="307" width="42" height="42" rx="10" fill="#181F27"/><rect x="67" y="259" width="42" height="42" rx="10" fill="#131C1F"/><rect x="211" y="259" width="42" height="42" rx="10" fill="#131C1F"/><rect x="403" y="259" width="42" height="42" rx="10" fill="#131C1F"/><rect x="67" y="211" width="42" height="42" rx="10" fill="#1E1E22"/><rect x="115" y="211" width="42" height="42" rx="10" fill="#1E1E22"/><rect x="211" y="211" width="42" height="42" rx="10" fill="#1E1E22"/><rect x="307" y="211" width="42" height="42" rx="10" fill="#1E1E22"/><rect x="67" y="163" width="42" height="42" rx="10" fill="#1D1C1E"/><rect x="115" y="163" width="42" height="42" rx="10" fill="#1D1C1E"/><rect x="307" y="163" width="42" height="42" rx="10" fill="#1D1C1E"/><rect x="355" y="163" width="42" height="42" rx="10" fill="#1D1C1E"/><rect x="67" y="115" width="42" height="42" rx="10" fill="#1C191E"/><rect x="115" y="115" width="42" height="42" rx="10" fill="#1C191E"/><rect x="163" y="115" width="42" height="42" rx="10" fill="#1C191E"/><rect x="211" y="115" width="42" height="42" rx="10" fill="#1C191E"/><rect x="259" y="115" width="42" height="42" rx="10" fill="#1C191E"/><rect x="307" y="115" width="42" height="42" rx="10" fill="#1C191E"/><rect x="355" y="115" width="42" height="42" rx="10" fill="#1C191E"/><rect x="67" y="67" width="42" height="42" rx="10" fill="#18141B"/><rect x="115" y="67" width="42" height="42" rx="10" fill="#18141B"/><rect x="163" y="67" width="42" height="42" rx="10" fill="#18141B"/><rect x="211" y="67" width="42" height="42" rx="10" fill="#18141B"/><rect x="259" y="67" width="42" height="42" rx="10" fill="#18141B"/><rect x="307" y="67" width="42" height="42" rx="10" fill="#18141B"/><rect x="355" y="67" width="42" height="42" rx="10" fill="#18141B"/><rect x="67" y="403" width="42" height="42" rx="10" fill="#2166ac"/><rect x="67" y="355" width="42" height="42" rx="10" fill="#4393c3"/><rect x="115" y="355" width="42" height="42" rx="10" fill="#4393c3"/><rect x="115" y="307" width="42" height="42" rx="10" fill="#92c5de"/><rect x="115" y="259" width="42" height="42" rx="10" fill="#4a9d5b"/><rect x="163" y="259" width="42" height="42" rx="10" fill="#4a9d5b"/><rect x="163" y="211" width="42" height="42" rx="10" fill="#f6c18a"/><rect x="163" y="163" width="42" height="42" rx="10" fill="#ef9351"/><rect x="211" y="163" width="42" height="42" rx="10" fill="#ef9351"/><rect x="259" y="163" width="42" height="42" rx="10" fill="#ef9351"/><rect x="259" y="211" width="42" height="42" rx="10" fill="#f6c18a"/><rect x="259" y="259" width="42" height="42" rx="10" fill="#4a9d5b"/><rect x="307" y="259" width="42" height="42" rx="10" fill="#4a9d5b"/><rect x="355" y="259" width="42" height="42" rx="10" fill="#4a9d5b"/><rect x="355" y="211" width="42" height="42" rx="10" fill="#f6c18a"/><rect x="403" y="211" width="42" height="42" rx="10" fill="#f6c18a"/><rect x="403" y="163" width="42" height="42" rx="10" fill="#ef9351"/><rect x="403" y="115" width="42" height="42" rx="10" fill="#dd6b52"/><rect x="403" y="67" width="42" height="42" rx="10" fill="#8f0e20"/></svg></span>Thermograph</a></{{ brand_tag|default('h1') }}>
<p class="tag">How unusual is the weather? Graded against ~45 years of local climate history.</p>
</div>
<details class="nav-menu">
<summary class="nav-toggle" aria-label="Menu"><svg viewBox="0 0 24 24" width="22" height="22" aria-hidden="true" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M4 7h16M4 12h16M4 17h16"/></svg></summary>
<div class="nav-panel">
{# data-view marks the links nav.js rewrites to carry the current
location hash, so moving between views keeps the place you picked.
Climate and Alerts deliberately opt out (they aren't cell-scoped).
Inert on the SEO pages, which never load nav.js. #}
<nav class="view-nav" aria-label="Views">
<a href="{{ base }}/" data-view="map"{% if section == 'home' %} class="active"{% endif %}>Weekly</a>
<a href="{{ base }}/calendar" data-view="calendar">Calendar</a>
<a href="{{ base }}/day" data-view="day">Day Detail</a>
<a href="{{ base }}/compare" data-view="compare">Compare</a>
<a href="{{ base }}/score" data-view="score"{% if section == 'score' %} class="active"{% endif %}>Score</a>
<a href="{{ base }}/climate"{% if section == 'climate' %} class="active"{% endif %}>Climate</a>
<a href="{{ base }}/alerts">Alerts</a>
</nav>
</div>
</details>
</div>
</header>
<main{% if main_class|default('content') %} class="{{ main_class|default('content') }}"{% endif %}>
{% block content %}{% endblock %}
</main>
<footer class="site-footer">
{# The compact digest signup is the site-wide footer pattern, not a
homepage-only surface: every page is a possible entry point.
Hidden for now; restore by uncommenting the form block below. #}
{#
<form class="digest-form digest-compact" method="post" action="{{ base }}/digest"
data-digest aria-labelledby="footer-digest-label">
<label id="footer-digest-label" for="footer-digest-email">Your city's weather, graded — monthly</label>
<div class="digest-row">
<input id="footer-digest-email" name="email" type="email" required
autocomplete="email" placeholder="you@example.com" />
<button type="submit">Get the digest</button>
</div>
<p class="digest-note muted">No spam, no sharing your address, unsubscribe anytime.</p>
<p class="digest-status" role="status" aria-live="polite" hidden></p>
</form>
#}
<nav aria-label="Footer">
<a href="{{ base }}/climate">City climates</a>
<a href="{{ base }}/glossary">Weather glossary</a>
<a href="{{ base }}/about">About &amp; methodology</a>
<a href="{{ base }}/privacy">Privacy</a>
<a href="{{ base }}/">Open the tool</a>
</nav>
<p class="muted">Free. No ads. No tracking. No account needed. Built by one person in the open.</p>
<p class="muted">Thermograph grades weather by its percentile against ~45 years of local
climate history. Data: ERA5 via Open-Meteo &middot; NASA POWER &middot; MET Norway.</p>
<p class="muted">Made by <a href="https://emigriffith.dev">emigriffith.dev</a>
<a href="mailto:emerytgriffith@gmail.com" aria-label="Email" title="emerytgriffith@gmail.com">@</a></p>
</footer>
{% block body_scripts %}
<!-- Header parity with the interactive pages: the °F/°C toggle, account + bell,
and the client-side temperature converter. All self-contained modules. -->
<script type="module" src="{{ base }}/units.js"></script>
<script type="module" src="{{ base }}/account.js"></script>
<script type="module" src="{{ base }}/climate.js"></script>
{% endblock %}
<script type="module" src="{{ base }}/digest.js"></script>
<script type="module" src="{{ base }}/ios-install.js"></script>
</body>
</html>

View file

@ -1,111 +0,0 @@
{% extends "base.html.j2" %}
{% block title %}{{ page_title }}{% endblock %}
{% block description %}{{ page_description }}{% endblock %}
{% block canonical_path %}{{ canonical_path }}{% endblock %}
{% block jsonld %}<script type="application/ld+json">{{ jsonld_str | safe }}</script>{% endblock %}
{% block content %}
<nav class="breadcrumb" aria-label="Breadcrumb">
{% for label, href in breadcrumb %}{% if href %}<a href="{{ href }}">{{ label }}</a>{% else %}<span>{{ label }}</span>{% endif %}{% if not loop.last %} <span class="sep"></span> {% endif %}{% endfor %}
</nav>
<article class="climate-page">
<h1>{{ display }} climate</h1>
<p class="lede">Average temperatures, precipitation and all-time records for <b>{{ display }}</b>,
with every day graded against ~{{ n_years }} years of local climate history
({{ year_range[0] }}{{ year_range[1] }}).{% if warmest and coldest %} Its warmest month is
<b>{{ warmest.name }}</b>, averaging a high of {{ warmest.high }}, and its coldest is
<b>{{ coldest.name }}</b>, with an average low of {{ coldest.low }}.{% endif %}</p>
{% if flavor %}
<p class="city-blurb">{{ flavor.extract }}{% if flavor.url %}
<a class="blurb-src" href="{{ flavor.url }}" rel="nofollow">via Wikipedia</a>{% endif %}</p>
{% endif %}
{% if today %}
<section class="climate-today">
<h2>How today compares</h2>
<p class="muted">Latest recorded day: {{ today.date }}. Each reading placed on {{ display }}'s
own ±7-day seasonal distribution.</p>
<div class="today-cards">
{% for c in today.cards %}
<div class="today-card" style="border-left-color: var(--{{ c.cls }})">
<span class="tc-label">{{ c.label }}</span>
<span class="tc-value">{{ c.value }}</span>
{% if c.percentile is not none %}<span class="tc-grade">{{ c.grade }} · {{ c.percentile|ordinal }} pct</span>
{% else %}<span class="tc-grade">{{ c.grade }}</span>{% endif %}
</div>
{% endfor %}
</div>
<p><a class="cta" href="{{ base }}/#{{ tool_hash }}">Open {{ name }} in the live weather grader →</a></p>
</section>
{% endif %}
{% if event %}
<section class="city-event">
<h2>Notable weather in {{ name }}</h2>
<p>{{ event.text }}{% if event.url %} <a href="{{ event.url }}" rel="nofollow">Read&nbsp;more&nbsp;→</a>{% endif %}</p>
</section>
{% endif %}
<section class="climate-normals">
<h2>{{ name }} average temperatures by month</h2>
<p>Typical daily high and low, and average precipitation, for each month in {{ display }},
based on {{ year_range[0] }}{{ year_range[1] }} records.
{% if wettest %}The wettest month is <b>{{ wettest.name }}</b> ({{ wettest.precip }} on an average day).{% endif %}</p>
<div class="table-wrap">
<table class="normals-table">
<thead><tr><th>Month</th><th>Avg high</th><th>Avg low</th><th>Avg precip</th></tr></thead>
<tbody>
{% for m in months %}
<tr>
<td><a href="{{ base }}/climate/{{ city.slug }}/{{ m.slug }}">{{ m.name }}</a></td>
<td class="t-cell t-{{ temp_class(m.high_f) }}">{{ m.high }}</td>
<td class="t-cell t-{{ temp_class(m.low_f) }}">{{ m.low }}</td>
<td>{{ m.precip }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
<div class="range-strip" aria-hidden="true">
{% for m in months %}
<div class="range-row">
<span class="range-month">{{ m.name[:3] }}</span>
<span class="range-track">
{% if m.bar %}<span class="range-fill" style="left:{{ m.bar.left }}%;width:{{ m.bar.width }}%;--c1:var(--{{ m.bar.c1 }});--c2:var(--{{ m.bar.c2 }})"></span>{% endif %}
</span>
{% if m.range_lo_f is not none and m.range_hi_f is not none %}
<span class="range-vals">{{ temp_bare(m.range_lo_f) }}{{ temp_bare(m.range_hi_f) }}</span>
{% else %}<span class="range-vals">—</span>{% endif %}
</div>
{% endfor %}
</div>
<p class="range-note muted">Each bar spans the 10th-percentile daily low to the 90th-percentile daily
high (the range most days fall within), coloured cold&nbsp;→&nbsp;hot on a shared
{{ temp(-10) }} to {{ temp(115) }} scale, so the whole year's rhythm reads at a glance.</p>
</section>
<section class="city-travel">
<h2>Thinking of visiting {{ name }}?</h2>
<p>Trips live and die by the weather. See how {{ name }}'s comfort stacks up against where
you live. {{ name }} is pre-loaded, just add your city.</p>
<p><a class="cta" href="{{ compare_url }}">Compare {{ name }}'s comfort with your city →</a></p>
</section>
{% if records.tmax or records.tmin %}
<section class="climate-records">
<h2>Record extremes</h2>
<ul>
{% if records.tmax %}<li><b>Hottest day on record:</b> {{ temp(records.tmax.max) }} on {{ records.tmax.max_date }}</li>{% endif %}
{% if records.tmin %}<li><b>Coldest day on record:</b> {{ temp(records.tmin.min) }} on {{ records.tmin.min_date }}</li>{% endif %}
</ul>
<p><a href="{{ base }}/climate/{{ city.slug }}/records">All {{ name }} weather records →</a></p>
</section>
{% endif %}
<p class="climate-foot muted">Percentiles compare each day to {{ display }}'s own history, so grades are
relative to this location: a “Near Record” day here isn't the same temperature as elsewhere.
<a href="{{ base }}/legend">How the grades work →</a></p>
</article>
{% endblock %}

View file

@ -1,19 +0,0 @@
{% extends "base.html.j2" %}
{% block title %}{{ page_title }}{% endblock %}
{% block description %}{{ page_description }}{% endblock %}
{% block canonical_path %}{{ canonical_path }}{% endblock %}
{% block content %}
<nav class="breadcrumb" aria-label="Breadcrumb">
{% for label, href in breadcrumb %}{% if href %}<a href="{{ href }}">{{ label }}</a>{% else %}<span>{{ label }}</span>{% endif %}{% if not loop.last %} <span class="sep"></span> {% endif %}{% endfor %}
</nav>
<article class="climate-page">
<h1>Weather &amp; climate glossary</h1>
<p class="lede">Plain-language definitions of the terms Thermograph uses to grade the weather.</p>
{% for t in terms %}
<div class="glossary-term">
<h2><a href="{{ base }}/glossary/{{ t.slug }}">{{ t.term }}</a></h2>
<p>{{ t.short }}</p>
</div>
{% endfor %}
</article>
{% endblock %}

View file

@ -1,21 +0,0 @@
{% extends "base.html.j2" %}
{% block title %}{{ page_title }}{% endblock %}
{% block description %}{{ page_description }}{% endblock %}
{% block canonical_path %}{{ canonical_path }}{% endblock %}
{% block jsonld %}<script type="application/ld+json">{"@context":"https://schema.org","@type":"DefinedTerm","name":{{ term | tojson }},"description":{{ page_description | tojson }},"inDefinedTermSet":"{{ base_url }}/glossary"}</script>{% endblock %}
{% block content %}
<nav class="breadcrumb" aria-label="Breadcrumb">
{% for label, href in breadcrumb %}{% if href %}<a href="{{ href }}">{{ label }}</a>{% else %}<span>{{ label }}</span>{% endif %}{% if not loop.last %} <span class="sep"></span> {% endif %}{% endfor %}
</nav>
<article class="climate-page">
<h1>{{ term }}</h1>
<p class="lede">{{ body | safe }}</p>
<p><a class="cta" href="{{ base }}/">See it live: grade any location's weather →</a></p>
<div class="climate-foot">
<h2>More terms</h2>
<ul class="city-links">
{% for o in others %}<li><a href="{{ base }}/glossary/{{ o.slug }}">{{ o.term }}</a></li>{% endfor %}
</ul>
</div>
</article>
{% endblock %}

View file

@ -1,205 +0,0 @@
{% 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="{{ 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&nbsp;sq&nbsp;mi cell, builds a &plusmn;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 tracking. 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="{{ base }}/day#lat={{ r.lat }}&amp;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 }} &middot; {{ r.pct_label }} pct
{%- if r.normal is not none %} &middot; normal {{ temp(r.normal) }}{% endif %}
{%- if r.date_label %} &middot; {{ 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="{{ base }}/legend">Full methodology →</a>
&middot; Data: ERA5 via Open-Meteo &middot; NASA POWER &middot; 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="{{ 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="{{ 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="{{ 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="{{ 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="{{ 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="{{ base }}/climate.js"></script>
{% endblock %}

View file

@ -1,89 +0,0 @@
{% extends "base.html.j2" %}
{% block title %}{{ page_title }}{% endblock %}
{% block description %}{{ page_description }}{% endblock %}
{% block canonical_path %}{{ canonical_path }}{% endblock %}
{% block content %}
<nav class="breadcrumb" aria-label="Breadcrumb">
{% for label, href in breadcrumb %}{% if href %}<a href="{{ href }}">{{ label }}</a>{% else %}<span>{{ label }}</span>{% endif %}{% if not loop.last %} <span class="sep"></span> {% endif %}{% endfor %}
</nav>
<article class="climate-page">
<h1>City climates</h1>
<p class="lede">Average temperatures by month, all-time records, and how today compares, for
{{ n_cities }} cities across {{ n_countries }} countries. Every day is graded against that
location's own ~45 years of climate history.</p>
<div class="hub-search">
<input type="search" id="hub-q" placeholder="Search a city or country…" autocomplete="off"
aria-label="Search cities" />
</div>
<ul class="hub-results" id="hub-results" hidden></ul>
<div id="hub-directory">
{% for country, cs in groups.items() %}
<details class="hub-country">
<summary>{{ country }} <span class="hub-count">{{ cs | length }}</span></summary>
<ul class="city-links">
{% for c in cs %}<li><a href="{{ base }}/climate/{{ c.slug }}">{{ c.name }}</a></li>{% endfor %}
</ul>
</details>
{% endfor %}
</div>
</article>
{% raw %}
<script>
(function () {
var q = document.getElementById("hub-q");
var results = document.getElementById("hub-results");
var directory = document.getElementById("hub-directory");
if (!q || !results || !directory) return;
// Build a flat, alphabetical index from the crawlable directory links.
var index = [];
directory.querySelectorAll("details.hub-country").forEach(function (d) {
var country = (d.querySelector("summary").firstChild.textContent || "").trim();
d.querySelectorAll(".city-links a").forEach(function (a) {
index.push({ name: a.textContent, country: country, href: a.getAttribute("href") });
});
});
index.sort(function (a, b) {
return a.name.toLowerCase().localeCompare(b.name.toLowerCase());
});
function esc(s) {
return s.replace(/[&<>"']/g, function (c) {
return { "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" }[c];
});
}
function render() {
var term = q.value.trim().toLowerCase();
if (!term) {
results.hidden = true;
results.innerHTML = "";
directory.hidden = false;
return;
}
directory.hidden = true;
results.hidden = false;
var matches = index.filter(function (e) {
return e.name.toLowerCase().indexOf(term) >= 0 ||
e.country.toLowerCase().indexOf(term) >= 0;
});
if (!matches.length) {
results.innerHTML = '<li class="hub-empty muted">No cities match “' + esc(q.value.trim()) + "”.</li>";
return;
}
results.innerHTML = matches.slice(0, 300).map(function (e) {
return '<li><a href="' + e.href + '">' + esc(e.name) + "</a>" +
'<span class="hub-r-country">' + esc(e.country) + "</span></li>";
}).join("") + (matches.length > 300
? '<li class="hub-empty muted">…and ' + (matches.length - 300) + " more. Keep typing to narrow.</li>"
: "");
}
q.addEventListener("input", render);
})();
</script>
{% endraw %}
{% endblock %}

View file

@ -1,63 +0,0 @@
{% extends "base.html.j2" %}
{% block title %}{{ page_title }}{% endblock %}
{% block description %}{{ page_description }}{% endblock %}
{% block canonical_path %}{{ canonical_path }}{% endblock %}
{% block content %}
<nav class="breadcrumb" aria-label="Breadcrumb">
{% for label, href in breadcrumb %}{% if href %}<a href="{{ href }}">{{ label }}</a>{% else %}<span>{{ label }}</span>{% endif %}{% if not loop.last %} <span class="sep"></span> {% endif %}{% endfor %}
</nav>
<article class="climate-page">
<h1>Weather in {{ display }} in {{ month_name }}</h1>
<p class="lede">In an average {{ month_name }}, {{ name }} sees a daily high around
<b class="t-inline t-{{ avg_high_cls }}">{{ avg_high }}</b> and a low around
<b class="t-inline t-{{ avg_low_cls }}">{{ avg_low }}</b>, based on
{{ year_range[0] }}{{ year_range[1] }} of local climate records.</p>
<table class="normals-table" style="max-width:520px">
<tbody>
{% for label, value in stats %}
<tr><th style="width:55%">{{ label }}</th><td>{{ value }}</td></tr>
{% endfor %}
</tbody>
</table>
{% if records %}
<h2>{{ month_name }} records</h2>
<p class="muted">The most extreme {{ month_name }} on record for each metric, across
{{ year_range[0] }}{{ year_range[1] }}. For rainfall, the wettest and driest are by
total {{ month_name }} accumulation.</p>
<div class="records-grid">
{% for r in records %}
<div class="record-card">
<h3 class="rc-metric">{{ r.label }}</h3>
<div class="rc-row rc-high">
<span class="rc-tag">{{ r.high_tag }}</span>
<span class="rc-val">{{ r.high }}</span>
<span class="rc-date">{{ r.high_date }}</span>
</div>
<div class="rc-row rc-low">
<span class="rc-tag">{{ r.low_tag }}</span>
<span class="rc-val">{{ r.low }}</span>
<span class="rc-date">{{ r.low_date }}</span>
</div>
</div>
{% endfor %}
</div>
{% endif %}
<p><a class="cta" href="{{ base }}/#{{ tool_hash }}">See {{ name }}'s live weather grade →</a></p>
<div class="travel-note">
<p>Visiting <b>{{ name }}</b> in {{ month_name }}? See how its comfort stacks up against
where you live.</p>
<a class="cta" href="{{ compare_url }}">Compare {{ name }} with your city →</a>
</div>
<p class="climate-foot muted">
<a href="{{ base }}/climate/{{ city.slug }}/{{ prev.slug }}"> {{ prev.name }}</a> ·
<a href="{{ base }}/climate/{{ city.slug }}">{{ name }} climate overview</a> ·
<a href="{{ base }}/climate/{{ city.slug }}/{{ next.slug }}">{{ next.name }} </a>
</p>
</article>
{% endblock %}

View file

@ -1,39 +0,0 @@
{% extends "base.html.j2" %}
{% block title %}{{ page_title }}{% endblock %}
{% block description %}{{ page_description }}{% endblock %}
{% block canonical_path %}{{ canonical_path }}{% endblock %}
{% block content %}
<nav class="breadcrumb" aria-label="Breadcrumb">
{% for label, href in breadcrumb %}{% if href %}<a href="{{ href }}">{{ label }}</a>{% else %}<span>{{ label }}</span>{% endif %}{% if not loop.last %} <span class="sep"></span> {% endif %}{% endfor %}
</nav>
<article class="climate-page">
<h1>Privacy</h1>
<p class="lede">Thermograph is free, has no ads, and needs no account. It is built so that
there is very little about you to collect in the first place.</p>
<h2>Your location</h2>
<p>Thermograph never looks up your location from your IP address. The only way the site
learns where you are is if you press <b>Use my location</b>, which asks your browser for
permission. You can decline, and picking a place on the map works just as well.</p>
<p>Whichever way you choose a place, the coordinates are sent to the server only as part of
the ordinary request for that grid square's climate data, and are not logged beyond the
normal web-server request handling every site does. The place you picked is remembered in
your own browser's local storage, not on the server, so clearing your browser data
forgets it.</p>
<h2>Analytics</h2>
<p>There is no analytics library, no cookies for tracking, and no per-visitor identifier.
The server keeps simple aggregate counts (how many people opened a page or tapped a
button, and which site referred them) with nothing tying any of it to an individual.</p>
<h2>Email</h2>
<p>If you sign up for the monthly digest, your address is used for that digest and nothing
else. It is never sold or shared, and every message can unsubscribe you.</p>
<h2>Accounts</h2>
<p>An account is optional and only exists to hold weather alerts you have asked for. It
stores your email address and the places you are watching.</p>
<p class="muted">Questions: see <a href="{{ base }}/about">About &amp; methodology</a>.</p>
</article>
{% endblock %}

View file

@ -1,96 +0,0 @@
{% extends "base.html.j2" %}
{% block title %}{{ page_title }}{% endblock %}
{% block description %}{{ page_description }}{% endblock %}
{% block canonical_path %}{{ canonical_path }}{% endblock %}
{% block jsonld %}<script type="application/ld+json">{{ jsonld_str | safe }}</script>{% endblock %}
{% block content %}
{# A metric's two extremes — ▲ warmest and ▼ coldest it has ever been — as tinted
chips with the date each occurred. Used for the daytime-high and overnight-low
columns of the month and season tables. #}
{% macro extremes(metric) %}
{% if metric and metric.warm and metric.cold %}
<div class="rec-ext"><span class="rec-arrow up" aria-hidden="true">▲</span><span class="t-inline t-{{ metric.warm.cls }}">{{ metric.warm.txt }}</span><span class="rec-on">{{ metric.warm.date }}</span></div>
<div class="rec-ext"><span class="rec-arrow down" aria-hidden="true">▼</span><span class="t-inline t-{{ metric.cold.cls }}">{{ metric.cold.txt }}</span><span class="rec-on">{{ metric.cold.date }}</span></div>
{% else %}—{% endif %}
{% endmacro %}
<nav class="breadcrumb" aria-label="Breadcrumb">
{% for label, href in breadcrumb %}{% if href %}<a href="{{ href }}">{{ label }}</a>{% else %}<span>{{ label }}</span>{% endif %}{% if not loop.last %} <span class="sep"></span> {% endif %}{% endfor %}
</nav>
<article class="climate-page">
<h1>{{ display }} weather records</h1>
<p class="lede">Record high and low temperatures for <b>{{ display }}</b>, by month, by season, and
all-time, with the dates they occurred, across {{ year_range[0] }}{{ year_range[1] }} of daily
climate history.{% if all_time.tmax and all_time.tmin %} Its hottest day on record reached
{{ temp(all_time.tmax.max) }} on {{ all_time.tmax.max_date }}; its coldest fell to
{{ temp(all_time.tmin.min) }} on {{ all_time.tmin.min_date }}.{% endif %}</p>
<section class="climate-records-section">
<h2>Records by month</h2>
<p>The warmest (▲) and coldest (▼) both the daytime high and the overnight low have ever been in
each calendar month, so each column shows its record high <i>and</i> its record low. Tap a
month for its full averages and typical range.</p>
<div class="table-wrap">
<table class="normals-table records-table records-ext">
<thead><tr><th>Month</th><th>Daytime high</th><th>Overnight low</th></tr></thead>
<tbody>
{% for m in monthly %}
<tr>
<td><a href="{{ base }}/climate/{{ city.slug }}/{{ m.slug }}">{{ m.name }}</a></td>
<td>{{ extremes(m.high) }}</td>
<td>{{ extremes(m.low) }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</section>
<section class="climate-records-section">
<h2>Records by season</h2>
<p>The warmest (▲) and coldest (▼) both the daytime high and the overnight low have reached in each
meteorological season ({{ hemisphere }} Hemisphere, each a three-month span).</p>
<div class="table-wrap">
<table class="normals-table records-table records-ext">
<thead><tr><th>Season</th><th>Daytime high</th><th>Overnight low</th></tr></thead>
<tbody>
{% for s in seasonal %}
<tr>
<td>{{ s.name }} <span class="season-span">({{ s.span }})</span></td>
<td>{{ extremes(s.high) }}</td>
<td>{{ extremes(s.low) }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</section>
<section class="climate-records-section">
<h2>All-time records</h2>
<p>The most extreme value {{ name }} has recorded for each metric across its entire history.</p>
<div class="records-grid">
{% for r in rows %}
<div class="record-card">
<h3 class="rc-metric">{{ r.label }}</h3>
<div class="rc-row rc-high">
<span class="rc-tag">{% if r.label == 'Precip' %}Wettest{% else %}Highest{% endif %}</span>
<span class="rc-val">{{ r.high }}</span>
<span class="rc-date">{{ r.high_date }}</span>
</div>
<div class="rc-row rc-low">
<span class="rc-tag">{% if r.label == 'Precip' %}Driest{% else %}Lowest{% endif %}</span>
<span class="rc-val">{{ r.low }}</span>
<span class="rc-date">{{ r.low_date }}</span>
</div>
</div>
{% endfor %}
</div>
<p class="records-note muted">For rainfall, the “driest” figure is the <b>longest run of consecutive
days without measurable rain</b>, dated to when that dry spell began.</p>
</section>
<p><a class="cta" href="{{ base }}/#{{ '%.5f,%.5f' | format(city.lat, city.lon) }}">Grade {{ name }}'s weather now →</a></p>
<p class="climate-foot muted"><a href="{{ base }}/climate/{{ city.slug }}"> Back to {{ name }} climate</a></p>
</article>
{% endblock %}

View file

@ -185,18 +185,6 @@ def test_suggest_falls_back_to_upstream_geocoder(client, monkeypatch):
assert body["corrected"] is None
def test_pages_serve_with_origin_filled_in(client):
"""/ moved to frontend_ssr (repo-split Stage 4) -- it renders via real Jinja
template vars now, not a __ORIGIN__ string substitution, so there's nothing
left to check about it here. /calendar is still a _page()-served static
shell with the substitution, unaffected by the split."""
r = client.get("/thermograph/calendar")
assert r.status_code == 200
assert "text/html" in r.headers["content-type"]
assert "__ORIGIN__" not in r.text
assert client.head("/thermograph/calendar").status_code == 200
def test_cell_neighbors_flag_enqueues_warming(client, monkeypatch):
from web import app as appmod
monkeypatch.setattr(appmod, "_WARM_SEEN", {})

View file

@ -11,14 +11,10 @@ import datetime
import json
import time
import pytest
from api import homepage
from data import climate
from data import store
B = "/thermograph"
# --- the precompute ----------------------------------------------------------
def test_build_never_fetches_upstream(monkeypatch):
@ -179,17 +175,3 @@ def test_staleness(monkeypatch):
assert not homepage.is_stale({"date": today, "generated_at": time.time()})
assert homepage.is_stale({"date": today, "generated_at": time.time() - 7 * 3600})
assert homepage.is_stale({"date": "1999-01-01", "generated_at": time.time()})
# --- static frontend, unaffected by the SSR content split --------------------
@pytest.fixture
def client():
from fastapi.testclient import TestClient
from web import app as appmod
return TestClient(appmod.app)
def test_old_static_index_is_gone(client):
"""index.html must not linger behind the static mount as indexable duplicate
content now that / is server-rendered (by frontend_ssr)."""
assert client.get(f"{B}/index.html").status_code == 404

View file

@ -5,20 +5,16 @@ import functools
import hashlib
import hmac
import json
import mimetypes
import os
import queue
import threading
import time
import html as html_escape
import httpx
from fastapi import APIRouter, FastAPI, HTTPException, Query, Request, Response
from fastapi.middleware.cors import CORSMiddleware
from fastapi.middleware.gzip import GZipMiddleware
from fastapi.responses import RedirectResponse
from fastapi.staticfiles import StaticFiles
from accounts import api_accounts
from api import content_routes
@ -40,8 +36,6 @@ from accounts import users
from api import payloads
from accounts.schemas import UserCreate, UserRead, UserUpdate
FRONTEND_DIR = paths.FRONTEND_DIR
# Everything (pages, assets, API) is served under this base path so the app can
# live at https://<host>/thermograph/ behind a shared domain. Override with the
# THERMOGRAPH_BASE env var. The frontend uses base-relative URLs, so it follows
@ -70,10 +64,6 @@ _frontend_client = httpx.AsyncClient(base_url=FRONTEND_BASE, timeout=10.0)
# every other env-driven constant here (BASE, WORKERS, ...).
ROLE = os.environ.get("THERMOGRAPH_ROLE", "all").strip().lower()
# The PWA manifest is served as a static file; register its media type so
# StaticFiles labels it correctly (not in Python's default mimetypes map).
mimetypes.add_type("application/manifest+json", ".webmanifest")
def _weather_fetch_error(e) -> HTTPException:
"""A clean, retryable 503 when weather data is temporarily unavailable; the
@ -792,33 +782,11 @@ async def _proxy_to_frontend(request: Request) -> Response:
return Response(content=upstream.content, status_code=upstream.status_code, headers=resp_headers)
# The exact path list content.register() used to own -- see backend/web/app.py's
# git history (repo-split Stage 3) for the Jinja implementation, now in
# frontend_ssr/content.py. /climate/{slug}/records must be registered before
# the {month} param route so the literal path wins, same ordering content.py
# itself used.
_FRONTEND_ROUTES = [
f"{BASE}/",
f"{BASE}/about",
f"{BASE}/privacy",
f"{BASE}/glossary",
f"{BASE}/glossary/{{term}}",
f"{BASE}/climate",
f"{BASE}/climate/{{slug}}/records",
f"{BASE}/climate/{{slug}}/{{month}}",
f"{BASE}/climate/{{slug}}",
f"{BASE}/robots.txt",
f"{BASE}/sitemap.xml",
]
for _frontend_path in _FRONTEND_ROUTES:
app.add_api_route(_frontend_path, _proxy_to_frontend, methods=["GET", "HEAD"], include_in_schema=False)
# The IndexNow ownership-verification key file, at a dynamic path fixed at
# startup -- mirrors how content.register() used to register it.
import indexnow # noqa: E402 - lazy, same as content.register() did
app.add_api_route(f"{BASE}/{indexnow.key()}.txt", _proxy_to_frontend,
methods=["GET", "HEAD"], include_in_schema=False)
# The catch-all fallback registered at the very end of this file (after every
# backend route) proxies everything else -- content pages, the IndexNow key
# file, static assets, the SPA shells -- to frontend. See it there for why a
# single fallback replaced what used to be an enumerated path list (repo-split
# Stage 7a).
# --- Discord slash commands (HTTP interactions) ----------------------------
@ -863,73 +831,31 @@ app.include_router(api_accounts.router, prefix=f"{BASE}/api/v2")
app.include_router(discord_link.router, prefix=f"{BASE}/api/v2")
# --- static frontend (served under BASE) -----------------------------------
def head_verify_html() -> str:
"""Search-engine ownership-verification <meta> tags, from env (empty when
unset). Injected into every _page()-served page's <head> — Google verifies
the homepage, which frontend_ssr's own copy of this same logic handles for
the SSR content pages."""
metas = []
google = os.environ.get("THERMOGRAPH_GOOGLE_VERIFY", "").strip()
bing = os.environ.get("THERMOGRAPH_BING_VERIFY", "").strip()
if google:
metas.append(f'<meta name="google-site-verification" content="{html_escape.escape(google, quote=True)}">')
if bing:
metas.append(f'<meta name="msvalidate.01" content="{html_escape.escape(bing, quote=True)}">')
return "\n ".join(metas)
def _page(name):
"""Route handler for one HTML page. Serves the file with its __ORIGIN__
placeholders (the link-preview/Open Graph tags) filled in as the request's
scheme://host + BASE preview crawlers (Discord, Slack, ) need absolute
URLs, and the host differs between LAN and prod. The scheme comes from
X-Forwarded-Proto when a reverse proxy (Caddy) fronts the plain-HTTP
uvicorn; the proxy passes the original Host header through untouched."""
path = os.path.join(FRONTEND_DIR, name)
def route(request: Request):
with open(path, encoding="utf-8") as f:
html = f.read()
proto = request.headers.get("x-forwarded-proto") or request.url.scheme
host = request.headers.get("host") or request.url.netloc
html = html.replace("__ORIGIN__", f"{proto}://{host}{BASE}")
# Search-engine verification <meta> tags (same source as the SEO pages), so
# the homepage Google/Bing verify against carries them too.
verify = head_verify_html()
if verify:
html = html.replace("<head>", f"<head>\n {verify}", 1)
etag = f'W/"{hashlib.sha1(html.encode()).hexdigest()[:20]}"'
if _not_modified(request, etag):
return Response(status_code=304, headers={"ETag": etag})
return Response(html, media_type="text/html", headers={"ETag": etag})
return route
# The un-slashed base redirects to BASE/ so the frontend's relative asset URLs
# resolve correctly. Under a sub-path this keeps the app scoped to BASE (never
# claiming "/", so the domain root stays free for another app via the proxy). When
# BASE is "" the app owns the root: "/" is already the index route below, so there
# is no bare base to redirect (and "" is not a valid route path).
# Pages also answer HEAD — link-preview crawlers probe with it before fetching.
if BASE:
app.add_api_route(BASE, lambda: RedirectResponse(url=f"{BASE}/"), methods=["GET", "HEAD"], include_in_schema=False)
# NOTE: "/" is not here — the homepage (and the other crawlable SSR pages) are
# proxied to the frontend_ssr service, registered above (_FRONTEND_ROUTES).
app.add_api_route(f"{BASE}/calendar", _page("calendar.html"), methods=["GET", "HEAD"], include_in_schema=False)
app.add_api_route(f"{BASE}/day", _page("day.html"), methods=["GET", "HEAD"], include_in_schema=False)
app.add_api_route(f"{BASE}/score", _page("score.html"), methods=["GET", "HEAD"], include_in_schema=False)
app.add_api_route(f"{BASE}/compare", _page("compare.html"), methods=["GET", "HEAD"], include_in_schema=False)
app.add_api_route(f"{BASE}/legend", _page("legend.html"), methods=["GET", "HEAD"], include_in_schema=False)
app.add_api_route(f"{BASE}/alerts", _page("subscriptions.html"), methods=["GET", "HEAD"], include_in_schema=False)
# Monthly-digest signup (the footer form on every page). Answers the same generic
# message whatever the outcome, so it can't be probed for whether an address is
# already subscribed.
app.add_api_route(f"{BASE}/digest", digest.signup, methods=["POST"], include_in_schema=False)
# Everything else under BASE (app.js, style.css, nav.js, …) is a static asset.
# Registered last so the explicit page routes above win. Mounts must start with
# "/", so at the root (BASE == "") mount at "/" — the earlier routes still win
# because Starlette matches in registration order.
app.mount(BASE or "/", StaticFiles(directory=FRONTEND_DIR), name="static")
# --- everything else: proxy to frontend (repo-split Stage 7a) ---------------
# Frontend now owns every page (the SSR content pages, and the interactive
# tool's SPA shells), every static asset (app.js, style.css, manifest,
# favicons, …), and the dynamic IndexNow key file -- backend serves none of
# that itself anymore (it used to read them from a sibling frontend/
# directory; see _proxy_to_frontend's own docstring for why that stops being
# true once backend and frontend are separate repos). A single catch-all,
# registered dead last so every real backend route above already won, proxies
# anything unmatched -- simpler than (and a superset of) the enumerated path
# list this replaced, since static asset filenames aren't enumerable the way
# content pages are. Caddy path-splits directly to whichever service owns a
# path in prod/beta (deploy/Caddyfile), so this is only ever exercised as the
# fallback for environments with no Caddy in front (LAN dev, bare-metal
# run.sh) -- same role _proxy_to_frontend has always had.
app.add_api_route(f"{BASE}/{{path:path}}", _proxy_to_frontend, methods=["GET", "HEAD"], include_in_schema=False)