Files
sunscope/dashboard.html
fraxle 71860b9dd9 5.4.0
Restructure
New Journal entries
Colour tabs update with wind
2026-08-31 15:12:45 +01:00

984 lines
38 KiB
HTML
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!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>