984 lines
38 KiB
HTML
984 lines
38 KiB
HTML
<!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 & 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 & 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>
|
||
© 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>
|