Restructure
New Journal entries
Colour tabs update with wind
This commit is contained in:
fraxle
2026-08-31 15:12:45 +01:00
parent 47818fba33
commit 71860b9dd9
40 changed files with 3430 additions and 443 deletions
+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>