Compare commits

...
1 Commits
Author SHA1 Message Date
fraxle 71860b9dd9 5.4.0
Restructure
New Journal entries
Colour tabs update with wind
2026-08-31 15:12:45 +01:00
40 changed files with 3430 additions and 443 deletions
+15 -3
View File
@@ -56,7 +56,10 @@
<header id="site-nav"> <header id="site-nav">
<div class="nav-overlay" onclick="toggleNav(false)"></div> <div class="nav-overlay" onclick="toggleNav(false)"></div>
<a href="./index.html" class="logo"><span class="logo-sun">Sun</span><svg class="logo-icon" viewBox="0 0 174 173" aria-hidden="true" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:1.5;"><g transform="matrix(0.986226,0,0,0.986226,-585.724252,-110.041209)"><clipPath id="ss-clip-ab"><circle cx="681.9838" cy="199.4502" r="83.2987"/></clipPath><g clip-path="url(#ss-clip-ab)"><g transform="matrix(0.948836,0,0,0.948836,19.787839,-2.49381)"><path d="M666.992,233.0768C666.3949,230.6581 666.1785,228.6317 666.1785,226.0296C666.1785,208.6786 680.2653,194.5919 697.6163,194.5919C714.9672,194.5919 729.054,208.6786 729.054,226.0296C729.054,228.4125 728.9892,230.9346 728.4859,233.1663" fill="none" stroke="#c8922a" stroke-width="7.48"/></g><g transform="matrix(0.520349,0,0,0.520349,304.766642,64.226716)"><path d="M724.9315,291.8131C777.359,291.8131 826.5862,305.6245 869.1698,329.8044" fill="none" stroke="currentColor" stroke-width="13.64"/></g><g transform="matrix(0.520349,0,0,0.520349,304.766642,64.226716)"><path d="M580.8436,329.719C623.3927,305.592 672.5657,291.8131 724.9315,291.8131" fill="none" stroke="currentColor" stroke-width="13.64"/></g></g><circle cx="681.9838" cy="199.4502" r="83.2987" fill="none" stroke="currentColor" stroke-width="9.13"/></g></svg><span class="logo-scope">Scope</span></a> <a href="./index.html" class="nav-logo">
<span class="nav-logo-wordmark"><span class="nav-logo-sun">Sun</span><svg class="nav-logo-icon" viewBox="0 0 174 173" aria-hidden="true" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:1.5;"><g transform="matrix(0.986226,0,0,0.986226,-585.724252,-110.041209)"><clipPath id="ss-clip-ab"><circle cx="681.9838" cy="199.4502" r="83.2987"/></clipPath><g clip-path="url(#ss-clip-ab)"><g transform="matrix(0.948836,0,0,0.948836,19.748711,-12.198562)"><path d="M666.992,233.0768C666.3949,230.6581 666.1785,228.6317 666.1785,226.0296C666.1785,208.6786 680.2653,194.5919 697.6163,194.5919C714.9672,194.5919 729.054,208.6786 729.054,226.0296C729.054,228.4125 728.9892,230.9346 728.4859,233.1663" fill="#f4b047" stroke="#f4b047" stroke-width="7.48" stroke-linecap="round"/></g><g transform="matrix(0.520349,0,0,0.520349,304.727514,54.521964)"><path d="M724.9315,291.8131C777.359,291.8131 826.5862,305.6245 869.1698,329.8044" fill="none" stroke="currentColor" stroke-width="13.64"/></g><g transform="matrix(0.520349,0,0,0.520349,304.727514,54.521964)"><path d="M580.8436,329.719C623.3927,305.592 672.5657,291.8131 724.9315,291.8131" fill="none" stroke="currentColor" stroke-width="13.64"/></g></g><circle cx="681.9838" cy="199.4502" r="83.2987" fill="none" stroke="currentColor" stroke-width="9.13"/></g></svg><span class="nav-logo-scope">Scope</span></span>
<span class="nav-logo-tag">See the world the way your skin does.</span>
</a>
<div class="nav-links" id="nav-drawer"> <div class="nav-links" id="nav-drawer">
<a href="./index.html">Forecast</a> <a href="./index.html">Forecast</a>
<div class="nav-dropdown"> <div class="nav-dropdown">
@@ -72,7 +75,14 @@
</div> </div>
</div> </div>
<a href="./faq.html">FAQ</a> <a href="./faq.html">FAQ</a>
<a href="https://billing.stripe.com/p/login/9B63cw7vl8k15Ei9DQd7q00" target="_blank" rel="noopener noreferrer">Account</a> <div class="nav-dropdown nav-dropdown--right">
<a href="./dashboard.html" class="nav-dropdown-toggle">Account<span class="caret"></span></a>
<div class="nav-dropdown-menu">
<a href="./dashboard.html">Dashboard</a>
<a id="nav-account-subscription" href="https://buy.stripe.com/9B63cw7vl8k15Ei9DQd7q00" target="_blank" rel="noopener noreferrer">Subscription</a>
<a id="nav-account-signinout" href="./dashboard.html">Sign in</a>
</div>
</div>
</div> </div>
<button class="utci-burger" aria-label="Open menu" aria-expanded="false" id="burger-btn" onclick="toggleNav()"> <button class="utci-burger" aria-label="Open menu" aria-expanded="false" id="burger-btn" onclick="toggleNav()">
<span></span><span></span><span></span> <span></span><span></span><span></span>
@@ -89,6 +99,7 @@
btn.setAttribute('aria-expanded', open ? 'true' : 'false'); btn.setAttribute('aria-expanded', open ? 'true' : 'false');
} }
</script> </script>
<script src="./assets/js/nav-account.js" defer></script>
<main> <main>
<h1>About SunScope</h1> <h1>About SunScope</h1>
@@ -195,7 +206,8 @@
</main> </main>
<footer> <footer>
&copy; 2026 <a href="https://fraxle.net" target="_blank" rel="noopener noreferrer">Fraxle.NET</a> · <a href="./index.html">Forecast</a> · <a href="./about.html">About</a> · <a href="./faq.html">FAQ</a> · <a href="https://billing.stripe.com/p/login/9B63cw7vl8k15Ei9DQd7q00" target="_blank" rel="noopener noreferrer">Account</a> · Data: <a href="https://open-meteo.com/" target="_blank" rel="noopener noreferrer">Open-Meteo</a> · UTCI: <a href="https://utci.org/" target="_blank" rel="noopener noreferrer">Bröde 2012</a> &copy; 2026 <a href="https://fraxle.net" target="_blank" rel="noopener noreferrer">Fraxle.NET</a> · <a href="./index.html">Forecast</a> · <a href="./about.html">About</a> · <a href="./faq.html">FAQ</a>
· <a href="./dashboard.html">Dashboard</a> · <a href="https://billing.stripe.com/p/login/9B63cw7vl8k15Ei9DQd7q00" target="_blank" rel="noopener noreferrer">Account</a> · Data: <a href="https://open-meteo.com/" target="_blank" rel="noopener noreferrer">Open-Meteo</a> · UTCI: <a href="https://utci.org/" target="_blank" rel="noopener noreferrer">Bröde 2012</a>
</footer> </footer>
</body> </body>
+18
View File
@@ -0,0 +1,18 @@
<?php
// POST { id } -> deletes an entry the caller owns.
require __DIR__ . '/../lib/bootstrap.php';
require_method('POST');
$user = require_user();
$body = json_body();
$id = (int)($body['id'] ?? 0);
if ($id <= 0) json_error('Invalid id.');
$stmt = db()->prepare('DELETE FROM journal_entries WHERE id = ? AND user_id = ?');
$stmt->execute([$id, $user['id']]);
if ($stmt->rowCount() === 0) {
json_error('Entry not found.', 404);
}
json_out(['ok' => true]);
+29
View File
@@ -0,0 +1,29 @@
<?php
// GET -> { ok:true, entries: [{ id, date, endDate, note, profile, solarEnv, reading, locationName, lat, lon, createdAt }, ...] }, newest first.
require __DIR__ . '/../lib/bootstrap.php';
$user = require_user();
$stmt = db()->prepare(
'SELECT id, entry_date, end_date, note, profile, solar_env, reading, location_name, lat, lon, created_at
FROM journal_entries
WHERE user_id = ?
ORDER BY entry_date DESC, id DESC'
);
$stmt->execute([$user['id']]);
$entries = array_map(fn($r) => [
'id' => (int)$r['id'],
'date' => $r['entry_date'],
'endDate' => $r['end_date'],
'note' => $r['note'],
'profile' => $r['profile'],
'solarEnv' => $r['solar_env'],
'reading' => $r['reading'] !== null ? (float)$r['reading'] : null,
'locationName' => $r['location_name'],
'lat' => $r['lat'] !== null ? (float)$r['lat'] : null,
'lon' => $r['lon'] !== null ? (float)$r['lon'] : null,
'createdAt' => $r['created_at'],
], $stmt->fetchAll());
json_out(['ok' => true, 'entries' => $entries]);
+102
View File
@@ -0,0 +1,102 @@
<?php
// POST { date, endDate?, note, id?, profile?, solarEnv?, reading?, locationName?, lat?, lon? }
// -> creates a new entry, or updates one the caller owns when id is given.
// { ok:true, id }.
//
// endDate is optional - a note can span a period (e.g. a holiday week)
// rather than a single day. Omitted/equal-to-date means a single-day entry.
//
// profile/solarEnv/reading/locationName/lat/lon are an optional attached
// "reading" - a computed felt-temperature number the browser derives
// client-side from the live 14-day forecast (same compute.js the main app
// uses, same peak-of-day rule the day tabs use, taken across the whole
// range for a multi-day entry). The server never touches the physics; it
// just stores whatever number the browser already computed, the same way
// it stores the note text. profile AND solarEnv are both stored because
// both change the number - the dashboard filters its entry list and graph
// down to whichever combination is currently selected, so a Desert reading
// never turns up mixed into a Beach trend.
require __DIR__ . '/../lib/bootstrap.php';
require_method('POST');
$user = require_user();
$body = json_body();
$date = (string)($body['date'] ?? '');
$note = trim((string)($body['note'] ?? ''));
$id = isset($body['id']) ? (int)$body['id'] : null;
if (!preg_match('/^\d{4}-\d{2}-\d{2}$/', $date)) {
json_error('Invalid date.');
}
$endDate = null;
if (isset($body['endDate']) && $body['endDate'] !== null && $body['endDate'] !== '') {
$endDate = (string)$body['endDate'];
if (!preg_match('/^\d{4}-\d{2}-\d{2}$/', $endDate)) {
json_error('Invalid end date.');
}
if ($endDate < $date) json_error('End date must be on or after the start date.');
if ($endDate === $date) $endDate = null; // same day - store as a single-day entry
}
if ($note === '') {
json_error('Note cannot be empty.');
}
if (strlen($note) > 4000) {
json_error('Note is too long.');
}
$profile = null;
$solarEnv = null;
$reading = null;
$locationName = null;
$lat = null;
$lon = null;
if (isset($body['reading']) && $body['reading'] !== null && $body['reading'] !== '') {
if (!is_numeric($body['reading'])) json_error('Invalid reading.');
$reading = round((float)$body['reading'], 1);
if ($reading < -100 || $reading > 150) json_error('Reading out of range.');
$profile = (string)($body['profile'] ?? '');
if (!in_array($profile, ['basic', 'home', 'vehicle', 'pets'], true)) {
json_error('Invalid profile.');
}
$solarEnv = (string)($body['solarEnv'] ?? '');
if (!in_array($solarEnv, ['open', 'urban', 'beach', 'river', 'forest', 'openwater', 'alpine', 'desert'], true)) {
json_error('Invalid solar model.');
}
$locationName = trim((string)($body['locationName'] ?? ''));
if ($locationName === '' || strlen($locationName) > 255) json_error('Invalid location.');
if (!is_numeric($body['lat'] ?? null) || !is_numeric($body['lon'] ?? null)) {
json_error('Invalid coordinates.');
}
$lat = round((float)$body['lat'], 5);
$lon = round((float)$body['lon'], 5);
if ($lat < -90 || $lat > 90 || $lon < -180 || $lon > 180) json_error('Invalid coordinates.');
}
if ($id) {
// Ownership enforced in the WHERE clause, not a prior SELECT.
$stmt = db()->prepare(
'UPDATE journal_entries
SET entry_date = ?, end_date = ?, note = ?, profile = ?, solar_env = ?, reading = ?, location_name = ?, lat = ?, lon = ?
WHERE id = ? AND user_id = ?'
);
$stmt->execute([$date, $endDate, $note, $profile, $solarEnv, $reading, $locationName, $lat, $lon, $id, $user['id']]);
if ($stmt->rowCount() === 0) {
json_error('Entry not found.', 404);
}
json_out(['ok' => true, 'id' => $id]);
}
$stmt = db()->prepare(
'INSERT INTO journal_entries (user_id, entry_date, end_date, note, profile, solar_env, reading, location_name, lat, lon)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)'
);
$stmt->execute([$user['id'], $date, $endDate, $note, $profile, $solarEnv, $reading, $locationName, $lat, $lon]);
json_out(['ok' => true, 'id' => (int)db()->lastInsertId()]);
+21
View File
@@ -0,0 +1,21 @@
<?php
// POST { email, password } -> signs the user in.
require __DIR__ . '/../lib/bootstrap.php';
require_method('POST');
$body = json_body();
$email = strtolower(trim($body['email'] ?? ''));
$password = (string)($body['password'] ?? '');
$stmt = db()->prepare('SELECT id, password_hash FROM users WHERE email = ?');
$stmt->execute([$email]);
$user = $stmt->fetch();
// Same message whether the email is unknown or the password is wrong -
// don't let a login form confirm which emails have accounts.
if (!$user || !password_verify($password, $user['password_hash'])) {
json_error('Incorrect email or password.', 401);
}
issue_session((int)$user['id']);
json_out(['ok' => true, 'email' => $email]);
+6
View File
@@ -0,0 +1,6 @@
<?php
require __DIR__ . '/../lib/bootstrap.php';
require_method('POST');
clear_session();
json_out(['ok' => true]);
+7
View File
@@ -0,0 +1,7 @@
<?php
// GET -> { ok:true, user: { email } } if signed in, else { ok:true, user:null }.
// Always 200 - this is a status check, not an auth gate.
require __DIR__ . '/../lib/bootstrap.php';
$user = current_user();
json_out(['ok' => true, 'user' => $user ? ['email' => $user['email']] : null]);
+30
View File
@@ -0,0 +1,30 @@
<?php
// POST { email, password } -> creates an account, signs the user in.
require __DIR__ . '/../lib/bootstrap.php';
require_method('POST');
$body = json_body();
$email = strtolower(trim($body['email'] ?? ''));
$password = (string)($body['password'] ?? '');
if (!filter_var($email, FILTER_VALIDATE_EMAIL)) {
json_error('Enter a valid email address.');
}
if (strlen($password) < 8) {
json_error('Password must be at least 8 characters.');
}
$stmt = db()->prepare('SELECT id FROM users WHERE email = ?');
$stmt->execute([$email]);
if ($stmt->fetch()) {
json_error('An account with that email already exists.', 409);
}
$hash = password_hash($password, PASSWORD_ARGON2ID);
$stmt = db()->prepare(
'INSERT INTO users (public_id, email, password_hash) VALUES (?, ?, ?)'
);
$stmt->execute([new_public_id(), $email, $hash]);
issue_session((int)db()->lastInsertId());
json_out(['ok' => true, 'email' => $email]);
+118 -17
View File
@@ -118,6 +118,23 @@ header a.active { color: #c8922a; }
} }
.nav-dropdown-menu a.active { color: #c8922a; } .nav-dropdown-menu a.active { color: #c8922a; }
/* The Account dropdown sits at the far right of the nav row, so a menu
centred under it (like About's) would spill past the viewport edge on
most screen widths. Right-align it to the toggle instead, growing left,
with its labels right-aligned to match the edge it hangs from. */
.nav-dropdown--right .nav-dropdown-menu {
left: auto;
right: 0;
transform: translateY(-4px);
}
.nav-dropdown--right.nav-dropdown:hover .nav-dropdown-menu,
.nav-dropdown--right.nav-dropdown:focus-within .nav-dropdown-menu {
transform: translateY(0);
}
.nav-dropdown--right .nav-dropdown-menu a {
text-align: right;
}
/* ── Burger button ──────────────────────────────────────────────────── */ /* ── Burger button ──────────────────────────────────────────────────── */
.utci-burger { .utci-burger {
display: none; display: none;
@@ -148,10 +165,106 @@ header a.active { color: #c8922a; }
display: none; display: none;
} }
/* Site identity — mirrors .nav-logo in css/base.css so the static pages and
the forecast app carry the same wordmark, icon and tagline. The header a
rules above dress every nav link, so the uppercase/brass treatment and the
hover underline are cancelled here. */
.nav-logo {
margin-right: auto;
display: flex;
flex-direction: column;
align-items: flex-start;
justify-content: center;
flex-shrink: 0;
text-transform: none;
letter-spacing: normal;
border-bottom: none;
padding-bottom: 0;
}
header a.nav-logo:hover { border-bottom-color: transparent; }
.nav-logo-wordmark {
display: flex;
align-items: center;
gap: 0.05em;
margin: 0;
font-size: 1rem;
font-weight: inherit;
}
.nav-logo-sun,
.nav-logo-scope {
font-family: 'Manrope', sans-serif;
font-weight: 250;
font-size: 1.6rem;
font-style: normal;
color: #1e1208;
letter-spacing: -0.01em;
}
.nav-logo-icon {
width: 1.26rem; /* same 0.79 ratio to the wordmark as the app's */
height: 1.26rem;
color: #1e1208;
flex-shrink: 0;
}
.nav-logo-tag {
display: block;
font-family: 'Manrope', sans-serif;
font-style: normal;
font-weight: 600;
font-size: 10px;
letter-spacing: 0.12em;
text-transform: uppercase;
color: #7a5c2a;
margin-top: 3px;
}
/* ── Mobile nav drawer ──────────────────────────────────────────────── */ /* ── Mobile nav drawer ──────────────────────────────────────────────── */
@media (max-width: 640px) { @media (max-width: 640px) {
/* Same pinned 72px bar as the app: wordmark centred, links behind the
burger. body pads itself to clear the fixed header. */
header { header {
padding: 14px 20px; position: fixed;
top: 0;
left: 0;
right: 0;
padding: 10px 20px;
height: 72px;
background: #f5edd6;
}
body {
padding-top: 72px;
}
.nav-logo {
position: absolute;
left: 0;
right: 0;
top: 50%;
transform: translateY(-50%);
align-items: center;
justify-content: center;
text-align: center;
margin-right: 0;
pointer-events: none; /* the full-width block must not swallow burger taps */
}
.nav-logo-wordmark,
.nav-logo-tag {
pointer-events: auto;
}
.nav-logo-wordmark {
display: block;
line-height: 1;
}
.nav-logo-sun,
.nav-logo-scope {
font-size: 2.1rem;
}
.nav-logo-icon {
width: 1.65rem;
height: 1.65rem;
} }
.utci-burger { .utci-burger {
@@ -207,6 +320,9 @@ header a.active { color: #c8922a; }
gap: 18px; gap: 18px;
} }
.nav-dropdown-menu a { padding: 0; font-size: 14px; } .nav-dropdown-menu a { padding: 0; font-size: 14px; }
/* Right-aligned text is a desktop-popup touch - on mobile everything is
one left-indented list, so match About's list instead of standing out. */
.nav-dropdown--right .nav-dropdown-menu a { text-align: left; }
/* Burger → X animation */ /* Burger → X animation */
header.nav-open .utci-burger span:nth-child(1) { header.nav-open .utci-burger span:nth-child(1) {
@@ -238,21 +354,6 @@ header a.active { color: #c8922a; }
} }
} }
.logo {
margin-right: auto;
font-family: 'Manrope', sans-serif;
font-weight: 250;
font-size: 1.6rem;
color: #1e1208;
letter-spacing: -0.01em;
text-transform: none;
display: flex;
align-items: center;
gap: 0.05em;
}
.logo-sun, .logo-scope { color: #1e1208; font-weight: 250; font-style: normal; }
.logo-icon { width: 0.82em; height: 0.82em; flex-shrink: 0; }
main { main {
max-width: 780px; max-width: 780px;
margin: 0 auto; margin: 0 auto;
@@ -434,5 +535,5 @@ footer {
@media (max-width: 600px) { @media (max-width: 600px) {
h1 { font-size: 1.6rem; } h1 { font-size: 1.6rem; }
main { padding: 2rem 1rem 4rem; } main { padding: 2rem 1rem 4rem; }
header { padding: 14px 16px; } header { padding: 10px 16px; }
} }
+180 -55
View File
@@ -65,7 +65,14 @@ body {
color: #1e1208; color: #1e1208;
background: #f5edd6; background: #f5edd6;
min-height: 100vh; min-height: 100vh;
padding: 52px 36px 32px; /* The side padding, as a variable: the header and the event note both
full-bleed by cancelling it, and would otherwise each need the number
restating at every breakpoint that changes it. */
--app-pad: 36px;
/* No top padding: .utci-topnav is a real header at the very top of the flow
now, and supplies its own. Was 52px, reserved for the nav when it was
absolutely positioned over this space. */
padding: 0 var(--app-pad) 32px;
position: relative; position: relative;
/* overflow-x: clip (not hidden) — `clip` clips horizontal overflow /* overflow-x: clip (not hidden) — `clip` clips horizontal overflow
WITHOUT making this a scroll container, so `position: sticky` on WITHOUT making this a scroll container, so `position: sticky` on
@@ -83,19 +90,27 @@ body {
} }
/* ── 3. TOP NAVIGATION (Home / About links in the corner) ──────────── */ /* ── 3. SITE HEADER (wordmark left, nav links right) ───────────────── */
/* Rendered as <header id="site-nav"> — the same element the static pages
(about.html et al) use — and it now carries the wordmark and tagline that
used to be repeated inside .utci-header. It sits FIRST in the flow, at the
very top of the page, with the event banner beneath it.
Full-bleed: .utci-app pads its content in by --app-pad a side, so the header
cancels that with a matching negative margin and pads itself instead — the
bar's rule reaches both page edges while its contents still line up with
the content below. */
.utci-topnav { .utci-topnav {
position: absolute; position: relative; /* keeps z-index working without leaving the flow */
top: 0;
left: 0;
right: 0;
z-index: 100; z-index: 100;
margin: 0 calc(var(--app-pad) * -1) 18px;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: flex-end; justify-content: flex-end; /* .nav-logo's margin-right:auto splits the row */
padding: 0px 32px; padding: 18px var(--app-pad);
gap: 28px; gap: 28px;
background: #f5edd6;
border-bottom: 1px solid #e0d0a8;
} }
.utci-topnav a { .utci-topnav a {
@@ -116,9 +131,9 @@ body {
border-bottom-color: #1e1208; border-bottom-color: #1e1208;
} }
/* Desktop: nav-links renders inline as part of nav flex row */ /* Desktop: nav-links renders inline as part of the header's flex row */
.utci-topnav .nav-links { .utci-topnav .nav-links {
display: contents; /* participates in nav's flex layout transparently */ display: contents; /* participates in the header's flex layout transparently */
} }
/* ── About dropdown ─────────────────────────────────────────────────── */ /* ── About dropdown ─────────────────────────────────────────────────── */
@@ -171,6 +186,22 @@ body {
visibility: visible; visibility: visible;
transform: translateX(-50%) translateY(0); transform: translateX(-50%) translateY(0);
} }
/* The Account dropdown sits at the far right of the nav row, so a menu
centred under it (like About's) would spill past the viewport edge on
most screen widths. Right-align it to the toggle instead, growing left. */
.utci-topnav .nav-dropdown--right .nav-dropdown-menu {
left: auto;
right: 0;
transform: translateY(-4px);
}
.utci-topnav .nav-dropdown--right.nav-dropdown:hover .nav-dropdown-menu,
.utci-topnav .nav-dropdown--right.nav-dropdown:focus-within .nav-dropdown-menu {
transform: translateY(0);
}
/* Labels align to the same right edge the menu itself hangs from. */
.utci-topnav .nav-dropdown--right .nav-dropdown-menu a {
text-align: right;
}
.utci-topnav .nav-dropdown-menu a { .utci-topnav .nav-dropdown-menu a {
text-transform: none; text-transform: none;
letter-spacing: normal; letter-spacing: normal;
@@ -190,15 +221,15 @@ body {
/* ── Event note ─────────────────────────────────────────────────────── */ /* ── Event note ─────────────────────────────────────────────────────── */
/* Active cosmic/weather events shown as a strip across the content width, /* Active cosmic/weather events shown as a strip across the content width,
above the nav. It is IN FLOW — it pushes the page down instead of directly under the site header. It is IN FLOW — it pushes the page down
covering anything, which is the whole point of it not being a flyout. instead of covering anything, which is the whole point of it not being a
One event at a time with its full message, cycling with a fade. flyout. One event at a time with its full message, cycling with a fade.
The nav is normally position:absolute and .utci-app reserves 52px of top It used to render ABOVE the nav, which meant repositioning the nav out of
padding for it. When a note is present the nav joins the flow instead so its absolute placement whenever a note was present. The header owns the top
the note can sit above it, and that padding is no longer needed. On true of the page now, so the note is simply the first thing under it — on desktop
mobile (≤640px) the nav is position:fixed and stays that way — there the and on mobile (where the header is fixed and .utci-app pads clear of it)
note simply becomes the first thing under it, so no override applies. */ alike, with no override either way. */
.event-note { .event-note {
display: flex; display: flex;
align-items: baseline; align-items: baseline;
@@ -222,11 +253,20 @@ body {
same device as .almanac-entry-accent. */ same device as .almanac-entry-accent. */
.event-note-accent { .event-note-accent {
position: absolute; position: absolute;
left: 0; inset: 0;
top: 0;
bottom: 0;
width: 3px;
opacity: 0.75; opacity: 0.75;
pointer-events: none;
/* One element, drawn as an L: a 1px strip down the left that turns and runs
along the bottom. The note's own overflow: hidden rounds the corner off
with the rest of the box. */
clip-path: polygon(
0 0,
1px 0,
1px calc(100% - 1px),
100% calc(100% - 1px),
100% 100%,
0 100%
);
} }
/* Safety-relevant weather (heat, frost, storm, wind, wet) — brass emphasis. /* Safety-relevant weather (heat, frost, storm, wind, wet) — brass emphasis.
@@ -408,33 +448,39 @@ body {
color: #2a1a08; color: #2a1a08;
} }
/* Collapse on the way out — height and vertical spacing animate to zero so /* Closing: the slot's height runs down to zero (measured and driven inline by
the page closes the gap smoothly instead of snapping upward. */ noteClose in app.js) and the note is anchored to its BOTTOM edge, so the
banner rides up behind the header's seam while everything below it rises by
the same amount at the same rate. One animated property for both movements —
a transform on the note would slide it independently of the page, and a
transform on the content below would break the fixed-position controls
living inside it. */
.event-note-slot {
/* The gap under the banner lives here rather than on the note, and is
deliberately equal to what the header gets back when .has-event-note is
dropped at the end of the close: the header's own margin-bottom (18px,
zeroed while a note is attached) plus, on a phone, the 8px .utci-app
stops padding once the note is gone. Without that, the space swapped in
at unmount showed up as a jump. */
margin-bottom: 18px;
display: grid;
grid-template-rows: 1fr; /* the row fills the slot, whatever its height */
align-items: end; /* ...and the note hangs from its bottom edge */
overflow: hidden;
}
.event-note-slot > .event-note {
min-height: 0; /* grid items floor at min-content without this */
}
/* The entry animation is fill-mode: both, so its final transform would stay
applied — cleared so it cannot offset the note against the slot. */
.event-note.is-closing { .event-note.is-closing {
animation: event-note-out 0.26s ease forwards; animation: none;
pointer-events: none; pointer-events: none;
} }
@keyframes event-note-out {
from { opacity: 1; transform: translateY(0); max-height: 200px; }
to { opacity: 0; transform: translateY(-4px); max-height: 0;
margin-bottom: 0; padding-top: 0; padding-bottom: 0; border-width: 0; }
}
/* Above true-mobile: the nav drops into the flow beneath the note. Its own /* The note follows the header in the flow now, so it needs no special
32px side padding is dropped so the links line up with the note and the positioning — only the gap the header's own margin-bottom would otherwise
content rather than sitting 32px inside them. */ have given the content it sits in front of. */
@media (min-width: 641px) {
.utci-app.has-event-note {
padding-top: 18px;
}
/* `relative`, not `static` — the nav keeps its z-index:100 that way, so
the About dropdown still paints over .utci-shell (which is z-index:1). */
.utci-app.has-event-note .utci-topnav {
position: relative;
padding-left: 0;
padding-right: 0;
}
}
@media (max-width: 640px) { @media (max-width: 640px) {
.event-note { .event-note {
@@ -456,26 +502,89 @@ body {
} }
} }
/* Nav logo — hidden on desktop, shown on mobile */ /* ── Note attached to the header ─────────────────────────────────────
When a note is present it hangs off the header's bottom edge rather than
floating below it as a separate panel: the header drops the gap it would
otherwise leave, and the note keeps a square top with no top border, inset
to the content edge so there is a gap either side. The header's own bottom
rule becomes the seam the strip hangs from.
Placed after the ≤640px block above deliberately — these override the
note's base padding and border at every width, mobile included. */
.has-event-note .utci-topnav {
margin-bottom: 0;
}
.has-event-note .event-note {
/* Inset to the content edge rather than full-bleed, so the strip keeps a
gap either side of it. The gap below is the slot's — see .event-note-slot. */
margin: 0;
padding: 10px 15px;
/* No top border and square top corners: the header's own bottom rule is the
seam it hangs from. A tinted note's inline borderColor still tints what is
left, since that sets the colour only and this sets style and width. */
border: 1.5px solid #c9b08a;
border-top: none;
border-radius: 0 0 4px 4px;
}
@media (max-width: 640px) {
/* Matches the two-class specificity above so the phone's tighter padding
survives the override. */
.has-event-note .event-note {
padding: 10px 12px;
}
/* 18px + the 8px of .utci-app padding-top below — see .event-note-slot. */
.event-note-slot {
margin-bottom: 26px;
}
/* The bar is position:fixed and 72px tall on a phone, and .utci-app pads 80px
clear of it — the spare 8px would show as a gap above the note. Two classes
so it outranks the single-class padding-top in ui.css whatever the order. */
.utci-app.has-event-note {
padding-top: 72px;
}
}
/* The site identity, in the header on every viewport. Was display:none here
and unhidden only on mobile, back when the big .utci-title wordmark carried
the desktop identity instead. */
.nav-logo { .nav-logo {
display: none; display: flex;
flex-direction: column;
align-items: flex-start;
justify-content: center;
flex-shrink: 0;
/* Same device about.css uses on .nav-logo: one auto margin pushes everything
after it to the right-hand end, which works with .nav-links staying
display:contents. */
margin-right: auto;
} }
.nav-logo-wordmark { .nav-logo-wordmark {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 0.05em; gap: 0.05em;
/* It is the page's <h1> now, so the browser's own h1 margin and 2em size are
cleared here — the wordmark's size and weight come from .nav-logo-sun and
.nav-logo-scope, and the icon is sized in rem. */
margin: 0;
font-size: 1rem;
font-weight: inherit;
} }
.nav-logo-sun, .nav-logo-sun,
.nav-logo-scope { .nav-logo-scope {
font-family: Manrope, sans-serif; font-family: Manrope, sans-serif;
font-weight: 250; font-weight: 250;
font-size: 2.1rem; /* 1.6rem matches .nav-logo-sun in about.css, so the wordmark is the same size on the
forecast and on every static page. The phone bar scales it back up (see the
≤640px block) where it is the centred focal point rather than a corner mark. */
font-size: 1.6rem;
color: #1e1208; color: #1e1208;
letter-spacing: -0.01em; letter-spacing: -0.01em;
} }
.nav-logo-icon { .nav-logo-icon {
width: 1.65rem; width: 1.26rem; /* same 0.79 ratio to the wordmark as .nav-logo-icon */
height: 1.65rem; height: 1.26rem;
color: #1e1208; color: #1e1208;
flex-shrink: 0; flex-shrink: 0;
} }
@@ -484,7 +593,7 @@ body {
font-family: Manrope, sans-serif; font-family: Manrope, sans-serif;
font-style: normal; font-style: normal;
font-weight: 600; font-weight: 600;
font-size: 8px; font-size: 10px;
letter-spacing: 0.12em; letter-spacing: 0.12em;
text-transform: uppercase; text-transform: uppercase;
color: #7a5c2a; color: #7a5c2a;
@@ -548,14 +657,26 @@ body {
display: block; display: block;
line-height: 1; line-height: 1;
} }
.nav-logo-sun,
.utci-title, .nav-logo-scope {
.utci-tagline { font-size: 2.1rem;
display: none; }
.nav-logo-icon {
width: 1.65rem;
height: 1.65rem;
} }
/* Phone keeps the bar pinned to the viewport, with the wordmark centred in
it and the links behind the burger. The offsets are set here rather than
inherited: the desktop rule is in-flow now and carries no top/left/right,
and its full-bleed negative margin has nothing to cancel once the bar is
fixed and already edge-to-edge. */
.utci-topnav { .utci-topnav {
position: fixed; position: fixed;
top: 0;
left: 0;
right: 0;
margin: 0;
padding: 10px 20px; padding: 10px 20px;
height: 72px; height: 72px;
background: #f5edd6; background: #f5edd6;
@@ -616,6 +737,10 @@ body {
gap: 18px; gap: 18px;
} }
.utci-topnav .nav-dropdown-menu a { padding: 0; font-size: 14px; } .utci-topnav .nav-dropdown-menu a { padding: 0; font-size: 14px; }
/* Right-aligned text is a desktop-popup touch (see the --right rule
above) - on mobile everything is one left-indented list, so match
About's list instead of standing out. */
.utci-topnav .nav-dropdown--right .nav-dropdown-menu a { text-align: left; }
/* Burger → X animation */ /* Burger → X animation */
.utci-topnav.nav-open .utci-burger span:nth-child(1) { .utci-topnav.nav-open .utci-burger span:nth-child(1) {
+48 -75
View File
@@ -18,18 +18,22 @@
.scope-col { grid-area: dial; } .scope-col { grid-area: dial; }
.header-right { grid-area: hright; } .header-right { grid-area: hright; }
/* Left column — wordmark, tagline, search and location stacked together */ /* Left column — the location line and its search overlay. The wordmark and
tagline that used to head this stack now live in the site header, so what is
left hangs from the top of the row, level with the top of the dial. */
.header-left { .header-left {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
align-items: flex-start; align-items: flex-start;
justify-content: flex-start;
} }
/* Wraps the location line + the reveal-on-demand search overlay so the /* Wraps the location line + the reveal-on-demand search overlay so the
search can position itself directly over the location to save space. */ search can position itself directly over the location to save space. */
.utci-loc-search { .utci-loc-search {
position: relative; position: relative;
margin-top: 18px; /* Was 18px, clearing the tagline above it. It leads the column now. */
margin-top: 0;
width: 380px; width: 380px;
max-width: 100%; max-width: 100%;
} }
@@ -40,45 +44,11 @@
display: flex; display: flex;
flex-direction: column; flex-direction: column;
align-items: flex-start; align-items: flex-start;
justify-content: center; justify-content: flex-start; /* level with the top of the dial */
} }
/* The big "SunScope" wordmark */ /* The wordmark and tagline that used to head this column now live in the
.utci-title { site header (.nav-logo in base.css) — see the header there. */
font-family: Manrope, sans-serif;
font-weight: 250;
font-size: clamp(48px, 6vw, 80px);
line-height: 1;
letter-spacing: -0.01em;
color: #1e1208;
display: flex;
align-items: center;
gap: 0.05em;
}
.utci-title .title-sun,
.utci-title .title-scope {
color: #1e1208;
}
.title-icon {
width: 0.82em;
height: 0.82em;
color: #1e1208;
flex-shrink: 0;
}
/* "See the world the way..." */
.utci-tagline {
font-family: Manrope, sans-serif;
font-style: normal;
font-weight: 600;
font-size: 11px;
letter-spacing: 0.12em;
text-transform: uppercase;
color: #7a5c2a;
margin-top: 12px;
}
/* "↳ Location, Somewhere" + the magnifier that reveals the search */ /* "↳ Location, Somewhere" + the magnifier that reveals the search */
.utci-current-loc { .utci-current-loc {
@@ -184,6 +154,7 @@
background: #fffcf2; background: #fffcf2;
color: #9a7a3a; color: #9a7a3a;
cursor: pointer; cursor: pointer;
text-decoration: none;
transition: color 0.2s, border-color 0.2s, background 0.2s, box-shadow 0.2s; transition: color 0.2s, border-color 0.2s, background 0.2s, box-shadow 0.2s;
} }
@@ -213,6 +184,39 @@
.utci-loc-action.is-busy svg { animation: none; opacity: 0.55; } .utci-loc-action.is-busy svg { animation: none; opacity: 0.55; }
} }
/* Tool row under the location line — the magnifier, locate, share and journal
buttons sit on their own row now that the header has the space, each with a
small caption so the icons don't have to carry the meaning alone. */
.utci-loc-tools {
display: flex;
flex-wrap: wrap;
align-items: flex-start;
gap: 16px;
margin-top: 10px;
}
.utci-loc-tool {
display: flex;
flex-direction: column;
align-items: center;
gap: 4px;
}
.utci-loc-tool-label {
font-family: Manrope, sans-serif;
font-style: normal;
font-weight: 600;
font-size: 9.5px;
letter-spacing: 0.08em;
text-transform: uppercase;
color: #a08c62;
white-space: nowrap;
transition: color 0.2s;
}
/* Light the caption up with its button so the pair reads as one control */
.utci-loc-tool:hover .utci-loc-tool-label { color: #c8922a; }
/* Transient note under the location row: "Link copied", permission errors */ /* Transient note under the location row: "Link copied", permission errors */
.utci-loc-note { .utci-loc-note {
display: flex; display: flex;
@@ -240,19 +244,6 @@
.utci-loc-note-x:hover { opacity: 1; } .utci-loc-note-x:hover { opacity: 1; }
/* The lat/lon below the location */
.utci-loc-coords {
font-family: JetBrains Mono, monospace;
font-style: normal;
font-size: 11px;
color: #b09870;
display: block;
flex-basis: 100%; /* drop onto its own line below the name + magnifier */
margin-top: 4px;
margin-left: 0;
letter-spacing: 0.08em;
}
/* The big UTCI dial in the middle of the header. /* The big UTCI dial in the middle of the header.
Change width/height to resize it. */ Change width/height to resize it. */
.scope-ring { .scope-ring {
@@ -301,7 +292,9 @@
display: flex; display: flex;
flex-direction: column; flex-direction: column;
align-items: center; align-items: center;
justify-content: center; /* Top-anchored so the dial defines the row's top edge and the location and
Solar model columns beside it start on the same line. */
justify-content: flex-start;
gap: 8px; gap: 8px;
} }
@@ -594,12 +587,6 @@ body.tl-scrubbing * { transition: none !important; }
box-shadow: 0 6px 20px #64461426; /* lift it off the location underneath */ box-shadow: 0 6px 20px #64461426; /* lift it off the location underneath */
} }
/* The 300px wrapper would let .utci-fetch-time's default right-align drift to
the far edge; keep it left-aligned beneath the coords as before. */
.utci-loc-search .utci-fetch-time {
text-align: left;
}
.utci-search-label { .utci-search-label {
font-family: Manrope, sans-serif; font-family: Manrope, sans-serif;
font-size: 10px; font-size: 10px;
@@ -716,16 +703,6 @@ body.tl-scrubbing * { transition: none !important; }
align-items: center; /* centre the "Why it feels like" panel under the dial */ align-items: center; /* centre the "Why it feels like" panel under the dial */
} }
.utci-title {
font-size: clamp(40px, 12vw, 60px);
}
}
@media (max-width: 640px) {
.utci-title,
.utci-tagline {
display: none;
}
} }
@media (max-width: 960px) { @media (max-width: 960px) {
@@ -753,12 +730,8 @@ body.tl-scrubbing * { transition: none !important; }
text-align: center; text-align: center;
} }
.utci-loc-coords { .utci-loc-tools {
text-align: center; justify-content: center; /* centre the tool row on mobile */
}
.utci-loc-search .utci-fetch-time {
text-align: center; /* match the desktop override's specificity */
} }
.utci-search-wrap { .utci-search-wrap {
+271 -32
View File
@@ -270,6 +270,34 @@
z-index: 1; z-index: 1;
} }
/* The sky glyph and anything composited onto it. Anchors .utci-day-wind and
is inline-block so it hugs the 30px icon rather than the full tab width. */
.utci-day-icon {
position: relative;
display: inline-block;
line-height: 0;
}
/* Gust lines overlaid on the sky glyph (Near Gale and above, where the wind
isn't already the main icon). Absolutely positioned, so it costs the tab no
layout width — at the 90px min-width there is no room for a second glyph
beside the sky icon, and the tab's rows stay aligned with the legend column.
Concentric with the sky glyph rather than tucked into a corner: hanging it
off one side made the pair read as two separate stickers, one of them
falling off the edge. Centred, the gusts read as blowing THROUGH the cloud —
one combined symbol. The -44% (rather than -50%) vertical offset drops it a
couple of pixels below true centre, so the lines cross the base of the cloud
instead of cutting through its middle. */
.utci-day-wind {
position: absolute;
left: 50%;
top: 50%;
transform: translate(-50%, -44%);
line-height: 0;
pointer-events: none;
}
/* Danger-band days breathe a slow dark-red inner glow */ /* Danger-band days breathe a slow dark-red inner glow */
.utci-day-tab.danger { .utci-day-tab.danger {
animation: dayTabDangerPulse 2.4s ease-in-out infinite; animation: dayTabDangerPulse 2.4s ease-in-out infinite;
@@ -609,6 +637,10 @@
position: absolute; position: absolute;
top: 10px; top: 10px;
right: 14px; right: 14px;
/* .profile-tab carries z-index:1, and a flex item honours z-index without
needing position — so without this the tabs paint straight over the
close button wherever the tab strip reaches the panel's right edge. */
z-index: 6;
background: none; background: none;
border: none; border: none;
font-size: 24px; font-size: 24px;
@@ -632,8 +664,15 @@
bottom: auto; bottom: auto;
max-height: calc(100vh - var(--profile-anchor-top, 0px) - 16px); max-height: calc(100vh - var(--profile-anchor-top, 0px) - 16px);
width: fit-content; width: fit-content;
min-width: 380px; /* The panel's width is pinned by the hidden Activities sizer (see
max-width: min(880px, calc(100vw - 32px)); .profile-scroll-sizer / ConfigPanel.js): 9 x 100px cards + gaps +
padding, ~1000px all-in. The old 880px cap clipped the last card or
two, so give it room — and a min-width past that natural size so the
config strip underneath has enough room to pack into fewer rows.
Both are capped to the viewport, so narrow desktops and tablets
still shrink normally. */
min-width: min(1080px, calc(100vw - 32px));
max-width: min(1120px, calc(100vw - 32px));
margin: 0 auto; margin: 0 auto;
border: 1.5px solid #c9b08a; border: 1.5px solid #c9b08a;
border-radius: 12px; border-radius: 12px;
@@ -696,7 +735,11 @@
} }
.utci-thead-sticky { .utci-thead-sticky {
position: sticky; position: sticky;
top: 28px !important; /* Corrected to match the height of the active-profile-row + margins */ /* Clears the profile row pinned above it. DayTabs measures that row and
publishes its height (+ its 4px margin) as --profile-row-h — it is one
line or two depending on the labels, and the old fixed 28px left the
table header tucked under it. */
top: var(--profile-row-h, 52px) !important;
z-index: 10; z-index: 10;
} }
} }
@@ -1257,6 +1300,48 @@
.profile-dropdown-body { .profile-dropdown-body {
max-height: calc(100vh - 16px); max-height: calc(100vh - 16px);
overflow-y: auto; overflow-y: auto;
/* Room for the close bar below — the tab strip goes full width on
phones, so the close button can no longer share that top line. */
padding-top: 42px;
}
/* Give the sheet its own header strip with a proper thumb-sized close
target. Previously the × sat on top of the full-width tab row, where
it was both invisible and unhittable — the sheet covers most of the
viewport on a phone, so there was almost no backdrop left to tap
either, and no way back to the table. The button is a child of the
fixed panel (not the scrolling body), so it stays put while the
config list scrolls under it. */
.profile-dropdown-close {
top: 6px;
right: 10px;
width: 34px;
height: 34px;
padding: 0;
display: flex;
align-items: center;
justify-content: center;
font-size: 22px;
color: #8a6a3a;
background: #fffcf2;
border: 1.5px solid #c9b08a;
border-radius: 50%;
}
/* Bottom-sheet grab handle — the usual "drag/tap me away" cue, and a
second visual hint that the top strip is chrome, not content. */
.profile-dropdown::before {
content: '';
position: absolute;
top: 15px;
left: 50%;
transform: translateX(-50%);
width: 42px;
height: 4px;
border-radius: 999px;
background: #d8c7a4;
z-index: 6;
pointer-events: none;
} }
.floating-profile-val { max-width: 90px; } .floating-profile-val { max-width: 90px; }
.floating-profile-sub { max-width: 90px; } .floating-profile-sub { max-width: 90px; }
@@ -1975,6 +2060,23 @@
border-radius: 5px; border-radius: 5px;
} }
/* ...except the plain single-picker cards (SunSoak, Fur, Burn, Pollen),
which pair up two per row. Six full-width cards turned the sheet into
a scrolling wall that buried the table; pairing halves that. Cards
holding a fused control group (Vehicle, Indoors) keep a full row —
they already wrap internally and have no width to spare — and so does
the main item, which carries the badge and the gold emphasis.
:has() gates the whole thing: a browser without it just drops these
rules and keeps the old one-per-row stack. */
.col-toggles-config-item:not(.col-toggles-config-item--main):not(:has(.col-toggle-group)) {
flex: 1 1 calc((100% - 8px) / 2);
}
.col-toggles-config-item:not(.col-toggles-config-item--main):not(:has(.col-toggle-group)) .cs-btn {
font-size: 11px;
padding: 8px 4px;
letter-spacing: 0.02em;
}
/* Re-affirm the gold emphasis for the "main" item so it still stands /* Re-affirm the gold emphasis for the "main" item so it still stands
out after the generic card border above. */ out after the generic card border above. */
.col-toggles-config-item--main { .col-toggles-config-item--main {
@@ -2277,7 +2379,9 @@
grid-template-columns: 1fr; grid-template-columns: 1fr;
justify-items: center; justify-items: center;
row-gap: 6px; row-gap: 6px;
padding: 6px 0 10px; /* No bottom padding: the tabs have to touch the col-toggles border
below them, or they read as floating above the box. */
padding: 6px 0 0;
} }
.fvt-toolbar-left { .fvt-toolbar-left {
grid-column: 1; grid-column: 1;
@@ -2285,6 +2389,12 @@
justify-self: center; justify-self: center;
padding-bottom: 0; padding-bottom: 0;
} }
/* Quick view needs the row flush so its thermal tabs attach to the box
below. Detailed and Table have no tabs, so the View buttons would
otherwise sit right on the table — give those a gap. */
.table-main:not(.table-main--simple) .table-toolbar-row {
padding-bottom: 10px;
}
.fvt-thermal-tabs { .fvt-thermal-tabs {
grid-column: 1; grid-column: 1;
grid-row: 2; grid-row: 2;
@@ -2360,7 +2470,16 @@
.table-main--simple .forecast-simple-wrap { .table-main--simple .forecast-simple-wrap {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
height: 420px; /* Sized by its contents — cards, then the curve at its own fixed height,
then the button. It used to be a fixed 420px with the curve set to flex: 1
to absorb whatever was left over, which made the curve's height a
subtraction: panel minus cards minus button. That left it one card-height
change away from falling under .fsc-temp-curve's min-height, at which point
the column overflowed .forecast-simple-scroll (overflow-y: hidden) and the
BOTTOM of the curve — where the cold end of the scale lives — was clipped
off. Content-sized panel + fixed-height curve: no leftover space to gap,
none to run out of. */
height: auto;
border: 1.5px solid #d4c0a0; border: 1.5px solid #d4c0a0;
background: #fdf8ee; background: #fdf8ee;
} }
@@ -2368,6 +2487,56 @@
display: none; display: none;
} }
/* ── SCROLL INDICATORS (quick view) ──────────────────────────────────────
Same fade + chevron pair the detailed table carries, driven by the
scroll-fade-left / scroll-fade-right classes app.js toggles from the
card strip's scroll position. The gradients fade from the panel's own
cream so they read as the content running under the edge. */
.table-main--simple .forecast-simple-wrap {
position: relative;
}
.forecast-simple-wrap::before,
.forecast-simple-wrap::after {
content: '';
position: absolute;
top: 0;
bottom: 0;
width: 48px;
pointer-events: none;
opacity: 0;
transition: opacity 0.2s ease;
z-index: 6;
}
.forecast-simple-wrap::before {
left: 0;
background: linear-gradient(to right, rgba(253,248,238,0.95) 0%, transparent 100%);
}
.forecast-simple-wrap::after {
right: 0;
background: linear-gradient(to left, rgba(253,248,238,0.95) 0%, transparent 100%);
}
.forecast-simple-wrap.scroll-fade-left::before { opacity: 1; }
.forecast-simple-wrap.scroll-fade-right::after { opacity: 1; }
.fsc-scroll-chevron {
position: absolute;
top: 50%;
transform: translateY(-50%);
z-index: 7;
pointer-events: none;
color: #8a6a3a;
font-size: 18px;
line-height: 1;
opacity: 0;
transition: opacity 0.2s ease;
user-select: none;
}
.fsc-scroll-chevron.left { left: 6px; }
.fsc-scroll-chevron.right { right: 6px; }
.forecast-simple-wrap.scroll-fade-left .fsc-scroll-chevron.left { opacity: 1; }
.forecast-simple-wrap.scroll-fade-right .fsc-scroll-chevron.right { opacity: 1; }
/* ── SIMPLE VIEW CARDS ───────────────────────────────────────────────────── */ /* ── SIMPLE VIEW CARDS ───────────────────────────────────────────────────── */
.forecast-simple-scroll { .forecast-simple-scroll {
@@ -2503,6 +2672,11 @@
opacity: 0.9; opacity: 0.9;
} }
.fsc-card--now .fsc-meta-row { opacity: 1; color: #4a3218; } .fsc-card--now .fsc-meta-row { opacity: 1; color: #4a3218; }
/* Speed and direction read as one fact ("21mph E"), so they share a line and
must never break across two. The direction sits a shade back from the
number — it qualifies the speed rather than competing with it. */
.fsc-meta-wind { white-space: nowrap; }
.fsc-meta-dir { opacity: 0.75; letter-spacing: 0.02em; }
.fsc-meta-rain { color: #2a6a90; } .fsc-meta-rain { color: #2a6a90; }
.fsc-card--now .fsc-meta-rain { color: #1a4a70; } .fsc-card--now .fsc-meta-rain { color: #1a4a70; }
@@ -2514,11 +2688,17 @@
line-height: 1; line-height: 1;
} }
/* The curve plots a FIXED 15…45°C scale across its full height (see fscMin /
fscMax in app.js) with preserveAspectRatio="none", so this height is the
whole scale — every degree from freezing to danger is somewhere in these
88px. It must not be flexible: the connector lines are drawn from the SVG's
top edge and have to stay flush under the cards, so any height the box does
not get is height the cold end loses off the bottom. */
.fsc-temp-curve { .fsc-temp-curve {
display: block; display: block;
width: 100%; width: 100%;
flex: 1; flex: 0 0 auto;
min-height: 60px; height: 88px;
} }
.fsc-table-btn-wrap { .fsc-table-btn-wrap {
@@ -2566,7 +2746,7 @@
padding: 2px 4px; padding: 2px 4px;
} }
.fsc-temp-curve { .fsc-temp-curve {
min-height: 48px; height: 72px;
} }
} }
@@ -2574,16 +2754,14 @@
ROTATED TABLE — hours along the top, metrics down the side ROTATED TABLE — hours along the top, metrics down the side
───────────────────────────────────────────────────────────────────── ─────────────────────────────────────────────────────────────────────
Toggled by the rotate button in the table's top-left cell (state lives Toggled by the rotate button in the table's top-left cell (state lives
in useAppState as tableRotated). Unlike the normal orientation — which in useAppState as tableRotated). Built the same way as the normal
is two tables kept in step by useTableScroll — this is ONE table in a orientation: head and body are separate tables sharing useTableScroll's
single scroll pane, so both sticky edges are pure CSS. width sync, so the hour row can stick to the viewport as the page
scrolls. (A <thead> inside .utci-tbody-scroll can only ever stick to
A single table in the shared .utci-tbody-scroll horizontal scroller. that scroller, which never scrolls vertically — so it would just
The normal orientation splits head from body so its header can stick travel away with the table.) The metric column pins with plain CSS
to the viewport; here the hour row scrolls away with the table by sticky inside the body scroller, and the corner is held to the left
design, so that machinery is not needed. The metric column pins with edge by the head track's counter-translate.
plain CSS sticky, which works fine inside the scroller. The table
keeps its natural height and the page scrolls normally.
══════════════════════════════════════════════════════════════════════ */ ══════════════════════════════════════════════════════════════════════ */
/* Fallback only, for the first paint before the measurement lands. /* Fallback only, for the first paint before the measurement lands.
@@ -2591,13 +2769,14 @@
inline — it can't be fixed here, because auto layout widens that inline — it can't be fixed here, because auto layout widens that
column to fit the longest metric name on show. */ column to fit the longest metric name on show. */
.utci-table-wrap.is-rotated { .utci-table-wrap.is-rotated {
--hour-col-w: 136px; --hour-col-w: 104px;
} }
.utci-table.utci-table-rotated { .utci-table.utci-table-rotated {
/* Auto layout: with a single table there are no header widths to match, /* Fixed layout, like the normal orientation: head and body are two tables
so each hour column can simply size to its own content. */ kept in step by useTableScroll's width sync, and only fixed layout
table-layout: auto; honours the widths it writes onto the first row of each. */
table-layout: fixed;
width: max-content; width: max-content;
min-width: 100%; min-width: 100%;
/* The separate border model is required, not cosmetic. Under /* The separate border model is required, not cosmetic. Under
@@ -2703,9 +2882,13 @@
/* The group rules carry a 2px left border that marks where a group STARTS /* The group rules carry a 2px left border that marks where a group STARTS
along the columns. Rotated, groups are stacked rows and have no left along the columns. Rotated, groups are stacked rows and have no left
edge to mark, so it shows up as a stray line — and its width offsets edge to mark, so it shows up as a stray line — and its width offsets
the label 2px against the metric names below it. */ the label 2px against the metric names below it.
The top rule matches .grp-label-fill's so the group boundary runs unbroken
from the pinned label across every hour column. */
.utci-table.utci-table-rotated tbody .grp-label-row--rotated th { .utci-table.utci-table-rotated tbody .grp-label-row--rotated th {
border-left: 0; border-left: 0;
border-top: 2px solid rgba(74, 50, 24, 0.18);
border-bottom: 0;
} }
/* The group heading pins with the metric names it labels. */ /* The group heading pins with the metric names it labels. */
@@ -2719,12 +2902,31 @@
} }
/* The per-hour cells filling out a divider row. They carry the group tint /* The per-hour cells filling out a divider row. They carry the group tint
and, in the current hour's column, its share of the brass bracket. */ and, in the current hour's column, its share of the brass bracket.
Composited over the SAME opaque cream base as the sticky label at the row's
left end, for the same reason that label needs one: --grp-tint is only 50%
opaque, so painting it straight onto the cell resolved it against the table
background while the label resolved it against #f0e4c4. The divider then read
as a dark cap at the left fading to a much paler band across the hours — one
tint, two backgrounds, two colours. One base, and the row is a single shade
end to end. The brass now-column bracket is unaffected: it is a border on
these cells, not a background, so it still paints over the top. */
.utci-table.utci-table-rotated tbody td.grp-label-fill { .utci-table.utci-table-rotated tbody td.grp-label-fill {
padding: 5px 0; padding: 5px 0;
min-width: 0; min-width: 0;
background: var(--grp-tint, transparent); background-color: #f0e4c4;
border-bottom: 1px solid rgba(74, 50, 24, 0.18); background-image: linear-gradient(var(--grp-tint, transparent), var(--grp-tint, transparent));
/* One rule, along the very top of the category block — the same 2px
rgba(74,50,24,0.18) the unrotated table draws down the left of each new
column group (.col-group-start), so a group boundary looks the same
whichever way the table is turned. Nothing along the bottom: the divider
and the metrics under it are one block, and a second line there just read
as a double rule around the label. Every cell in the row carries the edge
— under border-collapse:separate a <tr> border never paints, so the row's
rule has to be drawn cell by cell (see the row-separator rule above). */
border-top: 2px solid rgba(74, 50, 24, 0.18);
border-bottom: 0;
} }
.utci-table.utci-table-rotated tbody th.rot-metric-label { .utci-table.utci-table-rotated tbody th.rot-metric-label {
@@ -2732,7 +2934,10 @@
left: 0; left: 0;
z-index: 3; z-index: 3;
text-align: left; text-align: left;
min-width: 136px; /* Was 136px, sized around the old long date in the corner. The names and
their unit lines need far less than that, and every pixel here is taken
straight off the hours. */
min-width: 104px;
/* box-shadow rather than border-right so the divider sits outside the /* box-shadow rather than border-right so the divider sits outside the
cell box and cannot add to the column's measured width. */ cell box and cannot add to the column's measured width. */
box-shadow: 1px 0 0 rgba(74, 50, 24, 0.18); box-shadow: 1px 0 0 rgba(74, 50, 24, 0.18);
@@ -2745,12 +2950,14 @@
rule, which sets position:relative for the JS transform this view rule, which sets position:relative for the JS transform this view
doesn't use. */ doesn't use. */
.utci-table.utci-table-rotated thead th.rot-corner { .utci-table.utci-table-rotated thead th.rot-corner {
position: sticky; /* Left-pinned by the head-track counter-translate useTableScroll applies to
left: 0; thead th:first-child — the same device the normal orientation's Hour
header uses. CSS sticky would do nothing here: the head table sits in a
translated track, not in the scroller. */
position: relative;
z-index: 4; z-index: 4;
background: #f0e4c4; background: #f0e4c4;
text-align: left; text-align: left;
transform: none;
box-shadow: 1px 0 0 rgba(74, 50, 24, 0.18); box-shadow: 1px 0 0 rgba(74, 50, 24, 0.18);
} }
.utci-table-rotated .rot-corner-date { .utci-table-rotated .rot-corner-date {
@@ -2760,12 +2967,34 @@
color: #a08040; color: #a08040;
margin-bottom: 6px; margin-bottom: 6px;
} }
/* This cell is what widens the pinned metric-name column, so the date is
allowed to wrap rather than forcing the column past the names' width. */
.utci-table.utci-table-rotated thead th.rot-corner {
white-space: normal;
}
.utci-table-rotated .rot-corner-controls { .utci-table-rotated .rot-corner-controls {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 8px; gap: 8px;
} }
/* The pinned column carries text, not figures with icons, so it does not
need the 16px side padding the data cells use — and it is the one column
that costs the hours their width. */
.utci-table.utci-table-rotated thead th.rot-corner,
.utci-table.utci-table-rotated tbody th.rot-metric-label,
.utci-table.utci-table-rotated tbody th.rot-group-label {
padding-left: 10px;
padding-right: 10px;
}
/* Keep the interval stepper inside that width; it would otherwise set the
column on its own. */
.utci-table-rotated .rot-corner .row-stepper { gap: 2px; }
.utci-table-rotated .rot-corner .row-stepper-btn { min-width: 26px; }
.utci-table-rotated .rot-corner .row-stepper-value { min-width: 20px; }
@media (max-width: 640px) { @media (max-width: 640px) {
/* Tighter segments on narrow screens — the toolbar is already tight, /* Tighter segments on narrow screens — the toolbar is already tight,
and there are three of them now. */ and there are three of them now. */
@@ -2828,9 +3057,19 @@
min-width: 68px; min-width: 68px;
} }
.utci-table-rotated .rot-metric-label { .utci-table-rotated .rot-metric-label {
min-width: 112px; min-width: 96px;
}
.utci-table.utci-table-rotated thead th.rot-corner,
.utci-table.utci-table-rotated tbody th.rot-metric-label,
.utci-table.utci-table-rotated tbody th.rot-group-label {
padding-left: 8px;
padding-right: 8px;
}
.utci-table-rotated .rot-corner-date {
font-size: 9px;
letter-spacing: 0.04em;
} }
.utci-table-wrap.is-rotated { .utci-table-wrap.is-rotated {
--hour-col-w: 112px; --hour-col-w: 96px;
} }
} }
+73 -18
View File
@@ -1110,7 +1110,9 @@
flex-direction: column; flex-direction: column;
align-items: flex-start; align-items: flex-start;
gap: 3px; gap: 3px;
margin-top: 0.5rem; /* Was 0.5rem — the recents now follow the tool row, so they need a clearer
break above them. */
margin-top: 1.25rem;
} }
/* header.css sets align-items: center for the same tablet breakpoint, but /* header.css sets align-items: center for the same tablet breakpoint, but
since ui.css is @imported after header.css in sunscope.css it wins at since ui.css is @imported after header.css in sunscope.css it wins at
@@ -1381,9 +1383,15 @@
} }
.utci-app { .utci-app {
padding: 52px 10px 32px; /* No top padding — the header is in the flow at the very top of the page.
Was 52px, reserving space for the nav back when it was absolutely
positioned over it. Narrowing --app-pad re-aims the header's and the
event note's full-bleed at the same time. */
--app-pad: 10px;
padding: 0 var(--app-pad) 32px;
} }
.utci-header { .utci-header {
grid-template-columns: 1fr; /* stack the 3 header columns */ grid-template-columns: 1fr; /* stack the 3 header columns */
text-align: center; text-align: center;
@@ -1393,9 +1401,7 @@
align-items: center; align-items: center;
} }
.utci-current-loc, .utci-current-loc {
.utci-loc-coords,
.utci-fetch-time {
text-align: center; text-align: center;
} }
@@ -1446,10 +1452,6 @@
padding-top: 80px; padding-top: 80px;
} }
.utci-fetch-time {
text-align: center; /* override the default left-align on mobile */
}
/* Hide the export panel on phones — no room for it, and the export /* Hide the export panel on phones — no room for it, and the export
actions aren't useful at this size. */ actions aren't useful at this size. */
.export-panel { .export-panel {
@@ -1524,6 +1526,15 @@
max-width: 300px; max-width: 300px;
} }
/* "Last update" closes the panel out — a hairline above it keeps it apart
from the contribution rows it follows. */
.insight-panel--why .utci-fetch-time {
text-align: right;
margin-top: 8px;
padding-top: 6px;
border-top: 1px solid #ede4cc;
}
/* Solar model selector inside the Why it feels like panel */ /* Solar model selector inside the Why it feels like panel */
.insight-env-footer { .insight-env-footer {
display: flex; display: flex;
@@ -1627,10 +1638,13 @@
} }
/* Week rows carry a score bar, so they are a two-line grid rather than the /* Week rows carry a score bar, so they are a two-line grid rather than the
single flex line the glance rows use: day and window on top, bar beneath. */ single flex line the glance rows use: day and window on top, bar beneath.
Day and window read as one sentence - "Sun 30 : all day" - so they sit
together at the left rather than being pushed to opposite edges, where the
gap between them made two short phrases look like two columns of data. */
.insight-panel--week .insight-row { .insight-panel--week .insight-row {
display: grid; display: grid;
grid-template-columns: 20px 1fr auto; grid-template-columns: 20px auto minmax(0, 1fr);
grid-template-areas: grid-template-areas:
"icon label value" "icon label value"
"icon score score"; "icon score score";
@@ -1639,8 +1653,42 @@
align-items: center; align-items: center;
} }
.insight-panel--week .insight-icon { grid-area: icon; } .insight-panel--week .insight-icon { grid-area: icon; }
.insight-panel--week .insight-label { grid-area: label; } .insight-panel--week .insight-label { grid-area: label; flex: none; }
.insight-panel--week .insight-value { grid-area: value; } .insight-panel--week .insight-value {
grid-area: value;
text-align: left;
flex-shrink: 1;
min-width: 0;
}
/* The separator is drawn, not written into the string, so the value stays a
plain window ("all day") everywhere else it is used. */
.insight-panel--week .insight-value::before {
content: ': ';
color: #8a8375;
font-weight: 400;
}
/* The washout row - no day qualified, so there is no day name and no score,
just a sentence. Forcing it through the day-and-window layout squeezed that
sentence into the narrow value column and stranded the colon on a line of
its own, so it gets the full width and drops the separator. Specificity is
raised past the rail rules below deliberately: this row stacks at every
width. */
.insight-panel--week .insight-row.insight-row--week-note {
grid-template-columns: 20px minmax(0, 1fr);
grid-template-areas:
"icon label"
"icon value";
align-items: start;
row-gap: 2px;
}
.insight-panel--week .insight-row--week-note .insight-icon { padding-top: 2px; }
.insight-panel--week .insight-row--week-note .insight-value {
font-family: Manrope, sans-serif;
font-weight: 500;
white-space: normal;
}
.insight-panel--week .insight-row--week-note .insight-value::before { content: none; }
/* How good the day is out of 100 — the bar does the comparing, the number /* How good the day is out of 100 — the bar does the comparing, the number
settles close calls. */ settles close calls. */
@@ -1765,6 +1813,7 @@
margin: -6px 0 10px; margin: -6px 0 10px;
} }
.insight-thermal-cat { .insight-thermal-cat {
display: inline-block; display: inline-block;
padding: 2px 7px; padding: 2px 7px;
@@ -1930,14 +1979,20 @@
row-gap: 1px; row-gap: 1px;
align-items: start; align-items: start;
} }
/* The rail is only 240px wide, so the window drops under the day name and /* The rail is only 240px wide, but "Sun 30 : all day" still fits on one
the score bar takes a third line rather than sharing one. */ line, so the week rows keep day and window together and give the score
bar the second line - unlike the glance rows, which stack. */
.insight-panel--week .insight-row { .insight-panel--week .insight-row {
grid-template-columns: 20px auto minmax(0, 1fr);
grid-template-areas: grid-template-areas:
"icon label" "icon label value"
"icon value" "icon score score";
"icon score";
row-gap: 3px; row-gap: 3px;
align-items: center;
}
.insight-panel--week .insight-label,
.insight-panel--week .insight-value {
font-size: 14px;
} }
.insight-panel--glance .insight-icon, .insight-panel--glance .insight-icon,
.insight-panel--week .insight-icon { .insight-panel--week .insight-icon {
+323 -125
View File
@@ -17,7 +17,7 @@
// Preview the Pro view ......... useState(false) on isPro → flip to true // Preview the Pro view ......... useState(false) on isPro → flip to true
// Starting location ............ useState({...}) on `location` near top // Starting location ............ useState({...}) on `location` near top
// Default columns shown ........ useState({...}) on visibleCols // Default columns shown ........ useState({...}) on visibleCols
// Page tagline / about copy .... search "utci-tagline" or "utci-about-text" // Page tagline / about copy .... search "nav-logo-tag" or "utci-about-text"
// ════════════════════════════════════════════════════════════════════════ // ════════════════════════════════════════════════════════════════════════
import { h, render, Fragment } from '../vendor/preact.js'; import { h, render, Fragment } from '../vendor/preact.js';
@@ -255,6 +255,24 @@ export function UTCIForecast() {
// Search is hidden by default and revealed via the magnifier next to the // Search is hidden by default and revealed via the magnifier next to the
// location. The input then overlays the location line to save space. // location. The input then overlays the location line to save space.
// Dashboard sign-in state, purely for the Account menu's Sign out/in
// item - a lightweight session check against the dashboard's own auth
// (see api/me.php), independent of isPro (which is the separate
// localStorage-based Extra flag and has nothing to do with being signed
// in to the journal).
const [dashUser, setDashUser] = useState(null);
useEffect(() => {
fetch('./api/me.php', { credentials: 'same-origin' })
.then((r) => r.json())
.then((d) => setDashUser(d.user ?? null))
.catch(() => {});
}, []);
const signOutDashboard = () => {
fetch('./api/logout.php', { method: 'POST', credentials: 'same-origin' })
.then(() => setDashUser(null))
.catch(() => {});
};
const [searchOpen, setSearchOpen] = useState(false); const [searchOpen, setSearchOpen] = useState(false);
// While closing, keep the overlay mounted so it can fade/close out before // While closing, keep the overlay mounted so it can fade/close out before
// unmounting; cleared when the exit animation ends. // unmounting; cleared when the exit animation ends.
@@ -272,10 +290,30 @@ export function UTCIForecast() {
const NOTE_MS = 8000; const NOTE_MS = 8000;
const NOTE_FADE_MS = 350; const NOTE_FADE_MS = 350;
// Pending cross-fade timer. Held in a ref so a location change (or the
// strip closing) can cancel it: a fade that lands after the event list has
// been replaced used to set an index past the end of the new list, leaving
// the banner mounted with every slide hidden — a strip with nothing in it.
const noteFadeTimer = useRef(null);
const noteClosingRef = useRef(false);
const cancelNoteFade = () => {
if (noteFadeTimer.current) {
clearTimeout(noteFadeTimer.current);
noteFadeTimer.current = null;
}
setNoteFading(false);
};
const noteGoTo = (next) => { const noteGoTo = (next) => {
if (next === noteIndex) return; if (next === noteIndex) return;
setNoteFading(true); setNoteFading(true);
setTimeout(() => { setNoteIndex(next); setNoteFading(false); }, NOTE_FADE_MS); if (noteFadeTimer.current) clearTimeout(noteFadeTimer.current);
noteFadeTimer.current = setTimeout(() => {
noteFadeTimer.current = null;
setNoteIndex(next);
setNoteFading(false);
}, NOTE_FADE_MS);
}; };
// ── Dismissal ──────────────────────────────────────────────────────── // ── Dismissal ────────────────────────────────────────────────────────
@@ -293,31 +331,70 @@ export function UTCIForecast() {
const [noteClosing, setNoteClosing] = useState(false); const [noteClosing, setNoteClosing] = useState(false);
const noteDismissed = !!noteSig && noteSig === noteClosedSig; const noteDismissed = !!noteSig && noteSig === noteClosedSig;
const noteSlotRef = useRef(null);
const noteClose = () => { const noteClose = () => {
if (noteClosing) return; if (noteClosing) return;
// Pin the slot to the height it actually has, then run it to zero on the
// next frame. The slot is bottom-anchored (see .event-note-slot), so the
// banner rides its shrinking bottom edge up behind the header while the
// page below closes the gap at exactly the same rate — one property, one
// movement. It is measured here rather than guessed in CSS because every
// stylesheet-side version of this (max-height, a 1fr → 0fr row) had to
// interpolate against an assumed size, which is what stalled part-way.
const el = noteSlotRef.current;
if (el) {
const reduced = window.matchMedia
&& window.matchMedia('(prefers-reduced-motion: reduce)').matches;
el.style.height = `${el.offsetHeight}px`;
// Inline rather than through the is-closing class: that class only lands
// on the next render, which can be after the frame below.
el.style.transition = `height ${reduced ? 0.01 : NOTE_CLOSE_MS / 1000}s linear`;
void el.offsetHeight; // flush, so the next value starts a transition
requestAnimationFrame(() => {
if (noteSlotRef.current) noteSlotRef.current.style.height = '0px';
});
}
noteClosingRef.current = true;
cancelNoteFade();
setNoteClosing(true); setNoteClosing(true);
setTimeout(() => { setTimeout(() => {
try { sessionStorage.setItem(NOTE_CLOSE_KEY, noteSig); } catch (e) { /* ignore */ } try { sessionStorage.setItem(NOTE_CLOSE_KEY, noteSig); } catch (e) { /* ignore */ }
setNoteClosedSig(noteSig); setNoteClosedSig(noteSig);
setNoteClosing(false); setNoteClosing(false);
noteClosingRef.current = false;
}, NOTE_CLOSE_MS); }, NOTE_CLOSE_MS);
}; };
// Keep the index in range if the event list shrinks between forecasts. // Start from the first slide whenever the event set itself changes — a new
// location brings a different list, and carrying an index (or a half-run
// fade) across leaves the strip showing nothing.
useEffect(() => { useEffect(() => {
if (noteIndex >= activeEvents.length) setNoteIndex(0); cancelNoteFade();
}, [activeEvents.length]); setNoteIndex(0);
}, [noteSig]);
useEffect(() => { useEffect(() => {
if (activeEvents.length <= 1) return; if (activeEvents.length <= 1) return;
const id = setInterval(() => { const id = setInterval(() => {
// Nothing to cross-fade to mid-close — the height animation would have
// to re-target a banner whose content just changed under it.
if (noteClosingRef.current) return;
setNoteFading(true); setNoteFading(true);
setTimeout(() => { if (noteFadeTimer.current) clearTimeout(noteFadeTimer.current);
noteFadeTimer.current = setTimeout(() => {
noteFadeTimer.current = null;
setNoteIndex(i => (i + 1) % activeEvents.length); setNoteIndex(i => (i + 1) % activeEvents.length);
setNoteFading(false); setNoteFading(false);
}, NOTE_FADE_MS); }, NOTE_FADE_MS);
}, NOTE_MS); }, NOTE_MS);
return () => clearInterval(id); return () => {
clearInterval(id);
if (noteFadeTimer.current) {
clearTimeout(noteFadeTimer.current);
noteFadeTimer.current = null;
}
};
}, [activeEvents.length]); }, [activeEvents.length]);
const [colTogglesOpen, setColTogglesOpen] = useState(false); const [colTogglesOpen, setColTogglesOpen] = useState(false);
@@ -329,6 +406,33 @@ export function UTCIForecast() {
activeProfile === 'custom' || activeProfile === 'showall' || !!activeCols[key]; activeProfile === 'custom' || activeProfile === 'showall' || !!activeCols[key];
const searchInputRef = useRef(null); const searchInputRef = useRef(null);
const fscScrollRef = useRef(null); const fscScrollRef = useRef(null);
// Quick view's card strip scrolls sideways like the table does, so it gets
// the same edge fades and chevrons — see .forecast-simple-wrap in table.css.
const [fscCanScrollLeft, setFscCanScrollLeft] = useState(false);
const [fscCanScrollRight, setFscCanScrollRight] = useState(false);
useEffect(() => {
const el = fscScrollRef.current;
if (!el) return;
const update = () => {
setFscCanScrollLeft(el.scrollLeft > 1);
setFscCanScrollRight(el.scrollLeft + el.clientWidth < el.scrollWidth - 1);
};
update();
el.addEventListener('scroll', update, { passive: true });
// The strip's width and its content's width both move it: the viewport on
// resize, the card row when the day, interval or profile changes. Watching
// both keeps the deps list out of it.
const ro = typeof ResizeObserver !== 'undefined' ? new ResizeObserver(update) : null;
if (ro) {
ro.observe(el);
if (el.firstElementChild) ro.observe(el.firstElementChild);
}
return () => {
el.removeEventListener('scroll', update);
if (ro) ro.disconnect();
};
}, []);
useEffect(() => { useEffect(() => {
const el = fscScrollRef.current; const el = fscScrollRef.current;
@@ -585,6 +689,18 @@ export function UTCIForecast() {
return `${wd} ${day}${ord} ${mon} ${yr}`; return `${wd} ${day}${ord} ${mon} ${yr}`;
})(); })();
// Compact form for the rotated table's corner cell, e.g. "Thu 27 Aug 2026".
// That cell sets the width of the pinned metric-name column, and the long
// form ("Thu 27th August 2026") pushed it wider than the names need.
const glanceDateShort = (() => {
const key = days[selectedDay]?.key;
if (!key) return '';
const d = new Date(key + 'T00:00Z');
const wd = d.toLocaleDateString('en-GB', { weekday: 'short', timeZone: 'UTC' });
const mon = d.toLocaleDateString('en-GB', { month: 'short', timeZone: 'UTC' });
return `${wd} ${d.getUTCDate()} ${mon} ${d.getUTCFullYear()}`;
})();
// Pro: export the selected day's full hourly data as a styled spreadsheet. // Pro: export the selected day's full hourly data as a styled spreadsheet.
const handleExportDay = () => { const handleExportDay = () => {
exportDayXls(visible, { exportDayXls(visible, {
@@ -713,8 +829,59 @@ export function UTCIForecast() {
// • "utci-footer" — the "reading the table" note // • "utci-footer" — the "reading the table" note
return html` return html`
<div class=${`utci-app${activeEvents.length > 0 && !noteDismissed ? ' has-event-note' : ''}`}> <div class=${`utci-app${activeEvents.length > 0 && !noteDismissed ? ' has-event-note' : ''}`}>
<header class="utci-topnav" id="site-nav">
<div class="nav-overlay" onClick=${() => { const n = document.getElementById('site-nav'); n.classList.remove('nav-open'); document.getElementById('nav-drawer').classList.remove('is-open'); }}></div>
<div class="nav-logo">
<h1 class="nav-logo-wordmark"><span class="nav-logo-sun">Sun</span><svg class="nav-logo-icon" viewBox="0 0 174 173" aria-hidden="true" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:1.5;"><g transform="matrix(0.986226,0,0,0.986226,-585.724252,-110.041209)"><clipPath id="ss-clip-n"><circle cx="681.9838" cy="199.4502" r="83.2987"/></clipPath><g clip-path="url(#ss-clip-n)"><g transform="matrix(0.948836,0,0,0.948836,19.748711,-12.198562)"><path d="M666.992,233.0768C666.3949,230.6581 666.1785,228.6317 666.1785,226.0296C666.1785,208.6786 680.2653,194.5919 697.6163,194.5919C714.9672,194.5919 729.054,208.6786 729.054,226.0296C729.054,228.4125 728.9892,230.9346 728.4859,233.1663" fill="#f4b047" stroke="#f4b047" stroke-width="7.48" stroke-linecap="round"/></g><g transform="matrix(0.520349,0,0,0.520349,304.727514,54.521964)"><path d="M724.9315,291.8131C777.359,291.8131 826.5862,305.6245 869.1698,329.8044" fill="none" stroke="currentColor" stroke-width="13.64"/></g><g transform="matrix(0.520349,0,0,0.520349,304.727514,54.521964)"><path d="M580.8436,329.719C623.3927,305.592 672.5657,291.8131 724.9315,291.8131" fill="none" stroke="currentColor" stroke-width="13.64"/></g></g><circle cx="681.9838" cy="199.4502" r="83.2987" fill="none" stroke="currentColor" stroke-width="9.13"/></g></svg><span class="nav-logo-scope">Scope</span></h1>
<span class="nav-logo-tag">See the world the way your skin does.</span>
</div>
<nav class="nav-links" id="nav-drawer" aria-label="Main">
<a href="./index.html">Forecast</a>
<div class="nav-dropdown">
<a href="./about.html" class="nav-dropdown-toggle">About<span class="caret">▾</span></a>
<div class="nav-dropdown-menu">
<a href="./about.html">Overview</a>
<a href="./sunsoak.html">SunSoak & the science</a>
<a href="./profiles.html">Forecast profiles</a>
<a href="./columns.html">Column reference</a>
<a href="./temperatures.html">Derived temperatures</a>
<a href="./features.html">On-screen features</a>
<a href="./stress-bands.html">Stress bands</a>
</div>
</div>
<a href="./faq.html">FAQ</a>
<div class="nav-dropdown nav-dropdown--right">
<a href="./dashboard.html" class="nav-dropdown-toggle">Account<span class="caret">▾</span></a>
<div class="nav-dropdown-menu">
<a href="./dashboard.html">Dashboard</a>
<a href=${isPro
? 'https://billing.stripe.com/p/login/9B63cw7vl8k15Ei9DQd7q00'
: 'https://buy.stripe.com/9B63cw7vl8k15Ei9DQd7q00'}
target="_blank" rel="noopener noreferrer">Subscription</a>
${dashUser
? html`<a href="#" onClick=${(e) => { e.preventDefault(); signOutDashboard(); }}>Sign out</a>`
: html`<a href="./dashboard.html">Sign in</a>`}
</div>
</div>
</nav>
<button class="utci-burger" aria-label="Open menu" aria-expanded="false" id="burger-btn"
onClick=${() => {
const nav = document.getElementById('site-nav');
const drawer = document.getElementById('nav-drawer');
const btn = document.getElementById('burger-btn');
const open = nav.classList.toggle('nav-open');
drawer.classList.toggle('is-open', open);
btn.setAttribute('aria-expanded', open ? 'true' : 'false');
}}>
<span></span><span></span><span></span>
</button>
</header>
${activeEvents.length > 0 && !noteDismissed && (() => { ${activeEvents.length > 0 && !noteDismissed && (() => {
const ev = activeEvents[noteIndex] || activeEvents[0]; const ev = activeEvents[noteIndex] || activeEvents[0];
// Guards the window between an event list changing and the reset
// effect above running: an index past the end would hide every slide.
const slideIndex = noteIndex < activeEvents.length ? noteIndex : 0;
const isPriority = PRIORITY_WEATHER_IDS.has(ev.id); const isPriority = PRIORITY_WEATHER_IDS.has(ev.id);
const fmtDate = (iso) => iso const fmtDate = (iso) => iso
? new Date(iso + 'T00:00Z').toLocaleDateString('en-GB', { day: 'numeric', month: 'short', timeZone: 'UTC' }) ? new Date(iso + 'T00:00Z').toLocaleDateString('en-GB', { day: 'numeric', month: 'short', timeZone: 'UTC' })
@@ -746,6 +913,10 @@ export function UTCIForecast() {
}; };
})() : undefined; })() : undefined;
return html` return html`
${/* The slot clips the banner: on close it collapses to nothing while
the note itself slides up inside it, so the strip reads as
tucking back up under the header rather than fading in place. */''}
<div ref=${noteSlotRef} class=${`event-note-slot${noteClosing ? ' is-closing' : ''}`}>
<div class=${`event-note${isPriority ? ' is-priority' : ''}${noteTintStyle ? ' is-tinted' : ''}${noteClosing ? ' is-closing' : ''}`} style=${noteTintStyle}> <div class=${`event-note${isPriority ? ' is-priority' : ''}${noteTintStyle ? ' is-tinted' : ''}${noteClosing ? ' is-closing' : ''}`} style=${noteTintStyle}>
<span class="event-note-accent" style=${{ background: ev.color === '#fdf8ee' ? '#c8922a' : ev.color }} /> <span class="event-note-accent" style=${{ background: ev.color === '#fdf8ee' ? '#c8922a' : ev.color }} />
${/* Every slide is rendered, all stacked in one CSS grid cell, so the ${/* Every slide is rendered, all stacked in one CSS grid cell, so the
@@ -756,7 +927,7 @@ export function UTCIForecast() {
<div class="event-note-stack"> <div class="event-note-stack">
${activeEvents.map((e, i) => { ${activeEvents.map((e, i) => {
const dateLine = dateLineFor(e); const dateLine = dateLineFor(e);
const isActive = i === noteIndex && !noteFading; const isActive = i === slideIndex && !noteFading;
return html` return html`
<div <div
key=${e.id || i} key=${e.id || i}
@@ -780,7 +951,7 @@ export function UTCIForecast() {
<button <button
key=${i} key=${i}
type="button" type="button"
class=${`event-note-dot${i === noteIndex ? ' active' : ''}`} class=${`event-note-dot${i === slideIndex ? ' active' : ''}`}
aria-label=${`Show event ${i + 1} of ${activeEvents.length}`} aria-label=${`Show event ${i + 1} of ${activeEvents.length}`}
onClick=${() => noteGoTo(i)} onClick=${() => noteGoTo(i)}
/>`)} />`)}
@@ -798,54 +969,13 @@ export function UTCIForecast() {
fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" /> fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" />
</svg> </svg>
</button> </button>
</div>
</div>`; </div>`;
})()} })()}
<nav class="utci-topnav" id="site-nav">
<div class="nav-overlay" onClick=${() => { const n = document.getElementById('site-nav'); n.classList.remove('nav-open'); document.getElementById('nav-drawer').classList.remove('is-open'); }}></div>
<div class="nav-logo" aria-hidden="true">
<span class="nav-logo-wordmark"><span class="nav-logo-sun">Sun</span><svg class="nav-logo-icon" viewBox="0 0 174 173" aria-hidden="true" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:1.5;"><g transform="matrix(0.986226,0,0,0.986226,-585.724252,-110.041209)"><clipPath id="ss-clip-n"><circle cx="681.9838" cy="199.4502" r="83.2987"/></clipPath><g clip-path="url(#ss-clip-n)"><g transform="matrix(0.948836,0,0,0.948836,19.748711,-12.198562)"><path d="M666.992,233.0768C666.3949,230.6581 666.1785,228.6317 666.1785,226.0296C666.1785,208.6786 680.2653,194.5919 697.6163,194.5919C714.9672,194.5919 729.054,208.6786 729.054,226.0296C729.054,228.4125 728.9892,230.9346 728.4859,233.1663" fill="#f4b047" stroke="#f4b047" stroke-width="7.48" stroke-linecap="round"/></g><g transform="matrix(0.520349,0,0,0.520349,304.727514,54.521964)"><path d="M724.9315,291.8131C777.359,291.8131 826.5862,305.6245 869.1698,329.8044" fill="none" stroke="currentColor" stroke-width="13.64"/></g><g transform="matrix(0.520349,0,0,0.520349,304.727514,54.521964)"><path d="M580.8436,329.719C623.3927,305.592 672.5657,291.8131 724.9315,291.8131" fill="none" stroke="currentColor" stroke-width="13.64"/></g></g><circle cx="681.9838" cy="199.4502" r="83.2987" fill="none" stroke="currentColor" stroke-width="9.13"/></g></svg><span class="nav-logo-scope">Scope</span></span>
<span class="nav-logo-tag">See the world the way your skin does.</span>
</div>
<div class="nav-links" id="nav-drawer">
<a href="./index.html">Forecast</a>
<div class="nav-dropdown">
<a href="./about.html" class="nav-dropdown-toggle">About<span class="caret"></span></a>
<div class="nav-dropdown-menu">
<a href="./about.html">Overview</a>
<a href="./sunsoak.html">SunSoak & the science</a>
<a href="./profiles.html">Forecast profiles</a>
<a href="./columns.html">Column reference</a>
<a href="./temperatures.html">Derived temperatures</a>
<a href="./features.html">On-screen features</a>
<a href="./stress-bands.html">Stress bands</a>
</div>
</div>
<a href="./faq.html">FAQ</a>
<a href=${isPro
? 'https://billing.stripe.com/p/login/9B63cw7vl8k15Ei9DQd7q00'
: 'https://buy.stripe.com/9B63cw7vl8k15Ei9DQd7q00'}
target="_blank" rel="noopener noreferrer">Account</a>
</div>
<button class="utci-burger" aria-label="Open menu" aria-expanded="false" id="burger-btn"
onClick=${() => {
const nav = document.getElementById('site-nav');
const drawer = document.getElementById('nav-drawer');
const btn = document.getElementById('burger-btn');
const open = nav.classList.toggle('nav-open');
drawer.classList.toggle('is-open', open);
btn.setAttribute('aria-expanded', open ? 'true' : 'false');
}}>
<span></span><span></span><span></span>
</button>
</nav>
<main class="utci-shell"> <main class="utci-shell">
<div class="utci-header"> <div class="utci-header">
<div class="header-left"> <div class="header-left">
<h1 class="utci-title">
<span class="title-sun">Sun</span><svg class="title-icon" viewBox="0 0 174 173" aria-hidden="true" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:1.5;"><g transform="matrix(0.986226,0,0,0.986226,-585.724252,-110.041209)"><clipPath id="ss-clip-t"><circle cx="681.9838" cy="199.4502" r="83.2987"/></clipPath><g clip-path="url(#ss-clip-t)"><g transform="matrix(0.948836,0,0,0.948836,19.748711,-12.198562)"><path d="M666.992,233.0768C666.3949,230.6581 666.1785,228.6317 666.1785,226.0296C666.1785,208.6786 680.2653,194.5919 697.6163,194.5919C714.9672,194.5919 729.054,208.6786 729.054,226.0296C729.054,228.4125 728.9892,230.9346 728.4859,233.1663" fill="#f4b047" stroke="#f4b047" stroke-width="7.48" stroke-linecap="round"/></g><g transform="matrix(0.520349,0,0,0.520349,304.727514,54.521964)"><path d="M724.9315,291.8131C777.359,291.8131 826.5862,305.6245 869.1698,329.8044" fill="none" stroke="currentColor" stroke-width="13.64"/></g><g transform="matrix(0.520349,0,0,0.520349,304.727514,54.521964)"><path d="M580.8436,329.719C623.3927,305.592 672.5657,291.8131 724.9315,291.8131" fill="none" stroke="currentColor" stroke-width="13.64"/></g></g><circle cx="681.9838" cy="199.4502" r="83.2987" fill="none" stroke="currentColor" stroke-width="9.13"/></g></svg><span class="title-scope">Scope</span>
</h1>
<div class="utci-tagline">See the world the way your skin does.</div>
<div class="utci-loc-search" ref=${searchWrapRef}> <div class="utci-loc-search" ref=${searchWrapRef}>
<div class="utci-current-loc"> <div class="utci-current-loc">
<button <button
@@ -873,41 +1003,62 @@ export function UTCIForecast() {
<line x1="15.5" y1="15.5" x2="21" y2="21" stroke="currentColor" stroke-width="2" stroke-linecap="round" /> <line x1="15.5" y1="15.5" x2="21" y2="21" stroke="currentColor" stroke-width="2" stroke-linecap="round" />
</svg> </svg>
</button> </button>
<button </div>
type="button" <div class="utci-loc-tools">
class=${'utci-loc-action' + (locating ? ' is-busy' : '')} <div class="utci-loc-tool">
aria-label="Use my current location" <button
title="Use my current location" type="button"
disabled=${locating} class=${'utci-loc-action' + (locating ? ' is-busy' : '')}
onClick=${useMyLocation} aria-label="Use my current location"
> title="Use my current location"
<svg viewBox="0 0 24 24" width="16" height="16" aria-hidden="true"> disabled=${locating}
<circle cx="12" cy="12" r="4" fill="none" stroke="currentColor" stroke-width="2" /> onClick=${useMyLocation}
<circle cx="12" cy="12" r="1.6" fill="currentColor" /> >
<line x1="12" y1="1.5" x2="12" y2="5" stroke="currentColor" stroke-width="2" stroke-linecap="round" /> <svg viewBox="0 0 24 24" width="16" height="16" aria-hidden="true">
<line x1="12" y1="19" x2="12" y2="22.5" stroke="currentColor" stroke-width="2" stroke-linecap="round" /> <circle cx="12" cy="12" r="4" fill="none" stroke="currentColor" stroke-width="2" />
<line x1="1.5" y1="12" x2="5" y2="12" stroke="currentColor" stroke-width="2" stroke-linecap="round" /> <circle cx="12" cy="12" r="1.6" fill="currentColor" />
<line x1="19" y1="12" x2="22.5" y2="12" stroke="currentColor" stroke-width="2" stroke-linecap="round" /> <line x1="12" y1="1.5" x2="12" y2="5" stroke="currentColor" stroke-width="2" stroke-linecap="round" />
</svg> <line x1="12" y1="19" x2="12" y2="22.5" stroke="currentColor" stroke-width="2" stroke-linecap="round" />
</button> <line x1="1.5" y1="12" x2="5" y2="12" stroke="currentColor" stroke-width="2" stroke-linecap="round" />
<button <line x1="19" y1="12" x2="22.5" y2="12" stroke="currentColor" stroke-width="2" stroke-linecap="round" />
type="button" </svg>
class="utci-loc-action" </button>
aria-label="Share this forecast" <span class="utci-loc-tool-label">My spot</span>
title="Copy a link to this forecast" </div>
onClick=${shareForecast} <div class="utci-loc-tool">
> <button
<svg viewBox="0 0 24 24" width="16" height="16" aria-hidden="true"> type="button"
<circle cx="18" cy="5" r="2.6" fill="none" stroke="currentColor" stroke-width="2" /> class="utci-loc-action"
<circle cx="6" cy="12" r="2.6" fill="none" stroke="currentColor" stroke-width="2" /> aria-label="Share this forecast"
<circle cx="18" cy="19" r="2.6" fill="none" stroke="currentColor" stroke-width="2" /> title="Copy a link to this forecast"
<line x1="8.4" y1="10.8" x2="15.6" y2="6.2" stroke="currentColor" stroke-width="2" /> onClick=${shareForecast}
<line x1="8.4" y1="13.2" x2="15.6" y2="17.8" stroke="currentColor" stroke-width="2" /> >
</svg> <svg viewBox="0 0 24 24" width="16" height="16" aria-hidden="true">
</button> <circle cx="18" cy="5" r="2.6" fill="none" stroke="currentColor" stroke-width="2" />
<span class="utci-loc-coords"> <circle cx="6" cy="12" r="2.6" fill="none" stroke="currentColor" stroke-width="2" />
${location.lat.toFixed(3)}°, ${location.lon.toFixed(3)}° <circle cx="18" cy="19" r="2.6" fill="none" stroke="currentColor" stroke-width="2" />
</span> <line x1="8.4" y1="10.8" x2="15.6" y2="6.2" stroke="currentColor" stroke-width="2" />
<line x1="8.4" y1="13.2" x2="15.6" y2="17.8" stroke="currentColor" stroke-width="2" />
</svg>
</button>
<span class="utci-loc-tool-label">Share</span>
</div>
<div class="utci-loc-tool">
<a
class="utci-loc-action"
href="./dashboard.html"
aria-label="Keep a weather journal"
title="Keep a weather journal — Dashboard"
>
<svg viewBox="0 0 24 24" width="16" height="16" aria-hidden="true">
<rect x="4" y="3" width="16" height="18" rx="2" fill="none" stroke="currentColor" stroke-width="2" />
<line x1="8" y1="8" x2="16" y2="8" stroke="currentColor" stroke-width="2" stroke-linecap="round" />
<line x1="8" y1="12" x2="16" y2="12" stroke="currentColor" stroke-width="2" stroke-linecap="round" />
<line x1="8" y1="16" x2="13" y2="16" stroke="currentColor" stroke-width="2" stroke-linecap="round" />
</svg>
</a>
<span class="utci-loc-tool-label">Journal</span>
</div>
</div> </div>
${(locateError || shareState) && html` ${(locateError || shareState) && html`
<div class=${'utci-loc-note' + (locateError || shareState === 'failed' ? ' is-warn' : '')} role="status"> <div class=${'utci-loc-note' + (locateError || shareState === 'failed' ? ' is-warn' : '')} role="status">
@@ -918,10 +1069,6 @@ export function UTCIForecast() {
<button type="button" class="utci-loc-note-x" aria-label="Dismiss" <button type="button" class="utci-loc-note-x" aria-label="Dismiss"
onClick=${() => setLocateError(null)}>×</button>`} onClick=${() => setLocateError(null)}>×</button>`}
</div>`} </div>`}
${fetchedAt && !loading && html`
<div class=${'utci-fetch-time' + (isStale ? ' is-stale' : '')}>
${isStale ? html`<span class="stale-dot" title="Data is older than expected - retrying">● </span>` : ''}Last update: ${fetchedAt.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}
</div>`}
${(() => { ${(() => {
const curKey = `${location.lat.toFixed(3)},${location.lon.toFixed(3)}`; const curKey = `${location.lat.toFixed(3)},${location.lon.toFixed(3)}`;
const recents = (recentLocations || []) const recents = (recentLocations || [])
@@ -1094,6 +1241,10 @@ export function UTCIForecast() {
</div> </div>
`); `);
})()} })()}
${fetchedAt && !loading && html`
<div class=${'utci-fetch-time' + (isStale ? ' is-stale' : '')}>
${isStale ? html`<span class="stale-dot" title="Data is older than expected - retrying">● </span>` : ''}Last update: ${fetchedAt.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}
</div>`}
</div>`} </div>`}
</div> </div>
</div> </div>
@@ -1262,6 +1413,7 @@ export function UTCIForecast() {
<div class="col-toggles-body"> <div class="col-toggles-body">
${isPro && html`<${Fragment}> ${isPro && html`<${Fragment}>
${colOffered('utciP') && html`<button class=${`col-toggle grp-felt${visibleCols.utciP ? ' on' : ''}`} onClick=${() => toggleCol('utciP')}>SunSoak</button>`} ${colOffered('utciP') && html`<button class=${`col-toggle grp-felt${visibleCols.utciP ? ' on' : ''}`} onClick=${() => toggleCol('utciP')}>SunSoak</button>`}
${colOffered('shadeT') && html`<button class=${`col-toggle grp-felt${visibleCols.shadeT ? ' on' : ''}`} onClick=${() => toggleCol('shadeT')}>Shade</button>`}
${colOffered('vehicleT') && html`<button class=${`col-toggle grp-felt${visibleCols.vehicleT ? ' on' : ''}`} onClick=${() => { if (visibleCols.vehicleT) setVehicleVent(false); toggleCol('vehicleT'); }}>Vehicle</button>`} ${colOffered('vehicleT') && html`<button class=${`col-toggle grp-felt${visibleCols.vehicleT ? ' on' : ''}`} onClick=${() => { if (visibleCols.vehicleT) setVehicleVent(false); toggleCol('vehicleT'); }}>Vehicle</button>`}
${(colOffered('indoorT') || colOffered('managedT')) && html`<button class=${`col-toggle grp-felt${indoorMode === 'on' ? ' on' : ''}`} onClick=${() => { if (indoorMode === 'on') { setIndoorMode('off'); setIndoorManaged(false); } else { setIndoorMode('on'); } }}>Indoors</button>`} ${(colOffered('indoorT') || colOffered('managedT')) && html`<button class=${`col-toggle grp-felt${indoorMode === 'on' ? ' on' : ''}`} onClick=${() => { if (indoorMode === 'on') { setIndoorMode('off'); setIndoorManaged(false); } else { setIndoorMode('on'); } }}>Indoors</button>`}
${colOffered('utci') && html`<button class=${`col-toggle grp-felt${visibleCols.utci ? ' on' : ''}`} onClick=${() => toggleCol('utci')}>UTCI</button>`} ${colOffered('utci') && html`<button class=${`col-toggle grp-felt${visibleCols.utci ? ' on' : ''}`} onClick=${() => toggleCol('utci')}>UTCI</button>`}
@@ -1274,7 +1426,6 @@ export function UTCIForecast() {
${colOffered('pawT') && html`<button class=${`col-toggle grp-pets${visibleCols.pawT ? ' on' : ''}`} onClick=${() => toggleCol('pawT')}>Paw</button>`} ${colOffered('pawT') && html`<button class=${`col-toggle grp-pets${visibleCols.pawT ? ' on' : ''}`} onClick=${() => toggleCol('pawT')}>Paw</button>`}
${colOffered('petShadeT') && html`<button class=${`col-toggle grp-pets${visibleCols.petShadeT ? ' on' : ''}`} onClick=${() => toggleCol('petShadeT')}>Pet Shade</button>`} ${colOffered('petShadeT') && html`<button class=${`col-toggle grp-pets${visibleCols.petShadeT ? ' on' : ''}`} onClick=${() => toggleCol('petShadeT')}>Pet Shade</button>`}
${colOffered('petHomeT') && html`<button class=${`col-toggle grp-pets${visibleCols.petHomeT ? ' on' : ''}`} onClick=${() => toggleCol('petHomeT')}>Pet Home</button>`} ${colOffered('petHomeT') && html`<button class=${`col-toggle grp-pets${visibleCols.petHomeT ? ' on' : ''}`} onClick=${() => toggleCol('petHomeT')}>Pet Home</button>`}
${colOffered('shadeT') && html`<button class=${`col-toggle grp-ambient${visibleCols.shadeT ? ' on' : ''}`} onClick=${() => toggleCol('shadeT')}>Shade</button>`}
${colOffered('air') && html`<button class=${`col-toggle grp-ambient${visibleCols.air ? ' on' : ''}`} onClick=${() => toggleCol('air')}>Air</button>`} ${colOffered('air') && html`<button class=${`col-toggle grp-ambient${visibleCols.air ? ' on' : ''}`} onClick=${() => toggleCol('air')}>Air</button>`}
${colOffered('rh') && html`<button class=${`col-toggle grp-ambient${visibleCols.rh ? ' on' : ''}`} onClick=${() => toggleCol('rh')}>RH</button>`} ${colOffered('rh') && html`<button class=${`col-toggle grp-ambient${visibleCols.rh ? ' on' : ''}`} onClick=${() => toggleCol('rh')}>RH</button>`}
${colOffered('dew') && html`<button class=${`col-toggle grp-ambient${visibleCols.dew ? ' on' : ''}`} onClick=${() => toggleCol('dew')}>Dew</button>`} ${colOffered('dew') && html`<button class=${`col-toggle grp-ambient${visibleCols.dew ? ' on' : ''}`} onClick=${() => toggleCol('dew')}>Dew</button>`}
@@ -1307,7 +1458,11 @@ export function UTCIForecast() {
</div> </div>
</div> </div>
<div class="forecast-simple-wrap"> <div class=${'forecast-simple-wrap'
+ (fscCanScrollLeft ? ' scroll-fade-left' : '')
+ (fscCanScrollRight ? ' scroll-fade-right' : '')}>
<span class="fsc-scroll-chevron left" aria-hidden="true"></span>
<span class="fsc-scroll-chevron right" aria-hidden="true"></span>
<div class="forecast-simple-scroll" ref=${fscScrollRef}> <div class="forecast-simple-scroll" ref=${fscScrollRef}>
<div class="forecast-simple-inner"> <div class="forecast-simple-inner">
<div class="forecast-simple-cards" style=${{ gridTemplateColumns: fscPlot?.cols }}> <div class="forecast-simple-cards" style=${{ gridTemplateColumns: fscPlot?.cols }}>
@@ -1318,8 +1473,34 @@ export function UTCIForecast() {
const localHHMMs = h24s === 0 ? '12am' : h24s < 12 ? `${h24s}am` : h24s === 12 ? '12pm' : `${h24s - 12}pm`; const localHHMMs = h24s === 0 ? '12am' : h24s < 12 ? `${h24s}am` : h24s === 12 ? '12pm' : `${h24s - 12}pm`;
const isNow = r.isoHours ? r.isoHours.includes(nowLocalISO) : r.iso.slice(0, 13) === nowLocalISO; const isNow = r.isoHours ? r.isoHours.includes(nowLocalISO) : r.iso.slice(0, 13) === nowLocalISO;
const domIcon = (() => { const domIcon = (() => {
if (r.precipProb > 20 && (r.precip > 0 || r.snow > 0)) // Two rain numbers, reconciled the same way the day tabs do it
return html`<${PrecipIcon} precip=${r.precip} snow=${r.snow} size=${42} filled=${true} />`; // (see DayTabs). precipProb answers WHETHER it rains, precip
// answers HOW MUCH — and they come from different model runs,
// so a 97%-certain hour can still carry 0.0mm. Gating the icon
// on the amount alone left those hours wearing a cloud while
// the card underneath read 97%. So: probability decides IF the
// icon shows rain, and the two together decide how hard it looks.
// • >= 50% — rain icon, whatever the amount says
// • > 20% with mm/cm — rain icon (a light but real shower)
// • otherwise — cloud
const pProb = r.precipProb ?? 0;
const wet = r.precip > 0 || r.snow > 0;
if (pProb >= 50 || (pProb > 20 && wet)) {
const c01 = v => Math.max(0, Math.min(1, v));
// Confidence half: 0 at the 50% gate, 1 at a dead-certain 100%.
const probT = c01((pProb - 50) / 50);
// Amount half: "heavy" taken as 2mm in a single hour.
const amountT = c01((r.precip || 0) / 2);
// Certainty alone tops out at medium-high; only a genuinely
// wet hour reaches the 3-drop downpour.
const score = probT * 0.55 + amountT * 0.45;
const drops = score < 0.33 ? 1 : score < 0.70 ? 2 : 3;
// PrecipIcon draws its dry dash when precip and snow are both
// 0, so a certain-but-zero-amount hour needs a nominal trace
// to draw drops at all. drops= still sets the real intensity.
const iconPrecip = r.snow > 0 ? (r.precip || 0) : (r.precip > 0 ? r.precip : 0.01);
return html`<${PrecipIcon} precip=${iconPrecip} snow=${r.snow} drops=${drops} size=${42} filled=${true} />`;
}
if (r.cloudCat && r.cloudCat !== 'clear') if (r.cloudCat && r.cloudCat !== 'clear')
return html`<${CloudIcon} category=${r.cloudCat} elev=${r.elev} dt=${r.dt} size=${42} filled=${true} showSun=${false} />`; return html`<${CloudIcon} category=${r.cloudCat} elev=${r.elev} dt=${r.dt} size=${42} filled=${true} showSun=${false} />`;
return null; return null;
@@ -1356,8 +1537,7 @@ export function UTCIForecast() {
</div> </div>
<div class="fsc-meta"> <div class="fsc-meta">
<span class="fsc-meta-row fsc-meta-rain">${r.precipProb}%</span> <span class="fsc-meta-row fsc-meta-rain">${r.precipProb}%</span>
<span class="fsc-meta-row fsc-meta-wind">${windMph}mph</span> <span class="fsc-meta-row fsc-meta-wind">${windMph}mph${r.compass ? html` <span class="fsc-meta-dir">${r.compass.label}</span>` : ''}</span>
<span class="fsc-meta-row fsc-meta-dir">${r.compass ? r.compass.label : '—'}</span>
</div> </div>
<div class="fsc-temp">${Math.round(dispTemp)}°</div> <div class="fsc-temp">${Math.round(dispTemp)}°</div>
<div class="fsc-feel" style=${feelStyle}>${cat.label}</div> <div class="fsc-feel" style=${feelStyle}>${cat.label}</div>
@@ -1512,31 +1692,44 @@ export function UTCIForecast() {
<span class="utci-scroll-chevron left" aria-hidden="true"></span> <span class="utci-scroll-chevron left" aria-hidden="true"></span>
<span class="utci-scroll-chevron right" aria-hidden="true"></span> <span class="utci-scroll-chevron right" aria-hidden="true"></span>
<div class="utci-tbody-scroll" ref=${bodyScrollRef}> ${/* Head split out of the body table, exactly as the normal
<table class="utci-table utci-table-rotated" ref=${bodyTableRef}> orientation does it: the hour row has to pin to the
<thead> viewport as the page scrolls, and a <thead> inside the
<tr> horizontal scroller can only stick to that scroller.
<th class="rot-corner col-info-th" scope="col" useTableScroll keeps the two tables' column widths and
onClick=${(e) => handleThClick('hour', e)} horizontal offset in step. */''}
onMouseEnter=${(e) => handleThEnter('hour', e)} <div class="utci-thead-sticky" ref=${headStickyRef}>
onMouseLeave=${handleThLeave}> <div class="utci-thead-track" ref=${headTrackRef}>
<span class="rot-corner-date">${glanceDate}</span> <table class="utci-table utci-table-rotated utci-table-head" ref=${headTableRef}>
<span class="rot-corner-controls"> <thead>
<span class="hour-interval" onClick=${(e) => e.stopPropagation()}> <tr>
<${RowStepper} value=${tableInterval} onChange=${setTableInterval} showLabel=${false} /> <th class="rot-corner col-info-th" scope="col"
onClick=${(e) => handleThClick('hour', e)}
onMouseEnter=${(e) => handleThEnter('hour', e)}
onMouseLeave=${handleThLeave}>
<span class="rot-corner-date">${glanceDateShort}</span>
<span class="rot-corner-controls">
<span class="hour-interval" onClick=${(e) => e.stopPropagation()}>
<${RowStepper} value=${tableInterval} onChange=${setTableInterval} showLabel=${false} />
</span>
</span> </span>
</span> </th>
</th> ${tableRows.map(r => {
${tableRows.map(r => { const f = hourFlags(r);
const f = hourFlags(r); return html`
return html` <th key=${r.iso} scope="col"
<th key=${r.iso} scope="col" class=${`rot-hour-th ${f.isNight ? 'is-night' : ''} ${f.isNow ? 'is-now' : ''}`.replace(/\s+/g, ' ').trim()}>
class=${`rot-hour-th ${f.isNight ? 'is-night' : ''} ${f.isNow ? 'is-now' : ''}`.replace(/\s+/g, ' ').trim()}> ${timeCell(r)}
${timeCell(r)} </th>`;
</th>`; })}
})} </tr>
</tr> </thead>
</thead> </table>
</div>
</div>
<div class="utci-tbody-scroll" ref=${bodyScrollRef} onScroll=${handleBodyScroll}>
<table class="utci-table utci-table-rotated" ref=${bodyTableRef}>
<tbody> <tbody>
${visibleColumnDefs.map((d, i) => { ${visibleColumnDefs.map((d, i) => {
// A group heading row is emitted whenever the // A group heading row is emitted whenever the
@@ -1681,9 +1874,9 @@ export function UTCIForecast() {
<div class="insight-panel insight-panel--week"> <div class="insight-panel insight-panel--week">
<div class="insight-panel-title">The Week Ahead</div> <div class="insight-panel-title">The Week Ahead</div>
<div class="insight-panel-sub">${titleCaseText(bestDaysLabel(activeProfile, outdoorsVariant))}</div> <div class="insight-panel-sub">${titleCaseText(bestDaysLabel(activeProfile, outdoorsVariant))}</div>
${weekAhead.map(({ icon, label, value, score }) => html` ${weekAhead.map(({ icon, iconTitle, label, value, score }) => html`
<div class="insight-row" key=${label}> <div class=${`insight-row${score == null ? ' insight-row--week-note' : ''}`} key=${label}>
<span class="insight-icon">${icon}</span> <span class="insight-icon" title=${iconTitle}>${icon}</span>
<span class="insight-label">${titleCaseText(label)}</span> <span class="insight-label">${titleCaseText(label)}</span>
<span class="insight-value">${titleCaseText(value)}</span> <span class="insight-value">${titleCaseText(value)}</span>
${score != null && html` ${score != null && html`
@@ -1779,7 +1972,9 @@ export function UTCIForecast() {
<span class="utci-legend-label">Thermal stress bands</span> <span class="utci-legend-label">Thermal stress bands</span>
<div class="utci-legend-row"> <div class="utci-legend-row">
${[ ${[
{ t: -9, label: 'Freezing', value: '< 0°C' }, { t: -25, label: 'Extreme cold', value: '< -20°C' },
{ t: -15, label: 'Arctic', value: '-20-10°C' },
{ t: -5, label: 'Freezing', value: '-100°C' },
{ t: 1, label: 'Cold', value: '010°C' }, { t: 1, label: 'Cold', value: '010°C' },
{ t: 11, label: 'Cool', value: '1019°C' }, { t: 11, label: 'Cool', value: '1019°C' },
{ t: 21, label: 'Comfortable', value: '1924°C', bold: true }, { t: 21, label: 'Comfortable', value: '1924°C', bold: true },
@@ -1806,7 +2001,9 @@ export function UTCIForecast() {
<span class="utci-legend-label utci-legend-label--secondary">Pet thermal stress bands</span> <span class="utci-legend-label utci-legend-label--secondary">Pet thermal stress bands</span>
<div class="utci-legend-row"> <div class="utci-legend-row">
${[ ${[
{ t: -15, label: 'Freezing', value: '< -8°C' }, { t: -35, label: 'Extreme cold', value: '< -28°C' },
{ t: -23, label: 'Arctic', value: '-28-18°C' },
{ t: -13, label: 'Freezing', value: '-18-8°C' },
{ t: 0, label: 'Cold', value: '-82°C' }, { t: 0, label: 'Cold', value: '-82°C' },
{ t: 9, label: 'Cool', value: '211°C' }, { t: 9, label: 'Cool', value: '211°C' },
{ t: 18, label: 'Comfortable', value: '1125°C', bold: true }, { t: 18, label: 'Comfortable', value: '1125°C', bold: true },
@@ -1940,6 +2137,7 @@ export function UTCIForecast() {
· <a href="./index.html">Forecast</a> · <a href="./index.html">Forecast</a>
· <a href="./about.html">About</a> · <a href="./about.html">About</a>
· <a href="./faq.html">FAQ</a> · <a href="./faq.html">FAQ</a>
· <a href="./dashboard.html">Dashboard</a>
· <a href=${isPro · <a href=${isPro
? 'https://billing.stripe.com/p/login/9B63cw7vl8k15Ei9DQd7q00' ? 'https://billing.stripe.com/p/login/9B63cw7vl8k15Ei9DQd7q00'
: 'https://buy.stripe.com/9B63cw7vl8k15Ei9DQd7q00'} : 'https://buy.stripe.com/9B63cw7vl8k15Ei9DQd7q00'}
+40 -3
View File
@@ -1281,7 +1281,12 @@ export function ScopeReticle({ value, cat, loading, elev = 0, dt = new Date(), g
// heavy snow (- 0.5cm/h) - 2 flakes // heavy snow (- 0.5cm/h) - 2 flakes
// mixed - 1 drop + 1 flake // mixed - 1 drop + 1 flake
// ------------------------------------------------------------------- // -------------------------------------------------------------------
export function PrecipIcon({ precip = 0, snow = 0, size = 28, filled = false }) { // drops - optional override for the rain-drop count (1-3). The day tabs
// pass this because their intensity is a blend of amount AND
// forecast probability, not amount alone (see the "two rain
// numbers" block in components/DayTabs.js). Callers that only have
// an mm/h figure omit it and get the amount-derived bands above.
export function PrecipIcon({ precip = 0, snow = 0, size = 28, filled = false, drops = null }) {
const r = size / 2; const r = size / 2;
const hasRain = precip > 0; const hasRain = precip > 0;
const hasSnow = snow > 0; const hasSnow = snow > 0;
@@ -1321,7 +1326,9 @@ export function PrecipIcon({ precip = 0, snow = 0, size = 28, filled = false })
} }
// Rain intensity - number of drops // Rain intensity - number of drops
const rainDrops = !hasRain ? 0 : precip < 1 ? 1 : precip < 4 ? 2 : 3; const rainDrops = !hasRain ? 0
: drops != null ? Math.max(1, Math.min(3, drops))
: precip < 1 ? 1 : precip < 4 ? 2 : 3;
// Snow intensity - number of flakes // Snow intensity - number of flakes
const snowFlakes = !hasSnow ? 0 : snow < 0.5 ? 1 : 2; const snowFlakes = !hasSnow ? 0 : snow < 0.5 ? 1 : 2;
const fr = size * 0.08; // flake arm length const fr = size * 0.08; // flake arm length
@@ -1346,7 +1353,7 @@ export function PrecipIcon({ precip = 0, snow = 0, size = 28, filled = false })
: hasRain : hasRain
? Array.from({ length: rainDrops }, (_, i) => html` ? Array.from({ length: rainDrops }, (_, i) => html`
<${Fragment} key=${i}> <${Fragment} key=${i}>
${rainStroke(spacing * (i + 1), size * 0.53, precip >= 4)} ${rainStroke(spacing * (i + 1), size * 0.53, rainDrops >= 3)}
</>`) </>`)
: Array.from({ length: snowFlakes }, (_, i) => : Array.from({ length: snowFlakes }, (_, i) =>
flake(spacing * (i + 1), size * 0.66, fr) flake(spacing * (i + 1), size * 0.66, fr)
@@ -1425,6 +1432,36 @@ export function FogIcon({ size = 30 }) {
</svg>`; </svg>`;
} }
// WINDICON - Brass Line gust glyph for the day-tab wind badge. Body paths are
// Lucide's "wind" icon (ISC licence) on its native 24x24 grid, same approach
// as CarIcon. Gale-force and above (tier >= 3) swap the brass/ink pairing for
// a colder storm blue so the step up from "blustery" is visible at a glance.
// -------------------------------------------------------------------
// halo - draws each gust line twice, a wider white stroke underneath the
// coloured one. Needed when the glyph is overlaid on the cloud/rain
// icon: without it the two line drawings tangle into one another and
// neither reads. Costs nothing when drawn on a flat tab background.
export function WindIcon({ size = 30, tier = 2, halo = false }) {
const severe = tier >= 3;
const lead = severe ? '#2f5d82' : '#c8922a';
const trail = severe ? '#3a6a92' : '#2a1a08';
const gusts = [
{ d: 'M17.7 7.7a2.5 2.5 0 1 1 1.8 4.3H2', stroke: lead },
{ d: 'M9.6 4.6A2 2 0 1 1 11 8H2', stroke: trail },
{ d: 'M12.6 19.4A2 2 0 1 0 14 16H2', stroke: trail },
];
return html`
<svg width=${size} height=${size} viewBox="0 0 24 24" fill="none"
style=${{ flexShrink: 0, display: 'inline-block', verticalAlign: 'middle', overflow: 'visible' }}>
<g stroke-linecap="round" stroke-linejoin="round">
${halo && gusts.map((p, i) => html`
<path key=${'h' + i} d=${p.d} stroke="#fdf6e8" stroke-width="4.4" opacity="0.92" />`)}
${gusts.map((p, i) => html`
<path key=${i} d=${p.d} stroke=${p.stroke} stroke-width="1.9" />`)}
</g>
</svg>`;
}
export function IceIcon({ size = 30 }) { export function IceIcon({ size = 30 }) {
const icy = '#3f73c4'; const icy = '#3f73c4';
return html` return html`
+533 -66
View File
@@ -32,10 +32,11 @@
// ------------------------------------------------------------------------ // ------------------------------------------------------------------------
import { h, Fragment } from '../../vendor/preact.js'; import { h, Fragment } from '../../vendor/preact.js';
import { useEffect, useRef } from '../../vendor/preact-hooks.js';
import htm from '../../vendor/htm.js'; import htm from '../../vendor/htm.js';
import { confidenceBand, utciCategory, petCategory, UTCI_BANDS, PET_BANDS, bandRampRgb, hexToRgb, mixRgb, rainTint, cloudTint, snowTint, VEHICLE_TYPES, BUILDING_TYPES, FUR_COLORS } from '../utils.js'; import { confidenceBand, utciCategory, petCategory, DAY_TAB_UTCI_BANDS, DAY_TAB_PET_BANDS, bandRampRgb, hexToRgb, mixRgb, rainTint, snowTint, VEHICLE_TYPES, BUILDING_TYPES, FUR_COLORS } from '../utils.js';
import { FREE_DAYS, UTCI_ENVIRONMENTS, deriveProfileMain, FILTER_PROFILES, variantIcons } from '../config.js'; import { FREE_DAYS, UTCI_ENVIRONMENTS, deriveProfileMain, FILTER_PROFILES, variantIcons } from '../config.js';
import { CloudIcon, PrecipIcon, HouseIcon, CarIcon, FogIcon, IceIcon } from '../components.js'; import { CloudIcon, PrecipIcon, HouseIcon, CarIcon, FogIcon, IceIcon, WindIcon } from '../components.js';
import { SubscribeModal } from './SubscribeModal.js'; import { SubscribeModal } from './SubscribeModal.js';
const html = htm.bind(h); const html = htm.bind(h);
@@ -114,6 +115,82 @@ function sustainedHigh(vals) {
return sorted[Math.min(drop, sorted.length - 1)]; return sorted[Math.min(drop, sorted.length - 1)];
} }
// Lays a sky tint (rain blue, snow blue, cloud grey) over a temperature colour
// without the blend passing through green on the way.
//
// A straight mixRgb() from the Warm band's yellow to a rain blue travels
// through olive and then green — which is why the tabs used to pick ONE
// dimension by priority instead of blending. Desaturating the base toward its
// own luminance first routes yellow → khaki → grey → blue instead, so the
// temperature can genuinely shade toward the weather without inventing a hue
// that belongs to neither.
// base - [r,g,b] temperature colour from the band ramp
// target - [r,g,b] weather tint (rainTint / snowTint)
// w - 0..1 how strongly the weather pulls
function overlayTint(base, target, w) {
if (!(w > 0)) return base;
// The pre-desaturation exists ONLY to keep a hue-to-hue blend off the green
// diagonal — yellow travelling to rain-blue would otherwise pass through
// olive. A target with no hue of its own has no such diagonal to avoid, and
// mixing toward a grey already desaturates by definition, so applying the
// full guard to the cloud tint stripped the base colour twice over. Scale it
// by how much hue the target actually carries: rain (a saturated blue) keeps
// essentially all of it, cloud (a near-neutral grey) almost none.
const chroma = (Math.max(...target) - Math.min(...target)) / 255;
const hueGuard = Math.min(1, chroma * 2.2);
const lum = 0.299 * base[0] + 0.587 * base[1] + 0.114 * base[2];
const desat = mixRgb(base, [lum, lum, lum], Math.min(1, w * 0.9 * hueGuard));
return mixRgb(desat, target, w);
}
// Cloud is applied differently from rain, and deliberately so.
//
// Rain has a hue of its own — a day IS blue-grey with rain — so it earns a
// blend toward that colour. Cloud does not: an overcast 20° day is still a 20°
// day, just duller. Blending toward a grey HUE meant every warm overcast tab
// had to travel from gold to grey, and the route passes through khaki: at a
// middling weight #edd449 lands on #b4a85f, the muddy olive that reads as
// neither temperature nor weather. Pushing the weight high enough to clear the
// mud (0.80) instead erased the temperature entirely, which is how every day
// from +4° to +23° ended up the same grey.
//
// There is no weight that avoids both, because the problem is the path, not
// the distance. So cloud no longer moves the hue at all: it desaturates the
// temperature colour toward its OWN luminance and dims it slightly. Same hue,
// less vivid, a little darker — which is what overcast actually looks like —
// and because it never crosses between two hues there is no muddy middle at
// any weight.
function cloudDim(rgb, w) {
if (!(w > 0)) return rgb;
// Desaturate toward a neutral that is never DARKER than the colour it came
// from, and lift it slightly. This matters more than it looks: a darkened
// desaturated yellow is olive — that is simply what olive is — so the
// earlier version, which dulled toward the colour's own luminance and then
// dimmed 16%, walked the gold Comfortable band straight down into khaki
// (#edd449 -> #cabc75 -> #b3ad8c) and put green back on the strip by a
// different route than the palette had.
//
// Overcast is diffuse and flat, not dark, so lifting rather than dimming is
// also the truer look: gold travels gold -> cream -> pale sand
// (#e3d799 -> #e2dbb4), never through olive, and the blues at the cold end
// pale out the same way.
const lum = 0.299 * rgb[0] + 0.587 * rgb[1] + 0.114 * rgb[2];
const neutral = Math.min(255, lum + 14);
return mixRgb(rgb, [neutral, neutral, neutral], w);
}
// Weather weights are pushed through this before they are used.
//
// A linear weight means a day with middling rain sits at a 50/50 mix of
// temperature colour and rain blue — which is a muddy olive that reads as
// neither, and the whole point of the tab shade is that someone can tell what
// the day is at a glance. Smoothstep drags low weights lower and high weights
// higher, so the colour spends as little time as possible in that ambiguous
// middle: a bit of weather barely disturbs the temperature colour, and weather
// that genuinely IS the day takes the tab almost completely.
const clamp01 = (v) => Math.max(0, Math.min(1, v));
const decisive = (t) => { const c = clamp01(t); return c * c * (3 - 2 * c); };
function weatherGradientActive(r, g, b, edgeBlend = 0.42, centerBlend = null) { function weatherGradientActive(r, g, b, edgeBlend = 0.42, centerBlend = null) {
const blend = (c, amt) => Math.round(c + (255 - c) * amt); const blend = (c, amt) => Math.round(c + (255 - c) * amt);
const lighten = (c) => Math.min(255, Math.round(c + (255 - c) * 0.38)); const lighten = (c) => Math.min(255, Math.round(c + (255 - c) * 0.38));
@@ -179,10 +256,34 @@ export function DayTabs({
: utciEnv; : utciEnv;
const rowGradient = ROW_GRADIENTS[mainConfigKey]?.[rowOptionKey]; const rowGradient = ROW_GRADIENTS[mainConfigKey]?.[rowOptionKey];
// The row pins to the top of the viewport on wide screens, and the table's
// sticky hour header has to stop just below it. Publish the measured height
// (plus its 4px margin) as --profile-row-h rather than hard-coding one in
// table.css: the row is one line or two depending on how long the profile
// and thermal-basis labels are, so a fixed offset leaves the header tucked
// under it.
const profileRowRef = useRef(null);
useEffect(() => {
const el = profileRowRef.current;
if (!el) return;
const publish = () => {
const h = Math.round(el.getBoundingClientRect().height) + 4;
document.documentElement.style.setProperty('--profile-row-h', `${h}px`);
};
publish();
const ro = typeof ResizeObserver !== 'undefined' ? new ResizeObserver(publish) : null;
if (ro) ro.observe(el);
window.addEventListener('resize', publish);
return () => {
if (ro) ro.disconnect();
window.removeEventListener('resize', publish);
};
}, []);
return html` return html`
<${Fragment}> <${Fragment}>
<div class=${`active-profile-row ${rowGrpClass}`} onClick=${openPanel} role="button" tabIndex="0" <div ref=${profileRowRef} class=${`active-profile-row ${rowGrpClass}`} onClick=${openPanel} role="button" tabIndex="0"
onKeyDown=${(e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); openPanel(); } }} onKeyDown=${(e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); openPanel(); } }}
style=${rowGradient ? { '--row-bg': rowGradient } : undefined} style=${rowGradient ? { '--row-bg': rowGradient } : undefined}
title="Change profile & settings"> title="Change profile & settings">
@@ -244,6 +345,12 @@ export function DayTabs({
const dayHi = mainVals.length ? Math.round(Math.max(...mainVals)) : null; const dayHi = mainVals.length ? Math.round(Math.max(...mainVals)) : null;
const dayLo = mainVals.length ? Math.round(Math.min(...mainVals)) : null; const dayLo = mainVals.length ? Math.round(Math.min(...mainVals)) : null;
// Day-tab weather icon - use core daylight rows (elev > 10°) where
// available, falling back to any above-horizon rows, then all rows.
const coreRows = d.rows.filter(r => r.elev > 10);
const aboveRows = d.rows.filter(r => r.elev > 0);
const repRows = coreRows.length > 0 ? coreRows : aboveRows.length > 0 ? aboveRows : d.rows;
// The tab COLOUR is keyed to a spike-resistant "sustained high" rather than // The tab COLOUR is keyed to a spike-resistant "sustained high" rather than
// the raw max, so a single warm hour on a cool/damp day doesn't paint the // the raw max, so a single warm hour on a cool/damp day doesn't paint the
// whole tab warm. The Danger tier is the exception — it keeps alarming on // whole tab warm. The Danger tier is the exception — it keeps alarming on
@@ -252,8 +359,68 @@ export function DayTabs({
// always show the true dayHi/dayLo. // always show the true dayHi/dayLo.
const catFn = isPetsProfile ? petCategory : utciCategory; const catFn = isPetsProfile ? petCategory : utciCategory;
const isDanger = dayHi !== null && catFn(dayHi).solid === true; const isDanger = dayHi !== null && catFn(dayHi).solid === true;
// Spike-filter the DAYLIGHT hours, not the whole 24. Run over every
// row, sustainedHigh() drops the top ~10% of a 24-hour list — two or
// three hours — and on a summer day with a big diurnal swing that is
// the entire afternoon peak, not a spike. A 28° day was colouring
// from ~25°, a whole band down from the number printed on the tab.
// repRows is already what the icon, cloud and rain read, so the hue
// now comes from the same slice of the day as everything else.
const colourVals = repRows.map(r => r[mainField]).filter(v => isFinite(v));
// On a cold day the sustained HIGH is the wrong representative.
// Heat is a PEAK risk — the hottest hours are the dangerous ones, so
// colouring from the high is right at the top of the scale. Cold is
// an EXPOSURE risk: what matters is how cold it stayed all day, not
// the one mild moment. Invercargill on 31 Aug peaked at -4.8° at 6pm
// against daylight hours running -6.5° to -18.6°, so colouring from
// the peak put a pale Freezing tab on a genuinely Arctic day.
//
// Below 10° the colour therefore crossfades from the sustained high
// toward the daylight MEAN, arriving fully at the mean by 6°. It is
// crossfaded rather than switched so two adjacent days either side of
// the threshold can't jump a band against each other in the strip.
// The printed hi/lo numbers are untouched — they stay the true peak
// and trough, as they already do for the spike filter.
const colourAvg = colourVals.length
? colourVals.reduce((s, v) => s + v, 0) / colourVals.length
: null;
const sustHi = sustainedHigh(colourVals.length ? colourVals : mainVals);
const coldWeight = colourAvg === null ? 0 : clamp01((10 - colourAvg) / 4);
const colourHi = dayHi === null ? null const colourHi = dayHi === null ? null
: (isDanger ? dayHi : Math.round(sustainedHigh(mainVals))); : isDanger ? dayHi
: Math.round(sustHi + (colourAvg - sustHi) * coldWeight);
// ---- Which hours the tab's COLOUR should read the sky from -------
// The tint used to average cloud and total rain across every daylight
// hour, which silently assumes the day is all one weather. Split days
// break that badly: a heavy wet morning followed by a hot, clear
// afternoon totals up as "wet and overcast", so the tab got painted
// rain-blue even though the hours it is printing a temperature FOR
// were dry and sunny.
//
// The tab's number already comes from the hottest sustained hours, so
// the shading should come from the same ones. colourRows is that
// slice — daylight hours within 3° of colourHi, never fewer than the
// three hottest — and it drives the tint only.
//
// The ICON deliberately keeps reading the whole day: it did rain this
// morning, and the glyph is the right place to say so. Colour answers
// "how hot is this day", the glyph answers "did it rain".
const colourRows = (() => {
if (colourHi === null || !repRows.length) return repRows;
const near = repRows.filter(r => isFinite(r[mainField]) && r[mainField] >= colourHi - 3);
if (near.length >= 3) return near;
return [...repRows]
.filter(r => isFinite(r[mainField]))
.sort((a, b) => b[mainField] - a[mainField])
.slice(0, 3);
})();
const peakPrecip = colourRows.reduce((s, r) => s + (r.precip || 0), 0);
const peakSnow = colourRows.reduce((s, r) => s + (r.snow || 0), 0);
const peakProbs = colourRows.map(r => r.precipProb).filter(v => isFinite(v));
const peakProb = peakProbs.length ? Math.max(...peakProbs) : 0;
const peakWet = clamp01((peakProb - 50) / 25);
// Secondary row under each hi/lo: Shade for SunSoak, Managed for // Secondary row under each hi/lo: Shade for SunSoak, Managed for
// indoor, Pet Shade for pets, and plain air temperature for // indoor, Pet Shade for pets, and plain air temperature for
@@ -265,11 +432,6 @@ export function DayTabs({
shadeLo = secondaryVals.length ? Math.round(Math.min(...secondaryVals)) : null; shadeLo = secondaryVals.length ? Math.round(Math.min(...secondaryVals)) : null;
} }
// Day-tab weather icon - use core daylight rows (elev > 10°) where
// available, falling back to any above-horizon rows, then all rows.
const coreRows = d.rows.filter(r => r.elev > 10);
const aboveRows = d.rows.filter(r => r.elev > 0);
const repRows = coreRows.length > 0 ? coreRows : aboveRows.length > 0 ? aboveRows : d.rows;
const dayPrecip = repRows.reduce((s, r) => s + (r.precip || 0), 0); const dayPrecip = repRows.reduce((s, r) => s + (r.precip || 0), 0);
const daySnow = repRows.reduce((s, r) => s + (r.snow || 0), 0); const daySnow = repRows.reduce((s, r) => s + (r.snow || 0), 0);
const catCounts = {}; const catCounts = {};
@@ -279,7 +441,43 @@ export function DayTabs({
const noonRow = repRows.reduce((best, r) => (r.elev > (best?.elev ?? -Infinity) ? r : best), null); const noonRow = repRows.reduce((best, r) => (r.elev > (best?.elev ?? -Infinity) ? r : best), null);
const repElev = noonRow ? noonRow.elev : 45; const repElev = noonRow ? noonRow.elev : 45;
const repDt = noonRow ? noonRow.dt : dDate; const repDt = noonRow ? noonRow.dt : dDate;
const showPrecip = dayPrecip >= 0.3 || daySnow >= 0.1;
// ---- The two rain numbers, reconciled --------------------------
// The forecast gives us two different things and they answer
// different questions: precipProb is WHETHER it rains, precip is HOW
// MUCH if it does. Reading either one alone gets the day wrong — an
// amount-only reading puts a rain icon on a 37% day that will most
// likely stay dry, while a probability-only reading can't tell
// drizzle from a downpour.
//
// So: probability decides IF the tab shows rain at all, and then the
// two together decide how hard that rain looks.
// • under 50% — no rain icon; it probably won't rain
// • 50% → 100% — rain icon, climbing to medium-high on
// confidence alone
// • + heavy amount — climbs the rest of the way to a downpour
// The tab COLOUR reuses the same 50% gate, but reads it over the peak
// hours (peakWet, above), so the icon and the shade can never
// disagree about WHETHER it rains — only about whether it was still
// raining during the part of the day the tab is printing a
// temperature for.
const probVals = repRows.map(r => r.precipProb).filter(v => isFinite(v));
const maxProb = probVals.length ? Math.max(...probVals) : 0;
// Confidence half: 0 at the 50% gate, 1 at a dead-certain 100%.
const probT = clamp01((maxProb - 50) / 50);
// Amount half: the daylight total, "heavy" taken as 8mm across the day.
const amountT = clamp01(dayPrecip / 8);
// Weighted so certainty alone tops out around medium-high (2 drops)
// and only a genuinely wet forecast reaches the 3-drop downpour.
const rainScore = probT * 0.55 + amountT * 0.45;
const rainDrops = rainScore < 0.33 ? 1 : rainScore < 0.70 ? 2 : 3;
// Snow gets the same probability gate — precipProb covers all
// precipitation — but keeps its own amount-driven flake count.
const showSnow = daySnow >= 0.1 && maxProb >= 50;
const showRain = !showSnow && dayPrecip >= 0.3 && maxProb >= 50;
const showPrecip = showRain || showSnow;
// Vehicle tab: swap the default car glyph for a hazard icon when // Vehicle tab: swap the default car glyph for a hazard icon when
// conditions could affect driving. Uses the full day (not just // conditions could affect driving. Uses the full day (not just
@@ -305,12 +503,65 @@ export function DayTabs({
} }
} }
// Day-tab colour — PRIORITY model, no hue blending. // Wind badge — gusts over the WHOLE day, not just the daylight
// Mixing a warm yellow with grey/blue passes through green, so rather // repRows: a gale that runs into the evening still defines the day.
// than blend temperature with sky we pick ONE dimension by priority and // Tiers mirror windCategory() in events/weather-checks.js so the tab
// only vary its shade: heat → snow → rain → cloud → sun (first wins). // and the At a Glance advisory can never disagree about what counts
// • Hot/extreme days always keep their heat colour (a safety signal). // as a gale.
// • Otherwise rain wins, then cloud, then a clear "sunny"/cold colour. const gustVals = d.rows.map(r => r.gust ?? r.va).filter(v => isFinite(v));
const gustsMph = gustVals.map(v => v * 2.237);
// Round BEFORE tiering, not after. The table's gust column prints a
// rounded figure, so a 31.99 mph day already reads "32 mph" there —
// testing the raw value against a 32 mph threshold then withheld the
// badge from a day the rest of the UI was calling a 32 mph day.
// Tier the same number the user is actually shown.
const maxGust = gustsMph.length ? Math.round(Math.max(...gustsMph)) : 0;
const avgGust = gustsMph.length
? gustsMph.reduce((s, v) => s + v, 0) / gustsMph.length : 0;
// A day doesn't have to spike to be a windy day. Peak-only tiering
// missed the kind people actually notice — hour after hour of 25-30
// mph gusts, a Strong Breeze (Force 6) that never quite reaches Near
// Gale — while a day with one brief 32 mph gust between calm hours
// got the badge. So a day that stays blustery for at least half its
// hours earns the badge on its own. 25 mph is GUST_BLUSTERY in
// events/weather-checks.js.
const blusteryFrac = gustsMph.length
? gustsMph.filter(v => v >= 25).length / gustsMph.length : 0;
const sustainedWind = blusteryFrac >= 0.5;
const windTier = maxGust >= 74 ? 5 : maxGust >= 55 ? 4
: maxGust >= 39 ? 3 : maxGust >= 32 ? 2
: sustainedWind ? 2 : 0;
const windLabel = windTier >= 5 ? 'Storm force'
: windTier >= 4 ? 'Severe gale'
: windTier >= 3 ? 'Gale'
: maxGust >= 32 ? 'Near gale'
: 'Blustery';
// Badge size tracks what the day AVERAGED rather than how hard it
// spiked, so a day that simply stays windy reads heavier than one
// carried by a single gust. 18 mph averages to the smallest badge,
// 34 mph to the largest.
const windBadgeSize = Math.round(15 + 9 * clamp01((avgGust - 18) / 16));
// Day-tab colour — WEIGHTED model.
// Temperature is always the base: the band ramp, so the tab reads as a
// temperature first and a 17° day can never come out looking like a
// 26° one. Sky conditions then shade that base by influence, in order
// of how much they change the day:
// 1. temperature - base colour, from the band ramp
// 2. cloud cover - dulls that colour in place, same hue
// 3. rain / snow - pulls toward blue, by amount AND confidence
// Thermal first, then cloud, then precipitation. Cloud is the sky
// condition EVERY day has some of, so it is the general case and rain
// is the special one layered over it — and crucially it has to be
// cloudy for it to rain, so rain never lands on a raw band colour.
// By the time rain applies, cloud has already carried the tab to
// grey, and grey is the one starting point from which deep rain-blue
// is a clean path: gold straight to blue goes through khaki and
// olive, gold to grey to blue does not. That ordering is what keeps
// green off the strip, not a choice of weights.
// Rain and snow blend through overlayTint() so a warm yellow shading
// toward rain-blue routes via grey rather than passing through green;
// cloud uses cloudDim(), which never moves the hue at all.
// Shade ramps live in utils.js (WEATHER TINT PALETTE) so the warning // Shade ramps live in utils.js (WEATHER TINT PALETTE) so the warning
// event banner can colour itself from exactly the same source. // event banner can colour itself from exactly the same source.
const tBand = colourHi !== null ? catFn(colourHi) : { bg: '#f8e554' }; const tBand = colourHi !== null ? catFn(colourHi) : { bg: '#f8e554' };
@@ -321,17 +572,71 @@ export function DayTabs({
// alarm colour, not a point on the gradient. // alarm colour, not a point on the gradient.
const tBandRgb = tBand.solid const tBandRgb = tBand.solid
? hexToRgb(tBand.bg) ? hexToRgb(tBand.bg)
: bandRampRgb(colourHi, isPetsProfile ? PET_BANDS : UTCI_BANDS); : bandRampRgb(colourHi, isPetsProfile ? DAY_TAB_PET_BANDS : DAY_TAB_UTCI_BANDS);
const ccVals = repRows.map(r => r.cc).filter(v => isFinite(v)); // Cloud for the TINT, averaged over the peak hours only — see
// colourRows above. A wet overcast morning no longer greys out a
// clear hot afternoon.
const ccVals = colourRows.map(r => r.cc).filter(v => isFinite(v));
const avgCloud = ccVals.length ? ccVals.reduce((s, v) => s + v, 0) / ccVals.length : 0; const avgCloud = ccVals.length ? ccVals.reduce((s, v) => s + v, 0) / ccVals.length : 0;
// Hot/cold cutoffs below are calibrated against UTCI_BANDS; for the // Heat cutoffs are calibrated against the UTCI bands; for the Pets
// Pets profile (furSurfaceT, judged against the shifted PET_BANDS) // profile (furSurfaceT, judged against the shifted pet bands) the same cutoffs
// the same cutoffs are re-projected onto the pet scale so the // are re-projected onto the pet scale so each one kicks in at the same
// "hot hue" / "plain cold" branches kick in at the same relative // relative point in the band, not the same absolute degree.
// point in each band, not the same absolute degree. // heatT drives both how far the sky is allowed to shade the colour
const isHot = colourHi !== null && colourHi >= (isPetsProfile ? 35 : 27); // Hot band and above // (skyCap, below) and how pastel the tab is drawn (further down).
// heatFloor is the START of the hot half — the Warm band threshold,
// not the Caution one. Anchoring it at Caution meant a day sitting in
// the bottom of Caution scored heatT ~= 0 and was therefore treated
// by every heat guard below exactly like a 20° day: full sky shading,
// no band weighting. A 28° tab came out the same grey as a 17° one.
// Starting the ramp at Warm makes the guards engage across the whole
// hot half, which is where they were always meant to apply.
const heatFloor = isPetsProfile ? 25 : 24; // Warm threshold
const heatMid = isPetsProfile ? 40 : 32; // Extreme threshold
const heatTop = isPetsProfile ? 52 : 41; // Danger threshold
// The spike filter that protects the tab's BAND COLOUR should not
// also decide how hot the tab is allowed to LOOK. sustainedHigh()
// deliberately throws away the day's top ~10% of hours so a single
// warm hour can't paint a cool day warm — right for choosing the hue,
// wrong for choosing vividness. On a muggy UK heat day it discounts
// the part of the day people actually remember: 26 Aug 2026 at Path
// Hill printed 33° but coloured from 29°, which halved heatT and let
// 87% cloud grey out a genuinely hot afternoon.
// So the hue keeps riding on the spike-filtered colourHi, while
// vividness and cloud-resistance ride on the midpoint between that
// and the true peak: the day still has to have been broadly hot, but
// a real hot spell is no longer discounted away to nothing.
const heatDrive = colourHi === null ? null : (colourHi + dayHi) / 2;
const heatT = heatDrive === null ? 0 : clamp01((heatDrive - heatFloor) / (heatMid - heatFloor));
const overT = heatDrive === null ? 0 : clamp01((heatDrive - heatMid) / (heatTop - heatMid));
// Cold counterpart to heatT, anchored on the same two bands at the
// other end of the scale: it starts at the Very cold threshold and
// reaches full strength at Arctic. Used only for skyCap below — the
// pastel ramp stays keyed to heat.
// Two segments, mirroring heatFloor/heatMid/heatTop on the hot end.
// This used to be a single ramp that finished at the ARCTIC
// threshold, which meant coldT pinned to 1 from -10° downward and
// every day below it — the whole of Arctic AND the whole of Extreme
// cold — was drawn identically. Invercargill at -12° and at -28°
// came out the same tab. The hot end never had that problem because
// it ramps across Warm → Caution → Extreme; the cold end now does
// the same, with underT carrying the deep half.
const coldFloor = isPetsProfile ? -3 : 5; // Very cold threshold
const coldMid = isPetsProfile ? -18 : -10; // Arctic threshold
const coldBottom = isPetsProfile ? -28 : -20; // Extreme cold threshold
const coldT = colourHi === null ? 0 : clamp01((coldFloor - colourHi) / (coldFloor - coldMid));
const underT = colourHi === null ? 0 : clamp01((coldMid - colourHi) / (coldMid - coldBottom));
// How far a day sits from the benign middle of the scale, in either
// direction. Chilly/Cool/Comfortable/Warm days are ordinary weather
// and let the sky do the talking; the further a day pushes toward
// either extreme, the more the temperature holds the colour.
const extremityT = Math.max(heatT, coldT);
// Extreme band gets a less pastelised gradient so it reads as a // Extreme band gets a less pastelised gradient so it reads as a
// clear step between Caution and the pulsing Danger tier. // clear step between Caution and the pulsing Danger tier.
const isExtreme = tBand.label === 'Extreme'; const isExtreme = tBand.label === 'Extreme';
@@ -341,32 +646,93 @@ export function DayTabs({
// — no rain/snow/cloud tinting, and no weather icon. // — no rain/snow/cloud tinting, and no weather icon.
const tempOnly = isHomeOrOffice || isVehicleProfile; const tempOnly = isHomeOrOffice || isVehicleProfile;
// At gale force and above on a dry day, the wind IS the day's weather
// — a sun glyph with a wind badge tucked in the corner undersells a
// 41 mph gale on a bright, dry Monday. So the wind glyph takes the
// main icon slot instead, and the badge is only used for the tier
// below (Near Gale) or when rain already owns the main slot.
const windIsHeadline = !tempOnly && windTier >= 3 && !showPrecip;
// How far the temperature colour is allowed to be shaded by the sky.
// Both ends of the scale are a safety signal, so an Extreme-heat or
// Arctic day keeps its colour almost intact however thick the cloud —
// the sky can only really take the tab on the ordinary days in
// between. Danger is immovable.
// Heat gets a second, separate reduction on top of that. Heat and a
// wet sky are not opposites — a muggy 28° afternoon under rain is
// still a hot day, and the story is the heat. This used to be applied
// to CLOUD only (as cloudHeatCut), which left rain free to repaint a
// hot day blue at up to 0.92 strength; and because reducing the rain
// weight hands the leftover influence straight to cloud, cutting one
// without the other just swaps blue for grey. Cutting skyCap itself
// cuts both together, and keeps the rain→cloud handover coherent.
// Raised to a fractional power so the cut BITES IN LOW CAUTION rather
// than waiting for Extreme. A straight linear cut left a 28° day at
// ~0.43 sky influence — enough rain-blue to still read as a cold wet
// day — because the two caps multiply and each is gentle on its own.
// The rain/snow GLYPH already tells you the day is wet; past the
// middle of the hot half the colour's job is to say how hot it is.
const skyHeatCut = 1 - 0.92 * Math.pow(heatT, 0.75);
const skyCap = tBand.solid ? 0 : (1 - 0.80 * extremityT) * skyHeatCut;
let rgb; let rgb;
if (tempOnly) { if (tempOnly) {
rgb = tBandRgb; // Home/indoor/vehicle tabs show a modelled temperature, not the
} else if (isHot) { // outdoor sky — no weather shading at all.
// Keep the heat hue; cloud only mutes it a touch (orange/red never
// greens) and rain does not override heat.
const heatFactor = isPetsProfile
? (colourHi >= 49 ? 0.15 : colourHi >= 43 ? 0.30 : 0.45)
: (colourHi >= 39 ? 0.15 : colourHi >= 34 ? 0.30 : 0.45);
const t = Math.max(0, Math.min(1, (avgCloud - 30) / 70)) * heatFactor;
rgb = mixRgb(tBandRgb, [170, 162, 152], t);
} else if (daySnow >= 0.1) {
// Snow: pale → icy blue with depth.
rgb = snowTint(daySnow);
} else if (showPrecip) {
// Rain: light → deep blue with amount (pure blue, no temperature hue).
rgb = rainTint(dayPrecip);
} else if (avgCloud >= 30) {
// Cloud: light → dark grey with cover (pure grey, no temperature hue).
rgb = cloudTint(avgCloud);
} else if (colourHi !== null && colourHi < (isPetsProfile ? 2 : 10)) {
// Clear & cold: keep the cold temperature blue.
rgb = tBandRgb; rgb = tBandRgb;
} else { } else {
// Clear & mild/warm: a plain sunny yellow. rgb = tBandRgb;
rgb = hexToRgb('#f8e554');
// 2. Cloud — dulls the temperature colour in place (cloudDim:
// same hue, less vivid, slightly darker). Applied BEFORE rain,
// because cloud is the sky condition every day has some of, so
// it is the general case that rain is then layered onto.
// avgCloud comes from the peak hours (colourRows), so a wet
// overcast morning no longer greys out a clear hot afternoon.
let cloudW = 0;
if (avgCloud >= 30) {
cloudW = decisive((avgCloud - 30) / 70) * 0.85 * skyCap;
rgb = cloudDim(rgb, cloudW);
} else if (colourHi !== null) {
// Clear skies: a small warm lift so a sunny day of a given
// temperature reads brighter than an overcast one at the same
// temperature. Deliberately gentle — it tops out at 0.16, so it
// tints the band colour rather than replacing it the way the old
// flat "sunny yellow" branch did.
const sunW = clamp01((30 - avgCloud) / 30) * 0.16 * skyCap;
rgb = overlayTint(rgb, hexToRgb('#f8e554'), sunW);
}
// 3. Rain / snow — last, and only with the influence cloud did not
// already claim, so the two hand over smoothly instead of
// fighting. Sits behind the same 50% gate the icon uses, so the
// tab can never be painted wet while showing a dry sky glyph.
// Amount, confidence and tint colour all come from the peak
// hours (colourRows), so rain that has already cleared by the
// time the day gets hot no longer colours the tab. The showRain
// / showSnow gates still read the whole day, so a wet-morning /
// hot-afternoon day keeps its rain glyph over a warm tab.
// It has to be cloudy for it to rain, so by the time rain is
// applied the tab has ALREADY been taken to grey by step 2 — and
// grey is the one starting point from which deep rain-blue is a
// clean path. That is why rain is no longer throttled by
// (1 - cloudW): the previous version handed cloud the influence
// first and then left rain the scraps, which on a heavily overcast
// day meant rain could barely be seen at all. Rain now tints the
// grey at full strength for its amount and confidence.
//
// The ceiling drops from 0.92 to 0.60 to match: 0.92 was sized for
// pulling a saturated BAND colour toward blue, but pulling an
// already-grey tab that far just produces a flat saturated blue.
// 0.60 lands on a deep blue-grey — a wet overcast day — which is
// what the tab should say.
if (showSnow) {
rgb = overlayTint(rgb, snowTint(peakSnow),
decisive(peakSnow / 1.2) * 0.60 * skyCap);
} else if (showRain && peakWet > 0) {
rgb = overlayTint(rgb, rainTint(peakPrecip),
decisive(peakPrecip / 4) * peakWet * 0.60 * skyCap);
}
} }
// Extreme tabs lean toward Danger's crimson as the daily high climbs // Extreme tabs lean toward Danger's crimson as the daily high climbs
// through the band (32→41°C human, 40→52°C pet) — so a day peaking // through the band (32→41°C human, 40→52°C pet) — so a day peaking
@@ -386,22 +752,106 @@ export function DayTabs({
// while 32° snapped to a solid orange. Ramp the blend continuously // while 32° snapped to a solid orange. Ramp the blend continuously
// instead — the endpoints still land on the old Extreme (heatT = 1) // instead — the endpoints still land on the old Extreme (heatT = 1)
// and Danger (both = 1) values, so only the in-between days change. // and Danger (both = 1) values, so only the in-between days change.
const heatFloor = isPetsProfile ? 35 : 27; // start of the hot half //
const heatMid = isPetsProfile ? 40 : 32; // Extreme threshold // The cold end goes TWO-TONE instead of just getting darker. Cold
const heatTop = isPetsProfile ? 52 : 41; // Danger threshold // band colours are pale to begin with, so deepening them uniformly
const clamp01 = (v) => Math.max(0, Math.min(1, v)); // only makes a murky blue-grey — and a hard winter's day should look
const heatT = colourHi === null ? 0 : clamp01((colourHi - heatFloor) / (heatMid - heatFloor)); // unmistakably different from a mild one, not slightly duller. So the
const overT = colourHi === null ? 0 : clamp01((colourHi - heatMid) / (heatTop - heatMid)); // two ends of the gradient are pulled apart as the day gets colder:
const wBgNeutral = weatherGradientNeutral( // the rim deepens toward the true band colour while the core lifts
wR, wG, wB, // toward frost-white, giving a frosted, iced-over tab. Same mechanism
0.50 - 0.30 * heatT - 0.05 * overT, // edge: 0.50 → 0.20 → 0.15 // Danger already uses (dark edge, light centre) at the opposite end
0.69 - 0.03 * heatT + 0.06 * overT, // centre: 0.69 → 0.66 → 0.72 // of the scale. decisive() delays the onset so an ordinary 3°C
); // morning stays plain and only a genuinely cold day frosts over.
const wBgActive = weatherGradientActive( // The frost lift on the CENTRE was +0.22, which put the core at 0.91
wR, wG, wB, // — effectively pure white (#f3f9fd). The band colour survived only
0.42 - 0.26 * heatT - 0.04 * overT, // edge: 0.42 → 0.16 → 0.12 // as a thin rim, so the coldest, most dangerous days rendered as the
0.64 + 0.02 * heatT + 0.06 * overT, // centre: 0.64 → 0.66 → 0.72 // faintest tabs on the strip, inverting the safety hierarchy the hot
); // end enforces. Pulled back to +0.14 so the core still reads as iced
// over but the tab keeps its body; underT then takes the deep half
// DOWN again, so Extreme cold is the most saturated cold tab rather
// than the most washed-out.
const frostT = decisive(coldT);
// Band WEIGHT — how much of the band colour is left standing rather
// than washed out toward white. The two numbers below are "how far
// this stop blends toward white", so weighting a tier up means
// scaling BOTH stops down together. Scaling only the core is what the
// first attempt did, and it pulled the centre down onto the rim until
// the two met — which flattened the radial into a slab of flat colour
// and lost the lit-from-within look the whole tab strip is built on.
// Scaling both keeps the centre-to-rim distance intact and simply
// moves the whole gradient deeper into the band colour.
//
// Stepped by tier, because the tiers are the safety story and have to
// escalate visibly rather than read as three similar oranges.
// Danger is deliberately excluded: it already has its own treatment
// (flat crimson, dark rim, light core, outlined text) and weighting
// it as well buried its hi/lo numbers.
const tierFrac = tBand.solid ? 0
: tBand.label === 'Extreme' ? 0.75 // 75% more weight
: tBand.label === 'Caution' ? 0.50 // 50% more weight
: 0;
// Eased in across Caution so 27° is a smooth departure from an
// ordinary day rather than a hard step. Extreme is already past the
// top of that ramp, so it takes its weight flat.
const ramp = tBand.label === 'Caution' ? heatT : 1;
// Deepen BOTH stops by the same amount — never scale them. Scaling
// preserves their ratio but crushes the DISTANCE between them, which
// is what the eye actually reads as the lit-from-within core: at full
// Caution weight it closed the 0.41 core-to-rim gap to 0.24 and the
// radial flattened into a slab of colour. Subtracting a constant
// moves the whole gradient deeper into the band while leaving that
// distance untouched. The offset is a fraction of the rim's own
// headroom, so neither stop can be pushed past the pure band colour.
//
// But the offset is a RIM treatment — its whole job is to push the
// outer edge further into the band colour. Taking the full offset off
// the centre too dragged the core down in lockstep, so the weighted
// tiers (Caution and Extreme — the only ones with a non-zero offset)
// were exactly the tabs that lost their white core: the hotter the
// day, the more of the lit-from-within look it gave away. The centre
// now takes only part of the offset.
//
// Note this WIDENS the core-to-rim distance rather than closing it,
// which is the safe direction — the failure mode the note above warns
// about is the centre collapsing onto the rim, not lifting away from
// it. Lifting is the effect that reads as "lit from within".
const CENTRE_DEEPEN = 0.55;
const deepen = (edge, centre) => {
const off = clamp01(edge) * tierFrac * ramp;
return [clamp01(edge - off), clamp01(centre - off * CENTRE_DEEPEN)];
};
// Minimum core-to-rim distance — the "glow" floor.
//
// Deepening only ever fires on Caution/Extreme, and frostT only at
// the cold end, which leaves the entire middle of the scale — Cool,
// Comfortable, Warm — with its two stops barely 0.19 apart. At that
// distance the radial is invisible, so those tabs read as flat slabs
// of pale colour sitting next to hot ones that visibly glow.
//
// The middle tabs are not actually less white in the centre — 0.69
// beats Extreme's 0.59. They have no CONTRAST to make that whiteness
// legible. So the fix is distance, not lightness: push the two stops
// apart around their own midpoint, which lifts the core and deepens
// the rim together and leaves the tab's overall lightness where it
// was. Tabs already clearing the floor are returned untouched, so the
// hot and frosted ends keep exactly the treatment they have.
const MIN_GLOW = 0.36;
const glow = ([edge, centre]) => {
if (centre - edge >= MIN_GLOW) return [edge, centre];
const mid = (edge + centre) / 2;
return [clamp01(mid - MIN_GLOW / 2), clamp01(mid + MIN_GLOW / 2)];
};
const [edgeNeutral, centreNeutral] = glow(deepen(
0.50 - 0.30 * heatT - 0.05 * overT - 0.34 * frostT - 0.10 * underT, // edge: 0.50 → 0.20 → 0.15 · cold → 0.16 → 0.06
0.69 - 0.03 * heatT + 0.06 * overT + 0.14 * frostT - 0.06 * underT, // centre: 0.69 → 0.66 → 0.72 · cold → 0.83 → 0.77
));
const [edgeActive, centreActive] = glow(deepen(
0.42 - 0.26 * heatT - 0.04 * overT - 0.30 * frostT - 0.06 * underT, // edge: 0.42 → 0.16 → 0.12 · cold → 0.12 → 0.06
0.64 + 0.02 * heatT + 0.06 * overT + 0.18 * frostT - 0.06 * underT, // centre: 0.64 → 0.66 → 0.72 · cold → 0.82 → 0.76
));
const wBgNeutral = weatherGradientNeutral(wR, wG, wB, edgeNeutral, centreNeutral);
const wBgActive = weatherGradientActive(wR, wG, wB, edgeActive, centreActive);
const wText = '#2a1d10'; const wText = '#2a1d10';
// Active-tab outline: the day's weather colour, 20% darker. // Active-tab outline: the day's weather colour, 20% darker.
const wOutline = `rgb(${Math.round(wR * 0.8)},${Math.round(wG * 0.8)},${Math.round(wB * 0.8)})`; const wOutline = `rgb(${Math.round(wR * 0.8)},${Math.round(wG * 0.8)},${Math.round(wB * 0.8)})`;
@@ -422,9 +872,10 @@ export function DayTabs({
setProPromptDay(null); setProPromptDay(null);
} }
}} }}
title=${locked title=${(locked
? `${band.label} · SunScope Extra unlocks day ${i + 1}` ? `${band.label} · SunScope Extra unlocks day ${i + 1}`
: `${band.label} · day ${i + 1} of 14`} : `${band.label} · day ${i + 1} of 14`)
+ (windTier >= 2 ? ` · ${windLabel} — gusts to ${maxGust} mph` : '')}
style=${{ style=${{
background: isActive ? wBgActive : wBgNeutral, background: isActive ? wBgActive : wBgNeutral,
color: wText, color: wText,
@@ -447,6 +898,7 @@ export function DayTabs({
${dDate.toLocaleDateString('en-GB', { day: 'numeric', month: 'short', timeZone: 'UTC' })} ${dDate.toLocaleDateString('en-GB', { day: 'numeric', month: 'short', timeZone: 'UTC' })}
</span> </span>
<span style=${{ display: 'block', margin: '6px auto 0px', lineHeight: 1 }}> <span style=${{ display: 'block', margin: '6px auto 0px', lineHeight: 1 }}>
<span class="utci-day-icon">
${tempOnly ${tempOnly
? (isVehicleProfile ? (isVehicleProfile
? (vehicleHazard === 'snow' ? html`<${PrecipIcon} precip=${0} snow=${hazSnow} size=${30} />` ? (vehicleHazard === 'snow' ? html`<${PrecipIcon} precip=${0} snow=${hazSnow} size=${30} />`
@@ -456,8 +908,23 @@ export function DayTabs({
: html`<${CarIcon} size=${30} />`) : html`<${CarIcon} size=${30} />`)
: html`<${HouseIcon} size=${30} />`) : html`<${HouseIcon} size=${30} />`)
: showPrecip : showPrecip
? html`<${PrecipIcon} precip=${dayPrecip} snow=${daySnow} size=${30} />` ? html`<${PrecipIcon} precip=${showRain ? dayPrecip : 0} snow=${daySnow}
: html`<${CloudIcon} category=${modalCloudCat} elev=${repElev} dt=${repDt} size=${30} />`} drops=${rainDrops} size=${30} />`
: windIsHeadline
? html`<${WindIcon} size=${30} tier=${windTier} />`
: html`<${CloudIcon} category=${modalCloudCat} elev=${repElev} dt=${repDt} size=${30} />`}
${/* Wind that is real but isn't the day's headline (Near Gale, or
a gale on a day rain already speaks for) rides ON TOP of the
sky glyph rather than sitting off in the tab corner, so the
icon states one combined condition — "windy AND wet" — the
way the eye reads it anyway. The halo keeps the gust lines
legible over the cloud beneath. */''}
${windTier >= 2 && !windIsHeadline && html`
<span class="utci-day-wind"
aria-label=${`${windLabel}, gusts to ${maxGust} mph`}>
<${WindIcon} size=${windBadgeSize} tier=${windTier} halo />
</span>`}
</span>
</span> </span>
${dayHi !== null && html` ${dayHi !== null && html`
<span style=${{ <span style=${{
+155 -15
View File
@@ -1003,7 +1003,7 @@ function bestRun(arr, pred) {
// ------------------------------------------------------------------------ // ------------------------------------------------------------------------
const PICK_MIN_HOURS = 3; // shorter than this isn't worth naming a day for const PICK_MIN_HOURS = 3; // shorter than this isn't worth naming a day for
const PICK_MAX_DAYS = 4; // beyond this the shortlist stops being a shortlist const PICK_MAX_DAYS = 7; // the whole week, when the whole week is good
// Said when no day qualifies, phrased for what was actually being judged - // Said when no day qualifies, phrased for what was actually being judged -
// "no settled outdoor windows" is meaningless when the panel was reading a // "no settled outdoor windows" is meaningless when the panel was reading a
@@ -1116,19 +1116,147 @@ function comfortableHour(r, crit) {
return true; return true;
} }
// Each measure's own 0-1 score for one hour, keyed so the same numbers can be
// both averaged into a quality score and read back afterwards to say WHICH
// measure cost the day its marks.
function hourParts(r, crit) {
return {
thermal: plateauScore(r[crit.field], crit.pl),
rain: bandScore(r.precipProb ?? 0, BAND_RAIN),
gust: bandScore((r.gust ?? r.va ?? 0) * 2.237, BAND_GUST),
soil: bandScore(r.soilM, BAND_SOIL),
};
}
// How good an hour is WITHIN the bands, 0-1. Weighted by how much each // How good an hour is WITHIN the bands, 0-1. Weighted by how much each
// measure actually decides whether the day was worth going out for. // measure actually decides whether the day was worth going out for.
function hourQuality(r, crit) { function hourQuality(r, crit) {
const parts = [ const p = hourParts(r, crit);
[plateauScore(r[crit.field], crit.pl), crit.w.thermal], const parts = Object.keys(p)
[bandScore(r.precipProb ?? 0, BAND_RAIN), crit.w.rain], .map(k => [p[k], crit.w[k]])
[bandScore((r.gust ?? r.va ?? 0) * 2.237, BAND_GUST), crit.w.gust], .filter(([v, w]) => v != null && w > 0);
[bandScore(r.soilM, BAND_SOIL), crit.w.soil],
].filter(([v, w]) => v != null && w > 0);
const wsum = parts.reduce((s, [, w]) => s + w, 0); const wsum = parts.reduce((s, [, w]) => s + w, 0);
return wsum > 0 ? parts.reduce((s, [v, w]) => s + v * w, 0) / wsum : 0; return wsum > 0 ? parts.reduce((s, [v, w]) => s + v * w, 0) / wsum : 0;
} }
// ── Why a day scored what it scored ──────────────────────────────────────
// A calendar icon on every row said nothing the day name had not already
// said. The icon now names the single biggest reason the day is not a
// hundred, so a column of scores can be read without opening each day: three
// windy days and one hot one is a different week from four wet ones.
//
// The icon is always a weather influence - heat, cold, rain, wind, wet ground
// - never "the window was short". Length is a symptom: the window is short
// BECAUSE the morning was cold or it rained after three, and the times beside
// the icon already say how long it is. So when marks are lost to length, the
// hours outside the window are asked what ruled them out and that is what the
// row shows.
// One vocabulary for both routes to a cause - hours inside the window scoring
// poorly, and hours outside it being ruled out - so the same influence always
// reads the same way whichever way it cost the day marks.
const SHORTFALL_ICON = {
rain: { icon: '🌧️', name: 'Rain' },
gust: { icon: '💨', name: 'Wind' },
soil: { icon: '💧', name: 'Wet ground' },
hot: { icon: '🔥', name: 'Heat' },
cold: { icon: '🥶', name: 'Cold' },
none: { icon: '✅', name: 'Nothing much against it' },
};
// The tooltip is the influence and what it cost, in points of the day's own
// score: "Heat = 34%" says both what is wrong and how much it matters, so a
// 90% row and a 60% row with the same icon are not read as the same warning.
const causeTip = (cause) =>
cause.cost ? `${cause.name} = ${cause.cost}%` : cause.name;
// Why one hour failed to qualify. An hour can fail on more than one count -
// a cold wet morning is both - so every breach is returned and the day is
// decided on which comes up most, not on which happens to be tested first.
function failReasons(r, crit) {
const out = [];
const t = r[crit.field];
if (t == null) return out;
if (t < crit.pl.min) out.push('cold');
if (t > crit.pl.max) out.push('hot');
if (crit.w.rain && (r.precipProb ?? 0) > BAND_RAIN.limit) out.push('rain');
if (crit.w.gust && (r.gust ?? r.va ?? 0) * 2.237 > BAND_GUST.limit) out.push('gust');
if (crit.w.soil && r.soilM != null && r.soilM > BAND_SOIL.limit) out.push('soil');
return out;
}
// What kept the rest of the day out of the window. Returns null when those
// hours give no reason at all - dark hours for a daylight profile, or a gap
// in the data - so the caller can fall back to the in-window measures rather
// than print a non-answer.
function shortBecause(c, crit) {
const inRun = new Set(c.run.map(r => r.iso));
const tally = {};
for (const r of c.usableRows) {
if (inRun.has(r.iso)) continue;
for (const k of failReasons(r, crit)) tally[k] = (tally[k] ?? 0) + 1;
}
// Cost is attached by the caller: those hours were ruled out entirely, so
// what this influence cost is the whole of the length shortfall.
const worst = Object.keys(tally).sort((a, b) => tally[b] - tally[a])[0];
return worst ? SHORTFALL_ICON[worst] : null;
}
// Below this the day is as good as the bands allow and picking a "cause"
// would be inventing one: at five points the strongest complaint about the
// day is worth a twentieth of its score, which is noise, not a reason to
// stay in. Set higher and genuinely breezy days went out labelled faultless.
const SHORTFALL_FLOOR = 0.05;
// Marks lost, as whole points of the 0-100 score the row already shows, so
// the tooltip's number and the bar's number are in the same units.
const pts = (frac) => Math.round(frac * 100);
function limitingFactor(c, crit, span) {
const run = c.run;
// Mean score per measure across the run, so one bad hour in nine can't
// name the day.
const sums = {}, counts = {};
for (const r of run) {
const p = hourParts(r, crit);
for (const k of Object.keys(p)) {
if (p[k] == null || !crit.w[k]) continue;
sums[k] = (sums[k] ?? 0) + p[k];
counts[k] = (counts[k] ?? 0) + 1;
}
}
const wsum = Object.keys(counts).reduce((s, k) => s + crit.w[k], 0);
// Marks lost, in points of the final score: quality is half of it, split
// between the measures by weight; window length is the other half.
const lost = [['short', 0.5 * (1 - span)]];
for (const k of Object.keys(counts)) {
const mean = sums[k] / counts[k];
lost.push([k, 0.5 * (crit.w[k] / wsum) * (1 - mean)]);
}
lost.sort((a, b) => b[1] - a[1]);
if (lost[0][1] < SHORTFALL_FLOOR) return SHORTFALL_ICON.none;
// Length lost the most marks: name what ruled the other hours out. If they
// can't say, drop through to whichever measure was weakest inside the
// window - still an influence, which is the whole point of the icon.
if (lost[0][0] === 'short') {
const because = shortBecause(c, crit);
if (because) return { ...because, cost: pts(lost[0][1]) };
lost.shift();
if (lost.length === 0) return SHORTFALL_ICON.none;
}
const [worst, amount] = lost[0];
const cost = pts(amount);
// Thermal has two opposite causes and they want opposite icons - a cold
// morning and a scorching afternoon are not the same warning.
if (worst === 'thermal') {
const meanT = run.reduce((s, r) => s + (r[crit.field] ?? 0), 0) / run.length;
return { ...(meanT > crit.pl.flatHi ? SHORTFALL_ICON.hot : SHORTFALL_ICON.cold), cost };
}
return { ...SHORTFALL_ICON[worst], cost };
}
export function computeBestDay(weekDays, nowLocalISO, profile, variant) { export function computeBestDay(weekDays, nowLocalISO, profile, variant) {
if (!weekDays || weekDays.length === 0) return []; if (!weekDays || weekDays.length === 0) return [];
const crit = bestDayCriteria(profile, variant); const crit = bestDayCriteria(profile, variant);
@@ -1160,14 +1288,16 @@ export function computeBestDay(weekDays, nowLocalISO, profile, variant) {
const quality = run.reduce((s, r) => s + hourQuality(r, crit), 0) / run.length; const quality = run.reduce((s, r) => s + hourQuality(r, crit), 0) / run.length;
candidates.push({ candidates.push({
len: dayBest.len, quality, key: day.key, len: dayBest.len, quality, key: day.key, run,
from: rows[dayBest.start], to: rows[dayBest.end], from: rows[dayBest.start], to: rows[dayBest.end],
// What the window is measured AGAINST: the daylight for a profile that // What the window is measured AGAINST - and, for the hours outside the
// run, the evidence for WHY it stopped where it did: the daylight for a
// profile that
// only counts daylight hours, otherwise the whole day. Using daylight // only counts daylight hours, otherwise the whole day. Using daylight
// for a round-the-clock profile would let a 14-hour overnight window // for a round-the-clock profile would let a 14-hour overnight window
// score over 100% of a 16-hour day and print "all day" for a spell that // score over 100% of a 16-hour day and print "all day" for a spell that
// ends at breakfast. // ends at breakfast.
usable: crit.daylightOnly ? rows.filter(r => r.elev > 0).length : rows.length, usableRows: crit.daylightOnly ? rows.filter(r => r.elev > 0) : rows,
}); });
} }
// Nothing qualifying is itself worth saying - an empty panel reads as a bug, // Nothing qualifying is itself worth saying - an empty panel reads as a bug,
@@ -1192,12 +1322,19 @@ export function computeBestDay(weekDays, nowLocalISO, profile, variant) {
// window of perfect ones are both worth knowing about, and letting either // window of perfect ones are both worth knowing about, and letting either
// half dominate hides one of them. // half dominate hides one of them.
for (const c of candidates) { for (const c of candidates) {
const span = c.usable > 0 ? Math.min(c.len / c.usable, 1) : 0; const span = c.usableRows.length > 0
? Math.min(c.len / c.usableRows.length, 1)
: 0;
c.score = Math.round(100 * (0.5 * span + 0.5 * c.quality)); c.score = Math.round(100 * (0.5 * span + 0.5 * c.quality));
// Same span the score used, so the icon can never blame the weather for
// marks that were actually lost to a short window.
c.cause = limitingFactor(c, crit, span);
} }
// Only the top few are kept: past four rows the panel stops being a // A settled week really can have seven good days, and cutting it to a
// shortlist and turns back into the day tabs. // shortlist there would say the opposite of what the forecast shows. Only
// days that actually qualify get a row, so the panel still stays short in
// an unsettled week - the cap is the week itself, not an arbitrary four.
const shown = [...candidates] const shown = [...candidates]
.sort((a, b) => b.score - a.score) .sort((a, b) => b.score - a.score)
.slice(0, PICK_MAX_DAYS) .slice(0, PICK_MAX_DAYS)
@@ -1220,12 +1357,15 @@ export function computeBestDay(weekDays, nowLocalISO, profile, variant) {
// A run spanning nearly all the daylight is better said than shown: printing // A run spanning nearly all the daylight is better said than shown: printing
// "6am - 9pm" makes the reader parse a time range to learn "all day". // "6am - 9pm" makes the reader parse a time range to learn "all day".
const window = c.usable > 0 && c.len >= c.usable - 1 const window = c.usableRows.length > 0 && c.len >= c.usableRows.length - 1
? 'all day' ? 'all day'
: `${hh(c.from.iso)} ${hh(endIso)}`; : `${hh(c.from.iso)} ${hh(endIso)}`;
return { return {
icon: '📅', // Not a calendar - the row already says which day. The icon carries the
// one thing the score cannot: what is holding the day back.
icon: c.cause.icon,
iconTitle: causeTip(c.cause),
label: dayName, label: dayName,
value: window, value: window,
// Rows stay in date order - the order you plan in - so the ranking is // Rows stay in date order - the order you plan in - so the ranking is
+4 -5
View File
@@ -140,11 +140,10 @@ export function useTableScroll({
const bodyScroll = bodyScrollRef.current; const bodyScroll = bodyScrollRef.current;
if (!bodyTable || !bodyScroll) return; if (!bodyTable || !bodyScroll) return;
// Rotated renders a SINGLE table, so there is no head table and // Fallback for any layout rendered as a single table (no head to
// nothing to keep in step. The scroll-fade still needs to know how // keep in step). Both orientations split head from body now, so this
// wide the pinned column is, though — and it can't be assumed from // is a safety net rather than a path — the scroll-fade still needs
// CSS, because auto layout widens that column to fit the longest // the pinned column's width, so measure that and stop.
// metric name. Measure it, then stop.
if (!headTable) { if (!headTable) {
const firstCell = bodyTable.querySelector('tbody tr > *'); const firstCell = bodyTable.querySelector('tbody tr > *');
if (firstCell && tableWrapRef && tableWrapRef.current) { if (firstCell && tableWrapRef && tableWrapRef.current) {
+49
View File
@@ -0,0 +1,49 @@
// ------------------------------------------------------------------------
// nav-account.js - Wires the "Account" nav dropdown on every static page
// (about.html, faq.html, dashboard.html, etc.) so it behaves exactly like
// the Preact-driven one on the main app (see app.js's Account dropdown):
// - Subscription link points at the manage-billing URL if the visitor
// already has Extra (localStorage sunscope_pro), else the buy URL.
// - Sign in/out reflects the dashboard's own session (api/me.php),
// independent of the Extra flag above.
//
// Plain vanilla JS - these pages have no build step and no framework.
// Expects the markup produced by nav-account-menu() in build.js's
// injectAccountMenu(), or written by hand to match: a toggle link with
// id="nav-account-signinout" for the Sign in/out item.
// ------------------------------------------------------------------------
(function () {
const SUBSCRIBE_URL = 'https://buy.stripe.com/9B63cw7vl8k15Ei9DQd7q00';
const MANAGE_URL = 'https://billing.stripe.com/p/login/9B63cw7vl8k15Ei9DQd7q00';
const subLink = document.getElementById('nav-account-subscription');
if (subLink) {
subLink.href = localStorage.getItem('sunscope_pro') === '1' ? MANAGE_URL : SUBSCRIBE_URL;
}
const signLink = document.getElementById('nav-account-signinout');
if (!signLink) return;
function setSignedOut() {
signLink.textContent = 'Sign in';
signLink.href = './dashboard.html';
signLink.onclick = null;
}
function setSignedIn() {
signLink.textContent = 'Sign out';
signLink.href = '#';
signLink.onclick = function (e) {
e.preventDefault();
fetch('./api/logout.php', { method: 'POST', credentials: 'same-origin' })
.then(setSignedOut)
.catch(() => {});
};
}
fetch('./api/me.php', { credentials: 'same-origin' })
.then((r) => r.json())
.then((d) => (d.user ? setSignedIn() : setSignedOut()))
.catch(() => {});
})();
+11
View File
@@ -41,7 +41,18 @@ const html = htm.bind(h);
// ── COLOUR SCALE ──────────────────────────────────────────────────────── // ── COLOUR SCALE ────────────────────────────────────────────────────────
// Continuous temperature colour scale — shared by every temperature column // Continuous temperature colour scale — shared by every temperature column
// and by the thermal-stress legend in app.js. // and by the thermal-stress legend in app.js.
// Anchored to match the day tabs' band ramp at the cold end: -30 is the
// Extreme cold anchor, -20 the Arctic one and -10 the Freezing one, the same
// three points bandRampRgb() uses (see BAND COLOUR RAMP in utils.js). The
// scale used to stop at -10, so every reading below it clamped to one flat
// blue — Invercargill at -28 C coloured identically to -10 C in the table,
// and the whole Arctic and Extreme cold range was invisible in Quick,
// Detailed and Table alike. Pet columns inherit the fix for free: they route
// through petEquivHumanTemp(), which already maps pet -28/-18 onto human
// -20/-10 but had nothing to look up down there.
export const TEMP_STOPS = [ export const TEMP_STOPS = [
[-30, [125, 80, 180]],
[-20, [ 85, 125, 205]],
[-10, [ 90, 155, 220]], [-10, [ 90, 155, 220]],
[ 0, [140, 195, 235]], [ 0, [140, 195, 235]],
[ 10, [155, 215, 195]], [ 10, [155, 215, 195]],
+55 -4
View File
@@ -113,10 +113,11 @@ export function bandRampRgb(t, bands) {
// this weather", shared by the day tabs (components/DayTabs.js) and the // this weather", shared by the day tabs (components/DayTabs.js) and the
// warning event banner (events/weather-checks.js + app.js). // warning event banner (events/weather-checks.js + app.js).
// ------------------------------------------------------------------- // -------------------------------------------------------------------
// The day tabs pick ONE dimension by priority (heat -> snow -> rain -> // The day tabs start from a temperature colour (utciCategory().bg /
// cloud -> sun) and only vary its shade; these helpers are the shade // petCategory().bg, ramped by bandRampRgb) and then shade it toward the
// ramps for the non-temperature dimensions. Temperature-driven tints // sky by influence - rain/snow first, then cloud on days that will stay
// come from utciCategory().bg / petCategory().bg instead. // dry. These helpers are the shade ramps for those non-temperature
// dimensions; see the WEIGHTED model block in components/DayTabs.js.
// ------------------------------------------------------------------- // -------------------------------------------------------------------
export const hexToRgb = (hx) => { export const hexToRgb = (hx) => {
const n = parseInt(hx.replace('#', ''), 16); const n = parseInt(hx.replace('#', ''), 16);
@@ -179,6 +180,56 @@ export function petCategory(t) {
} }
} }
// -------------------------------------------------------------------
// DAY-TAB BAND VARIANTS - the same bands, one colour swapped.
// -------------------------------------------------------------------
// In the table a cell answers "which stress band is this reading in", and
// green is the right answer for Comfortable: it is the safe band in a
// column of warnings. A day tab answers a different question - "what will
// this day feel like" - and there green reads as damp and mild rather
// than as the best day of the week. So on the tabs (and ONLY on the tabs)
// Comfortable becomes a deep gold: sitting just under Warm's brighter
// yellow, it puts the settled 19-24 -C days on the same warm run as the
// rest of the pleasant half of the scale instead of interrupting it with
// a green step.
//
// Everything else - labels, thresholds, fg colours, the solid Danger
// alarm - is shared with the table palettes above, so the two can never
// drift apart on anything but this one deliberate colour.
// -------------------------------------------------------------------
const DAY_TAB_COMFORTABLE = '#edd449'; // deep gold, one step under Warm's #f8e554
// The same reasoning, carried down the rest of the HUMAN scale.
//
// UTCI_BANDS runs cyan -> mint -> green -> gold across Cold, Chilly and Cool,
// so a tab crossing from the cold half of the scale to the warm half has to
// travel through green. That hurts twice over: the strip reads as a green
// interruption between the blues and the golds, and because cloud dulls a tab
// toward its own hue, every overcast mid-scale day desaturates into a sickly
// green-grey rather than into something neutral.
//
// On the tabs the scale therefore goes blue -> GREY -> gold, with the neutral
// point sitting exactly where a day has stopped being cold but has not yet
// become pleasant - which is what Chilly means. Nothing passes through green
// in either direction.
//
// Human only. The pet scale is already blue through the equivalent bands
// (pet Cool is #b5dee9, not a green), and its thresholds sit at completely
// different temperatures, so it keeps the Comfortable override alone.
const DAY_TAB_HUMAN = {
Comfortable: DAY_TAB_COMFORTABLE,
Cool: '#ded9c3', // warm oatmeal, one step under Comfortable's gold
Chilly: '#d3d5d2', // the neutral pivot between the warm and cold halves
Cold: '#c2d3dd', // first step back into blue
};
const withTabComfort = (bands) =>
bands.map(b => (b.label === 'Comfortable' ? { ...b, bg: DAY_TAB_COMFORTABLE } : b));
export const DAY_TAB_UTCI_BANDS =
UTCI_BANDS.map(b => (DAY_TAB_HUMAN[b.label] ? { ...b, bg: DAY_TAB_HUMAN[b.label] } : b));
export const DAY_TAB_PET_BANDS = withTabComfort(PET_BANDS);
// Diagonal sweep gradient for a band background hex colour. // Diagonal sweep gradient for a band background hex colour.
// Pre-blends with 50% white to sit harmoniously alongside lighter table cells, // Pre-blends with 50% white to sit harmoniously alongside lighter table cells,
// then applies a light→mid→dark sweep for depth. // then applies a light→mid→dark sweep for depth.
+2 -2
View File
@@ -7,7 +7,7 @@
Page background css/base.css body { background } Page background css/base.css body { background }
Body text colour css/base.css .utci-app { color } Body text colour css/base.css .utci-app { color }
Brand "brass" colour search files for #c8922a Brand "brass" colour search files for #c8922a
Headline font css/header.css .utci-title { font-family } Headline font css/base.css .nav-logo-sun { font-family }
Body text font css/base.css .utci-app { font-family } Body text font css/base.css .utci-app { font-family }
Max page width css/base.css .utci-shell { max-width } Max page width css/base.css .utci-shell { max-width }
Mobile breakpoint css/ui.css @media (max-width: 800px) Mobile breakpoint css/ui.css @media (max-width: 800px)
@@ -21,7 +21,7 @@
FILE STRUCTURE FILE STRUCTURE
css/base.css Colour palette docs, fonts+reset, page shell, nav css/base.css Colour palette docs, fonts+reset, page shell, nav
css/header.css Header grid, title, dial, search box css/header.css Header grid, dial, search box (site header: base.css)
css/table.css Day tabs, profiles, column toggles, hourly table, css/table.css Day tabs, profiles, column toggles, hourly table,
now-row + night-row decorations now-row + night-row decorations
css/ui.css Status banners, legend, about, footer, responsive css/ui.css Status banners, legend, about, footer, responsive
+29 -2
View File
@@ -44,6 +44,7 @@ function cleanDist() {
path.join(DIST, 'temperatures.html'), path.join(DIST, 'temperatures.html'),
path.join(DIST, 'features.html'), path.join(DIST, 'features.html'),
path.join(DIST, 'stress-bands.html'), path.join(DIST, 'stress-bands.html'),
path.join(DIST, 'dashboard.html'),
path.join(ASSETS, '_tmp_flat.css'), path.join(ASSETS, '_tmp_flat.css'),
path.join(ASSETS, 'about.min.css'), path.join(ASSETS, 'about.min.css'),
]; ];
@@ -86,7 +87,10 @@ function bumpVersion() {
// about.min.css with a version param, and injects the JS bundle. // about.min.css with a version param, and injects the JS bundle.
function rewriteHTML(html, version) { function rewriteHTML(html, version) {
html = html.replace(/<link\s[^>]*href=["'][^"']*assets\/sunscope\.css[^"']*["'][^>]*>/gi, ''); html = html.replace(/<link\s[^>]*href=["'][^"']*assets\/sunscope\.css[^"']*["'][^>]*>/gi, '');
html = html.replace(/<script\s[^>]*src=["'][^"']*assets\/js\/[^"']*["'][^>]*><\/script>/gi, ''); // Only strips the dev entry point (main.js) - other plain scripts these
// pages load directly, like nav-account.js, are not part of the Preact
// bundle and must survive untouched.
html = html.replace(/<script\s[^>]*src=["'][^"']*assets\/js\/main\.js[^"']*["'][^>]*><\/script>/gi, '');
// Rewrite standalone about.css reference to versioned minified file. // Rewrite standalone about.css reference to versioned minified file.
html = html.replace( html = html.replace(
/(<link\s[^>]*href=["'][^"']*assets\/)about\.css[^"']*(["'][^>]*>)/gi, /(<link\s[^>]*href=["'][^"']*assets\/)about\.css[^"']*(["'][^>]*>)/gi,
@@ -232,8 +236,12 @@ async function build() {
// 8. Copy static files // 8. Copy static files
console.log(' Copying static files...'); console.log(' Copying static files...');
// dashboard.html is copied as-is, not run through rewriteHTML - it is a
// standalone page (inline CSS/JS, no #root) and has no use for the
// Preact app bundle the other pages get.
for (const f of ['robots.txt', 'sitemap.xml', 'og-image.png', 'favicon.svg', 'restore.php', 'track.php', 'stats.php', for (const f of ['robots.txt', 'sitemap.xml', 'og-image.png', 'favicon.svg', 'restore.php', 'track.php', 'stats.php',
'verify-session.php', 'check-subscription.php', 'dev-unlock.php', 'secrets.local.php']) { 'verify-session.php', 'check-subscription.php', 'dev-unlock.php', 'secrets.local.php', 'dashboard.html',
'assets/js/nav-account.js']) {
const src = path.join(ROOT, f); const src = path.join(ROOT, f);
if (fs.existsSync(src)) { if (fs.existsSync(src)) {
copyFile(src, path.join(DIST, f)); copyFile(src, path.join(DIST, f));
@@ -241,6 +249,25 @@ async function build() {
} }
} }
// 9. Copy the dashboard backend (lib/ shared PHP + api/ endpoints)
console.log(' Copying dashboard backend...');
const copyDirRecursive = (src, dest) => {
fs.mkdirSync(dest, { recursive: true });
for (const entry of fs.readdirSync(src, { withFileTypes: true })) {
const srcPath = path.join(src, entry.name);
const destPath = path.join(dest, entry.name);
if (entry.isDirectory()) copyDirRecursive(srcPath, destPath);
else fs.copyFileSync(srcPath, destPath);
}
};
for (const dir of ['lib', 'api']) {
const src = path.join(ROOT, dir);
if (fs.existsSync(src)) {
copyDirRecursive(src, path.join(DIST, dir));
console.log(' -> ' + dir + '/');
}
}
const elapsed = ((Date.now() - t0) / 1000).toFixed(2); const elapsed = ((Date.now() - t0) / 1000).toFixed(2);
console.log(''); console.log('');
console.log('========================================'); console.log('========================================');
+15 -3
View File
@@ -36,7 +36,10 @@
<header id="site-nav"> <header id="site-nav">
<div class="nav-overlay" onclick="toggleNav(false)"></div> <div class="nav-overlay" onclick="toggleNav(false)"></div>
<a href="./index.html" class="logo"><span class="logo-sun">Sun</span><svg class="logo-icon" viewBox="0 0 174 173" aria-hidden="true" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:1.5;"><g transform="matrix(0.986226,0,0,0.986226,-585.724252,-110.041209)"><clipPath id="ss-clip-co"><circle cx="681.9838" cy="199.4502" r="83.2987"/></clipPath><g clip-path="url(#ss-clip-co)"><g transform="matrix(0.948836,0,0,0.948836,19.787839,-2.49381)"><path d="M666.992,233.0768C666.3949,230.6581 666.1785,228.6317 666.1785,226.0296C666.1785,208.6786 680.2653,194.5919 697.6163,194.5919C714.9672,194.5919 729.054,208.6786 729.054,226.0296C729.054,228.4125 728.9892,230.9346 728.4859,233.1663" fill="none" stroke="#c8922a" stroke-width="7.48"/></g><g transform="matrix(0.520349,0,0,0.520349,304.766642,64.226716)"><path d="M724.9315,291.8131C777.359,291.8131 826.5862,305.6245 869.1698,329.8044" fill="none" stroke="currentColor" stroke-width="13.64"/></g><g transform="matrix(0.520349,0,0,0.520349,304.766642,64.226716)"><path d="M580.8436,329.719C623.3927,305.592 672.5657,291.8131 724.9315,291.8131" fill="none" stroke="currentColor" stroke-width="13.64"/></g></g><circle cx="681.9838" cy="199.4502" r="83.2987" fill="none" stroke="currentColor" stroke-width="9.13"/></g></svg><span class="logo-scope">Scope</span></a> <a href="./index.html" class="nav-logo">
<span class="nav-logo-wordmark"><span class="nav-logo-sun">Sun</span><svg class="nav-logo-icon" viewBox="0 0 174 173" aria-hidden="true" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:1.5;"><g transform="matrix(0.986226,0,0,0.986226,-585.724252,-110.041209)"><clipPath id="ss-clip-co"><circle cx="681.9838" cy="199.4502" r="83.2987"/></clipPath><g clip-path="url(#ss-clip-co)"><g transform="matrix(0.948836,0,0,0.948836,19.748711,-12.198562)"><path d="M666.992,233.0768C666.3949,230.6581 666.1785,228.6317 666.1785,226.0296C666.1785,208.6786 680.2653,194.5919 697.6163,194.5919C714.9672,194.5919 729.054,208.6786 729.054,226.0296C729.054,228.4125 728.9892,230.9346 728.4859,233.1663" fill="#f4b047" stroke="#f4b047" stroke-width="7.48" stroke-linecap="round"/></g><g transform="matrix(0.520349,0,0,0.520349,304.727514,54.521964)"><path d="M724.9315,291.8131C777.359,291.8131 826.5862,305.6245 869.1698,329.8044" fill="none" stroke="currentColor" stroke-width="13.64"/></g><g transform="matrix(0.520349,0,0,0.520349,304.727514,54.521964)"><path d="M580.8436,329.719C623.3927,305.592 672.5657,291.8131 724.9315,291.8131" fill="none" stroke="currentColor" stroke-width="13.64"/></g></g><circle cx="681.9838" cy="199.4502" r="83.2987" fill="none" stroke="currentColor" stroke-width="9.13"/></g></svg><span class="nav-logo-scope">Scope</span></span>
<span class="nav-logo-tag">See the world the way your skin does.</span>
</a>
<div class="nav-links" id="nav-drawer"> <div class="nav-links" id="nav-drawer">
<a href="./index.html">Forecast</a> <a href="./index.html">Forecast</a>
<div class="nav-dropdown"> <div class="nav-dropdown">
@@ -52,7 +55,14 @@
</div> </div>
</div> </div>
<a href="./faq.html">FAQ</a> <a href="./faq.html">FAQ</a>
<a href="https://billing.stripe.com/p/login/9B63cw7vl8k15Ei9DQd7q00" target="_blank" rel="noopener noreferrer">Account</a> <div class="nav-dropdown nav-dropdown--right">
<a href="./dashboard.html" class="nav-dropdown-toggle">Account<span class="caret"></span></a>
<div class="nav-dropdown-menu">
<a href="./dashboard.html">Dashboard</a>
<a id="nav-account-subscription" href="https://buy.stripe.com/9B63cw7vl8k15Ei9DQd7q00" target="_blank" rel="noopener noreferrer">Subscription</a>
<a id="nav-account-signinout" href="./dashboard.html">Sign in</a>
</div>
</div>
</div> </div>
<button class="utci-burger" aria-label="Open menu" aria-expanded="false" id="burger-btn" onclick="toggleNav()"> <button class="utci-burger" aria-label="Open menu" aria-expanded="false" id="burger-btn" onclick="toggleNav()">
<span></span><span></span><span></span> <span></span><span></span><span></span>
@@ -69,6 +79,7 @@
btn.setAttribute('aria-expanded', open ? 'true' : 'false'); btn.setAttribute('aria-expanded', open ? 'true' : 'false');
} }
</script> </script>
<script src="./assets/js/nav-account.js" defer></script>
<main> <main>
<h1>Column reference</h1> <h1>Column reference</h1>
@@ -129,7 +140,8 @@
</main> </main>
<footer> <footer>
&copy; 2026 <a href="https://fraxle.net" target="_blank" rel="noopener noreferrer">Fraxle.NET</a> · <a href="./index.html">Forecast</a> · <a href="./about.html">About</a> · <a href="./faq.html">FAQ</a> · <a href="https://billing.stripe.com/p/login/9B63cw7vl8k15Ei9DQd7q00" target="_blank" rel="noopener noreferrer">Account</a> · Data: <a href="https://open-meteo.com/" target="_blank" rel="noopener noreferrer">Open-Meteo</a> · UTCI: <a href="https://utci.org/" target="_blank" rel="noopener noreferrer">Bröde 2012</a> &copy; 2026 <a href="https://fraxle.net" target="_blank" rel="noopener noreferrer">Fraxle.NET</a> · <a href="./index.html">Forecast</a> · <a href="./about.html">About</a> · <a href="./faq.html">FAQ</a>
· <a href="./dashboard.html">Dashboard</a> · <a href="https://billing.stripe.com/p/login/9B63cw7vl8k15Ei9DQd7q00" target="_blank" rel="noopener noreferrer">Account</a> · Data: <a href="https://open-meteo.com/" target="_blank" rel="noopener noreferrer">Open-Meteo</a> · UTCI: <a href="https://utci.org/" target="_blank" rel="noopener noreferrer">Bröde 2012</a>
</footer> </footer>
</body> </body>
+983
View File
@@ -0,0 +1,983 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Dashboard · SunScope</title>
<meta name="description" content="Your SunScope journal — sign in to record notes against the conditions on any given day." />
<meta name="robots" content="noindex, nofollow" />
<link rel="icon" type="image/svg+xml" href="./favicon.svg" />
<link rel="stylesheet" href="./assets/about.css?v=15b">
<style>
main { max-width: 620px; }
.db-columns { display: block; }
.db-col { min-width: 0; }
/* Two columns once there's room for both without squeezing either -
left carries the inputs (location/profile, add entry), right carries
the review (trend graph, entry list). The account panel and page
heading stay full-width above this. */
@media (min-width: 920px) {
main { max-width: 1020px; }
.db-columns {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 0 2rem;
align-items: start;
}
}
.db-panel {
background: #fef8ea;
border: 1px solid #e3d3ac;
border-radius: 10px;
padding: 1.5rem 1.75rem;
margin-bottom: 1.75rem;
}
.db-date-range {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 0.8rem;
}
.db-field-optional {
text-transform: none;
font-weight: 500;
color: #b09870;
}
.db-field { margin-bottom: 1rem; }
.db-field label {
display: block;
font-size: 0.8rem;
font-weight: 700;
letter-spacing: 0.04em;
text-transform: uppercase;
color: #7a5c2a;
margin-bottom: 0.35rem;
}
.db-field input,
.db-field textarea {
width: 100%;
font-family: 'Manrope', sans-serif;
font-size: 0.95rem;
color: #1e1208;
background: #fffdf6;
border: 1px solid #d4c0a0;
border-radius: 6px;
padding: 0.6rem 0.7rem;
}
.db-field textarea { resize: vertical; min-height: 4.5rem; }
.db-btn {
display: inline-flex;
align-items: center;
gap: 0.4rem;
font-family: 'Manrope', sans-serif;
font-size: 0.85rem;
font-weight: 700;
letter-spacing: 0.03em;
color: #fffdf6;
background: #c8922a;
border: none;
border-radius: 999px;
padding: 0.6rem 1.3rem;
cursor: pointer;
transition: background 0.15s;
}
.db-btn:hover { background: #a8761c; }
.db-btn:disabled { opacity: 0.5; cursor: default; }
.db-btn--ghost {
background: transparent;
color: #9a7a4a;
border: 1px solid #d4c0a0;
}
.db-btn--ghost:hover { background: #f0e4c4; color: #1e1208; }
.db-error {
color: #a03a2a;
font-size: 0.85rem;
margin-top: -0.4rem;
margin-bottom: 0.9rem;
}
.db-toggle-row {
font-size: 0.85rem;
color: #7a5c2a;
margin-top: 0.9rem;
}
.db-toggle-row a { cursor: pointer; }
.db-account-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
flex-wrap: wrap;
}
.db-account-email {
font-family: 'Fraunces', serif;
font-style: italic;
font-weight: 700;
font-size: 1.05rem;
}
.db-entry-form {
display: flex;
flex-direction: column;
gap: 0;
}
.db-entry-form-actions {
display: flex;
align-items: center;
gap: 0.7rem;
}
.db-entries { list-style: none; }
.db-entry {
border-bottom: 1px solid #e8dcbf;
padding: 0.9rem 0;
display: flex;
justify-content: space-between;
gap: 1rem;
align-items: flex-start;
}
.db-entry:last-child { border-bottom: none; }
.db-entry-date {
font-family: 'JetBrains Mono', monospace;
font-size: 0.78rem;
color: #9a7a4a;
white-space: nowrap;
flex-shrink: 0;
padding-top: 0.15rem;
}
.db-entry-note {
flex: 1;
font-size: 0.92rem;
color: #3a2a12;
white-space: pre-wrap;
overflow-wrap: anywhere;
}
.db-entry-actions {
display: flex;
gap: 0.3rem;
flex-shrink: 0;
}
.db-entry-edit,
.db-entry-del {
background: none;
border: none;
color: #b09870;
font-size: 0.8rem;
cursor: pointer;
flex-shrink: 0;
padding: 0.15rem 0.3rem;
}
.db-entry-edit:hover { color: #c8922a; }
.db-entry-del:hover { color: #a03a2a; }
.db-empty {
color: #9a7a4a;
font-style: italic;
font-size: 0.9rem;
}
.db-loc-row {
display: flex;
align-items: center;
gap: 0.6rem;
flex-wrap: wrap;
margin-bottom: 1rem;
}
.db-loc-name {
font-family: 'Fraunces', serif;
font-style: italic;
font-weight: 700;
font-size: 0.98rem;
color: #1e1208;
}
.db-loc-name.is-unset { color: #9a7a4a; font-style: normal; font-weight: 500; }
.db-loc-change {
background: none;
border: none;
color: #c8922a;
font-size: 0.8rem;
font-weight: 700;
text-decoration: underline;
cursor: pointer;
padding: 0;
}
.db-loc-search {
display: flex;
gap: 0.5rem;
margin-bottom: 0.6rem;
}
.db-loc-search input { flex: 1; }
.db-loc-results {
display: flex;
flex-direction: column;
gap: 0.3rem;
margin-bottom: 0.6rem;
}
.db-loc-result {
text-align: left;
background: #fffdf6;
border: 1px solid #e3d3ac;
border-radius: 6px;
padding: 0.45rem 0.65rem;
font-size: 0.85rem;
cursor: pointer;
color: #3a2a12;
}
.db-loc-result:hover { background: #f0e4c4; }
.db-select {
width: 100%;
font-family: 'Manrope', sans-serif;
font-size: 0.95rem;
color: #1e1208;
background: #fffdf6;
border: 1px solid #d4c0a0;
border-radius: 6px;
padding: 0.6rem 0.7rem;
}
.db-reading-preview {
font-size: 0.85rem;
color: #7a5c2a;
margin: -0.4rem 0 1rem;
}
.db-reading-preview strong { color: #1e1208; }
.db-entry-reading {
color: #c8922a;
font-weight: 700;
}
.db-entry-meta {
color: #b09870;
font-weight: 500;
}
.db-graph-empty {
color: #9a7a4a;
font-style: italic;
font-size: 0.9rem;
}
.db-graph-svg { width: 100%; height: auto; overflow: visible; }
.db-graph-line { fill: none; stroke: #c8922a; stroke-width: 2; }
.db-graph-dot { fill: #c8922a; stroke: #fef8ea; stroke-width: 1.5; cursor: pointer; }
.db-graph-dot:hover { fill: #a8761c; }
.db-graph-axis { stroke: #d4c0a0; stroke-width: 1; }
.db-graph-label {
font-family: 'JetBrains Mono', monospace;
font-size: 9px;
fill: #9a7a4a;
}
.db-graph-legend {
font-size: 0.78rem;
color: #9a7a4a;
margin-top: 0.6rem;
}
[hidden] { display: none !important; }
</style>
<script type="application/ld+json">
{
"@context": "https://schema.org",
"@type": "WebPage",
"name": "SunScope Dashboard",
"url": "https://sunscope.net/dashboard.html"
}
</script>
</head>
<body>
<header id="site-nav">
<div class="nav-overlay" onclick="toggleNav(false)"></div>
<a href="./index.html" class="nav-logo">
<span class="nav-logo-wordmark"><span class="nav-logo-sun">Sun</span><svg class="nav-logo-icon" viewBox="0 0 174 173" aria-hidden="true" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:1.5;"><g transform="matrix(0.986226,0,0,0.986226,-585.724252,-110.041209)"><clipPath id="ss-clip-db"><circle cx="681.9838" cy="199.4502" r="83.2987"/></clipPath><g clip-path="url(#ss-clip-db)"><g transform="matrix(0.948836,0,0,0.948836,19.748711,-12.198562)"><path d="M666.992,233.0768C666.3949,230.6581 666.1785,228.6317 666.1785,226.0296C666.1785,208.6786 680.2653,194.5919 697.6163,194.5919C714.9672,194.5919 729.054,208.6786 729.054,226.0296C729.054,228.4125 728.9892,230.9346 728.4859,233.1663" fill="#f4b047" stroke="#f4b047" stroke-width="7.48" stroke-linecap="round"/></g><g transform="matrix(0.520349,0,0,0.520349,304.727514,54.521964)"><path d="M724.9315,291.8131C777.359,291.8131 826.5862,305.6245 869.1698,329.8044" fill="none" stroke="currentColor" stroke-width="13.64"/></g><g transform="matrix(0.520349,0,0,0.520349,304.727514,54.521964)"><path d="M580.8436,329.719C623.3927,305.592 672.5657,291.8131 724.9315,291.8131" fill="none" stroke="currentColor" stroke-width="13.64"/></g></g><circle cx="681.9838" cy="199.4502" r="83.2987" fill="none" stroke="currentColor" stroke-width="9.13"/></g></svg><span class="nav-logo-scope">Scope</span></span>
<span class="nav-logo-tag">See the world the way your skin does.</span>
</a>
<div class="nav-links" id="nav-drawer">
<a href="./index.html">Forecast</a>
<div class="nav-dropdown">
<a href="./about.html" class="nav-dropdown-toggle">About<span class="caret"></span></a>
<div class="nav-dropdown-menu">
<a href="./about.html">Overview</a>
<a href="./sunsoak.html">SunSoak &amp; the science</a>
<a href="./profiles.html">Forecast profiles</a>
<a href="./columns.html">Column reference</a>
<a href="./temperatures.html">Derived temperatures</a>
<a href="./features.html">On-screen features</a>
<a href="./stress-bands.html">Stress bands</a>
</div>
</div>
<a href="./faq.html">FAQ</a>
<div class="nav-dropdown nav-dropdown--right">
<a href="./dashboard.html" class="nav-dropdown-toggle active">Account<span class="caret"></span></a>
<div class="nav-dropdown-menu">
<a href="./dashboard.html" class="active">Dashboard</a>
<a id="nav-account-subscription" href="https://buy.stripe.com/9B63cw7vl8k15Ei9DQd7q00" target="_blank" rel="noopener noreferrer">Subscription</a>
<a id="nav-account-signinout" href="./dashboard.html">Sign in</a>
</div>
</div>
</div>
<button class="utci-burger" aria-label="Open menu" aria-expanded="false" id="burger-btn" onclick="toggleNav()">
<span></span><span></span><span></span>
</button>
</header>
<script>
function toggleNav(force) {
const nav = document.getElementById('site-nav');
const drawer = document.getElementById('nav-drawer');
const btn = document.getElementById('burger-btn');
const open = force !== undefined ? force : !nav.classList.contains('nav-open');
nav.classList.toggle('nav-open', open);
drawer.classList.toggle('is-open', open);
btn.setAttribute('aria-expanded', open ? 'true' : 'false');
}
</script>
<main>
<h1>Dashboard</h1>
<div class="tagline">Your SunScope journal.</div>
<div id="db-signed-out">
<div class="db-panel">
<form id="db-login-form">
<div class="db-field">
<label for="db-email">Email</label>
<input id="db-email" type="email" autocomplete="email" required />
</div>
<div class="db-field">
<label for="db-password">Password</label>
<input id="db-password" type="password" autocomplete="current-password" required minlength="8" />
</div>
<p class="db-error" id="db-auth-error" hidden></p>
<button class="db-btn" type="submit" id="db-auth-submit">Sign in</button>
<div class="db-toggle-row">
<span id="db-mode-prompt">New here? <a id="db-mode-toggle">Create an account</a></span>
</div>
</form>
</div>
<p>
Signing in lets you keep a running journal of notes against the weather — a place to record how a day
actually felt, separate from the forecast itself.
</p>
</div>
<div id="db-signed-in" hidden>
<div class="db-panel">
<div class="db-account-row">
<span class="db-account-email" id="db-account-email"></span>
<button class="db-btn db-btn--ghost" id="db-logout-btn">Sign out</button>
</div>
</div>
<div class="db-columns">
<div class="db-col">
<h2>Location &amp; profile</h2>
<div class="db-panel">
<div class="db-loc-row">
<span class="db-loc-name is-unset" id="db-loc-name">No location set</span>
<button class="db-loc-change" type="button" id="db-loc-change-btn">Change</button>
</div>
<div class="db-loc-search" id="db-loc-search" hidden>
<input type="text" id="db-loc-input" placeholder="Search for a town or city…" />
<button class="db-btn db-btn--ghost" type="button" id="db-loc-search-btn">Search</button>
</div>
<div class="db-loc-results" id="db-loc-results"></div>
<div class="db-field">
<label for="db-profile-select">Profile</label>
<select class="db-select" id="db-profile-select">
<option value="basic">Basic (SunSoak)</option>
<option value="home">Home (Indoor)</option>
<option value="vehicle">Vehicle (Cabin)</option>
<option value="pets">Pets (Fur)</option>
</select>
</div>
<div class="db-field" id="db-env-field" style="margin-bottom: 0;">
<label for="db-env-select">Solar model</label>
<select class="db-select" id="db-env-select"></select>
</div>
</div>
<h2 id="db-entry-form-heading">Add an entry</h2>
<div class="db-panel">
<form id="db-entry-form" class="db-entry-form">
<div class="db-date-range">
<div class="db-field">
<label for="db-entry-date">Start date</label>
<input id="db-entry-date" type="date" required />
</div>
<div class="db-field">
<label for="db-entry-end-date">End date <span class="db-field-optional">(optional)</span></label>
<input id="db-entry-end-date" type="date" />
</div>
</div>
<p class="db-reading-preview" id="db-reading-preview"></p>
<div class="db-field">
<label for="db-entry-note">Note</label>
<textarea id="db-entry-note" placeholder="How did the day feel?" required></textarea>
</div>
<p class="db-error" id="db-entry-error" hidden></p>
<div class="db-entry-form-actions">
<button class="db-btn" type="submit" id="db-entry-submit">Save entry</button>
<button class="db-btn db-btn--ghost" type="button" id="db-entry-cancel" hidden>Cancel edit</button>
</div>
</form>
</div>
</div>
<div class="db-col">
<h2>Your trend</h2>
<div class="db-panel">
<div id="db-graph-wrap"></div>
<p class="db-graph-empty" id="db-graph-empty">Save a few entries with a location and profile set to see your trend here.</p>
</div>
<h2>Your entries</h2>
<ul class="db-entries" id="db-entries-list"></ul>
<p class="db-empty" id="db-entries-empty" hidden>Nothing recorded yet — add your first entry above.</p>
</div>
</div>
</div>
</main>
<footer>
&copy; 2026 <a href="https://fraxle.net" target="_blank" rel="noopener noreferrer">Fraxle.NET</a> · <a href="./index.html">Forecast</a> · <a href="./about.html">About</a> · <a href="./faq.html">FAQ</a> · <a href="./dashboard.html">Dashboard</a> · <a href="https://billing.stripe.com/p/login/9B63cw7vl8k15Ei9DQd7q00" target="_blank" rel="noopener noreferrer">Account</a>
</footer>
<script type="module">
import { buildHourlyRows } from './assets/js/compute.js';
import { deriveProfileMain, UTCI_ENVIRONMENTS } from './assets/js/config.js';
const $ = (id) => document.getElementById(id);
const PROFILE_LABELS = { basic: 'SunSoak', home: 'Indoor', vehicle: 'Cabin', pets: 'Fur' };
async function api(path, body) {
const res = await fetch('./api/' + path, {
method: body ? 'POST' : 'GET',
headers: body ? { 'Content-Type': 'application/json' } : {},
body: body ? JSON.stringify(body) : undefined,
credentials: 'same-origin',
});
const data = await res.json().catch(() => ({}));
if (!res.ok || data.ok === false) throw new Error(data.error || 'Something went wrong.');
return data;
}
// ── Auth ────────────────────────────────────────────────────────────
let mode = 'login'; // or 'register'
// Delegated on the prompt's parent, not the <a> itself - the toggle link
// is re-created by the innerHTML swap below, so a listener bound directly
// to it would be gone after the first click.
$('db-mode-prompt').parentElement.addEventListener('click', (e) => {
if (e.target.id !== 'db-mode-toggle') return;
mode = mode === 'login' ? 'register' : 'login';
$('db-auth-submit').textContent = mode === 'login' ? 'Sign in' : 'Create account';
$('db-mode-prompt').innerHTML = mode === 'login'
? 'New here? <a id="db-mode-toggle">Create an account</a>'
: 'Already have an account? <a id="db-mode-toggle">Sign in</a>';
$('db-auth-error').hidden = true;
});
// Keeps the "Account" nav dropdown's Sign in/out item in step with the
// page's own auth state, live - rather than loading nav-account.js
// alongside this script to do its own separate, one-shot me.php check
// that would go stale the moment someone signs in or out down here.
function syncNavAccountLink(signedIn) {
const link = $('nav-account-signinout');
if (!link) return;
if (signedIn) {
link.textContent = 'Sign out';
link.href = '#';
link.onclick = (e) => { e.preventDefault(); $('db-logout-btn').click(); };
} else {
link.textContent = 'Sign in';
link.href = './dashboard.html';
link.onclick = null;
}
}
$('db-login-form').addEventListener('submit', async (e) => {
e.preventDefault();
$('db-auth-error').hidden = true;
const email = $('db-email').value.trim();
const password = $('db-password').value;
try {
await api(mode === 'login' ? 'login.php' : 'register.php', { email, password });
$('db-account-email').textContent = email;
syncNavAccountLink(true);
await showDashboard();
} catch (err) {
$('db-auth-error').textContent = err.message;
$('db-auth-error').hidden = false;
}
});
$('db-logout-btn').addEventListener('click', async () => {
await api('logout.php', {});
$('db-signed-in').hidden = true;
$('db-signed-out').hidden = false;
syncNavAccountLink(false);
});
// ── Location & profile ─────────────────────────────────────────────
// A location the browser can pull the live 14-day forecast for, so an
// entry's date can carry a computed felt-temp reading the same way a
// day tab does - peak-of-day value of the profile's main field. Kept in
// localStorage, independent of the main app's own saved location.
const LOC_KEY = 'sunscope_dashboard_location';
const PROFILE_KEY = 'sunscope_dashboard_profile';
const ENV_KEY = 'sunscope_dashboard_env';
function getLocation() {
try { return JSON.parse(localStorage.getItem(LOC_KEY) || 'null'); } catch (e) { return null; }
}
function setLocation(loc) {
localStorage.setItem(LOC_KEY, JSON.stringify(loc));
renderLocation();
forecastDaysCache = null;
updateReadingPreview();
}
function getProfile() {
return localStorage.getItem(PROFILE_KEY) || 'basic';
}
function setProfile(p) {
localStorage.setItem(PROFILE_KEY, p);
}
function getEnv() {
const saved = localStorage.getItem(ENV_KEY);
return saved && UTCI_ENVIRONMENTS[saved] ? saved : 'open';
}
function setEnv(e) {
localStorage.setItem(ENV_KEY, e);
forecastDaysCache = null;
}
// Solar model options, same list and labels as the main app's selector -
// only actually moves the Basic/SunSoak reading (Home/Vehicle/Pets don't
// read the environment modifier), but it's offered alongside the profile
// regardless, same as the main app does.
for (const [key, env] of Object.entries(UTCI_ENVIRONMENTS)) {
const opt = document.createElement('option');
opt.value = key;
opt.textContent = env.label;
$('db-env-select').appendChild(opt);
}
$('db-env-select').value = getEnv();
$('db-env-select').addEventListener('change', () => {
setEnv($('db-env-select').value);
updateReadingPreview();
});
function renderLocation() {
const loc = getLocation();
const nameEl = $('db-loc-name');
if (loc) {
nameEl.textContent = loc.name;
nameEl.classList.remove('is-unset');
} else {
nameEl.textContent = 'No location set';
nameEl.classList.add('is-unset');
}
}
$('db-profile-select').value = getProfile();
$('db-profile-select').addEventListener('change', () => {
setProfile($('db-profile-select').value);
updateReadingPreview();
});
$('db-loc-change-btn').addEventListener('click', () => {
const search = $('db-loc-search');
search.hidden = !search.hidden;
if (!search.hidden) $('db-loc-input').focus();
});
async function runLocationSearch() {
const query = $('db-loc-input').value.trim();
const results = $('db-loc-results');
results.innerHTML = '';
if (!query) return;
let list = [];
try {
const res = await fetch(`https://geocoding-api.open-meteo.com/v1/search?name=${encodeURIComponent(query)}&count=5&language=en&format=json`);
const data = await res.json();
list = data.results || [];
} catch (e) {
results.innerHTML = '<span class="db-empty">Search failed - try again.</span>';
return;
}
if (!list.length) {
results.innerHTML = '<span class="db-empty">No matches found.</span>';
return;
}
for (const r of list) {
const btn = document.createElement('button');
btn.type = 'button';
btn.className = 'db-loc-result';
btn.textContent = `${r.name}${r.admin1 ? ', ' + r.admin1 : ''}, ${r.country}`;
btn.addEventListener('click', () => {
setLocation({ name: `${r.name}${r.admin1 ? ', ' + r.admin1 : ''}`, lat: r.latitude, lon: r.longitude });
$('db-loc-search').hidden = true;
$('db-loc-input').value = '';
results.innerHTML = '';
});
results.appendChild(btn);
}
}
$('db-loc-search-btn').addEventListener('click', runLocationSearch);
$('db-loc-input').addEventListener('keydown', (e) => {
if (e.key === 'Enter') { e.preventDefault(); runLocationSearch(); }
});
// ── Live forecast → computed reading ───────────────────────────────
// Same hourly fields the main app requests, same buildHourlyRows()
// engine, same "peak value of the day" rule the day tabs use for their
// headline number - see deriveProfileMain() in config.js.
function buildForecastUrl(loc) {
return `https://api.open-meteo.com/v1/forecast` +
`?latitude=${loc.lat}&longitude=${loc.lon}` +
`&hourly=temperature_2m,relative_humidity_2m,dew_point_2m,` +
`wind_speed_10m,wind_direction_10m,wind_gusts_10m,` +
`direct_radiation,diffuse_radiation,shortwave_radiation,` +
`cloud_cover,cloud_cover_low,cloud_cover_mid,cloud_cover_high,` +
`uv_index,precipitation,precipitation_probability,lightning_potential,cape,snowfall,visibility,` +
`soil_temperature_0cm,soil_temperature_6cm,soil_moisture_0_to_1cm` +
`&wind_speed_unit=ms&timezone=auto&forecast_days=14`;
}
let forecastDaysCache = null; // { cacheKey, days } - cacheKey includes the solar model, since that reshapes the computed rows
async function getForecastDays(loc) {
const fetchKey = `${loc.lat.toFixed(3)},${loc.lon.toFixed(3)}`;
const env = getEnv();
const cacheKey = fetchKey + ',' + env;
if (forecastDaysCache && forecastDaysCache.cacheKey === cacheKey) return forecastDaysCache.days;
const sessionKey = 'sunscope_dashboard_fc_' + fetchKey;
let forecast = null;
try {
const cached = JSON.parse(sessionStorage.getItem(sessionKey) || 'null');
if (cached && (Date.now() - cached.fetchedAt) < 30 * 60 * 1000) forecast = cached.data;
} catch (e) { /* ignore */ }
if (!forecast) {
const res = await fetch(buildForecastUrl(loc));
if (!res.ok) throw new Error('Could not reach the weather service.');
forecast = await res.json();
try { sessionStorage.setItem(sessionKey, JSON.stringify({ data: forecast, fetchedAt: Date.now() })); } catch (e) { /* ignore, e.g. quota */ }
}
const { days } = buildHourlyRows({
forecast, airQuality: null, location: loc,
vehicleType: 'car', vehicleVent: false, vehicleSpeed: 0, // mph - "Static"/parked; buildHourlyRows wants a number here, not the VEHICLE_SPEEDS key
buildingType: 'brick', furColor: 'brown', utciEnv: env,
});
forecastDaysCache = { cacheKey, days };
return days;
}
// Every date from start to end inclusive, capped at 60 days as a sanity
// bound - real coverage is limited by the 14-day forecast anyway.
function enumerateDates(start, end) {
const dates = [];
const d = new Date(start + 'T00:00:00Z');
const endD = new Date(end + 'T00:00:00Z');
for (let guard = 0; d <= endD && guard < 60; guard++) {
dates.push(d.toISOString().slice(0, 10));
d.setUTCDate(d.getUTCDate() + 1);
}
return dates;
}
// Peak value of the active profile's main field across the entry's date
// range - a single date behaves exactly as a day tab would (peak of that
// one day); a multi-day range takes the peak across every day in it that
// falls inside the 14-day forecast window. Returns null if none of the
// range is covered, or nothing is set up yet to compute it from.
async function computeReading(dateStr, endDateStr) {
const loc = getLocation();
if (!loc || !dateStr) return null;
let days;
try { days = await getForecastDays(loc); } catch (e) { return null; }
const { mainField } = deriveProfileMain(getProfile(), null);
const range = (endDateStr && endDateStr > dateStr) ? enumerateDates(dateStr, endDateStr) : [dateStr];
const allVals = [];
for (const key of range) {
const day = days.find(d => d.key === key);
if (day) allVals.push(...day.rows.map(r => r[mainField]).filter(v => Number.isFinite(v)));
}
if (!allVals.length) return null;
return Math.round(Math.max(...allVals) * 10) / 10;
}
// The end-date picker can't be set before the start date.
function syncEndDateMin() {
$('db-entry-end-date').min = $('db-entry-date').value;
if ($('db-entry-end-date').value && $('db-entry-end-date').value < $('db-entry-date').value) {
$('db-entry-end-date').value = '';
}
}
let currentReading = null;
// Bumped on every call so a slower, older lookup can never clobber a
// newer one's result - comparing the date/end-date fields alone wasn't
// enough, since profile/solar-model changes (or two lookups racing after
// a flaky network response) don't touch those fields at all.
let readingRequestSeq = 0;
async function updateReadingPreview() {
const requestId = ++readingRequestSeq;
const preview = $('db-reading-preview');
const date = $('db-entry-date').value;
const endDate = $('db-entry-end-date').value;
const isRange = endDate && endDate > date;
const loc = getLocation();
if (!loc) {
if (requestId !== readingRequestSeq) return;
preview.textContent = 'Set a location above to attach a reading to this entry.';
currentReading = null;
return;
}
preview.textContent = 'Checking the forecast…';
const reading = await computeReading(date, endDate);
// A newer call has since started (date/end-date/profile/solar-model
// changed again, or another lookup is already further along) - let
// that one own the result instead.
if (requestId !== readingRequestSeq) return;
currentReading = reading;
if (reading == null) {
preview.textContent = isRange
? "None of this range falls in the 14-day forecast - this entry will be note-only."
: 'Outside the 14-day forecast - this entry will be note-only.';
} else {
const when = isRange ? `peak across the range` : 'peak for the day';
preview.innerHTML = `Auto: <strong>${PROFILE_LABELS[getProfile()]} ${reading}°</strong> (${when}, ${UTCI_ENVIRONMENTS[getEnv()].label})`;
}
}
$('db-entry-date').addEventListener('input', () => { syncEndDateMin(); updateReadingPreview(); });
$('db-entry-end-date').addEventListener('input', updateReadingPreview);
// ── Journal ─────────────────────────────────────────────────────────
// Editing reuses the same form as adding - startEdit() fills it from an
// existing entry and remembers its id; the submit handler includes that
// id when present so journal-save.php updates instead of inserting. The
// reading is always recomputed fresh against whatever profile/solar
// model/location are currently selected, same as a brand new entry -
// an edited entry is never left showing a stale number.
let editingId = null;
function startEdit(entry) {
editingId = entry.id;
$('db-entry-date').value = entry.date;
$('db-entry-end-date').value = entry.endDate || '';
syncEndDateMin();
$('db-entry-note').value = entry.note;
$('db-entry-form-heading').textContent = 'Edit entry';
$('db-entry-submit').textContent = 'Update entry';
$('db-entry-cancel').hidden = false;
updateReadingPreview();
$('db-entry-form').scrollIntoView({ behavior: 'smooth', block: 'start' });
}
function cancelEdit() {
editingId = null;
$('db-entry-form').reset();
$('db-entry-date').valueAsDate = new Date();
syncEndDateMin();
$('db-entry-form-heading').textContent = 'Add an entry';
$('db-entry-submit').textContent = 'Save entry';
$('db-entry-cancel').hidden = true;
$('db-entry-error').hidden = true;
updateReadingPreview();
}
$('db-entry-cancel').addEventListener('click', cancelEdit);
$('db-entry-form').addEventListener('submit', async (e) => {
e.preventDefault();
$('db-entry-error').hidden = true;
const date = $('db-entry-date').value;
const endDate = $('db-entry-end-date').value;
const note = $('db-entry-note').value.trim();
const loc = getLocation();
const payload = { date, note };
if (editingId) payload.id = editingId;
if (endDate && endDate > date) payload.endDate = endDate;
if (currentReading != null && loc) {
payload.reading = currentReading;
payload.profile = getProfile();
payload.solarEnv = getEnv();
payload.locationName = loc.name;
payload.lat = loc.lat;
payload.lon = loc.lon;
}
try {
await api('journal-save.php', payload);
const wasEditing = editingId != null;
editingId = null;
$('db-entry-note').value = '';
$('db-entry-end-date').value = '';
if (wasEditing) {
$('db-entry-form-heading').textContent = 'Add an entry';
$('db-entry-submit').textContent = 'Save entry';
$('db-entry-cancel').hidden = true;
}
await loadEntries();
} catch (err) {
$('db-entry-error').textContent = err.message;
$('db-entry-error').hidden = false;
}
});
// Every entry, always shown in full - no filtering by the current
// profile/solar model. A reading only means what it says alongside
// whatever profile and solar model produced it, so each one carries that
// label with it (see the chip in renderEntriesList and the tooltip in
// renderGraph) rather than the page hiding entries that don't match
// what happens to be selected right now.
let allEntries = [];
async function loadEntries() {
const { entries } = await api('journal-list.php');
allEntries = entries;
renderEntriesList(allEntries);
renderGraph(allEntries.filter(e => e.reading != null));
}
// "2026-08-12" for a single day, "2026-08-12 2026-08-19" for a range.
function formatDateLabel(entry) {
return entry.endDate ? `${entry.date} ${entry.endDate}` : entry.date;
}
function renderEntriesList(entries) {
const list = $('db-entries-list');
list.innerHTML = '';
$('db-entries-empty').hidden = entries.length > 0;
for (const entry of entries) {
const li = document.createElement('li');
li.className = 'db-entry';
const dateSpan = document.createElement('span');
dateSpan.className = 'db-entry-date';
dateSpan.textContent = formatDateLabel(entry);
const noteSpan = document.createElement('span');
noteSpan.className = 'db-entry-note';
noteSpan.textContent = entry.note;
if (entry.reading != null) {
const chip = document.createElement('span');
chip.className = 'db-entry-reading';
chip.textContent = ` · ${PROFILE_LABELS[entry.profile] ?? entry.profile} ${entry.reading}°`;
const meta = document.createElement('span');
meta.className = 'db-entry-meta';
meta.textContent = ` (${UTCI_ENVIRONMENTS[entry.solarEnv]?.label ?? entry.solarEnv}${entry.locationName ? ' · ' + entry.locationName : ''})`;
noteSpan.append(chip, meta);
}
const actions = document.createElement('span');
actions.className = 'db-entry-actions';
const editBtn = document.createElement('button');
editBtn.className = 'db-entry-edit';
editBtn.textContent = 'Edit';
editBtn.addEventListener('click', () => startEdit(entry));
const delBtn = document.createElement('button');
delBtn.className = 'db-entry-del';
delBtn.textContent = 'Delete';
delBtn.addEventListener('click', async () => {
if (!confirm('Delete this entry? This can\'t be undone.')) return;
await api('journal-delete.php', { id: entry.id });
if (editingId === entry.id) cancelEdit();
await loadEntries();
});
actions.append(editBtn, delBtn);
li.append(dateSpan, noteSpan, actions);
list.appendChild(li);
}
}
// ── Graph ───────────────────────────────────────────────────────────
// Hand-rolled inline SVG, no charting library - house style (see the
// scope dial and day timeline in the main app). Plots every entry that
// carries a reading, oldest to newest.
function renderGraph(entries) {
const wrap = $('db-graph-wrap');
const points = entries
.filter(e => e.reading != null)
.slice()
.sort((a, b) => a.date.localeCompare(b.date));
wrap.innerHTML = '';
$('db-graph-empty').hidden = points.length > 0;
if (points.length === 0) return;
const W = 560, H = 180, padL = 34, padR = 12, padT = 14, padB = 22;
const innerW = W - padL - padR, innerH = H - padT - padB;
const values = points.map(p => p.reading);
let min = Math.min(...values), max = Math.max(...values);
if (min === max) { min -= 1; max += 1; } // flat lines still get visible headroom
const pad = (max - min) * 0.12;
min -= pad; max += pad;
const x = (i) => padL + (points.length === 1 ? innerW / 2 : (i / (points.length - 1)) * innerW);
const y = (v) => padT + innerH - ((v - min) / (max - min)) * innerH;
const linePath = points.map((p, i) => `${i === 0 ? 'M' : 'L'} ${x(i).toFixed(1)} ${y(p.reading).toFixed(1)}`).join(' ');
// Y-axis: min/mid/max gridlines with labels.
const ticks = [min + (max - min) * 0.02, (min + max) / 2, max - (max - min) * 0.02];
const svgParts = [];
svgParts.push(`<line class="db-graph-axis" x1="${padL}" y1="${padT}" x2="${padL}" y2="${padT + innerH}" />`);
svgParts.push(`<line class="db-graph-axis" x1="${padL}" y1="${padT + innerH}" x2="${padL + innerW}" y2="${padT + innerH}" />`);
for (const t of [min + pad, (min + max) / 2, max - pad]) {
const ty = y(t);
svgParts.push(`<text class="db-graph-label" x="${padL - 6}" y="${ty + 3}" text-anchor="end">${t.toFixed(1)}°</text>`);
}
// X-axis: first and last date only, to stay legible at any entry count.
svgParts.push(`<text class="db-graph-label" x="${x(0)}" y="${H - 4}" text-anchor="start">${points[0].date}</text>`);
if (points.length > 1) {
svgParts.push(`<text class="db-graph-label" x="${x(points.length - 1)}" y="${H - 4}" text-anchor="end">${points[points.length - 1].date}</text>`);
}
svgParts.push(`<path class="db-graph-line" d="${linePath}" />`);
points.forEach((p, i) => {
const envLabel = UTCI_ENVIRONMENTS[p.solarEnv]?.label ?? p.solarEnv;
svgParts.push(`<circle class="db-graph-dot" cx="${x(i).toFixed(1)}" cy="${y(p.reading).toFixed(1)}" r="3.5"><title>${formatDateLabel(p)} · ${PROFILE_LABELS[p.profile] ?? p.profile} ${p.reading}° (${envLabel})${p.locationName ? ' · ' + p.locationName : ''}</title></circle>`);
});
wrap.innerHTML = `<svg class="db-graph-svg" viewBox="0 0 ${W} ${H}" preserveAspectRatio="xMidYMid meet">${svgParts.join('')}</svg>`;
}
async function showDashboard() {
$('db-signed-out').hidden = true;
$('db-signed-in').hidden = false;
$('db-entry-date').valueAsDate = new Date();
syncEndDateMin();
renderLocation();
updateReadingPreview();
await loadEntries();
}
// Nav dropdown's Subscription link - same rule as nav-account.js, just
// set directly here since this page already has its own boot sequence.
const subLink = $('nav-account-subscription');
if (subLink) {
subLink.href = localStorage.getItem('sunscope_pro') === '1'
? 'https://billing.stripe.com/p/login/9B63cw7vl8k15Ei9DQd7q00'
: 'https://buy.stripe.com/9B63cw7vl8k15Ei9DQd7q00';
}
// ── Boot: check for an existing session ────────────────────────────
api('me.php').then(({ user }) => {
if (user) {
$('db-account-email').textContent = user.email;
syncNavAccountLink(true);
showDashboard();
}
});
</script>
</body>
</html>
+30
View File
@@ -205,5 +205,35 @@
"profiles": { "profiles": {
"vehicle": 1 "vehicle": 1
} }
},
"2026-08-26": {
"visits": 99,
"profiles": {
"basic": 15,
"home": 13,
"vehicle": 11,
"pets": 8,
"alltemps": 5,
"showall": 4,
"custom": 1
}
},
"2026-08-27": {
"visits": 82,
"profiles": {
"basic": 8,
"showall": 3,
"alltemps": 11,
"vehicle": 9,
"home": 10,
"pets": 13,
"farming": 2
}
},
"2026-08-30": {
"visits": 17,
"profiles": {
"showall": 1
}
} }
} }
+43
View File
@@ -0,0 +1,43 @@
-- SunScope Dashboard — initial schema.
-- Minimal v1: accounts (email + password) and journal entries.
-- No Stripe/entitlement linking yet — that stays in the front-end
-- localStorage Pro check for now, same as the rest of the site.
CREATE TABLE IF NOT EXISTS schema_migrations (
version VARCHAR(32) NOT NULL PRIMARY KEY,
applied_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE TABLE IF NOT EXISTS users (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
public_id CHAR(22) NOT NULL,
email VARCHAR(255) NOT NULL,
password_hash VARCHAR(255) NOT NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
UNIQUE KEY uq_users_public_id (public_id),
UNIQUE KEY uq_users_email (email)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE TABLE IF NOT EXISTS sessions (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
user_id BIGINT UNSIGNED NOT NULL,
token_hash BINARY(32) NOT NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
expires_at DATETIME NOT NULL,
KEY ix_sessions_user (user_id),
UNIQUE KEY uq_sessions_token_hash (token_hash),
CONSTRAINT fk_sessions_user FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE TABLE IF NOT EXISTS journal_entries (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
user_id BIGINT UNSIGNED NOT NULL,
entry_date DATE NOT NULL,
note TEXT NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
KEY ix_journal_user_date (user_id, entry_date),
CONSTRAINT fk_journal_user FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
INSERT IGNORE INTO schema_migrations (version) VALUES ('001_init');
+14
View File
@@ -0,0 +1,14 @@
-- Adds an optional computed reading to a journal entry, captured the same
-- way the day tabs derive their headline number: peak value of the active
-- profile's main field across that day's hourly forecast rows. Only
-- possible for dates inside the 14-day forecast window, so it's nullable -
-- older/out-of-range entries stay note-only.
ALTER TABLE journal_entries
ADD COLUMN profile VARCHAR(32) NULL AFTER note,
ADD COLUMN reading DECIMAL(5,1) NULL AFTER profile,
ADD COLUMN location_name VARCHAR(255) NULL AFTER reading,
ADD COLUMN lat DECIMAL(8,5) NULL AFTER location_name,
ADD COLUMN lon DECIMAL(8,5) NULL AFTER lat;
INSERT IGNORE INTO schema_migrations (version) VALUES ('002_add_reading');
+8
View File
@@ -0,0 +1,8 @@
-- The solar model (environment modifier) changes the SunSoak reading just
-- as much as the profile does, so a reading needs both stored to mean
-- anything when filtering the journal/graph by "what's currently selected".
ALTER TABLE journal_entries
ADD COLUMN solar_env VARCHAR(16) NULL AFTER profile;
INSERT IGNORE INTO schema_migrations (version) VALUES ('003_add_solar_env');
+8
View File
@@ -0,0 +1,8 @@
-- A note can now span a period rather than one day (e.g. a holiday week).
-- entry_date remains the start of the range; end_date is NULL for a
-- single-day entry, keeping every existing row valid with no backfill.
ALTER TABLE journal_entries
ADD COLUMN end_date DATE NULL AFTER entry_date;
INSERT IGNORE INTO schema_migrations (version) VALUES ('004_add_end_date');
+14 -3
View File
@@ -72,7 +72,10 @@
<header id="site-nav"> <header id="site-nav">
<div class="nav-overlay" onclick="toggleNav(false)"></div> <div class="nav-overlay" onclick="toggleNav(false)"></div>
<a href="./index.html" class="logo"><span class="logo-sun">Sun</span><svg class="logo-icon" viewBox="0 0 174 173" aria-hidden="true" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:1.5;"><g transform="matrix(0.986226,0,0,0.986226,-585.724252,-110.041209)"><clipPath id="ss-clip-fq"><circle cx="681.9838" cy="199.4502" r="83.2987"/></clipPath><g clip-path="url(#ss-clip-fq)"><g transform="matrix(0.948836,0,0,0.948836,19.787839,-2.49381)"><path d="M666.992,233.0768C666.3949,230.6581 666.1785,228.6317 666.1785,226.0296C666.1785,208.6786 680.2653,194.5919 697.6163,194.5919C714.9672,194.5919 729.054,208.6786 729.054,226.0296C729.054,228.4125 728.9892,230.9346 728.4859,233.1663" fill="none" stroke="#c8922a" stroke-width="7.48"/></g><g transform="matrix(0.520349,0,0,0.520349,304.766642,64.226716)"><path d="M724.9315,291.8131C777.359,291.8131 826.5862,305.6245 869.1698,329.8044" fill="none" stroke="currentColor" stroke-width="13.64"/></g><g transform="matrix(0.520349,0,0,0.520349,304.766642,64.226716)"><path d="M580.8436,329.719C623.3927,305.592 672.5657,291.8131 724.9315,291.8131" fill="none" stroke="currentColor" stroke-width="13.64"/></g></g><circle cx="681.9838" cy="199.4502" r="83.2987" fill="none" stroke="currentColor" stroke-width="9.13"/></g></svg><span class="logo-scope">Scope</span></a> <a href="./index.html" class="nav-logo">
<span class="nav-logo-wordmark"><span class="nav-logo-sun">Sun</span><svg class="nav-logo-icon" viewBox="0 0 174 173" aria-hidden="true" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:1.5;"><g transform="matrix(0.986226,0,0,0.986226,-585.724252,-110.041209)"><clipPath id="ss-clip-fq"><circle cx="681.9838" cy="199.4502" r="83.2987"/></clipPath><g clip-path="url(#ss-clip-fq)"><g transform="matrix(0.948836,0,0,0.948836,19.748711,-12.198562)"><path d="M666.992,233.0768C666.3949,230.6581 666.1785,228.6317 666.1785,226.0296C666.1785,208.6786 680.2653,194.5919 697.6163,194.5919C714.9672,194.5919 729.054,208.6786 729.054,226.0296C729.054,228.4125 728.9892,230.9346 728.4859,233.1663" fill="#f4b047" stroke="#f4b047" stroke-width="7.48" stroke-linecap="round"/></g><g transform="matrix(0.520349,0,0,0.520349,304.727514,54.521964)"><path d="M724.9315,291.8131C777.359,291.8131 826.5862,305.6245 869.1698,329.8044" fill="none" stroke="currentColor" stroke-width="13.64"/></g><g transform="matrix(0.520349,0,0,0.520349,304.727514,54.521964)"><path d="M580.8436,329.719C623.3927,305.592 672.5657,291.8131 724.9315,291.8131" fill="none" stroke="currentColor" stroke-width="13.64"/></g></g><circle cx="681.9838" cy="199.4502" r="83.2987" fill="none" stroke="currentColor" stroke-width="9.13"/></g></svg><span class="nav-logo-scope">Scope</span></span>
<span class="nav-logo-tag">See the world the way your skin does.</span>
</a>
<div class="nav-links" id="nav-drawer"> <div class="nav-links" id="nav-drawer">
<a href="./index.html">Forecast</a> <a href="./index.html">Forecast</a>
<div class="nav-dropdown"> <div class="nav-dropdown">
@@ -88,7 +91,14 @@
</div> </div>
</div> </div>
<a href="./faq.html" class="active">FAQ</a> <a href="./faq.html" class="active">FAQ</a>
<a href="https://billing.stripe.com/p/login/9B63cw7vl8k15Ei9DQd7q00" target="_blank" rel="noopener noreferrer">Account</a> <div class="nav-dropdown nav-dropdown--right">
<a href="./dashboard.html" class="nav-dropdown-toggle">Account<span class="caret"></span></a>
<div class="nav-dropdown-menu">
<a href="./dashboard.html">Dashboard</a>
<a id="nav-account-subscription" href="https://buy.stripe.com/9B63cw7vl8k15Ei9DQd7q00" target="_blank" rel="noopener noreferrer">Subscription</a>
<a id="nav-account-signinout" href="./dashboard.html">Sign in</a>
</div>
</div>
</div> </div>
<button class="utci-burger" aria-label="Open menu" aria-expanded="false" id="burger-btn" onclick="toggleNav()"> <button class="utci-burger" aria-label="Open menu" aria-expanded="false" id="burger-btn" onclick="toggleNav()">
<span></span><span></span><span></span> <span></span><span></span><span></span>
@@ -105,6 +115,7 @@
btn.setAttribute('aria-expanded', open ? 'true' : 'false'); btn.setAttribute('aria-expanded', open ? 'true' : 'false');
} }
</script> </script>
<script src="./assets/js/nav-account.js" defer></script>
<main> <main>
<h1>Frequently Asked Questions</h1> <h1>Frequently Asked Questions</h1>
@@ -358,7 +369,7 @@
</main> </main>
<footer> <footer>
&copy; 2026 <a href="https://fraxle.net" target="_blank" rel="noopener noreferrer">Fraxle.NET</a> · <a href="./index.html">Forecast</a> · <a href="./about.html">About</a> · <a href="./faq.html">FAQ</a> · <a href="https://billing.stripe.com/p/login/9B63cw7vl8k15Ei9DQd7q00" target="_blank" rel="noopener noreferrer">Account</a> · Data: <a href="https://open-meteo.com/" target="_blank" rel="noopener noreferrer">Open-Meteo</a> · UTCI: <a href="https://utci.org/" target="_blank" rel="noopener noreferrer">Bröde 2012</a> &copy; 2026 <a href="https://fraxle.net" target="_blank" rel="noopener noreferrer">Fraxle.NET</a> · <a href="./index.html">Forecast</a> · <a href="./about.html">About</a> · <a href="./faq.html">FAQ</a> · · <a href="./dashboard.html">Dashboard</a> · <a href="https://billing.stripe.com/p/login/9B63cw7vl8k15Ei9DQd7q00" target="_blank" rel="noopener noreferrer">Account</a> · Data: <a href="https://open-meteo.com/" target="_blank" rel="noopener noreferrer">Open-Meteo</a> · UTCI: <a href="https://utci.org/" target="_blank" rel="noopener noreferrer">Bröde 2012</a>
</footer> </footer>
</body> </body>
+15 -3
View File
@@ -36,7 +36,10 @@
<header id="site-nav"> <header id="site-nav">
<div class="nav-overlay" onclick="toggleNav(false)"></div> <div class="nav-overlay" onclick="toggleNav(false)"></div>
<a href="./index.html" class="logo"><span class="logo-sun">Sun</span><svg class="logo-icon" viewBox="0 0 174 173" aria-hidden="true" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:1.5;"><g transform="matrix(0.986226,0,0,0.986226,-585.724252,-110.041209)"><clipPath id="ss-clip-fe"><circle cx="681.9838" cy="199.4502" r="83.2987"/></clipPath><g clip-path="url(#ss-clip-fe)"><g transform="matrix(0.948836,0,0,0.948836,19.787839,-2.49381)"><path d="M666.992,233.0768C666.3949,230.6581 666.1785,228.6317 666.1785,226.0296C666.1785,208.6786 680.2653,194.5919 697.6163,194.5919C714.9672,194.5919 729.054,208.6786 729.054,226.0296C729.054,228.4125 728.9892,230.9346 728.4859,233.1663" fill="none" stroke="#c8922a" stroke-width="7.48"/></g><g transform="matrix(0.520349,0,0,0.520349,304.766642,64.226716)"><path d="M724.9315,291.8131C777.359,291.8131 826.5862,305.6245 869.1698,329.8044" fill="none" stroke="currentColor" stroke-width="13.64"/></g><g transform="matrix(0.520349,0,0,0.520349,304.766642,64.226716)"><path d="M580.8436,329.719C623.3927,305.592 672.5657,291.8131 724.9315,291.8131" fill="none" stroke="currentColor" stroke-width="13.64"/></g></g><circle cx="681.9838" cy="199.4502" r="83.2987" fill="none" stroke="currentColor" stroke-width="9.13"/></g></svg><span class="logo-scope">Scope</span></a> <a href="./index.html" class="nav-logo">
<span class="nav-logo-wordmark"><span class="nav-logo-sun">Sun</span><svg class="nav-logo-icon" viewBox="0 0 174 173" aria-hidden="true" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:1.5;"><g transform="matrix(0.986226,0,0,0.986226,-585.724252,-110.041209)"><clipPath id="ss-clip-fe"><circle cx="681.9838" cy="199.4502" r="83.2987"/></clipPath><g clip-path="url(#ss-clip-fe)"><g transform="matrix(0.948836,0,0,0.948836,19.748711,-12.198562)"><path d="M666.992,233.0768C666.3949,230.6581 666.1785,228.6317 666.1785,226.0296C666.1785,208.6786 680.2653,194.5919 697.6163,194.5919C714.9672,194.5919 729.054,208.6786 729.054,226.0296C729.054,228.4125 728.9892,230.9346 728.4859,233.1663" fill="#f4b047" stroke="#f4b047" stroke-width="7.48" stroke-linecap="round"/></g><g transform="matrix(0.520349,0,0,0.520349,304.727514,54.521964)"><path d="M724.9315,291.8131C777.359,291.8131 826.5862,305.6245 869.1698,329.8044" fill="none" stroke="currentColor" stroke-width="13.64"/></g><g transform="matrix(0.520349,0,0,0.520349,304.727514,54.521964)"><path d="M580.8436,329.719C623.3927,305.592 672.5657,291.8131 724.9315,291.8131" fill="none" stroke="currentColor" stroke-width="13.64"/></g></g><circle cx="681.9838" cy="199.4502" r="83.2987" fill="none" stroke="currentColor" stroke-width="9.13"/></g></svg><span class="nav-logo-scope">Scope</span></span>
<span class="nav-logo-tag">See the world the way your skin does.</span>
</a>
<div class="nav-links" id="nav-drawer"> <div class="nav-links" id="nav-drawer">
<a href="./index.html">Forecast</a> <a href="./index.html">Forecast</a>
<div class="nav-dropdown"> <div class="nav-dropdown">
@@ -52,7 +55,14 @@
</div> </div>
</div> </div>
<a href="./faq.html">FAQ</a> <a href="./faq.html">FAQ</a>
<a href="https://billing.stripe.com/p/login/9B63cw7vl8k15Ei9DQd7q00" target="_blank" rel="noopener noreferrer">Account</a> <div class="nav-dropdown nav-dropdown--right">
<a href="./dashboard.html" class="nav-dropdown-toggle">Account<span class="caret"></span></a>
<div class="nav-dropdown-menu">
<a href="./dashboard.html">Dashboard</a>
<a id="nav-account-subscription" href="https://buy.stripe.com/9B63cw7vl8k15Ei9DQd7q00" target="_blank" rel="noopener noreferrer">Subscription</a>
<a id="nav-account-signinout" href="./dashboard.html">Sign in</a>
</div>
</div>
</div> </div>
<button class="utci-burger" aria-label="Open menu" aria-expanded="false" id="burger-btn" onclick="toggleNav()"> <button class="utci-burger" aria-label="Open menu" aria-expanded="false" id="burger-btn" onclick="toggleNav()">
<span></span><span></span><span></span> <span></span><span></span><span></span>
@@ -69,6 +79,7 @@
btn.setAttribute('aria-expanded', open ? 'true' : 'false'); btn.setAttribute('aria-expanded', open ? 'true' : 'false');
} }
</script> </script>
<script src="./assets/js/nav-account.js" defer></script>
<main> <main>
<h1>On-screen features</h1> <h1>On-screen features</h1>
@@ -163,7 +174,8 @@
</main> </main>
<footer> <footer>
&copy; 2026 <a href="https://fraxle.net" target="_blank" rel="noopener noreferrer">Fraxle.NET</a> · <a href="./index.html">Forecast</a> · <a href="./about.html">About</a> · <a href="./faq.html">FAQ</a> · <a href="https://billing.stripe.com/p/login/9B63cw7vl8k15Ei9DQd7q00" target="_blank" rel="noopener noreferrer">Account</a> · Data: <a href="https://open-meteo.com/" target="_blank" rel="noopener noreferrer">Open-Meteo</a> · UTCI: <a href="https://utci.org/" target="_blank" rel="noopener noreferrer">Bröde 2012</a> &copy; 2026 <a href="https://fraxle.net" target="_blank" rel="noopener noreferrer">Fraxle.NET</a> · <a href="./index.html">Forecast</a> · <a href="./about.html">About</a> · <a href="./faq.html">FAQ</a>
· <a href="./dashboard.html">Dashboard</a> · <a href="https://billing.stripe.com/p/login/9B63cw7vl8k15Ei9DQd7q00" target="_blank" rel="noopener noreferrer">Account</a> · Data: <a href="https://open-meteo.com/" target="_blank" rel="noopener noreferrer">Open-Meteo</a> · UTCI: <a href="https://utci.org/" target="_blank" rel="noopener noreferrer">Bröde 2012</a>
</footer> </footer>
</body> </body>
+68
View File
@@ -0,0 +1,68 @@
<?php
// ─── SunScope — Session auth ──────────────────────────────────────────────
// Opaque DB-backed session token in an HttpOnly cookie, not $_SESSION —
// keeps the door open for a future magic-link flow without a rewrite, and
// means a session can be revoked by deleting one row.
//
// The cookie holds the plaintext token; only its SHA-256 hash is stored,
// same pattern as a password hash, so a DB read alone can't forge a login.
const SESSION_COOKIE = 'ss_session';
const SESSION_DAYS = 30;
function new_public_id(): string {
return rtrim(strtr(base64_encode(random_bytes(16)), '+/', '-_'), '=');
}
function issue_session(int $userId): void {
$token = rtrim(strtr(base64_encode(random_bytes(32)), '+/', '-_'), '=');
$hash = hash('sha256', $token, true);
$expiresAt = (new DateTime())->modify('+' . SESSION_DAYS . ' days')->format('Y-m-d H:i:s');
$stmt = db()->prepare(
'INSERT INTO sessions (user_id, token_hash, expires_at) VALUES (?, ?, ?)'
);
$stmt->execute([$userId, $hash, $expiresAt]);
setcookie(SESSION_COOKIE, $token, [
'expires' => time() + SESSION_DAYS * 86400,
'path' => '/',
'httponly' => true,
'samesite' => 'Lax',
// 'secure' left off for local XAMPP http:// dev; set true behind HTTPS in production.
]);
}
function clear_session(): void {
$token = $_COOKIE[SESSION_COOKIE] ?? null;
if ($token) {
$hash = hash('sha256', $token, true);
$stmt = db()->prepare('DELETE FROM sessions WHERE token_hash = ?');
$stmt->execute([$hash]);
}
setcookie(SESSION_COOKIE, '', ['expires' => time() - 3600, 'path' => '/']);
}
// Returns the logged-in user's row, or null if there is no valid session.
function current_user(): ?array {
$token = $_COOKIE[SESSION_COOKIE] ?? null;
if (!$token) return null;
$hash = hash('sha256', $token, true);
$stmt = db()->prepare(
'SELECT u.id, u.public_id, u.email
FROM sessions s
JOIN users u ON u.id = s.user_id
WHERE s.token_hash = ? AND s.expires_at > NOW()'
);
$stmt->execute([$hash]);
$row = $stmt->fetch();
return $row ?: null;
}
// Ends the request with 401 if not logged in; otherwise returns the user row.
function require_user(): array {
$user = current_user();
if (!$user) json_error('Not signed in', 401);
return $user;
}
+8
View File
@@ -0,0 +1,8 @@
<?php
// ─── SunScope — API bootstrap ─────────────────────────────────────────────
// require'd at the top of every api/*.php endpoint.
require __DIR__ . '/../secrets.local.php';
require __DIR__ . '/db.php';
require __DIR__ . '/http.php';
require __DIR__ . '/auth.php';
+21
View File
@@ -0,0 +1,21 @@
<?php
// ─── SunScope — Database connection ──────────────────────────────────────
// One shared PDO connection, MySQL/MariaDB via XAMPP locally. Credentials
// live in secrets.local.php alongside the Stripe key, never committed.
function db(): PDO {
static $pdo = null;
if ($pdo !== null) return $pdo;
$pdo = new PDO(
'mysql:host=' . DB_HOST . ';dbname=' . DB_NAME . ';charset=utf8mb4',
DB_USER,
DB_PASS,
[
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
PDO::ATTR_EMULATE_PREPARES => false,
]
);
return $pdo;
}
+24
View File
@@ -0,0 +1,24 @@
<?php
// ─── SunScope — JSON response helpers ────────────────────────────────────
function json_out(array $data, int $status = 200): void {
http_response_code($status);
header('Content-Type: application/json');
echo json_encode($data);
exit;
}
function json_error(string $message, int $status = 400): void {
json_out(['ok' => false, 'error' => $message], $status);
}
function json_body(): array {
$body = json_decode(file_get_contents('php://input'), true);
return is_array($body) ? $body : [];
}
function require_method(string $method): void {
if ($_SERVER['REQUEST_METHOD'] !== $method) {
json_error('Method not allowed', 405);
}
}
+15 -3
View File
@@ -36,7 +36,10 @@
<header id="site-nav"> <header id="site-nav">
<div class="nav-overlay" onclick="toggleNav(false)"></div> <div class="nav-overlay" onclick="toggleNav(false)"></div>
<a href="./index.html" class="logo"><span class="logo-sun">Sun</span><svg class="logo-icon" viewBox="0 0 174 173" aria-hidden="true" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:1.5;"><g transform="matrix(0.986226,0,0,0.986226,-585.724252,-110.041209)"><clipPath id="ss-clip-pr"><circle cx="681.9838" cy="199.4502" r="83.2987"/></clipPath><g clip-path="url(#ss-clip-pr)"><g transform="matrix(0.948836,0,0,0.948836,19.787839,-2.49381)"><path d="M666.992,233.0768C666.3949,230.6581 666.1785,228.6317 666.1785,226.0296C666.1785,208.6786 680.2653,194.5919 697.6163,194.5919C714.9672,194.5919 729.054,208.6786 729.054,226.0296C729.054,228.4125 728.9892,230.9346 728.4859,233.1663" fill="none" stroke="#c8922a" stroke-width="7.48"/></g><g transform="matrix(0.520349,0,0,0.520349,304.766642,64.226716)"><path d="M724.9315,291.8131C777.359,291.8131 826.5862,305.6245 869.1698,329.8044" fill="none" stroke="currentColor" stroke-width="13.64"/></g><g transform="matrix(0.520349,0,0,0.520349,304.766642,64.226716)"><path d="M580.8436,329.719C623.3927,305.592 672.5657,291.8131 724.9315,291.8131" fill="none" stroke="currentColor" stroke-width="13.64"/></g></g><circle cx="681.9838" cy="199.4502" r="83.2987" fill="none" stroke="currentColor" stroke-width="9.13"/></g></svg><span class="logo-scope">Scope</span></a> <a href="./index.html" class="nav-logo">
<span class="nav-logo-wordmark"><span class="nav-logo-sun">Sun</span><svg class="nav-logo-icon" viewBox="0 0 174 173" aria-hidden="true" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:1.5;"><g transform="matrix(0.986226,0,0,0.986226,-585.724252,-110.041209)"><clipPath id="ss-clip-pr"><circle cx="681.9838" cy="199.4502" r="83.2987"/></clipPath><g clip-path="url(#ss-clip-pr)"><g transform="matrix(0.948836,0,0,0.948836,19.748711,-12.198562)"><path d="M666.992,233.0768C666.3949,230.6581 666.1785,228.6317 666.1785,226.0296C666.1785,208.6786 680.2653,194.5919 697.6163,194.5919C714.9672,194.5919 729.054,208.6786 729.054,226.0296C729.054,228.4125 728.9892,230.9346 728.4859,233.1663" fill="#f4b047" stroke="#f4b047" stroke-width="7.48" stroke-linecap="round"/></g><g transform="matrix(0.520349,0,0,0.520349,304.727514,54.521964)"><path d="M724.9315,291.8131C777.359,291.8131 826.5862,305.6245 869.1698,329.8044" fill="none" stroke="currentColor" stroke-width="13.64"/></g><g transform="matrix(0.520349,0,0,0.520349,304.727514,54.521964)"><path d="M580.8436,329.719C623.3927,305.592 672.5657,291.8131 724.9315,291.8131" fill="none" stroke="currentColor" stroke-width="13.64"/></g></g><circle cx="681.9838" cy="199.4502" r="83.2987" fill="none" stroke="currentColor" stroke-width="9.13"/></g></svg><span class="nav-logo-scope">Scope</span></span>
<span class="nav-logo-tag">See the world the way your skin does.</span>
</a>
<div class="nav-links" id="nav-drawer"> <div class="nav-links" id="nav-drawer">
<a href="./index.html">Forecast</a> <a href="./index.html">Forecast</a>
<div class="nav-dropdown"> <div class="nav-dropdown">
@@ -52,7 +55,14 @@
</div> </div>
</div> </div>
<a href="./faq.html">FAQ</a> <a href="./faq.html">FAQ</a>
<a href="https://billing.stripe.com/p/login/9B63cw7vl8k15Ei9DQd7q00" target="_blank" rel="noopener noreferrer">Account</a> <div class="nav-dropdown nav-dropdown--right">
<a href="./dashboard.html" class="nav-dropdown-toggle">Account<span class="caret"></span></a>
<div class="nav-dropdown-menu">
<a href="./dashboard.html">Dashboard</a>
<a id="nav-account-subscription" href="https://buy.stripe.com/9B63cw7vl8k15Ei9DQd7q00" target="_blank" rel="noopener noreferrer">Subscription</a>
<a id="nav-account-signinout" href="./dashboard.html">Sign in</a>
</div>
</div>
</div> </div>
<button class="utci-burger" aria-label="Open menu" aria-expanded="false" id="burger-btn" onclick="toggleNav()"> <button class="utci-burger" aria-label="Open menu" aria-expanded="false" id="burger-btn" onclick="toggleNav()">
<span></span><span></span><span></span> <span></span><span></span><span></span>
@@ -69,6 +79,7 @@
btn.setAttribute('aria-expanded', open ? 'true' : 'false'); btn.setAttribute('aria-expanded', open ? 'true' : 'false');
} }
</script> </script>
<script src="./assets/js/nav-account.js" defer></script>
<main> <main>
<h1>Forecast profiles</h1> <h1>Forecast profiles</h1>
@@ -447,7 +458,8 @@
</main> </main>
<footer> <footer>
&copy; 2026 <a href="https://fraxle.net" target="_blank" rel="noopener noreferrer">Fraxle.NET</a> · <a href="./index.html">Forecast</a> · <a href="./about.html">About</a> · <a href="./faq.html">FAQ</a> · <a href="https://billing.stripe.com/p/login/9B63cw7vl8k15Ei9DQd7q00" target="_blank" rel="noopener noreferrer">Account</a> · Data: <a href="https://open-meteo.com/" target="_blank" rel="noopener noreferrer">Open-Meteo</a> · UTCI: <a href="https://utci.org/" target="_blank" rel="noopener noreferrer">Bröde 2012</a> &copy; 2026 <a href="https://fraxle.net" target="_blank" rel="noopener noreferrer">Fraxle.NET</a> · <a href="./index.html">Forecast</a> · <a href="./about.html">About</a> · <a href="./faq.html">FAQ</a>
· <a href="./dashboard.html">Dashboard</a> · <a href="https://billing.stripe.com/p/login/9B63cw7vl8k15Ei9DQd7q00" target="_blank" rel="noopener noreferrer">Account</a> · Data: <a href="https://open-meteo.com/" target="_blank" rel="noopener noreferrer">Open-Meteo</a> · UTCI: <a href="https://utci.org/" target="_blank" rel="noopener noreferrer">Bröde 2012</a>
</footer> </footer>
</body> </body>
+15 -3
View File
@@ -36,7 +36,10 @@
<header id="site-nav"> <header id="site-nav">
<div class="nav-overlay" onclick="toggleNav(false)"></div> <div class="nav-overlay" onclick="toggleNav(false)"></div>
<a href="./index.html" class="logo"><span class="logo-sun">Sun</span><svg class="logo-icon" viewBox="0 0 174 173" aria-hidden="true" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:1.5;"><g transform="matrix(0.986226,0,0,0.986226,-585.724252,-110.041209)"><clipPath id="ss-clip-sb"><circle cx="681.9838" cy="199.4502" r="83.2987"/></clipPath><g clip-path="url(#ss-clip-sb)"><g transform="matrix(0.948836,0,0,0.948836,19.787839,-2.49381)"><path d="M666.992,233.0768C666.3949,230.6581 666.1785,228.6317 666.1785,226.0296C666.1785,208.6786 680.2653,194.5919 697.6163,194.5919C714.9672,194.5919 729.054,208.6786 729.054,226.0296C729.054,228.4125 728.9892,230.9346 728.4859,233.1663" fill="none" stroke="#c8922a" stroke-width="7.48"/></g><g transform="matrix(0.520349,0,0,0.520349,304.766642,64.226716)"><path d="M724.9315,291.8131C777.359,291.8131 826.5862,305.6245 869.1698,329.8044" fill="none" stroke="currentColor" stroke-width="13.64"/></g><g transform="matrix(0.520349,0,0,0.520349,304.766642,64.226716)"><path d="M580.8436,329.719C623.3927,305.592 672.5657,291.8131 724.9315,291.8131" fill="none" stroke="currentColor" stroke-width="13.64"/></g></g><circle cx="681.9838" cy="199.4502" r="83.2987" fill="none" stroke="currentColor" stroke-width="9.13"/></g></svg><span class="logo-scope">Scope</span></a> <a href="./index.html" class="nav-logo">
<span class="nav-logo-wordmark"><span class="nav-logo-sun">Sun</span><svg class="nav-logo-icon" viewBox="0 0 174 173" aria-hidden="true" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:1.5;"><g transform="matrix(0.986226,0,0,0.986226,-585.724252,-110.041209)"><clipPath id="ss-clip-sb"><circle cx="681.9838" cy="199.4502" r="83.2987"/></clipPath><g clip-path="url(#ss-clip-sb)"><g transform="matrix(0.948836,0,0,0.948836,19.748711,-12.198562)"><path d="M666.992,233.0768C666.3949,230.6581 666.1785,228.6317 666.1785,226.0296C666.1785,208.6786 680.2653,194.5919 697.6163,194.5919C714.9672,194.5919 729.054,208.6786 729.054,226.0296C729.054,228.4125 728.9892,230.9346 728.4859,233.1663" fill="#f4b047" stroke="#f4b047" stroke-width="7.48" stroke-linecap="round"/></g><g transform="matrix(0.520349,0,0,0.520349,304.727514,54.521964)"><path d="M724.9315,291.8131C777.359,291.8131 826.5862,305.6245 869.1698,329.8044" fill="none" stroke="currentColor" stroke-width="13.64"/></g><g transform="matrix(0.520349,0,0,0.520349,304.727514,54.521964)"><path d="M580.8436,329.719C623.3927,305.592 672.5657,291.8131 724.9315,291.8131" fill="none" stroke="currentColor" stroke-width="13.64"/></g></g><circle cx="681.9838" cy="199.4502" r="83.2987" fill="none" stroke="currentColor" stroke-width="9.13"/></g></svg><span class="nav-logo-scope">Scope</span></span>
<span class="nav-logo-tag">See the world the way your skin does.</span>
</a>
<div class="nav-links" id="nav-drawer"> <div class="nav-links" id="nav-drawer">
<a href="./index.html">Forecast</a> <a href="./index.html">Forecast</a>
<div class="nav-dropdown"> <div class="nav-dropdown">
@@ -52,7 +55,14 @@
</div> </div>
</div> </div>
<a href="./faq.html">FAQ</a> <a href="./faq.html">FAQ</a>
<a href="https://billing.stripe.com/p/login/9B63cw7vl8k15Ei9DQd7q00" target="_blank" rel="noopener noreferrer">Account</a> <div class="nav-dropdown nav-dropdown--right">
<a href="./dashboard.html" class="nav-dropdown-toggle">Account<span class="caret"></span></a>
<div class="nav-dropdown-menu">
<a href="./dashboard.html">Dashboard</a>
<a id="nav-account-subscription" href="https://buy.stripe.com/9B63cw7vl8k15Ei9DQd7q00" target="_blank" rel="noopener noreferrer">Subscription</a>
<a id="nav-account-signinout" href="./dashboard.html">Sign in</a>
</div>
</div>
</div> </div>
<button class="utci-burger" aria-label="Open menu" aria-expanded="false" id="burger-btn" onclick="toggleNav()"> <button class="utci-burger" aria-label="Open menu" aria-expanded="false" id="burger-btn" onclick="toggleNav()">
<span></span><span></span><span></span> <span></span><span></span><span></span>
@@ -69,6 +79,7 @@
btn.setAttribute('aria-expanded', open ? 'true' : 'false'); btn.setAttribute('aria-expanded', open ? 'true' : 'false');
} }
</script> </script>
<script src="./assets/js/nav-account.js" defer></script>
<main> <main>
<h1>Reading the stress bands</h1> <h1>Reading the stress bands</h1>
@@ -107,7 +118,8 @@
</main> </main>
<footer> <footer>
&copy; 2026 <a href="https://fraxle.net" target="_blank" rel="noopener noreferrer">Fraxle.NET</a> · <a href="./index.html">Forecast</a> · <a href="./about.html">About</a> · <a href="./faq.html">FAQ</a> · <a href="https://billing.stripe.com/p/login/9B63cw7vl8k15Ei9DQd7q00" target="_blank" rel="noopener noreferrer">Account</a> · Data: <a href="https://open-meteo.com/" target="_blank" rel="noopener noreferrer">Open-Meteo</a> · UTCI: <a href="https://utci.org/" target="_blank" rel="noopener noreferrer">Bröde 2012</a> &copy; 2026 <a href="https://fraxle.net" target="_blank" rel="noopener noreferrer">Fraxle.NET</a> · <a href="./index.html">Forecast</a> · <a href="./about.html">About</a> · <a href="./faq.html">FAQ</a>
· <a href="./dashboard.html">Dashboard</a> · <a href="https://billing.stripe.com/p/login/9B63cw7vl8k15Ei9DQd7q00" target="_blank" rel="noopener noreferrer">Account</a> · Data: <a href="https://open-meteo.com/" target="_blank" rel="noopener noreferrer">Open-Meteo</a> · UTCI: <a href="https://utci.org/" target="_blank" rel="noopener noreferrer">Bröde 2012</a>
</footer> </footer>
</body> </body>
+15 -3
View File
@@ -36,7 +36,10 @@
<header id="site-nav"> <header id="site-nav">
<div class="nav-overlay" onclick="toggleNav(false)"></div> <div class="nav-overlay" onclick="toggleNav(false)"></div>
<a href="./index.html" class="logo"><span class="logo-sun">Sun</span><svg class="logo-icon" viewBox="0 0 174 173" aria-hidden="true" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:1.5;"><g transform="matrix(0.986226,0,0,0.986226,-585.724252,-110.041209)"><clipPath id="ss-clip-ss"><circle cx="681.9838" cy="199.4502" r="83.2987"/></clipPath><g clip-path="url(#ss-clip-ss)"><g transform="matrix(0.948836,0,0,0.948836,19.787839,-2.49381)"><path d="M666.992,233.0768C666.3949,230.6581 666.1785,228.6317 666.1785,226.0296C666.1785,208.6786 680.2653,194.5919 697.6163,194.5919C714.9672,194.5919 729.054,208.6786 729.054,226.0296C729.054,228.4125 728.9892,230.9346 728.4859,233.1663" fill="none" stroke="#c8922a" stroke-width="7.48"/></g><g transform="matrix(0.520349,0,0,0.520349,304.766642,64.226716)"><path d="M724.9315,291.8131C777.359,291.8131 826.5862,305.6245 869.1698,329.8044" fill="none" stroke="currentColor" stroke-width="13.64"/></g><g transform="matrix(0.520349,0,0,0.520349,304.766642,64.226716)"><path d="M580.8436,329.719C623.3927,305.592 672.5657,291.8131 724.9315,291.8131" fill="none" stroke="currentColor" stroke-width="13.64"/></g></g><circle cx="681.9838" cy="199.4502" r="83.2987" fill="none" stroke="currentColor" stroke-width="9.13"/></g></svg><span class="logo-scope">Scope</span></a> <a href="./index.html" class="nav-logo">
<span class="nav-logo-wordmark"><span class="nav-logo-sun">Sun</span><svg class="nav-logo-icon" viewBox="0 0 174 173" aria-hidden="true" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:1.5;"><g transform="matrix(0.986226,0,0,0.986226,-585.724252,-110.041209)"><clipPath id="ss-clip-ss"><circle cx="681.9838" cy="199.4502" r="83.2987"/></clipPath><g clip-path="url(#ss-clip-ss)"><g transform="matrix(0.948836,0,0,0.948836,19.748711,-12.198562)"><path d="M666.992,233.0768C666.3949,230.6581 666.1785,228.6317 666.1785,226.0296C666.1785,208.6786 680.2653,194.5919 697.6163,194.5919C714.9672,194.5919 729.054,208.6786 729.054,226.0296C729.054,228.4125 728.9892,230.9346 728.4859,233.1663" fill="#f4b047" stroke="#f4b047" stroke-width="7.48" stroke-linecap="round"/></g><g transform="matrix(0.520349,0,0,0.520349,304.727514,54.521964)"><path d="M724.9315,291.8131C777.359,291.8131 826.5862,305.6245 869.1698,329.8044" fill="none" stroke="currentColor" stroke-width="13.64"/></g><g transform="matrix(0.520349,0,0,0.520349,304.727514,54.521964)"><path d="M580.8436,329.719C623.3927,305.592 672.5657,291.8131 724.9315,291.8131" fill="none" stroke="currentColor" stroke-width="13.64"/></g></g><circle cx="681.9838" cy="199.4502" r="83.2987" fill="none" stroke="currentColor" stroke-width="9.13"/></g></svg><span class="nav-logo-scope">Scope</span></span>
<span class="nav-logo-tag">See the world the way your skin does.</span>
</a>
<div class="nav-links" id="nav-drawer"> <div class="nav-links" id="nav-drawer">
<a href="./index.html">Forecast</a> <a href="./index.html">Forecast</a>
<div class="nav-dropdown"> <div class="nav-dropdown">
@@ -52,7 +55,14 @@
</div> </div>
</div> </div>
<a href="./faq.html">FAQ</a> <a href="./faq.html">FAQ</a>
<a href="https://billing.stripe.com/p/login/9B63cw7vl8k15Ei9DQd7q00" target="_blank" rel="noopener noreferrer">Account</a> <div class="nav-dropdown nav-dropdown--right">
<a href="./dashboard.html" class="nav-dropdown-toggle">Account<span class="caret"></span></a>
<div class="nav-dropdown-menu">
<a href="./dashboard.html">Dashboard</a>
<a id="nav-account-subscription" href="https://buy.stripe.com/9B63cw7vl8k15Ei9DQd7q00" target="_blank" rel="noopener noreferrer">Subscription</a>
<a id="nav-account-signinout" href="./dashboard.html">Sign in</a>
</div>
</div>
</div> </div>
<button class="utci-burger" aria-label="Open menu" aria-expanded="false" id="burger-btn" onclick="toggleNav()"> <button class="utci-burger" aria-label="Open menu" aria-expanded="false" id="burger-btn" onclick="toggleNav()">
<span></span><span></span><span></span> <span></span><span></span><span></span>
@@ -69,6 +79,7 @@
btn.setAttribute('aria-expanded', open ? 'true' : 'false'); btn.setAttribute('aria-expanded', open ? 'true' : 'false');
} }
</script> </script>
<script src="./assets/js/nav-account.js" defer></script>
<main> <main>
<h1>SunSoak &amp; the science</h1> <h1>SunSoak &amp; the science</h1>
@@ -137,7 +148,8 @@
</main> </main>
<footer> <footer>
&copy; 2026 <a href="https://fraxle.net" target="_blank" rel="noopener noreferrer">Fraxle.NET</a> · <a href="./index.html">Forecast</a> · <a href="./about.html">About</a> · <a href="./faq.html">FAQ</a> · <a href="https://billing.stripe.com/p/login/9B63cw7vl8k15Ei9DQd7q00" target="_blank" rel="noopener noreferrer">Account</a> · Data: <a href="https://open-meteo.com/" target="_blank" rel="noopener noreferrer">Open-Meteo</a> · UTCI: <a href="https://utci.org/" target="_blank" rel="noopener noreferrer">Bröde 2012</a> &copy; 2026 <a href="https://fraxle.net" target="_blank" rel="noopener noreferrer">Fraxle.NET</a> · <a href="./index.html">Forecast</a> · <a href="./about.html">About</a> · <a href="./faq.html">FAQ</a>
· <a href="./dashboard.html">Dashboard</a> · <a href="https://billing.stripe.com/p/login/9B63cw7vl8k15Ei9DQd7q00" target="_blank" rel="noopener noreferrer">Account</a> · Data: <a href="https://open-meteo.com/" target="_blank" rel="noopener noreferrer">Open-Meteo</a> · UTCI: <a href="https://utci.org/" target="_blank" rel="noopener noreferrer">Bröde 2012</a>
</footer> </footer>
</body> </body>
+15 -3
View File
@@ -36,7 +36,10 @@
<header id="site-nav"> <header id="site-nav">
<div class="nav-overlay" onclick="toggleNav(false)"></div> <div class="nav-overlay" onclick="toggleNav(false)"></div>
<a href="./index.html" class="logo"><span class="logo-sun">Sun</span><svg class="logo-icon" viewBox="0 0 174 173" aria-hidden="true" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:1.5;"><g transform="matrix(0.986226,0,0,0.986226,-585.724252,-110.041209)"><clipPath id="ss-clip-te"><circle cx="681.9838" cy="199.4502" r="83.2987"/></clipPath><g clip-path="url(#ss-clip-te)"><g transform="matrix(0.948836,0,0,0.948836,19.787839,-2.49381)"><path d="M666.992,233.0768C666.3949,230.6581 666.1785,228.6317 666.1785,226.0296C666.1785,208.6786 680.2653,194.5919 697.6163,194.5919C714.9672,194.5919 729.054,208.6786 729.054,226.0296C729.054,228.4125 728.9892,230.9346 728.4859,233.1663" fill="none" stroke="#c8922a" stroke-width="7.48"/></g><g transform="matrix(0.520349,0,0,0.520349,304.766642,64.226716)"><path d="M724.9315,291.8131C777.359,291.8131 826.5862,305.6245 869.1698,329.8044" fill="none" stroke="currentColor" stroke-width="13.64"/></g><g transform="matrix(0.520349,0,0,0.520349,304.766642,64.226716)"><path d="M580.8436,329.719C623.3927,305.592 672.5657,291.8131 724.9315,291.8131" fill="none" stroke="currentColor" stroke-width="13.64"/></g></g><circle cx="681.9838" cy="199.4502" r="83.2987" fill="none" stroke="currentColor" stroke-width="9.13"/></g></svg><span class="logo-scope">Scope</span></a> <a href="./index.html" class="nav-logo">
<span class="nav-logo-wordmark"><span class="nav-logo-sun">Sun</span><svg class="nav-logo-icon" viewBox="0 0 174 173" aria-hidden="true" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:1.5;"><g transform="matrix(0.986226,0,0,0.986226,-585.724252,-110.041209)"><clipPath id="ss-clip-te"><circle cx="681.9838" cy="199.4502" r="83.2987"/></clipPath><g clip-path="url(#ss-clip-te)"><g transform="matrix(0.948836,0,0,0.948836,19.748711,-12.198562)"><path d="M666.992,233.0768C666.3949,230.6581 666.1785,228.6317 666.1785,226.0296C666.1785,208.6786 680.2653,194.5919 697.6163,194.5919C714.9672,194.5919 729.054,208.6786 729.054,226.0296C729.054,228.4125 728.9892,230.9346 728.4859,233.1663" fill="#f4b047" stroke="#f4b047" stroke-width="7.48" stroke-linecap="round"/></g><g transform="matrix(0.520349,0,0,0.520349,304.727514,54.521964)"><path d="M724.9315,291.8131C777.359,291.8131 826.5862,305.6245 869.1698,329.8044" fill="none" stroke="currentColor" stroke-width="13.64"/></g><g transform="matrix(0.520349,0,0,0.520349,304.727514,54.521964)"><path d="M580.8436,329.719C623.3927,305.592 672.5657,291.8131 724.9315,291.8131" fill="none" stroke="currentColor" stroke-width="13.64"/></g></g><circle cx="681.9838" cy="199.4502" r="83.2987" fill="none" stroke="currentColor" stroke-width="9.13"/></g></svg><span class="nav-logo-scope">Scope</span></span>
<span class="nav-logo-tag">See the world the way your skin does.</span>
</a>
<div class="nav-links" id="nav-drawer"> <div class="nav-links" id="nav-drawer">
<a href="./index.html">Forecast</a> <a href="./index.html">Forecast</a>
<div class="nav-dropdown"> <div class="nav-dropdown">
@@ -52,7 +55,14 @@
</div> </div>
</div> </div>
<a href="./faq.html">FAQ</a> <a href="./faq.html">FAQ</a>
<a href="https://billing.stripe.com/p/login/9B63cw7vl8k15Ei9DQd7q00" target="_blank" rel="noopener noreferrer">Account</a> <div class="nav-dropdown nav-dropdown--right">
<a href="./dashboard.html" class="nav-dropdown-toggle">Account<span class="caret"></span></a>
<div class="nav-dropdown-menu">
<a href="./dashboard.html">Dashboard</a>
<a id="nav-account-subscription" href="https://buy.stripe.com/9B63cw7vl8k15Ei9DQd7q00" target="_blank" rel="noopener noreferrer">Subscription</a>
<a id="nav-account-signinout" href="./dashboard.html">Sign in</a>
</div>
</div>
</div> </div>
<button class="utci-burger" aria-label="Open menu" aria-expanded="false" id="burger-btn" onclick="toggleNav()"> <button class="utci-burger" aria-label="Open menu" aria-expanded="false" id="burger-btn" onclick="toggleNav()">
<span></span><span></span><span></span> <span></span><span></span><span></span>
@@ -69,6 +79,7 @@
btn.setAttribute('aria-expanded', open ? 'true' : 'false'); btn.setAttribute('aria-expanded', open ? 'true' : 'false');
} }
</script> </script>
<script src="./assets/js/nav-account.js" defer></script>
<main> <main>
<h1>Derived temperatures</h1> <h1>Derived temperatures</h1>
@@ -173,7 +184,8 @@
</main> </main>
<footer> <footer>
&copy; 2026 <a href="https://fraxle.net" target="_blank" rel="noopener noreferrer">Fraxle.NET</a> · <a href="./index.html">Forecast</a> · <a href="./about.html">About</a> · <a href="./faq.html">FAQ</a> · <a href="https://billing.stripe.com/p/login/9B63cw7vl8k15Ei9DQd7q00" target="_blank" rel="noopener noreferrer">Account</a> · Data: <a href="https://open-meteo.com/" target="_blank" rel="noopener noreferrer">Open-Meteo</a> · UTCI: <a href="https://utci.org/" target="_blank" rel="noopener noreferrer">Bröde 2012</a> &copy; 2026 <a href="https://fraxle.net" target="_blank" rel="noopener noreferrer">Fraxle.NET</a> · <a href="./index.html">Forecast</a> · <a href="./about.html">About</a> · <a href="./faq.html">FAQ</a>
· <a href="./dashboard.html">Dashboard</a> · <a href="https://billing.stripe.com/p/login/9B63cw7vl8k15Ei9DQd7q00" target="_blank" rel="noopener noreferrer">Account</a> · Data: <a href="https://open-meteo.com/" target="_blank" rel="noopener noreferrer">Open-Meteo</a> · UTCI: <a href="https://utci.org/" target="_blank" rel="noopener noreferrer">Bröde 2012</a>
</footer> </footer>
</body> </body>