Move static asset + SPA-shell ownership from backend to frontend (repo-split Stage 7a) (#22)
This commit is contained in:
parent
897f413f3c
commit
452788fb18
15 changed files with 29 additions and 928 deletions
14
paths.py
14
paths.py
|
|
@ -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/.
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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 sq 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>±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 %}
|
||||
|
|
@ -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 & 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 · NASA POWER · 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>
|
||||
|
|
@ -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 more →</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 → 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 %}
|
||||
|
|
@ -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 & 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 %}
|
||||
|
|
@ -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 %}
|
||||
|
|
@ -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 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 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 }}&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="{{ 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="{{ 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 %}
|
||||
|
|
@ -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 { "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[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 %}
|
||||
|
|
@ -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 %}
|
||||
|
|
@ -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 & methodology</a>.</p>
|
||||
</article>
|
||||
{% endblock %}
|
||||
|
|
@ -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 %}
|
||||
|
|
@ -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", {})
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
114
web/app.py
114
web/app.py
|
|
@ -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)
|
||||
|
|
|
|||
Loading…
Reference in a new issue