Files
sunscope/dashboard.html
T
fraxle 862f631beb 5.5
New big and small scope sunset and sunrise simulation
New calculations for the day tabs
Added missing icons to simple view
New record popups
Week ahead description popups
Camping profile
Tweak to some of the indoor tems
2026-10-08 09:53:41 +01:00

1776 lines
68 KiB
HTML
Raw 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 (calendar, 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;
}
/* Landed here from the event banner's Quick Record button with no
session - without this the hand-off is just a login page appearing
for no stated reason. Brass card, so it reads as the reason for the
form under it rather than an error about it. */
.db-quick-note {
background: #f7edd8;
border: 1px solid #d9c39b;
border-left: 3px solid #c8922a;
border-radius: 4px;
padding: 0.7rem 0.9rem;
margin-bottom: 1rem;
font-size: 0.9rem;
color: #6b4f2a;
}
.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-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;
padding-bottom: 0.2rem;
/* Carries the same band colour as that entry's dot on the calendar.
Transparent by default so a banded row and an unbanded one still
line up, and dashed-grey where there is no reading to band. */
border-bottom: 3px solid transparent;
}
.db-entry-date--none { border-bottom: 3px dashed #c9b58e; }
.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-cal-empty {
color: #9a7a4a;
font-style: italic;
font-size: 0.9rem;
}
.db-cal-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.5rem;
margin-bottom: 0.6rem;
}
.db-cal-title {
font-weight: 700;
font-size: 0.95rem;
color: #6b4f1d;
}
.db-cal-nav {
width: 26px;
height: 26px;
line-height: 1;
padding: 0;
background: none;
border: 1px solid #e3d3ac;
border-radius: 6px;
color: #9a7a4a;
cursor: pointer;
}
.db-cal-nav:hover:not(:disabled) { background: #f6ead0; color: #6b4f1d; }
.db-cal-nav:disabled { opacity: 0.35; cursor: default; }
.db-cal-grid {
display: grid;
grid-template-columns: repeat(7, 1fr);
gap: 2px;
}
.db-cal-dow {
text-align: center;
font-size: 0.66rem;
letter-spacing: 0.04em;
text-transform: uppercase;
color: #b09870;
padding-bottom: 2px;
}
.db-cal-cell {
min-height: 46px;
border: 1px solid #f0e4c8;
border-radius: 5px;
padding: 2px 3px;
}
.db-cal-cell--blank { border-color: transparent; }
.db-cal-cell--today { border-color: #c8922a; background: #fdf3dd; }
.db-cal-daynum {
font-family: 'JetBrains Mono', monospace;
font-size: 0.64rem;
color: #b09870;
}
.db-cal-cell--today .db-cal-daynum { color: #a8761c; font-weight: 700; }
.db-cal-dots {
display: flex;
flex-wrap: wrap;
gap: 2px;
margin-top: 1px;
}
/* One dot per entry, filled with its thermal-stress band colour. An
entry saved without a reading has no band, so it gets a hollow one. */
.db-cal-dot {
width: 11px;
height: 11px;
padding: 0;
border-radius: 50%;
border: 1px solid rgba(0, 0, 0, 0.18);
cursor: pointer;
}
.db-cal-dot:hover, .db-cal-dot:focus-visible {
outline: 2px solid #a8761c;
outline-offset: 1px;
}
.db-cal-dot--none { background: #fff; border-style: dashed; border-color: #c9b58e; }
/* A cosmic Quick Record - white with a hard black rim, so a meteor shower
or an eclipse reads as its own kind of entry rather than as a thermal
band. Replaces --none rather than joining it: most cosmic entries have
no reading (their peak is usually past the 14-day forecast) and the
dashed hollow dot would say "no reading" where this says "cosmic". */
.db-cal-dot--cosmic { background: #fff; border-style: solid; border-color: #1e1208; }
.db-cal-more {
font-family: 'JetBrains Mono', monospace;
font-size: 0.6rem;
color: #9a7a4a;
align-self: center;
}
.db-cal-legend {
display: flex;
flex-wrap: wrap;
gap: 0.3rem 0.7rem;
margin-top: 0.7rem;
font-size: 0.7rem;
color: #9a7a4a;
}
.db-cal-legend span { display: flex; align-items: center; gap: 0.25rem; }
.db-cal-swatch {
width: 9px;
height: 9px;
border-radius: 50%;
border: 1px solid rgba(0, 0, 0, 0.18);
}
/* ── Journal toolbar ────────────────────────────────────
Writing an entry happens in the wizard, so the page itself only
needs the way in, plus a standing reminder of which location,
profile and solar model a new reading would come from. */
.db-toolbar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.9rem 1.5rem;
flex-wrap: wrap;
}
.db-context {
font-size: 0.85rem;
color: #7a5c2a;
line-height: 1.5;
}
.db-context strong { color: #1e1208; }
/* ── Entry wizard ───────────────────────────────────────
Adding and editing both run through the same popup, one question
per page. Same overlay language as the main app's welcome popup
(.welcome-overlay in css/ui.css), rebuilt here because this page
is standalone and doesn't load the app's stylesheets. */
.db-wiz-overlay {
position: fixed;
inset: 0;
background: rgba(30, 18, 8, 0.45);
display: flex;
align-items: center;
justify-content: center;
z-index: 10000;
padding: 20px;
animation: db-wiz-fade 0.18s ease-out;
}
@keyframes db-wiz-fade { from { opacity: 0; } to { opacity: 1; } }
.db-wiz-card {
position: relative;
display: flex;
flex-direction: column;
background: #fdf8ee;
border: 1.5px solid #c9b08a;
border-radius: 12px;
padding: 1.7rem 1.75rem 1.35rem;
width: 100%;
max-width: 480px;
max-height: 90vh;
box-shadow: 0 8px 32px rgba(30, 18, 8, 0.28);
animation: db-wiz-pop 0.28s cubic-bezier(0.34, 1.4, 0.64, 1);
}
@keyframes db-wiz-pop {
from { opacity: 0; transform: scale(0.94) translateY(8px); }
to { opacity: 1; transform: scale(1) translateY(0); }
}
@media (prefers-reduced-motion: reduce) {
.db-wiz-overlay, .db-wiz-card { animation: none; }
}
.db-wiz-close {
position: absolute;
top: 10px;
right: 13px;
background: none;
border: none;
font-size: 26px;
line-height: 1;
color: #b09870;
cursor: pointer;
padding: 0 0.2rem;
}
.db-wiz-close:hover { color: #1e1208; }
.db-wiz-kicker {
font-size: 0.72rem;
font-weight: 700;
letter-spacing: 0.12em;
text-transform: uppercase;
color: #c8922a;
}
.db-wiz-title {
font-family: 'Fraunces', serif;
font-weight: 900;
font-size: 1.4rem;
line-height: 1.25;
margin: 0.15rem 0 0;
padding-right: 1.6rem;
}
.db-wiz-sub {
font-size: 0.88rem;
color: #7a5c2a;
line-height: 1.55;
margin: 0.4rem 0 0;
}
/* One bar per page: filled behind you, bright on the one you're on. */
.db-wiz-progress {
display: flex;
gap: 0.3rem;
list-style: none;
margin: 1.1rem 0 1.3rem;
}
.db-wiz-progress li {
flex: 1;
height: 4px;
border-radius: 999px;
background: #e8dcbf;
transition: background 0.2s;
}
.db-wiz-progress li.is-done { background: #e0b661; }
.db-wiz-progress li.is-current { background: #c8922a; }
.db-wiz-form { display: flex; flex-direction: column; min-height: 0; }
/* Only the pages scroll - the head, progress bar and footer stay put. */
.db-wiz-body { flex: 1; min-height: 0; overflow-y: auto; }
.db-wiz-step[data-step="2"] textarea { min-height: 8rem; }
.db-wiz-hint {
font-size: 0.82rem;
color: #9a7a4a;
line-height: 1.55;
margin: -0.35rem 0 0;
}
.db-wiz-review-row {
display: flex;
align-items: baseline;
gap: 0.9rem;
padding: 0.6rem 0;
border-bottom: 1px solid #e8dcbf;
}
.db-wiz-review-row:first-child { padding-top: 0; }
.db-wiz-review-row:last-child { border-bottom: none; padding-bottom: 0; }
.db-wiz-review-key {
flex-shrink: 0;
width: 4.8rem;
font-size: 0.72rem;
font-weight: 700;
letter-spacing: 0.04em;
text-transform: uppercase;
color: #7a5c2a;
}
.db-wiz-review-val {
flex: 1;
font-size: 0.92rem;
color: #3a2a12;
white-space: pre-wrap;
overflow-wrap: anywhere;
}
.db-wiz-review-val.is-muted { color: #9a7a4a; font-style: italic; }
.db-wiz-review-edit {
flex-shrink: 0;
background: none;
border: none;
color: #c8922a;
font-size: 0.78rem;
font-weight: 700;
text-decoration: underline;
cursor: pointer;
padding: 0;
}
.db-wiz-review-edit:hover { color: #a8761c; }
.db-wiz-foot {
display: flex;
align-items: center;
gap: 0.7rem;
margin-top: 1.2rem;
padding-top: 1.1rem;
border-top: 1px solid #e8dcbf;
}
.db-wiz-count {
flex: 1;
text-align: center;
font-family: 'JetBrains Mono', monospace;
font-size: 0.72rem;
color: #b09870;
}
[hidden] { display: none !important; }
.db-cookie-notice {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
flex-wrap: wrap;
background: #fdf0da;
border: 1px solid #e0b661;
border-left: 4px solid #c8922a;
border-radius: 8px;
padding: 0.85rem 1.1rem;
margin-bottom: 1.5rem;
font-size: 0.88rem;
font-weight: 500;
color: #4a3416;
line-height: 1.5;
box-shadow: 0 2px 8px rgba(200, 146, 42, 0.15);
}
.db-cookie-notice code {
background: #f0dcb0;
border-radius: 3px;
padding: 0.1rem 0.3rem;
font-family: 'JetBrains Mono', monospace;
font-size: 0.85em;
}
.db-cookie-notice-dismiss {
flex-shrink: 0;
background: #c8922a;
border: none;
border-radius: 6px;
color: #fffdf6;
font-size: 0.8rem;
font-weight: 700;
cursor: pointer;
padding: 0.4rem 0.9rem;
}
.db-cookie-notice-dismiss:hover { background: #a8761c; }
</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 class="db-cookie-notice" id="db-cookie-notice" hidden>
<span>🍪 This page uses one essential cookie, <code>ss_session</code>, to keep you signed in. It's not used for tracking or advertising.</span>
<button class="db-cookie-notice-dismiss" type="button" id="db-cookie-notice-dismiss">Got it</button>
</div>
<div id="db-signed-out">
<p class="db-quick-note" id="db-quick-note" hidden></p>
<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-panel db-toolbar">
<button class="db-btn" type="button" id="db-new-entry-btn">+ New entry</button>
<p class="db-context" id="db-context"></p>
</div>
<div class="db-columns">
<div class="db-col">
<h2>Your calendar</h2>
<div class="db-panel">
<div id="db-cal-wrap"></div>
<p class="db-cal-empty" id="db-cal-empty">Save a few entries with a location and profile set to see them on the calendar.</p>
</div>
</div>
<div class="db-col">
<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 — hit “New entry” to write your first.</p>
</div>
</div>
</div>
</main>
<!-- ── Entry wizard ──────────────────────────────────────────────
One popup for both adding and editing, a page at a time. The pages
are all in the same <form>, shown one at a time, so a half-finished
entry keeps everything typed on the pages either side of it. -->
<div class="db-wiz-overlay" id="db-wiz" hidden>
<div class="db-wiz-card" role="dialog" aria-modal="true" aria-labelledby="db-wiz-title">
<button class="db-wiz-close" type="button" id="db-wiz-close" aria-label="Close">×</button>
<div class="db-wiz-head">
<div class="db-wiz-kicker" id="db-wiz-kicker">New entry</div>
<h2 class="db-wiz-title" id="db-wiz-title"></h2>
<p class="db-wiz-sub" id="db-wiz-sub"></p>
</div>
<ol class="db-wiz-progress" id="db-wiz-progress" aria-hidden="true"></ol>
<form id="db-entry-form" class="db-wiz-form">
<div class="db-wiz-body">
<section class="db-wiz-step" data-step="0" hidden>
<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-wiz-hint">Leave the end date blank for a single day. A range takes the peak
across every day in it that falls inside the 14-day forecast.</p>
</section>
<section class="db-wiz-step" data-step="1" hidden>
<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">Thermal Value</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">
<label for="db-env-select">Solar model</label>
<select class="db-select" id="db-env-select"></select>
</div>
<p class="db-reading-preview" id="db-reading-preview"></p>
</section>
<section class="db-wiz-step" data-step="2" hidden>
<div class="db-field" style="margin-bottom: 0;">
<label for="db-entry-note">Note</label>
<textarea id="db-entry-note" placeholder="How did the day feel?" required></textarea>
</div>
</section>
<section class="db-wiz-step" data-step="3" hidden>
<div class="db-wiz-review" id="db-wiz-review"></div>
</section>
</div>
<p class="db-error" id="db-entry-error" hidden></p>
<div class="db-wiz-foot">
<button class="db-btn db-btn--ghost" type="button" id="db-wiz-back" hidden>Back</button>
<span class="db-wiz-count" id="db-wiz-count"></span>
<button class="db-btn" type="button" id="db-wiz-next">Next</button>
<button class="db-btn" type="submit" id="db-entry-submit" hidden>Save entry</button>
</div>
</form>
</div>
</div>
<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';
import { utciCategory, petCategory } from './assets/js/utils.js';
import { COSMIC_CALENDAR } from './assets/js/events/cosmic-calendar.js';
import { ALMANAC_CALENDAR } from './assets/js/events/almanac-calendar.js';
const $ = (id) => document.getElementById(id);
const PROFILE_LABELS = { basic: 'SunSoak', home: 'Indoor', vehicle: 'Cabin', pets: 'Fur' };
// ── Cookie notice ───────────────────────────────────────────────────
// Only ss_session (auth) is set on this page - flagged once per browser,
// not tied to sign-in state, since the cookie only appears after signing
// in and by then it's too late to have told the visitor about it.
const COOKIE_NOTICE_KEY = 'sunscope_dashboard_cookie_notice_dismissed';
if (!localStorage.getItem(COOKIE_NOTICE_KEY)) {
$('db-cookie-notice').hidden = false;
}
$('db-cookie-notice-dismiss').addEventListener('click', () => {
localStorage.setItem(COOKIE_NOTICE_KEY, '1');
$('db-cookie-notice').hidden = true;
});
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);
renderContext();
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');
}
renderContext();
}
$('db-profile-select').value = getProfile();
$('db-profile-select').addEventListener('change', () => {
setProfile($('db-profile-select').value);
renderContext();
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 });
wizDirty = true;
$('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;
// True from the moment a lookup starts until the one that owns the
// result finishes - the review page shows "checking" rather than
// "note-only" while it's in flight.
let readingPending = false;
// 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;
readingPending = true;
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;
readingPending = false;
if (wizOpen && wizStep === WIZ_LAST) renderReview();
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;
readingPending = false;
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})`;
}
if (wizOpen && wizStep === WIZ_LAST) renderReview();
}
$('db-entry-date').addEventListener('input', () => { syncEndDateMin(); updateReadingPreview(); });
$('db-entry-end-date').addEventListener('input', updateReadingPreview);
// ── Entry wizard ────────────────────────────────────────────────────
// Adding and editing both run through one popup, a page at a time:
// when → where → note → review. openWizard(entry) fills the pages from
// an existing entry and remembers its id, so journal-save.php updates
// instead of inserting; openWizard(null) starts a blank one. The
// reading is always recomputed fresh against whatever profile, solar
// model and location are set on the "where" page, same as a brand new
// entry - an edited entry is never left showing a stale number.
const WIZ_STEPS = [
{
title: 'When was it?',
sub: 'A single day, or a run of days if the entry covers a stretch of weather.',
},
{
title: 'Where, and for whom?',
sub: 'What the reading gets worked out from. These stay put as the defaults for your next entry too.',
},
{
title: 'How did it feel?',
sub: 'The note is the entry - the reading only sits alongside it.',
},
{
title: 'Ready to save',
sub: 'A last look before this goes in the journal.',
},
];
const WIZ_LAST = WIZ_STEPS.length - 1;
let editingId = null; // null while adding, the entry's id while editing
let wizKicker = null; // overrides the Add/Edit kicker for one run (see the quick-record hand-off)
let wizOpen = false;
let wizStep = 0;
let wizDirty = false; // something typed or picked - guards the discard prompt
let wizBackdropDown = false;
// One progress bar per page, built from WIZ_STEPS so the two can't drift.
$('db-wiz-progress').innerHTML = WIZ_STEPS.map(() => '<li></li>').join('');
// The page's standing reminder of where a new entry's reading would come
// from - the same three settings the wizard's "where" page owns, shown
// out here because they persist between entries.
function renderContext() {
const el = $('db-context');
if (!el) return;
el.textContent = '';
const loc = getLocation();
if (!loc) {
el.textContent = 'No location set yet — new entries will be note-only.';
return;
}
const name = document.createElement('strong');
name.textContent = loc.name;
el.append('Readings from ', name,
` · ${PROFILE_LABELS[getProfile()]} · ${UTCI_ENVIRONMENTS[getEnv()].label}`);
}
function openWizard(entry) {
editingId = entry ? entry.id : null;
wizKicker = null;
if (entry) $('db-entry-date').value = entry.date;
else $('db-entry-date').valueAsDate = new Date();
$('db-entry-end-date').value = (entry && entry.endDate) || '';
$('db-entry-note').value = (entry && entry.note) || '';
syncEndDateMin();
$('db-entry-error').hidden = true;
// The location search is a scratch control - never carry a previous
// trip's query or results into a fresh one.
$('db-loc-search').hidden = true;
$('db-loc-input').value = '';
$('db-loc-results').innerHTML = '';
$('db-profile-select').value = getProfile();
$('db-env-select').value = getEnv();
renderLocation();
wizDirty = false;
wizOpen = true;
wizStep = 0;
$('db-wiz').hidden = false;
document.body.style.overflow = 'hidden';
document.addEventListener('keydown', onWizKey);
renderWizard();
updateReadingPreview();
}
function closeWizard(force) {
if (!wizOpen) return;
if (!force && wizDirty &&
!confirm(editingId ? 'Discard your changes to this entry?' : 'Discard this entry?')) return;
wizOpen = false;
editingId = null;
wizDirty = false;
$('db-wiz').hidden = true;
document.body.style.overflow = '';
document.removeEventListener('keydown', onWizKey);
$('db-new-entry-btn').focus();
}
function onWizKey(e) {
if (e.key === 'Escape') { e.preventDefault(); closeWizard(false); }
}
// Each page has to stand on its own before you can leave it. The
// browser's own required-field message never fires here - Next is an
// ordinary button, and the real submit only exists on the last page.
function validateStep(step) {
if (step === 0 && !$('db-entry-date').value) return 'Pick a start date for this entry.';
if (step === 2 && !$('db-entry-note').value.trim()) return "Add a note - that's the entry itself.";
return null;
}
// Walks every page between here and the target, so jumping forward from
// the review page can't skip one that's since been emptied.
function goToStep(next) {
if (next > wizStep) {
for (let s = wizStep; s < next; s++) {
const err = validateStep(s);
if (err) {
wizStep = s;
renderWizard();
$('db-entry-error').textContent = err;
$('db-entry-error').hidden = false;
return;
}
}
}
$('db-entry-error').hidden = true;
wizStep = Math.max(0, Math.min(WIZ_LAST, next));
renderWizard();
}
function renderWizard() {
const step = WIZ_STEPS[wizStep];
$('db-wiz-kicker').textContent = wizKicker || (editingId ? 'Edit entry' : 'New entry');
$('db-wiz-title').textContent = step.title;
$('db-wiz-sub').textContent = step.sub;
$('db-wiz-count').textContent = `Step ${wizStep + 1} of ${WIZ_STEPS.length}`;
for (const sec of document.querySelectorAll('.db-wiz-step')) {
sec.hidden = Number(sec.dataset.step) !== wizStep;
}
const bars = $('db-wiz-progress').children;
for (let i = 0; i < bars.length; i++) {
bars[i].className = i < wizStep ? 'is-done' : i === wizStep ? 'is-current' : '';
}
$('db-wiz-back').hidden = wizStep === 0;
$('db-wiz-next').hidden = wizStep === WIZ_LAST;
$('db-entry-submit').hidden = wizStep !== WIZ_LAST;
$('db-entry-submit').textContent = editingId ? 'Update entry' : 'Save entry';
if (wizStep === WIZ_LAST) renderReview();
// Land on whatever the page is actually asking for; the review page
// puts the cursor on Save, so Enter finishes the job.
if (wizStep === 0) $('db-entry-date').focus();
else if (wizStep === 2) $('db-entry-note').focus();
else if (wizStep === WIZ_LAST) $('db-entry-submit').focus();
}
// The reading can still be in flight when the review page opens, so it
// says so rather than claiming the entry is note-only - the lookup
// re-renders this row when it lands (see updateReadingPreview).
function reviewReading(loc) {
if (readingPending) return { val: 'Checking the forecast…', muted: true };
if (currentReading == null || !loc) return { val: 'Note-only - no reading attached', muted: true };
return {
val: `${PROFILE_LABELS[getProfile()]} ${currentReading}° (${UTCI_ENVIRONMENTS[getEnv()].label})`,
};
}
function renderReview() {
const loc = getLocation();
const date = $('db-entry-date').value;
const endDate = $('db-entry-end-date').value;
const note = $('db-entry-note').value.trim();
const rows = [
{ step: 0, key: 'When', val: (endDate && endDate > date) ? `${date} – ${endDate}` : (date || 'Not set'), muted: !date },
{ step: 1, key: 'Where', val: loc ? loc.name : 'No location set', muted: !loc },
{ step: 1, key: 'Thermal Value', val: `${PROFILE_LABELS[getProfile()]} · ${UTCI_ENVIRONMENTS[getEnv()].label}` },
Object.assign({ step: 1, key: 'Reading' }, reviewReading(loc)),
{ step: 2, key: 'Note', val: note || 'Nothing written yet', muted: !note },
];
const wrap = $('db-wiz-review');
wrap.innerHTML = '';
for (const r of rows) {
const row = document.createElement('div');
row.className = 'db-wiz-review-row';
const key = document.createElement('span');
key.className = 'db-wiz-review-key';
key.textContent = r.key;
const val = document.createElement('span');
val.className = 'db-wiz-review-val' + (r.muted ? ' is-muted' : '');
val.textContent = r.val;
const edit = document.createElement('button');
edit.type = 'button';
edit.className = 'db-wiz-review-edit';
edit.textContent = 'Change';
edit.setAttribute('aria-label', `Change ${r.key.toLowerCase()}`);
edit.addEventListener('click', () => goToStep(r.step));
row.append(key, val, edit);
wrap.appendChild(row);
}
}
$('db-new-entry-btn').addEventListener('click', () => openWizard(null));
$('db-wiz-close').addEventListener('click', () => closeWizard(false));
$('db-wiz-back').addEventListener('click', () => goToStep(wizStep - 1));
$('db-wiz-next').addEventListener('click', () => goToStep(wizStep + 1));
// Backdrop dismiss, but only for a click that both started and ended on
// it - a drag that begins inside the card (selecting note text, say) and
// releases outside must not throw the entry away.
$('db-wiz').addEventListener('mousedown', (e) => { wizBackdropDown = e.target === $('db-wiz'); });
$('db-wiz').addEventListener('click', (e) => {
if (e.target === $('db-wiz') && wizBackdropDown) closeWizard(false);
});
for (const id of ['db-entry-date', 'db-entry-end-date', 'db-entry-note']) {
$(id).addEventListener('input', () => { wizDirty = true; });
}
// Enter anywhere in the form lands here, since the submit button is the
// only one of its type - on any page but the last it just moves on.
$('db-entry-form').addEventListener('submit', async (e) => {
e.preventDefault();
if (wizStep !== WIZ_LAST) { goToStep(wizStep + 1); return; }
$('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;
}
const submit = $('db-entry-submit');
submit.disabled = true;
try {
await api('journal-save.php', payload);
closeWizard(true);
await loadEntries();
} catch (err) {
$('db-entry-error').textContent = err.message;
$('db-entry-error').hidden = false;
} finally {
submit.disabled = false;
}
});
// ── Quick Record hand-off ───────────────────────────────────
// The main app's event banner has a Record button that can't save
// anything itself - the journal sits behind this page's session. It
// parks the entry it wants in localStorage and navigates here; we pick
// it up and open the wizard on its review page, filled in, so the whole
// thing is one click over there and one Save here. See
// QUICK_RECORD_KEY in assets/js/app.js for the other half.
const PREFILL_KEY = 'sunscope_journal_prefill';
// Long enough to cover creating an account on the way in, short enough
// that a hand-off forgotten about last week never ambushes a later visit
// with a wizard nobody asked for.
const PREFILL_MAX_AGE_MS = 30 * 60 * 1000;
// consume: true removes it, so one hand-off opens one wizard. The
// signed-out hint peeks with consume: false, since the entry still has
// to survive the sign-in that follows.
function readPrefill(consume) {
let raw = null;
try { raw = localStorage.getItem(PREFILL_KEY); } catch (e) { return null; }
if (!raw) return null;
if (consume) { try { localStorage.removeItem(PREFILL_KEY); } catch (e) { /* ignore */ } }
let p;
try { p = JSON.parse(raw); } catch (e) { return null; }
if (!p || typeof p.note !== 'string' || !p.note.trim()) return null;
if (!/^\d{4}-\d{2}-\d{2}$/.test(p.date || '')) return null;
if (!(Date.now() - (Number(p.savedAt) || 0) < PREFILL_MAX_AGE_MS)) return null;
return p;
}
// The three settings the wizard's "where" page owns are taken from the
// hand-off when it carries them, and persisted exactly as picking them
// by hand would - the reading is meant to match what the banner was
// showing. All three are visible on the review page with a Change link,
// so nothing here is silent.
function openWizardFromPrefill(p) {
const loc = p.location;
if (loc && loc.name && Number.isFinite(loc.lat) && Number.isFinite(loc.lon)) {
setLocation({ name: String(loc.name).slice(0, 255), lat: loc.lat, lon: loc.lon });
}
if (p.profile && PROFILE_LABELS[p.profile]) setProfile(p.profile);
if (p.solarEnv && UTCI_ENVIRONMENTS[p.solarEnv]) setEnv(p.solarEnv);
// openWizard reads all three back out of storage for its "where" page,
// so they have to be set before it, not after.
openWizard(null);
// openWizard fills a blank entry and starts its own reading lookup off
// today's date; overwrite the fields, then re-run it. The older lookup
// can't win - updateReadingPreview's request counter drops it.
$('db-entry-date').value = p.date;
$('db-entry-end-date').value = /^\d{4}-\d{2}-\d{2}$/.test(p.endDate || '') ? p.endDate : '';
$('db-entry-note').value = p.note.slice(0, 4000);
syncEndDateMin();
wizKicker = 'Quick record';
// Nothing was typed, but there is something to lose - closing should
// still ask rather than throwing the hand-off away silently.
wizDirty = true;
updateReadingPreview();
goToStep(WIZ_LAST);
}
// 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
// renderCalendar) 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);
renderCalendar(allEntries);
}
// "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');
const band = bandFor(entry);
dateSpan.className = 'db-entry-date' + (band ? '' : ' db-entry-date--none');
if (band) dateSpan.style.borderBottomColor = band.bg;
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', () => openWizard(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) closeWizard(true);
await loadEntries();
});
actions.append(editBtn, delBtn);
li.append(dateSpan, noteSpan, actions);
list.appendChild(li);
}
}
// ── Calendar ────────────────────────────────────────────
// A month at a time, one dot per entry sitting on its own day, filled
// with the thermal-stress band colour that reading falls in - the same
// UTCI_BANDS/PET_BANDS palette the forecast table uses (utils.js), so a
// green dot means the same thing here as it does there. Entries are
// occasional and dated, which a calendar can show honestly: an empty
// cell is a day with nothing recorded, where a chart had to either
// stretch the gap away or draw a line through days that never happened.
const CAL_DOW = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'];
const CAL_MONTHS = ['January', 'February', 'March', 'April', 'May', 'June',
'July', 'August', 'September', 'October', 'November', 'December'];
const DAY_MS = 86400000;
// Which month the grid is showing, as {y, m} with m 0-based. Kept across
// re-renders so saving an entry doesn't bounce you back to another month.
let calMonth = null;
const monthIndex = (mo) => mo.y * 12 + mo.m;
const utcKey = (t) => new Date(t).toISOString().slice(0, 10);
// Pets read the same air on a different scale - a dog in its coat is not
// in the band a person would be, so the profile picks the palette.
function bandFor(entry) {
if (entry.reading == null) return null;
return entry.profile === 'pets' ? petCategory(entry.reading) : utciCategory(entry.reading);
}
// Cosmic Quick Records get their own dot on the calendar - a meteor shower
// isn't a thermal reading and shouldn't sit in the band palette beside one.
// Nothing on the entry says "cosmic": journal-save.php stores a note, a date
// and a reading, with no column for what prompted it. So they're recognised
// by the head of the note, which quickRecord in assets/js/app.js writes as
// "<emoji> <title> — <description>" from these same two calendars. Editing
// that opening line turns the entry back into an ordinary one, which is the
// right way round: the marker follows what the note actually says.
const COSMIC_NOTE_HEADS = new Set(
[...COSMIC_CALENDAR, ...ALMANAC_CALENDAR].map(e => `${e.emoji} ${e.title}`)
);
function isCosmicEntry(entry) {
return COSMIC_NOTE_HEADS.has(String(entry.note ?? '').split(' — ')[0].trim());
}
// 'YYYY-MM-DD' -> entries on that day. A multi-day entry (endDate) marks
// every day it covers, so a week away reads as a week, not a single dot.
function entriesByDay(entries) {
const byDay = new Map();
for (const entry of entries) {
const startT = Date.parse(entry.date.slice(0, 10) + 'T00:00:00Z');
if (!Number.isFinite(startT)) continue;
const endT = entry.endDate ? Date.parse(entry.endDate.slice(0, 10) + 'T00:00:00Z') : startT;
// A reversed or wild range still marks its start day and nothing more.
const lastT = Number.isFinite(endT) && endT >= startT
? Math.min(endT, startT + 366 * DAY_MS)
: startT;
for (let t = startT; t <= lastT; t += DAY_MS) {
const key = utcKey(t);
if (!byDay.has(key)) byDay.set(key, []);
byDay.get(key).push(entry);
}
}
return byDay;
}
function calTooltip(entry) {
const parts = [formatDateLabel(entry)];
if (entry.reading != null) {
const envLabel = UTCI_ENVIRONMENTS[entry.solarEnv]?.label ?? entry.solarEnv;
parts.push(`${PROFILE_LABELS[entry.profile] ?? entry.profile} ${entry.reading}° · ${bandFor(entry).label} (${envLabel})`);
}
if (entry.locationName) parts.push(entry.locationName);
if (entry.note) parts.push(entry.note);
return parts.join('\n');
}
function renderCalendar(entries) {
const wrap = $('db-cal-wrap');
wrap.innerHTML = '';
$('db-cal-empty').hidden = entries.length > 0;
if (entries.length === 0) { calMonth = null; return; }
const byDay = entriesByDay(entries);
const keys = [...byDay.keys()].sort();
const monthOf = (key) => ({ y: +key.slice(0, 4), m: +key.slice(5, 7) - 1 });
// Paging stops at the entries themselves on one side and today on the
// other, so the arrows never wander off into empty years.
const now = new Date();
const firstMonth = monthOf(keys[0]);
const lastEntryMonth = monthOf(keys[keys.length - 1]);
const thisMonth = { y: now.getFullYear(), m: now.getMonth() };
const lastMonth = monthIndex(lastEntryMonth) > monthIndex(thisMonth) ? lastEntryMonth : thisMonth;
// Open on the newest entry's month - always a month with something in it.
if (!calMonth) calMonth = { ...lastEntryMonth };
if (monthIndex(calMonth) < monthIndex(firstMonth)) calMonth = { ...firstMonth };
if (monthIndex(calMonth) > monthIndex(lastMonth)) calMonth = { ...lastMonth };
const step = (delta) => {
const moved = new Date(Date.UTC(calMonth.y, calMonth.m + delta, 1));
calMonth = { y: moved.getUTCFullYear(), m: moved.getUTCMonth() };
renderCalendar(entries);
};
const head = document.createElement('div');
head.className = 'db-cal-head';
const prevBtn = document.createElement('button');
prevBtn.type = 'button';
prevBtn.className = 'db-cal-nav';
prevBtn.textContent = '‹';
prevBtn.setAttribute('aria-label', 'Previous month');
prevBtn.disabled = monthIndex(calMonth) <= monthIndex(firstMonth);
prevBtn.addEventListener('click', () => step(-1));
const title = document.createElement('span');
title.className = 'db-cal-title';
title.textContent = `${CAL_MONTHS[calMonth.m]} ${calMonth.y}`;
const nextBtn = document.createElement('button');
nextBtn.type = 'button';
nextBtn.className = 'db-cal-nav';
nextBtn.textContent = '›';
nextBtn.setAttribute('aria-label', 'Next month');
nextBtn.disabled = monthIndex(calMonth) >= monthIndex(lastMonth);
nextBtn.addEventListener('click', () => step(1));
head.append(prevBtn, title, nextBtn);
const grid = document.createElement('div');
grid.className = 'db-cal-grid';
for (const dow of CAL_DOW) {
const cell = document.createElement('div');
cell.className = 'db-cal-dow';
cell.textContent = dow;
grid.appendChild(cell);
}
// Weeks start Monday, so Sunday (getUTCDay() 0) is the seventh column.
const lead = (new Date(Date.UTC(calMonth.y, calMonth.m, 1)).getUTCDay() + 6) % 7;
const daysInMonth = new Date(Date.UTC(calMonth.y, calMonth.m + 1, 0)).getUTCDate();
for (let i = 0; i < lead; i++) {
const blank = document.createElement('div');
blank.className = 'db-cal-cell db-cal-cell--blank';
grid.appendChild(blank);
}
const todayKey = utcKey(Date.UTC(now.getFullYear(), now.getMonth(), now.getDate()));
const seenBands = new Map();
for (let day = 1; day <= daysInMonth; day++) {
const key = utcKey(Date.UTC(calMonth.y, calMonth.m, day));
const cell = document.createElement('div');
cell.className = 'db-cal-cell' + (key === todayKey ? ' db-cal-cell--today' : '');
const num = document.createElement('div');
num.className = 'db-cal-daynum';
num.textContent = String(day);
cell.appendChild(num);
const dayEntries = byDay.get(key) ?? [];
if (dayEntries.length > 0) {
const dots = document.createElement('div');
dots.className = 'db-cal-dots';
// Four fit across a cell; any more are still reachable from the
// entry list, so the cell just says how many it isn't showing.
const shown = dayEntries.slice(0, 4);
for (const entry of shown) {
const cosmic = isCosmicEntry(entry);
const band = cosmic ? null : bandFor(entry);
const tip = calTooltip(entry);
const dot = document.createElement('button');
dot.type = 'button';
dot.className = 'db-cal-dot'
+ (cosmic ? ' db-cal-dot--cosmic' : band ? '' : ' db-cal-dot--none');
if (band) {
dot.style.background = band.bg;
if (!seenBands.has(band.label)) seenBands.set(band.label, { band, sort: entry.reading });
}
dot.title = tip;
dot.setAttribute('aria-label', `Edit entry: ${tip.replace(/\n/g, ' · ')}`);
dot.addEventListener('click', () => openWizard(entry));
dots.appendChild(dot);
}
if (dayEntries.length > shown.length) {
const more = document.createElement('span');
more.className = 'db-cal-more';
more.textContent = `+${dayEntries.length - shown.length}`;
dots.appendChild(more);
}
cell.appendChild(dots);
}
grid.appendChild(cell);
}
wrap.append(head, grid);
// Legend covers only the bands actually on screen this month - the full
// twelve-band scale lives on the forecast page, not here.
if (seenBands.size > 0) {
const legend = document.createElement('div');
legend.className = 'db-cal-legend';
const ordered = [...seenBands.values()].sort((a, b) => a.sort - b.sort);
for (const { band } of ordered) {
const item = document.createElement('span');
const swatch = document.createElement('i');
swatch.className = 'db-cal-swatch';
swatch.style.background = band.bg;
item.append(swatch, band.label);
legend.appendChild(item);
}
wrap.appendChild(legend);
}
}
async function showDashboard() {
$('db-signed-out').hidden = true;
$('db-signed-in').hidden = false;
renderLocation();
await loadEntries();
// After the list, so the wizard opens over a page that already looks
// like itself rather than an empty one that fills in behind it.
const prefill = readPrefill(true);
if (prefill) openWizardFromPrefill(prefill);
}
// 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();
return;
}
// Arrived from the event banner's Record button with no session - say
// so, or the hand-off just looks like a login page for no reason. The
// entry stays put until the sign-in below reaches showDashboard().
const pending = readPrefill(false);
if (pending) {
const note = $('db-quick-note');
note.textContent = pending.title
? `Sign in to add “${pending.title}” to your journal.`
: 'Sign in to add this to your journal.';
note.hidden = false;
}
});
</script>
</body>
</html>