diff --git a/about.html b/about.html index fac7bda..f32c5c4 100644 --- a/about.html +++ b/about.html @@ -56,7 +56,10 @@ + ${activeEvents.length > 0 && !noteDismissed && (() => { const ev = activeEvents[noteIndex] || activeEvents[0]; + // Guards the window between an event list changing and the reset + // effect above running: an index past the end would hide every slide. + const slideIndex = noteIndex < activeEvents.length ? noteIndex : 0; const isPriority = PRIORITY_WEATHER_IDS.has(ev.id); const fmtDate = (iso) => iso ? new Date(iso + 'T00:00Z').toLocaleDateString('en-GB', { day: 'numeric', month: 'short', timeZone: 'UTC' }) @@ -746,6 +913,10 @@ export function UTCIForecast() { }; })() : undefined; return html` + ${/* The slot clips the banner: on close it collapses to nothing while + the note itself slides up inside it, so the strip reads as + tucking back up under the header rather than fading in place. */''} +
${/* Every slide is rendered, all stacked in one CSS grid cell, so the @@ -756,7 +927,7 @@ export function UTCIForecast() {
${activeEvents.map((e, i) => { const dateLine = dateLineFor(e); - const isActive = i === noteIndex && !noteFading; + const isActive = i === slideIndex && !noteFading; return html`
noteGoTo(i)} />`)} @@ -798,54 +969,13 @@ export function UTCIForecast() { fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" /> +
`; })()} -
-

- SunScope -

-
See the world the way your skin does.
`); })()} + ${fetchedAt && !loading && html` +
+ ${isStale ? html`` : ''}Last update: ${fetchedAt.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })} +
`}
`}
@@ -1262,6 +1413,7 @@ export function UTCIForecast() {
${isPro && html`<${Fragment}> ${colOffered('utciP') && html``} + ${colOffered('shadeT') && html``} ${colOffered('vehicleT') && html``} ${(colOffered('indoorT') || colOffered('managedT')) && html``} ${colOffered('utci') && html``} @@ -1274,7 +1426,6 @@ export function UTCIForecast() { ${colOffered('pawT') && html``} ${colOffered('petShadeT') && html``} ${colOffered('petHomeT') && html``} - ${colOffered('shadeT') && html``} ${colOffered('air') && html``} ${colOffered('rh') && html``} ${colOffered('dew') && html``} @@ -1307,7 +1458,11 @@ export function UTCIForecast() {
-
+
+ +
@@ -1318,8 +1473,34 @@ export function UTCIForecast() { const localHHMMs = h24s === 0 ? '12am' : h24s < 12 ? `${h24s}am` : h24s === 12 ? '12pm' : `${h24s - 12}pm`; const isNow = r.isoHours ? r.isoHours.includes(nowLocalISO) : r.iso.slice(0, 13) === nowLocalISO; const domIcon = (() => { - if (r.precipProb > 20 && (r.precip > 0 || r.snow > 0)) - return html`<${PrecipIcon} precip=${r.precip} snow=${r.snow} size=${42} filled=${true} />`; + // Two rain numbers, reconciled the same way the day tabs do it + // (see DayTabs). precipProb answers WHETHER it rains, precip + // answers HOW MUCH — and they come from different model runs, + // so a 97%-certain hour can still carry 0.0mm. Gating the icon + // on the amount alone left those hours wearing a cloud while + // the card underneath read 97%. So: probability decides IF the + // icon shows rain, and the two together decide how hard it looks. + // • >= 50% — rain icon, whatever the amount says + // • > 20% with mm/cm — rain icon (a light but real shower) + // • otherwise — cloud + const pProb = r.precipProb ?? 0; + const wet = r.precip > 0 || r.snow > 0; + if (pProb >= 50 || (pProb > 20 && wet)) { + const c01 = v => Math.max(0, Math.min(1, v)); + // Confidence half: 0 at the 50% gate, 1 at a dead-certain 100%. + const probT = c01((pProb - 50) / 50); + // Amount half: "heavy" taken as 2mm in a single hour. + const amountT = c01((r.precip || 0) / 2); + // Certainty alone tops out at medium-high; only a genuinely + // wet hour reaches the 3-drop downpour. + const score = probT * 0.55 + amountT * 0.45; + const drops = score < 0.33 ? 1 : score < 0.70 ? 2 : 3; + // PrecipIcon draws its dry dash when precip and snow are both + // 0, so a certain-but-zero-amount hour needs a nominal trace + // to draw drops at all. drops= still sets the real intensity. + const iconPrecip = r.snow > 0 ? (r.precip || 0) : (r.precip > 0 ? r.precip : 0.01); + return html`<${PrecipIcon} precip=${iconPrecip} snow=${r.snow} drops=${drops} size=${42} filled=${true} />`; + } if (r.cloudCat && r.cloudCat !== 'clear') return html`<${CloudIcon} category=${r.cloudCat} elev=${r.elev} dt=${r.dt} size=${42} filled=${true} showSun=${false} />`; return null; @@ -1356,8 +1537,7 @@ export function UTCIForecast() {
${r.precipProb}% - ${windMph}mph - ${r.compass ? r.compass.label : '—'} + ${windMph}mph${r.compass ? html` ${r.compass.label}` : ''}
${Math.round(dispTemp)}°
${cat.label}
@@ -1512,31 +1692,44 @@ export function UTCIForecast() { -
- - - - inside the + horizontal scroller can only stick to that scroller. + useTableScroll keeps the two tables' column widths and + horizontal offset in step. */''} +
+
+
handleThClick('hour', e)} - onMouseEnter=${(e) => handleThEnter('hour', e)} - onMouseLeave=${handleThLeave}> - ${glanceDate} - - e.stopPropagation()}> - <${RowStepper} value=${tableInterval} onChange=${setTableInterval} showLabel=${false} /> + ${/* Head split out of the body table, exactly as the normal + orientation does it: the hour row has to pin to the + viewport as the page scrolls, and a
+ + + - ${tableRows.map(r => { - const f = hourFlags(r); - return html` - `; - })} - - + + ${tableRows.map(r => { + const f = hourFlags(r); + return html` + `; + })} + + +
handleThClick('hour', e)} + onMouseEnter=${(e) => handleThEnter('hour', e)} + onMouseLeave=${handleThLeave}> + ${glanceDateShort} + + e.stopPropagation()}> + <${RowStepper} value=${tableInterval} onChange=${setTableInterval} showLabel=${false} /> + - - - ${timeCell(r)} -
+ ${timeCell(r)} +
+
+
+ +
+ ${visibleColumnDefs.map((d, i) => { // A group heading row is emitted whenever the @@ -1681,9 +1874,9 @@ export function UTCIForecast() {
The Week Ahead
${titleCaseText(bestDaysLabel(activeProfile, outdoorsVariant))}
- ${weekAhead.map(({ icon, label, value, score }) => html` -
- ${icon} + ${weekAhead.map(({ icon, iconTitle, label, value, score }) => html` +
+ ${icon} ${titleCaseText(label)} ${titleCaseText(value)} ${score != null && html` @@ -1779,7 +1972,9 @@ export function UTCIForecast() { Thermal stress bands
${[ - { t: -9, label: 'Freezing', value: '< 0°C' }, + { t: -25, label: 'Extreme cold', value: '< -20°C' }, + { t: -15, label: 'Arctic', value: '-20–-10°C' }, + { t: -5, label: 'Freezing', value: '-10–0°C' }, { t: 1, label: 'Cold', value: '0–10°C' }, { t: 11, label: 'Cool', value: '10–19°C' }, { t: 21, label: 'Comfortable', value: '19–24°C', bold: true }, @@ -1806,7 +2001,9 @@ export function UTCIForecast() { Pet thermal stress bands
${[ - { t: -15, label: 'Freezing', value: '< -8°C' }, + { t: -35, label: 'Extreme cold', value: '< -28°C' }, + { t: -23, label: 'Arctic', value: '-28–-18°C' }, + { t: -13, label: 'Freezing', value: '-18–-8°C' }, { t: 0, label: 'Cold', value: '-8–2°C' }, { t: 9, label: 'Cool', value: '2–11°C' }, { t: 18, label: 'Comfortable', value: '11–25°C', bold: true }, @@ -1940,6 +2137,7 @@ export function UTCIForecast() { · Forecast · About · FAQ + · Dashboard · 0; const hasSnow = snow > 0; @@ -1321,7 +1326,9 @@ export function PrecipIcon({ precip = 0, snow = 0, size = 28, filled = false }) } // Rain intensity - number of drops - const rainDrops = !hasRain ? 0 : precip < 1 ? 1 : precip < 4 ? 2 : 3; + const rainDrops = !hasRain ? 0 + : drops != null ? Math.max(1, Math.min(3, drops)) + : precip < 1 ? 1 : precip < 4 ? 2 : 3; // Snow intensity - number of flakes const snowFlakes = !hasSnow ? 0 : snow < 0.5 ? 1 : 2; const fr = size * 0.08; // flake arm length @@ -1346,7 +1353,7 @@ export function PrecipIcon({ precip = 0, snow = 0, size = 28, filled = false }) : hasRain ? Array.from({ length: rainDrops }, (_, i) => html` <${Fragment} key=${i}> - ${rainStroke(spacing * (i + 1), size * 0.53, precip >= 4)} + ${rainStroke(spacing * (i + 1), size * 0.53, rainDrops >= 3)} `) : Array.from({ length: snowFlakes }, (_, i) => flake(spacing * (i + 1), size * 0.66, fr) @@ -1425,6 +1432,36 @@ export function FogIcon({ size = 30 }) { `; } +// WINDICON - Brass Line gust glyph for the day-tab wind badge. Body paths are +// Lucide's "wind" icon (ISC licence) on its native 24x24 grid, same approach +// as CarIcon. Gale-force and above (tier >= 3) swap the brass/ink pairing for +// a colder storm blue so the step up from "blustery" is visible at a glance. +// ------------------------------------------------------------------- +// halo - draws each gust line twice, a wider white stroke underneath the +// coloured one. Needed when the glyph is overlaid on the cloud/rain +// icon: without it the two line drawings tangle into one another and +// neither reads. Costs nothing when drawn on a flat tab background. +export function WindIcon({ size = 30, tier = 2, halo = false }) { + const severe = tier >= 3; + const lead = severe ? '#2f5d82' : '#c8922a'; + const trail = severe ? '#3a6a92' : '#2a1a08'; + const gusts = [ + { d: 'M17.7 7.7a2.5 2.5 0 1 1 1.8 4.3H2', stroke: lead }, + { d: 'M9.6 4.6A2 2 0 1 1 11 8H2', stroke: trail }, + { d: 'M12.6 19.4A2 2 0 1 0 14 16H2', stroke: trail }, + ]; + return html` + + + ${halo && gusts.map((p, i) => html` + `)} + ${gusts.map((p, i) => html` + `)} + + `; +} + export function IceIcon({ size = 30 }) { const icy = '#3f73c4'; return html` diff --git a/assets/js/components/DayTabs.js b/assets/js/components/DayTabs.js index 4958a4a..b1ef03e 100644 --- a/assets/js/components/DayTabs.js +++ b/assets/js/components/DayTabs.js @@ -32,10 +32,11 @@ // ------------------------------------------------------------------------ import { h, Fragment } from '../../vendor/preact.js'; +import { useEffect, useRef } from '../../vendor/preact-hooks.js'; import htm from '../../vendor/htm.js'; -import { confidenceBand, utciCategory, petCategory, UTCI_BANDS, PET_BANDS, bandRampRgb, hexToRgb, mixRgb, rainTint, cloudTint, snowTint, VEHICLE_TYPES, BUILDING_TYPES, FUR_COLORS } from '../utils.js'; +import { confidenceBand, utciCategory, petCategory, DAY_TAB_UTCI_BANDS, DAY_TAB_PET_BANDS, bandRampRgb, hexToRgb, mixRgb, rainTint, snowTint, VEHICLE_TYPES, BUILDING_TYPES, FUR_COLORS } from '../utils.js'; import { FREE_DAYS, UTCI_ENVIRONMENTS, deriveProfileMain, FILTER_PROFILES, variantIcons } from '../config.js'; -import { CloudIcon, PrecipIcon, HouseIcon, CarIcon, FogIcon, IceIcon } from '../components.js'; +import { CloudIcon, PrecipIcon, HouseIcon, CarIcon, FogIcon, IceIcon, WindIcon } from '../components.js'; import { SubscribeModal } from './SubscribeModal.js'; const html = htm.bind(h); @@ -114,6 +115,82 @@ function sustainedHigh(vals) { return sorted[Math.min(drop, sorted.length - 1)]; } +// Lays a sky tint (rain blue, snow blue, cloud grey) over a temperature colour +// without the blend passing through green on the way. +// +// A straight mixRgb() from the Warm band's yellow to a rain blue travels +// through olive and then green — which is why the tabs used to pick ONE +// dimension by priority instead of blending. Desaturating the base toward its +// own luminance first routes yellow → khaki → grey → blue instead, so the +// temperature can genuinely shade toward the weather without inventing a hue +// that belongs to neither. +// base - [r,g,b] temperature colour from the band ramp +// target - [r,g,b] weather tint (rainTint / snowTint) +// w - 0..1 how strongly the weather pulls +function overlayTint(base, target, w) { + if (!(w > 0)) return base; + // The pre-desaturation exists ONLY to keep a hue-to-hue blend off the green + // diagonal — yellow travelling to rain-blue would otherwise pass through + // olive. A target with no hue of its own has no such diagonal to avoid, and + // mixing toward a grey already desaturates by definition, so applying the + // full guard to the cloud tint stripped the base colour twice over. Scale it + // by how much hue the target actually carries: rain (a saturated blue) keeps + // essentially all of it, cloud (a near-neutral grey) almost none. + const chroma = (Math.max(...target) - Math.min(...target)) / 255; + const hueGuard = Math.min(1, chroma * 2.2); + const lum = 0.299 * base[0] + 0.587 * base[1] + 0.114 * base[2]; + const desat = mixRgb(base, [lum, lum, lum], Math.min(1, w * 0.9 * hueGuard)); + return mixRgb(desat, target, w); +} + +// Cloud is applied differently from rain, and deliberately so. +// +// Rain has a hue of its own — a day IS blue-grey with rain — so it earns a +// blend toward that colour. Cloud does not: an overcast 20° day is still a 20° +// day, just duller. Blending toward a grey HUE meant every warm overcast tab +// had to travel from gold to grey, and the route passes through khaki: at a +// middling weight #edd449 lands on #b4a85f, the muddy olive that reads as +// neither temperature nor weather. Pushing the weight high enough to clear the +// mud (0.80) instead erased the temperature entirely, which is how every day +// from +4° to +23° ended up the same grey. +// +// There is no weight that avoids both, because the problem is the path, not +// the distance. So cloud no longer moves the hue at all: it desaturates the +// temperature colour toward its OWN luminance and dims it slightly. Same hue, +// less vivid, a little darker — which is what overcast actually looks like — +// and because it never crosses between two hues there is no muddy middle at +// any weight. +function cloudDim(rgb, w) { + if (!(w > 0)) return rgb; + // Desaturate toward a neutral that is never DARKER than the colour it came + // from, and lift it slightly. This matters more than it looks: a darkened + // desaturated yellow is olive — that is simply what olive is — so the + // earlier version, which dulled toward the colour's own luminance and then + // dimmed 16%, walked the gold Comfortable band straight down into khaki + // (#edd449 -> #cabc75 -> #b3ad8c) and put green back on the strip by a + // different route than the palette had. + // + // Overcast is diffuse and flat, not dark, so lifting rather than dimming is + // also the truer look: gold travels gold -> cream -> pale sand + // (#e3d799 -> #e2dbb4), never through olive, and the blues at the cold end + // pale out the same way. + const lum = 0.299 * rgb[0] + 0.587 * rgb[1] + 0.114 * rgb[2]; + const neutral = Math.min(255, lum + 14); + return mixRgb(rgb, [neutral, neutral, neutral], w); +} + +// Weather weights are pushed through this before they are used. +// +// A linear weight means a day with middling rain sits at a 50/50 mix of +// temperature colour and rain blue — which is a muddy olive that reads as +// neither, and the whole point of the tab shade is that someone can tell what +// the day is at a glance. Smoothstep drags low weights lower and high weights +// higher, so the colour spends as little time as possible in that ambiguous +// middle: a bit of weather barely disturbs the temperature colour, and weather +// that genuinely IS the day takes the tab almost completely. +const clamp01 = (v) => Math.max(0, Math.min(1, v)); +const decisive = (t) => { const c = clamp01(t); return c * c * (3 - 2 * c); }; + function weatherGradientActive(r, g, b, edgeBlend = 0.42, centerBlend = null) { const blend = (c, amt) => Math.round(c + (255 - c) * amt); const lighten = (c) => Math.min(255, Math.round(c + (255 - c) * 0.38)); @@ -179,10 +256,34 @@ export function DayTabs({ : utciEnv; const rowGradient = ROW_GRADIENTS[mainConfigKey]?.[rowOptionKey]; + // The row pins to the top of the viewport on wide screens, and the table's + // sticky hour header has to stop just below it. Publish the measured height + // (plus its 4px margin) as --profile-row-h rather than hard-coding one in + // table.css: the row is one line or two depending on how long the profile + // and thermal-basis labels are, so a fixed offset leaves the header tucked + // under it. + const profileRowRef = useRef(null); + useEffect(() => { + const el = profileRowRef.current; + if (!el) return; + const publish = () => { + const h = Math.round(el.getBoundingClientRect().height) + 4; + document.documentElement.style.setProperty('--profile-row-h', `${h}px`); + }; + publish(); + const ro = typeof ResizeObserver !== 'undefined' ? new ResizeObserver(publish) : null; + if (ro) ro.observe(el); + window.addEventListener('resize', publish); + return () => { + if (ro) ro.disconnect(); + window.removeEventListener('resize', publish); + }; + }, []); + return html` <${Fragment}> -
{ if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); openPanel(); } }} style=${rowGradient ? { '--row-bg': rowGradient } : undefined} title="Change profile & settings"> @@ -244,6 +345,12 @@ export function DayTabs({ const dayHi = mainVals.length ? Math.round(Math.max(...mainVals)) : null; const dayLo = mainVals.length ? Math.round(Math.min(...mainVals)) : null; + // Day-tab weather icon - use core daylight rows (elev > 10°) where + // available, falling back to any above-horizon rows, then all rows. + const coreRows = d.rows.filter(r => r.elev > 10); + const aboveRows = d.rows.filter(r => r.elev > 0); + const repRows = coreRows.length > 0 ? coreRows : aboveRows.length > 0 ? aboveRows : d.rows; + // The tab COLOUR is keyed to a spike-resistant "sustained high" rather than // the raw max, so a single warm hour on a cool/damp day doesn't paint the // whole tab warm. The Danger tier is the exception — it keeps alarming on @@ -252,8 +359,68 @@ export function DayTabs({ // always show the true dayHi/dayLo. const catFn = isPetsProfile ? petCategory : utciCategory; const isDanger = dayHi !== null && catFn(dayHi).solid === true; + // Spike-filter the DAYLIGHT hours, not the whole 24. Run over every + // row, sustainedHigh() drops the top ~10% of a 24-hour list — two or + // three hours — and on a summer day with a big diurnal swing that is + // the entire afternoon peak, not a spike. A 28° day was colouring + // from ~25°, a whole band down from the number printed on the tab. + // repRows is already what the icon, cloud and rain read, so the hue + // now comes from the same slice of the day as everything else. + const colourVals = repRows.map(r => r[mainField]).filter(v => isFinite(v)); + + // On a cold day the sustained HIGH is the wrong representative. + // Heat is a PEAK risk — the hottest hours are the dangerous ones, so + // colouring from the high is right at the top of the scale. Cold is + // an EXPOSURE risk: what matters is how cold it stayed all day, not + // the one mild moment. Invercargill on 31 Aug peaked at -4.8° at 6pm + // against daylight hours running -6.5° to -18.6°, so colouring from + // the peak put a pale Freezing tab on a genuinely Arctic day. + // + // Below 10° the colour therefore crossfades from the sustained high + // toward the daylight MEAN, arriving fully at the mean by 6°. It is + // crossfaded rather than switched so two adjacent days either side of + // the threshold can't jump a band against each other in the strip. + // The printed hi/lo numbers are untouched — they stay the true peak + // and trough, as they already do for the spike filter. + const colourAvg = colourVals.length + ? colourVals.reduce((s, v) => s + v, 0) / colourVals.length + : null; + const sustHi = sustainedHigh(colourVals.length ? colourVals : mainVals); + const coldWeight = colourAvg === null ? 0 : clamp01((10 - colourAvg) / 4); const colourHi = dayHi === null ? null - : (isDanger ? dayHi : Math.round(sustainedHigh(mainVals))); + : isDanger ? dayHi + : Math.round(sustHi + (colourAvg - sustHi) * coldWeight); + + // ---- Which hours the tab's COLOUR should read the sky from ------- + // The tint used to average cloud and total rain across every daylight + // hour, which silently assumes the day is all one weather. Split days + // break that badly: a heavy wet morning followed by a hot, clear + // afternoon totals up as "wet and overcast", so the tab got painted + // rain-blue even though the hours it is printing a temperature FOR + // were dry and sunny. + // + // The tab's number already comes from the hottest sustained hours, so + // the shading should come from the same ones. colourRows is that + // slice — daylight hours within 3° of colourHi, never fewer than the + // three hottest — and it drives the tint only. + // + // The ICON deliberately keeps reading the whole day: it did rain this + // morning, and the glyph is the right place to say so. Colour answers + // "how hot is this day", the glyph answers "did it rain". + const colourRows = (() => { + if (colourHi === null || !repRows.length) return repRows; + const near = repRows.filter(r => isFinite(r[mainField]) && r[mainField] >= colourHi - 3); + if (near.length >= 3) return near; + return [...repRows] + .filter(r => isFinite(r[mainField])) + .sort((a, b) => b[mainField] - a[mainField]) + .slice(0, 3); + })(); + const peakPrecip = colourRows.reduce((s, r) => s + (r.precip || 0), 0); + const peakSnow = colourRows.reduce((s, r) => s + (r.snow || 0), 0); + const peakProbs = colourRows.map(r => r.precipProb).filter(v => isFinite(v)); + const peakProb = peakProbs.length ? Math.max(...peakProbs) : 0; + const peakWet = clamp01((peakProb - 50) / 25); // Secondary row under each hi/lo: Shade for SunSoak, Managed for // indoor, Pet Shade for pets, and plain air temperature for @@ -265,11 +432,6 @@ export function DayTabs({ shadeLo = secondaryVals.length ? Math.round(Math.min(...secondaryVals)) : null; } - // Day-tab weather icon - use core daylight rows (elev > 10°) where - // available, falling back to any above-horizon rows, then all rows. - const coreRows = d.rows.filter(r => r.elev > 10); - const aboveRows = d.rows.filter(r => r.elev > 0); - const repRows = coreRows.length > 0 ? coreRows : aboveRows.length > 0 ? aboveRows : d.rows; const dayPrecip = repRows.reduce((s, r) => s + (r.precip || 0), 0); const daySnow = repRows.reduce((s, r) => s + (r.snow || 0), 0); const catCounts = {}; @@ -279,7 +441,43 @@ export function DayTabs({ const noonRow = repRows.reduce((best, r) => (r.elev > (best?.elev ?? -Infinity) ? r : best), null); const repElev = noonRow ? noonRow.elev : 45; const repDt = noonRow ? noonRow.dt : dDate; - const showPrecip = dayPrecip >= 0.3 || daySnow >= 0.1; + + // ---- The two rain numbers, reconciled -------------------------- + // The forecast gives us two different things and they answer + // different questions: precipProb is WHETHER it rains, precip is HOW + // MUCH if it does. Reading either one alone gets the day wrong — an + // amount-only reading puts a rain icon on a 37% day that will most + // likely stay dry, while a probability-only reading can't tell + // drizzle from a downpour. + // + // So: probability decides IF the tab shows rain at all, and then the + // two together decide how hard that rain looks. + // • under 50% — no rain icon; it probably won't rain + // • 50% → 100% — rain icon, climbing to medium-high on + // confidence alone + // • + heavy amount — climbs the rest of the way to a downpour + // The tab COLOUR reuses the same 50% gate, but reads it over the peak + // hours (peakWet, above), so the icon and the shade can never + // disagree about WHETHER it rains — only about whether it was still + // raining during the part of the day the tab is printing a + // temperature for. + const probVals = repRows.map(r => r.precipProb).filter(v => isFinite(v)); + const maxProb = probVals.length ? Math.max(...probVals) : 0; + + // Confidence half: 0 at the 50% gate, 1 at a dead-certain 100%. + const probT = clamp01((maxProb - 50) / 50); + // Amount half: the daylight total, "heavy" taken as 8mm across the day. + const amountT = clamp01(dayPrecip / 8); + // Weighted so certainty alone tops out around medium-high (2 drops) + // and only a genuinely wet forecast reaches the 3-drop downpour. + const rainScore = probT * 0.55 + amountT * 0.45; + const rainDrops = rainScore < 0.33 ? 1 : rainScore < 0.70 ? 2 : 3; + + // Snow gets the same probability gate — precipProb covers all + // precipitation — but keeps its own amount-driven flake count. + const showSnow = daySnow >= 0.1 && maxProb >= 50; + const showRain = !showSnow && dayPrecip >= 0.3 && maxProb >= 50; + const showPrecip = showRain || showSnow; // Vehicle tab: swap the default car glyph for a hazard icon when // conditions could affect driving. Uses the full day (not just @@ -305,12 +503,65 @@ export function DayTabs({ } } - // Day-tab colour — PRIORITY model, no hue blending. - // Mixing a warm yellow with grey/blue passes through green, so rather - // than blend temperature with sky we pick ONE dimension by priority and - // only vary its shade: heat → snow → rain → cloud → sun (first wins). - // • Hot/extreme days always keep their heat colour (a safety signal). - // • Otherwise rain wins, then cloud, then a clear "sunny"/cold colour. + // Wind badge — gusts over the WHOLE day, not just the daylight + // repRows: a gale that runs into the evening still defines the day. + // Tiers mirror windCategory() in events/weather-checks.js so the tab + // and the At a Glance advisory can never disagree about what counts + // as a gale. + const gustVals = d.rows.map(r => r.gust ?? r.va).filter(v => isFinite(v)); + const gustsMph = gustVals.map(v => v * 2.237); + // Round BEFORE tiering, not after. The table's gust column prints a + // rounded figure, so a 31.99 mph day already reads "32 mph" there — + // testing the raw value against a 32 mph threshold then withheld the + // badge from a day the rest of the UI was calling a 32 mph day. + // Tier the same number the user is actually shown. + const maxGust = gustsMph.length ? Math.round(Math.max(...gustsMph)) : 0; + const avgGust = gustsMph.length + ? gustsMph.reduce((s, v) => s + v, 0) / gustsMph.length : 0; + // A day doesn't have to spike to be a windy day. Peak-only tiering + // missed the kind people actually notice — hour after hour of 25-30 + // mph gusts, a Strong Breeze (Force 6) that never quite reaches Near + // Gale — while a day with one brief 32 mph gust between calm hours + // got the badge. So a day that stays blustery for at least half its + // hours earns the badge on its own. 25 mph is GUST_BLUSTERY in + // events/weather-checks.js. + const blusteryFrac = gustsMph.length + ? gustsMph.filter(v => v >= 25).length / gustsMph.length : 0; + const sustainedWind = blusteryFrac >= 0.5; + const windTier = maxGust >= 74 ? 5 : maxGust >= 55 ? 4 + : maxGust >= 39 ? 3 : maxGust >= 32 ? 2 + : sustainedWind ? 2 : 0; + const windLabel = windTier >= 5 ? 'Storm force' + : windTier >= 4 ? 'Severe gale' + : windTier >= 3 ? 'Gale' + : maxGust >= 32 ? 'Near gale' + : 'Blustery'; + // Badge size tracks what the day AVERAGED rather than how hard it + // spiked, so a day that simply stays windy reads heavier than one + // carried by a single gust. 18 mph averages to the smallest badge, + // 34 mph to the largest. + const windBadgeSize = Math.round(15 + 9 * clamp01((avgGust - 18) / 16)); + + // Day-tab colour — WEIGHTED model. + // Temperature is always the base: the band ramp, so the tab reads as a + // temperature first and a 17° day can never come out looking like a + // 26° one. Sky conditions then shade that base by influence, in order + // of how much they change the day: + // 1. temperature - base colour, from the band ramp + // 2. cloud cover - dulls that colour in place, same hue + // 3. rain / snow - pulls toward blue, by amount AND confidence + // Thermal first, then cloud, then precipitation. Cloud is the sky + // condition EVERY day has some of, so it is the general case and rain + // is the special one layered over it — and crucially it has to be + // cloudy for it to rain, so rain never lands on a raw band colour. + // By the time rain applies, cloud has already carried the tab to + // grey, and grey is the one starting point from which deep rain-blue + // is a clean path: gold straight to blue goes through khaki and + // olive, gold to grey to blue does not. That ordering is what keeps + // green off the strip, not a choice of weights. + // Rain and snow blend through overlayTint() so a warm yellow shading + // toward rain-blue routes via grey rather than passing through green; + // cloud uses cloudDim(), which never moves the hue at all. // Shade ramps live in utils.js (WEATHER TINT PALETTE) so the warning // event banner can colour itself from exactly the same source. const tBand = colourHi !== null ? catFn(colourHi) : { bg: '#f8e554' }; @@ -321,17 +572,71 @@ export function DayTabs({ // alarm colour, not a point on the gradient. const tBandRgb = tBand.solid ? hexToRgb(tBand.bg) - : bandRampRgb(colourHi, isPetsProfile ? PET_BANDS : UTCI_BANDS); + : bandRampRgb(colourHi, isPetsProfile ? DAY_TAB_PET_BANDS : DAY_TAB_UTCI_BANDS); - const ccVals = repRows.map(r => r.cc).filter(v => isFinite(v)); + // Cloud for the TINT, averaged over the peak hours only — see + // colourRows above. A wet overcast morning no longer greys out a + // clear hot afternoon. + const ccVals = colourRows.map(r => r.cc).filter(v => isFinite(v)); const avgCloud = ccVals.length ? ccVals.reduce((s, v) => s + v, 0) / ccVals.length : 0; - // Hot/cold cutoffs below are calibrated against UTCI_BANDS; for the - // Pets profile (furSurfaceT, judged against the shifted PET_BANDS) - // the same cutoffs are re-projected onto the pet scale so the - // "hot hue" / "plain cold" branches kick in at the same relative - // point in each band, not the same absolute degree. - const isHot = colourHi !== null && colourHi >= (isPetsProfile ? 35 : 27); // Hot band and above + // Heat cutoffs are calibrated against the UTCI bands; for the Pets + // profile (furSurfaceT, judged against the shifted pet bands) the same cutoffs + // are re-projected onto the pet scale so each one kicks in at the same + // relative point in the band, not the same absolute degree. + // heatT drives both how far the sky is allowed to shade the colour + // (skyCap, below) and how pastel the tab is drawn (further down). + // heatFloor is the START of the hot half — the Warm band threshold, + // not the Caution one. Anchoring it at Caution meant a day sitting in + // the bottom of Caution scored heatT ~= 0 and was therefore treated + // by every heat guard below exactly like a 20° day: full sky shading, + // no band weighting. A 28° tab came out the same grey as a 17° one. + // Starting the ramp at Warm makes the guards engage across the whole + // hot half, which is where they were always meant to apply. + const heatFloor = isPetsProfile ? 25 : 24; // Warm threshold + const heatMid = isPetsProfile ? 40 : 32; // Extreme threshold + const heatTop = isPetsProfile ? 52 : 41; // Danger threshold + + // The spike filter that protects the tab's BAND COLOUR should not + // also decide how hot the tab is allowed to LOOK. sustainedHigh() + // deliberately throws away the day's top ~10% of hours so a single + // warm hour can't paint a cool day warm — right for choosing the hue, + // wrong for choosing vividness. On a muggy UK heat day it discounts + // the part of the day people actually remember: 26 Aug 2026 at Path + // Hill printed 33° but coloured from 29°, which halved heatT and let + // 87% cloud grey out a genuinely hot afternoon. + // So the hue keeps riding on the spike-filtered colourHi, while + // vividness and cloud-resistance ride on the midpoint between that + // and the true peak: the day still has to have been broadly hot, but + // a real hot spell is no longer discounted away to nothing. + const heatDrive = colourHi === null ? null : (colourHi + dayHi) / 2; + const heatT = heatDrive === null ? 0 : clamp01((heatDrive - heatFloor) / (heatMid - heatFloor)); + const overT = heatDrive === null ? 0 : clamp01((heatDrive - heatMid) / (heatTop - heatMid)); + + // Cold counterpart to heatT, anchored on the same two bands at the + // other end of the scale: it starts at the Very cold threshold and + // reaches full strength at Arctic. Used only for skyCap below — the + // pastel ramp stays keyed to heat. + // Two segments, mirroring heatFloor/heatMid/heatTop on the hot end. + // This used to be a single ramp that finished at the ARCTIC + // threshold, which meant coldT pinned to 1 from -10° downward and + // every day below it — the whole of Arctic AND the whole of Extreme + // cold — was drawn identically. Invercargill at -12° and at -28° + // came out the same tab. The hot end never had that problem because + // it ramps across Warm → Caution → Extreme; the cold end now does + // the same, with underT carrying the deep half. + const coldFloor = isPetsProfile ? -3 : 5; // Very cold threshold + const coldMid = isPetsProfile ? -18 : -10; // Arctic threshold + const coldBottom = isPetsProfile ? -28 : -20; // Extreme cold threshold + const coldT = colourHi === null ? 0 : clamp01((coldFloor - colourHi) / (coldFloor - coldMid)); + const underT = colourHi === null ? 0 : clamp01((coldMid - colourHi) / (coldMid - coldBottom)); + + // How far a day sits from the benign middle of the scale, in either + // direction. Chilly/Cool/Comfortable/Warm days are ordinary weather + // and let the sky do the talking; the further a day pushes toward + // either extreme, the more the temperature holds the colour. + const extremityT = Math.max(heatT, coldT); + // Extreme band gets a less pastelised gradient so it reads as a // clear step between Caution and the pulsing Danger tier. const isExtreme = tBand.label === 'Extreme'; @@ -341,32 +646,93 @@ export function DayTabs({ // — no rain/snow/cloud tinting, and no weather icon. const tempOnly = isHomeOrOffice || isVehicleProfile; + // At gale force and above on a dry day, the wind IS the day's weather + // — a sun glyph with a wind badge tucked in the corner undersells a + // 41 mph gale on a bright, dry Monday. So the wind glyph takes the + // main icon slot instead, and the badge is only used for the tier + // below (Near Gale) or when rain already owns the main slot. + const windIsHeadline = !tempOnly && windTier >= 3 && !showPrecip; + + // How far the temperature colour is allowed to be shaded by the sky. + // Both ends of the scale are a safety signal, so an Extreme-heat or + // Arctic day keeps its colour almost intact however thick the cloud — + // the sky can only really take the tab on the ordinary days in + // between. Danger is immovable. + // Heat gets a second, separate reduction on top of that. Heat and a + // wet sky are not opposites — a muggy 28° afternoon under rain is + // still a hot day, and the story is the heat. This used to be applied + // to CLOUD only (as cloudHeatCut), which left rain free to repaint a + // hot day blue at up to 0.92 strength; and because reducing the rain + // weight hands the leftover influence straight to cloud, cutting one + // without the other just swaps blue for grey. Cutting skyCap itself + // cuts both together, and keeps the rain→cloud handover coherent. + // Raised to a fractional power so the cut BITES IN LOW CAUTION rather + // than waiting for Extreme. A straight linear cut left a 28° day at + // ~0.43 sky influence — enough rain-blue to still read as a cold wet + // day — because the two caps multiply and each is gentle on its own. + // The rain/snow GLYPH already tells you the day is wet; past the + // middle of the hot half the colour's job is to say how hot it is. + const skyHeatCut = 1 - 0.92 * Math.pow(heatT, 0.75); + const skyCap = tBand.solid ? 0 : (1 - 0.80 * extremityT) * skyHeatCut; + let rgb; if (tempOnly) { - rgb = tBandRgb; - } else if (isHot) { - // Keep the heat hue; cloud only mutes it a touch (orange/red never - // greens) and rain does not override heat. - const heatFactor = isPetsProfile - ? (colourHi >= 49 ? 0.15 : colourHi >= 43 ? 0.30 : 0.45) - : (colourHi >= 39 ? 0.15 : colourHi >= 34 ? 0.30 : 0.45); - const t = Math.max(0, Math.min(1, (avgCloud - 30) / 70)) * heatFactor; - rgb = mixRgb(tBandRgb, [170, 162, 152], t); - } else if (daySnow >= 0.1) { - // Snow: pale → icy blue with depth. - rgb = snowTint(daySnow); - } else if (showPrecip) { - // Rain: light → deep blue with amount (pure blue, no temperature hue). - rgb = rainTint(dayPrecip); - } else if (avgCloud >= 30) { - // Cloud: light → dark grey with cover (pure grey, no temperature hue). - rgb = cloudTint(avgCloud); - } else if (colourHi !== null && colourHi < (isPetsProfile ? 2 : 10)) { - // Clear & cold: keep the cold temperature blue. + // Home/indoor/vehicle tabs show a modelled temperature, not the + // outdoor sky — no weather shading at all. rgb = tBandRgb; } else { - // Clear & mild/warm: a plain sunny yellow. - rgb = hexToRgb('#f8e554'); + rgb = tBandRgb; + + // 2. Cloud — dulls the temperature colour in place (cloudDim: + // same hue, less vivid, slightly darker). Applied BEFORE rain, + // because cloud is the sky condition every day has some of, so + // it is the general case that rain is then layered onto. + // avgCloud comes from the peak hours (colourRows), so a wet + // overcast morning no longer greys out a clear hot afternoon. + let cloudW = 0; + if (avgCloud >= 30) { + cloudW = decisive((avgCloud - 30) / 70) * 0.85 * skyCap; + rgb = cloudDim(rgb, cloudW); + } else if (colourHi !== null) { + // Clear skies: a small warm lift so a sunny day of a given + // temperature reads brighter than an overcast one at the same + // temperature. Deliberately gentle — it tops out at 0.16, so it + // tints the band colour rather than replacing it the way the old + // flat "sunny yellow" branch did. + const sunW = clamp01((30 - avgCloud) / 30) * 0.16 * skyCap; + rgb = overlayTint(rgb, hexToRgb('#f8e554'), sunW); + } + + // 3. Rain / snow — last, and only with the influence cloud did not + // already claim, so the two hand over smoothly instead of + // fighting. Sits behind the same 50% gate the icon uses, so the + // tab can never be painted wet while showing a dry sky glyph. + // Amount, confidence and tint colour all come from the peak + // hours (colourRows), so rain that has already cleared by the + // time the day gets hot no longer colours the tab. The showRain + // / showSnow gates still read the whole day, so a wet-morning / + // hot-afternoon day keeps its rain glyph over a warm tab. + // It has to be cloudy for it to rain, so by the time rain is + // applied the tab has ALREADY been taken to grey by step 2 — and + // grey is the one starting point from which deep rain-blue is a + // clean path. That is why rain is no longer throttled by + // (1 - cloudW): the previous version handed cloud the influence + // first and then left rain the scraps, which on a heavily overcast + // day meant rain could barely be seen at all. Rain now tints the + // grey at full strength for its amount and confidence. + // + // The ceiling drops from 0.92 to 0.60 to match: 0.92 was sized for + // pulling a saturated BAND colour toward blue, but pulling an + // already-grey tab that far just produces a flat saturated blue. + // 0.60 lands on a deep blue-grey — a wet overcast day — which is + // what the tab should say. + if (showSnow) { + rgb = overlayTint(rgb, snowTint(peakSnow), + decisive(peakSnow / 1.2) * 0.60 * skyCap); + } else if (showRain && peakWet > 0) { + rgb = overlayTint(rgb, rainTint(peakPrecip), + decisive(peakPrecip / 4) * peakWet * 0.60 * skyCap); + } } // Extreme tabs lean toward Danger's crimson as the daily high climbs // through the band (32→41°C human, 40→52°C pet) — so a day peaking @@ -386,22 +752,106 @@ export function DayTabs({ // while 32° snapped to a solid orange. Ramp the blend continuously // instead — the endpoints still land on the old Extreme (heatT = 1) // and Danger (both = 1) values, so only the in-between days change. - const heatFloor = isPetsProfile ? 35 : 27; // start of the hot half - const heatMid = isPetsProfile ? 40 : 32; // Extreme threshold - const heatTop = isPetsProfile ? 52 : 41; // Danger threshold - const clamp01 = (v) => Math.max(0, Math.min(1, v)); - const heatT = colourHi === null ? 0 : clamp01((colourHi - heatFloor) / (heatMid - heatFloor)); - const overT = colourHi === null ? 0 : clamp01((colourHi - heatMid) / (heatTop - heatMid)); - const wBgNeutral = weatherGradientNeutral( - wR, wG, wB, - 0.50 - 0.30 * heatT - 0.05 * overT, // edge: 0.50 → 0.20 → 0.15 - 0.69 - 0.03 * heatT + 0.06 * overT, // centre: 0.69 → 0.66 → 0.72 - ); - const wBgActive = weatherGradientActive( - wR, wG, wB, - 0.42 - 0.26 * heatT - 0.04 * overT, // edge: 0.42 → 0.16 → 0.12 - 0.64 + 0.02 * heatT + 0.06 * overT, // centre: 0.64 → 0.66 → 0.72 - ); + // + // The cold end goes TWO-TONE instead of just getting darker. Cold + // band colours are pale to begin with, so deepening them uniformly + // only makes a murky blue-grey — and a hard winter's day should look + // unmistakably different from a mild one, not slightly duller. So the + // two ends of the gradient are pulled apart as the day gets colder: + // the rim deepens toward the true band colour while the core lifts + // toward frost-white, giving a frosted, iced-over tab. Same mechanism + // Danger already uses (dark edge, light centre) at the opposite end + // of the scale. decisive() delays the onset so an ordinary 3°C + // morning stays plain and only a genuinely cold day frosts over. + // The frost lift on the CENTRE was +0.22, which put the core at 0.91 + // — effectively pure white (#f3f9fd). The band colour survived only + // as a thin rim, so the coldest, most dangerous days rendered as the + // faintest tabs on the strip, inverting the safety hierarchy the hot + // end enforces. Pulled back to +0.14 so the core still reads as iced + // over but the tab keeps its body; underT then takes the deep half + // DOWN again, so Extreme cold is the most saturated cold tab rather + // than the most washed-out. + const frostT = decisive(coldT); + // Band WEIGHT — how much of the band colour is left standing rather + // than washed out toward white. The two numbers below are "how far + // this stop blends toward white", so weighting a tier up means + // scaling BOTH stops down together. Scaling only the core is what the + // first attempt did, and it pulled the centre down onto the rim until + // the two met — which flattened the radial into a slab of flat colour + // and lost the lit-from-within look the whole tab strip is built on. + // Scaling both keeps the centre-to-rim distance intact and simply + // moves the whole gradient deeper into the band colour. + // + // Stepped by tier, because the tiers are the safety story and have to + // escalate visibly rather than read as three similar oranges. + // Danger is deliberately excluded: it already has its own treatment + // (flat crimson, dark rim, light core, outlined text) and weighting + // it as well buried its hi/lo numbers. + const tierFrac = tBand.solid ? 0 + : tBand.label === 'Extreme' ? 0.75 // 75% more weight + : tBand.label === 'Caution' ? 0.50 // 50% more weight + : 0; + // Eased in across Caution so 27° is a smooth departure from an + // ordinary day rather than a hard step. Extreme is already past the + // top of that ramp, so it takes its weight flat. + const ramp = tBand.label === 'Caution' ? heatT : 1; + // Deepen BOTH stops by the same amount — never scale them. Scaling + // preserves their ratio but crushes the DISTANCE between them, which + // is what the eye actually reads as the lit-from-within core: at full + // Caution weight it closed the 0.41 core-to-rim gap to 0.24 and the + // radial flattened into a slab of colour. Subtracting a constant + // moves the whole gradient deeper into the band while leaving that + // distance untouched. The offset is a fraction of the rim's own + // headroom, so neither stop can be pushed past the pure band colour. + // + // But the offset is a RIM treatment — its whole job is to push the + // outer edge further into the band colour. Taking the full offset off + // the centre too dragged the core down in lockstep, so the weighted + // tiers (Caution and Extreme — the only ones with a non-zero offset) + // were exactly the tabs that lost their white core: the hotter the + // day, the more of the lit-from-within look it gave away. The centre + // now takes only part of the offset. + // + // Note this WIDENS the core-to-rim distance rather than closing it, + // which is the safe direction — the failure mode the note above warns + // about is the centre collapsing onto the rim, not lifting away from + // it. Lifting is the effect that reads as "lit from within". + const CENTRE_DEEPEN = 0.55; + const deepen = (edge, centre) => { + const off = clamp01(edge) * tierFrac * ramp; + return [clamp01(edge - off), clamp01(centre - off * CENTRE_DEEPEN)]; + }; + // Minimum core-to-rim distance — the "glow" floor. + // + // Deepening only ever fires on Caution/Extreme, and frostT only at + // the cold end, which leaves the entire middle of the scale — Cool, + // Comfortable, Warm — with its two stops barely 0.19 apart. At that + // distance the radial is invisible, so those tabs read as flat slabs + // of pale colour sitting next to hot ones that visibly glow. + // + // The middle tabs are not actually less white in the centre — 0.69 + // beats Extreme's 0.59. They have no CONTRAST to make that whiteness + // legible. So the fix is distance, not lightness: push the two stops + // apart around their own midpoint, which lifts the core and deepens + // the rim together and leaves the tab's overall lightness where it + // was. Tabs already clearing the floor are returned untouched, so the + // hot and frosted ends keep exactly the treatment they have. + const MIN_GLOW = 0.36; + const glow = ([edge, centre]) => { + if (centre - edge >= MIN_GLOW) return [edge, centre]; + const mid = (edge + centre) / 2; + return [clamp01(mid - MIN_GLOW / 2), clamp01(mid + MIN_GLOW / 2)]; + }; + const [edgeNeutral, centreNeutral] = glow(deepen( + 0.50 - 0.30 * heatT - 0.05 * overT - 0.34 * frostT - 0.10 * underT, // edge: 0.50 → 0.20 → 0.15 · cold → 0.16 → 0.06 + 0.69 - 0.03 * heatT + 0.06 * overT + 0.14 * frostT - 0.06 * underT, // centre: 0.69 → 0.66 → 0.72 · cold → 0.83 → 0.77 + )); + const [edgeActive, centreActive] = glow(deepen( + 0.42 - 0.26 * heatT - 0.04 * overT - 0.30 * frostT - 0.06 * underT, // edge: 0.42 → 0.16 → 0.12 · cold → 0.12 → 0.06 + 0.64 + 0.02 * heatT + 0.06 * overT + 0.18 * frostT - 0.06 * underT, // centre: 0.64 → 0.66 → 0.72 · cold → 0.82 → 0.76 + )); + const wBgNeutral = weatherGradientNeutral(wR, wG, wB, edgeNeutral, centreNeutral); + const wBgActive = weatherGradientActive(wR, wG, wB, edgeActive, centreActive); const wText = '#2a1d10'; // Active-tab outline: the day's weather colour, 20% darker. const wOutline = `rgb(${Math.round(wR * 0.8)},${Math.round(wG * 0.8)},${Math.round(wB * 0.8)})`; @@ -422,9 +872,10 @@ export function DayTabs({ setProPromptDay(null); } }} - title=${locked + title=${(locked ? `${band.label} · SunScope Extra unlocks day ${i + 1}` - : `${band.label} · day ${i + 1} of 14`} + : `${band.label} · day ${i + 1} of 14`) + + (windTier >= 2 ? ` · ${windLabel} — gusts to ${maxGust} mph` : '')} style=${{ background: isActive ? wBgActive : wBgNeutral, color: wText, @@ -447,6 +898,7 @@ export function DayTabs({ ${dDate.toLocaleDateString('en-GB', { day: 'numeric', month: 'short', timeZone: 'UTC' })} + ${tempOnly ? (isVehicleProfile ? (vehicleHazard === 'snow' ? html`<${PrecipIcon} precip=${0} snow=${hazSnow} size=${30} />` @@ -456,8 +908,23 @@ export function DayTabs({ : html`<${CarIcon} size=${30} />`) : html`<${HouseIcon} size=${30} />`) : showPrecip - ? html`<${PrecipIcon} precip=${dayPrecip} snow=${daySnow} size=${30} />` - : html`<${CloudIcon} category=${modalCloudCat} elev=${repElev} dt=${repDt} size=${30} />`} + ? html`<${PrecipIcon} precip=${showRain ? dayPrecip : 0} snow=${daySnow} + drops=${rainDrops} size=${30} />` + : windIsHeadline + ? html`<${WindIcon} size=${30} tier=${windTier} />` + : html`<${CloudIcon} category=${modalCloudCat} elev=${repElev} dt=${repDt} size=${30} />`} + ${/* Wind that is real but isn't the day's headline (Near Gale, or + a gale on a day rain already speaks for) rides ON TOP of the + sky glyph rather than sitting off in the tab corner, so the + icon states one combined condition — "windy AND wet" — the + way the eye reads it anyway. The halo keeps the gust lines + legible over the cloud beneath. */''} + ${windTier >= 2 && !windIsHeadline && html` + + <${WindIcon} size=${windBadgeSize} tier=${windTier} halo /> + `} + ${dayHi !== null && html` v != null && w > 0); + const p = hourParts(r, crit); + const parts = Object.keys(p) + .map(k => [p[k], crit.w[k]]) + .filter(([v, w]) => v != null && w > 0); const wsum = parts.reduce((s, [, w]) => s + w, 0); return wsum > 0 ? parts.reduce((s, [v, w]) => s + v * w, 0) / wsum : 0; } +// ── Why a day scored what it scored ────────────────────────────────────── +// A calendar icon on every row said nothing the day name had not already +// said. The icon now names the single biggest reason the day is not a +// hundred, so a column of scores can be read without opening each day: three +// windy days and one hot one is a different week from four wet ones. +// +// The icon is always a weather influence - heat, cold, rain, wind, wet ground +// - never "the window was short". Length is a symptom: the window is short +// BECAUSE the morning was cold or it rained after three, and the times beside +// the icon already say how long it is. So when marks are lost to length, the +// hours outside the window are asked what ruled them out and that is what the +// row shows. +// One vocabulary for both routes to a cause - hours inside the window scoring +// poorly, and hours outside it being ruled out - so the same influence always +// reads the same way whichever way it cost the day marks. +const SHORTFALL_ICON = { + rain: { icon: '🌧️', name: 'Rain' }, + gust: { icon: '💨', name: 'Wind' }, + soil: { icon: '💧', name: 'Wet ground' }, + hot: { icon: '🔥', name: 'Heat' }, + cold: { icon: '🥶', name: 'Cold' }, + none: { icon: '✅', name: 'Nothing much against it' }, +}; + +// The tooltip is the influence and what it cost, in points of the day's own +// score: "Heat = 34%" says both what is wrong and how much it matters, so a +// 90% row and a 60% row with the same icon are not read as the same warning. +const causeTip = (cause) => + cause.cost ? `${cause.name} = ${cause.cost}%` : cause.name; + +// Why one hour failed to qualify. An hour can fail on more than one count - +// a cold wet morning is both - so every breach is returned and the day is +// decided on which comes up most, not on which happens to be tested first. +function failReasons(r, crit) { + const out = []; + const t = r[crit.field]; + if (t == null) return out; + if (t < crit.pl.min) out.push('cold'); + if (t > crit.pl.max) out.push('hot'); + if (crit.w.rain && (r.precipProb ?? 0) > BAND_RAIN.limit) out.push('rain'); + if (crit.w.gust && (r.gust ?? r.va ?? 0) * 2.237 > BAND_GUST.limit) out.push('gust'); + if (crit.w.soil && r.soilM != null && r.soilM > BAND_SOIL.limit) out.push('soil'); + return out; +} + +// What kept the rest of the day out of the window. Returns null when those +// hours give no reason at all - dark hours for a daylight profile, or a gap +// in the data - so the caller can fall back to the in-window measures rather +// than print a non-answer. +function shortBecause(c, crit) { + const inRun = new Set(c.run.map(r => r.iso)); + const tally = {}; + for (const r of c.usableRows) { + if (inRun.has(r.iso)) continue; + for (const k of failReasons(r, crit)) tally[k] = (tally[k] ?? 0) + 1; + } + // Cost is attached by the caller: those hours were ruled out entirely, so + // what this influence cost is the whole of the length shortfall. + const worst = Object.keys(tally).sort((a, b) => tally[b] - tally[a])[0]; + return worst ? SHORTFALL_ICON[worst] : null; +} + +// Below this the day is as good as the bands allow and picking a "cause" +// would be inventing one: at five points the strongest complaint about the +// day is worth a twentieth of its score, which is noise, not a reason to +// stay in. Set higher and genuinely breezy days went out labelled faultless. +const SHORTFALL_FLOOR = 0.05; + +// Marks lost, as whole points of the 0-100 score the row already shows, so +// the tooltip's number and the bar's number are in the same units. +const pts = (frac) => Math.round(frac * 100); + +function limitingFactor(c, crit, span) { + const run = c.run; + // Mean score per measure across the run, so one bad hour in nine can't + // name the day. + const sums = {}, counts = {}; + for (const r of run) { + const p = hourParts(r, crit); + for (const k of Object.keys(p)) { + if (p[k] == null || !crit.w[k]) continue; + sums[k] = (sums[k] ?? 0) + p[k]; + counts[k] = (counts[k] ?? 0) + 1; + } + } + const wsum = Object.keys(counts).reduce((s, k) => s + crit.w[k], 0); + + // Marks lost, in points of the final score: quality is half of it, split + // between the measures by weight; window length is the other half. + const lost = [['short', 0.5 * (1 - span)]]; + for (const k of Object.keys(counts)) { + const mean = sums[k] / counts[k]; + lost.push([k, 0.5 * (crit.w[k] / wsum) * (1 - mean)]); + } + lost.sort((a, b) => b[1] - a[1]); + if (lost[0][1] < SHORTFALL_FLOOR) return SHORTFALL_ICON.none; + + // Length lost the most marks: name what ruled the other hours out. If they + // can't say, drop through to whichever measure was weakest inside the + // window - still an influence, which is the whole point of the icon. + if (lost[0][0] === 'short') { + const because = shortBecause(c, crit); + if (because) return { ...because, cost: pts(lost[0][1]) }; + lost.shift(); + if (lost.length === 0) return SHORTFALL_ICON.none; + } + const [worst, amount] = lost[0]; + const cost = pts(amount); + + // Thermal has two opposite causes and they want opposite icons - a cold + // morning and a scorching afternoon are not the same warning. + if (worst === 'thermal') { + const meanT = run.reduce((s, r) => s + (r[crit.field] ?? 0), 0) / run.length; + return { ...(meanT > crit.pl.flatHi ? SHORTFALL_ICON.hot : SHORTFALL_ICON.cold), cost }; + } + return { ...SHORTFALL_ICON[worst], cost }; +} + export function computeBestDay(weekDays, nowLocalISO, profile, variant) { if (!weekDays || weekDays.length === 0) return []; const crit = bestDayCriteria(profile, variant); @@ -1160,14 +1288,16 @@ export function computeBestDay(weekDays, nowLocalISO, profile, variant) { const quality = run.reduce((s, r) => s + hourQuality(r, crit), 0) / run.length; candidates.push({ - len: dayBest.len, quality, key: day.key, + len: dayBest.len, quality, key: day.key, run, from: rows[dayBest.start], to: rows[dayBest.end], - // What the window is measured AGAINST: the daylight for a profile that + // What the window is measured AGAINST - and, for the hours outside the + // run, the evidence for WHY it stopped where it did: the daylight for a + // profile that // only counts daylight hours, otherwise the whole day. Using daylight // for a round-the-clock profile would let a 14-hour overnight window // score over 100% of a 16-hour day and print "all day" for a spell that // ends at breakfast. - usable: crit.daylightOnly ? rows.filter(r => r.elev > 0).length : rows.length, + usableRows: crit.daylightOnly ? rows.filter(r => r.elev > 0) : rows, }); } // Nothing qualifying is itself worth saying - an empty panel reads as a bug, @@ -1192,12 +1322,19 @@ export function computeBestDay(weekDays, nowLocalISO, profile, variant) { // window of perfect ones are both worth knowing about, and letting either // half dominate hides one of them. for (const c of candidates) { - const span = c.usable > 0 ? Math.min(c.len / c.usable, 1) : 0; + const span = c.usableRows.length > 0 + ? Math.min(c.len / c.usableRows.length, 1) + : 0; c.score = Math.round(100 * (0.5 * span + 0.5 * c.quality)); + // Same span the score used, so the icon can never blame the weather for + // marks that were actually lost to a short window. + c.cause = limitingFactor(c, crit, span); } - // Only the top few are kept: past four rows the panel stops being a - // shortlist and turns back into the day tabs. + // A settled week really can have seven good days, and cutting it to a + // shortlist there would say the opposite of what the forecast shows. Only + // days that actually qualify get a row, so the panel still stays short in + // an unsettled week - the cap is the week itself, not an arbitrary four. const shown = [...candidates] .sort((a, b) => b.score - a.score) .slice(0, PICK_MAX_DAYS) @@ -1220,12 +1357,15 @@ export function computeBestDay(weekDays, nowLocalISO, profile, variant) { // A run spanning nearly all the daylight is better said than shown: printing // "6am - 9pm" makes the reader parse a time range to learn "all day". - const window = c.usable > 0 && c.len >= c.usable - 1 + const window = c.usableRows.length > 0 && c.len >= c.usableRows.length - 1 ? 'all day' : `${hh(c.from.iso)} – ${hh(endIso)}`; return { - icon: '📅', + // Not a calendar - the row already says which day. The icon carries the + // one thing the score cannot: what is holding the day back. + icon: c.cause.icon, + iconTitle: causeTip(c.cause), label: dayName, value: window, // Rows stay in date order - the order you plan in - so the ranking is diff --git a/assets/js/hooks/useTableScroll.js b/assets/js/hooks/useTableScroll.js index 6f988a1..2db8347 100644 --- a/assets/js/hooks/useTableScroll.js +++ b/assets/js/hooks/useTableScroll.js @@ -140,11 +140,10 @@ export function useTableScroll({ const bodyScroll = bodyScrollRef.current; if (!bodyTable || !bodyScroll) return; - // Rotated renders a SINGLE table, so there is no head table and - // nothing to keep in step. The scroll-fade still needs to know how - // wide the pinned column is, though — and it can't be assumed from - // CSS, because auto layout widens that column to fit the longest - // metric name. Measure it, then stop. + // Fallback for any layout rendered as a single table (no head to + // keep in step). Both orientations split head from body now, so this + // is a safety net rather than a path — the scroll-fade still needs + // the pinned column's width, so measure that and stop. if (!headTable) { const firstCell = bodyTable.querySelector('tbody tr > *'); if (firstCell && tableWrapRef && tableWrapRef.current) { diff --git a/assets/js/nav-account.js b/assets/js/nav-account.js new file mode 100644 index 0000000..3279d3f --- /dev/null +++ b/assets/js/nav-account.js @@ -0,0 +1,49 @@ +// ------------------------------------------------------------------------ +// nav-account.js - Wires the "Account" nav dropdown on every static page +// (about.html, faq.html, dashboard.html, etc.) so it behaves exactly like +// the Preact-driven one on the main app (see app.js's Account dropdown): +// - Subscription link points at the manage-billing URL if the visitor +// already has Extra (localStorage sunscope_pro), else the buy URL. +// - Sign in/out reflects the dashboard's own session (api/me.php), +// independent of the Extra flag above. +// +// Plain vanilla JS - these pages have no build step and no framework. +// Expects the markup produced by nav-account-menu() in build.js's +// injectAccountMenu(), or written by hand to match: a toggle link with +// id="nav-account-signinout" for the Sign in/out item. +// ------------------------------------------------------------------------ + +(function () { + const SUBSCRIBE_URL = 'https://buy.stripe.com/9B63cw7vl8k15Ei9DQd7q00'; + const MANAGE_URL = 'https://billing.stripe.com/p/login/9B63cw7vl8k15Ei9DQd7q00'; + + const subLink = document.getElementById('nav-account-subscription'); + if (subLink) { + subLink.href = localStorage.getItem('sunscope_pro') === '1' ? MANAGE_URL : SUBSCRIBE_URL; + } + + const signLink = document.getElementById('nav-account-signinout'); + if (!signLink) return; + + function setSignedOut() { + signLink.textContent = 'Sign in'; + signLink.href = './dashboard.html'; + signLink.onclick = null; + } + + function setSignedIn() { + signLink.textContent = 'Sign out'; + signLink.href = '#'; + signLink.onclick = function (e) { + e.preventDefault(); + fetch('./api/logout.php', { method: 'POST', credentials: 'same-origin' }) + .then(setSignedOut) + .catch(() => {}); + }; + } + + fetch('./api/me.php', { credentials: 'same-origin' }) + .then((r) => r.json()) + .then((d) => (d.user ? setSignedIn() : setSignedOut())) + .catch(() => {}); +})(); diff --git a/assets/js/tableColumns.js b/assets/js/tableColumns.js index 9580273..ac949ec 100644 --- a/assets/js/tableColumns.js +++ b/assets/js/tableColumns.js @@ -41,7 +41,18 @@ const html = htm.bind(h); // ── COLOUR SCALE ──────────────────────────────────────────────────────── // Continuous temperature colour scale — shared by every temperature column // and by the thermal-stress legend in app.js. +// Anchored to match the day tabs' band ramp at the cold end: -30 is the +// Extreme cold anchor, -20 the Arctic one and -10 the Freezing one, the same +// three points bandRampRgb() uses (see BAND COLOUR RAMP in utils.js). The +// scale used to stop at -10, so every reading below it clamped to one flat +// blue — Invercargill at -28 C coloured identically to -10 C in the table, +// and the whole Arctic and Extreme cold range was invisible in Quick, +// Detailed and Table alike. Pet columns inherit the fix for free: they route +// through petEquivHumanTemp(), which already maps pet -28/-18 onto human +// -20/-10 but had nothing to look up down there. export const TEMP_STOPS = [ + [-30, [125, 80, 180]], + [-20, [ 85, 125, 205]], [-10, [ 90, 155, 220]], [ 0, [140, 195, 235]], [ 10, [155, 215, 195]], diff --git a/assets/js/utils.js b/assets/js/utils.js index e5e83bb..5b3de91 100644 --- a/assets/js/utils.js +++ b/assets/js/utils.js @@ -113,10 +113,11 @@ export function bandRampRgb(t, bands) { // this weather", shared by the day tabs (components/DayTabs.js) and the // warning event banner (events/weather-checks.js + app.js). // ------------------------------------------------------------------- -// The day tabs pick ONE dimension by priority (heat -> snow -> rain -> -// cloud -> sun) and only vary its shade; these helpers are the shade -// ramps for the non-temperature dimensions. Temperature-driven tints -// come from utciCategory().bg / petCategory().bg instead. +// The day tabs start from a temperature colour (utciCategory().bg / +// petCategory().bg, ramped by bandRampRgb) and then shade it toward the +// sky by influence - rain/snow first, then cloud on days that will stay +// dry. These helpers are the shade ramps for those non-temperature +// dimensions; see the WEIGHTED model block in components/DayTabs.js. // ------------------------------------------------------------------- export const hexToRgb = (hx) => { const n = parseInt(hx.replace('#', ''), 16); @@ -179,6 +180,56 @@ export function petCategory(t) { } } +// ------------------------------------------------------------------- +// DAY-TAB BAND VARIANTS - the same bands, one colour swapped. +// ------------------------------------------------------------------- +// In the table a cell answers "which stress band is this reading in", and +// green is the right answer for Comfortable: it is the safe band in a +// column of warnings. A day tab answers a different question - "what will +// this day feel like" - and there green reads as damp and mild rather +// than as the best day of the week. So on the tabs (and ONLY on the tabs) +// Comfortable becomes a deep gold: sitting just under Warm's brighter +// yellow, it puts the settled 19-24 -C days on the same warm run as the +// rest of the pleasant half of the scale instead of interrupting it with +// a green step. +// +// Everything else - labels, thresholds, fg colours, the solid Danger +// alarm - is shared with the table palettes above, so the two can never +// drift apart on anything but this one deliberate colour. +// ------------------------------------------------------------------- +const DAY_TAB_COMFORTABLE = '#edd449'; // deep gold, one step under Warm's #f8e554 + +// The same reasoning, carried down the rest of the HUMAN scale. +// +// UTCI_BANDS runs cyan -> mint -> green -> gold across Cold, Chilly and Cool, +// so a tab crossing from the cold half of the scale to the warm half has to +// travel through green. That hurts twice over: the strip reads as a green +// interruption between the blues and the golds, and because cloud dulls a tab +// toward its own hue, every overcast mid-scale day desaturates into a sickly +// green-grey rather than into something neutral. +// +// On the tabs the scale therefore goes blue -> GREY -> gold, with the neutral +// point sitting exactly where a day has stopped being cold but has not yet +// become pleasant - which is what Chilly means. Nothing passes through green +// in either direction. +// +// Human only. The pet scale is already blue through the equivalent bands +// (pet Cool is #b5dee9, not a green), and its thresholds sit at completely +// different temperatures, so it keeps the Comfortable override alone. +const DAY_TAB_HUMAN = { + Comfortable: DAY_TAB_COMFORTABLE, + Cool: '#ded9c3', // warm oatmeal, one step under Comfortable's gold + Chilly: '#d3d5d2', // the neutral pivot between the warm and cold halves + Cold: '#c2d3dd', // first step back into blue +}; + +const withTabComfort = (bands) => + bands.map(b => (b.label === 'Comfortable' ? { ...b, bg: DAY_TAB_COMFORTABLE } : b)); + +export const DAY_TAB_UTCI_BANDS = + UTCI_BANDS.map(b => (DAY_TAB_HUMAN[b.label] ? { ...b, bg: DAY_TAB_HUMAN[b.label] } : b)); +export const DAY_TAB_PET_BANDS = withTabComfort(PET_BANDS); + // Diagonal sweep gradient for a band background hex colour. // Pre-blends with 50% white to sit harmoniously alongside lighter table cells, // then applies a light→mid→dark sweep for depth. diff --git a/assets/sunscope.css b/assets/sunscope.css index 1532dec..b25cf60 100644 --- a/assets/sunscope.css +++ b/assets/sunscope.css @@ -7,7 +7,7 @@ Page background css/base.css body { background } Body text colour css/base.css .utci-app { color } Brand "brass" colour search files for #c8922a - Headline font css/header.css .utci-title { font-family } + Headline font css/base.css .nav-logo-sun { font-family } Body text font css/base.css .utci-app { font-family } Max page width css/base.css .utci-shell { max-width } Mobile breakpoint css/ui.css @media (max-width: 800px) @@ -21,7 +21,7 @@ FILE STRUCTURE ────────────────────────────────────────────────────────────────────── css/base.css Colour palette docs, fonts+reset, page shell, nav - css/header.css Header grid, title, dial, search box + css/header.css Header grid, dial, search box (site header: base.css) css/table.css Day tabs, profiles, column toggles, hourly table, now-row + night-row decorations css/ui.css Status banners, legend, about, footer, responsive diff --git a/build.js b/build.js index e3b9ea2..9fc3931 100644 --- a/build.js +++ b/build.js @@ -44,6 +44,7 @@ function cleanDist() { path.join(DIST, 'temperatures.html'), path.join(DIST, 'features.html'), path.join(DIST, 'stress-bands.html'), + path.join(DIST, 'dashboard.html'), path.join(ASSETS, '_tmp_flat.css'), path.join(ASSETS, 'about.min.css'), ]; @@ -86,7 +87,10 @@ function bumpVersion() { // about.min.css with a version param, and injects the JS bundle. function rewriteHTML(html, version) { html = html.replace(/]*href=["'][^"']*assets\/sunscope\.css[^"']*["'][^>]*>/gi, ''); - html = html.replace(/]*src=["'][^"']*assets\/js\/[^"']*["'][^>]*><\/script>/gi, ''); + // Only strips the dev entry point (main.js) - other plain scripts these + // pages load directly, like nav-account.js, are not part of the Preact + // bundle and must survive untouched. + html = html.replace(/]*src=["'][^"']*assets\/js\/main\.js[^"']*["'][^>]*><\/script>/gi, ''); // Rewrite standalone about.css reference to versioned minified file. html = html.replace( /(]*href=["'][^"']*assets\/)about\.css[^"']*(["'][^>]*>)/gi, @@ -232,8 +236,12 @@ async function build() { // 8. Copy static files console.log(' Copying static files...'); + // dashboard.html is copied as-is, not run through rewriteHTML - it is a + // standalone page (inline CSS/JS, no #root) and has no use for the + // Preact app bundle the other pages get. for (const f of ['robots.txt', 'sitemap.xml', 'og-image.png', 'favicon.svg', 'restore.php', 'track.php', 'stats.php', - 'verify-session.php', 'check-subscription.php', 'dev-unlock.php', 'secrets.local.php']) { + 'verify-session.php', 'check-subscription.php', 'dev-unlock.php', 'secrets.local.php', 'dashboard.html', + 'assets/js/nav-account.js']) { const src = path.join(ROOT, f); if (fs.existsSync(src)) { copyFile(src, path.join(DIST, f)); @@ -241,6 +249,25 @@ async function build() { } } + // 9. Copy the dashboard backend (lib/ shared PHP + api/ endpoints) + console.log(' Copying dashboard backend...'); + const copyDirRecursive = (src, dest) => { + fs.mkdirSync(dest, { recursive: true }); + for (const entry of fs.readdirSync(src, { withFileTypes: true })) { + const srcPath = path.join(src, entry.name); + const destPath = path.join(dest, entry.name); + if (entry.isDirectory()) copyDirRecursive(srcPath, destPath); + else fs.copyFileSync(srcPath, destPath); + } + }; + for (const dir of ['lib', 'api']) { + const src = path.join(ROOT, dir); + if (fs.existsSync(src)) { + copyDirRecursive(src, path.join(DIST, dir)); + console.log(' -> ' + dir + '/'); + } + } + const elapsed = ((Date.now() - t0) / 1000).toFixed(2); console.log(''); console.log('========================================'); diff --git a/columns.html b/columns.html index 01b8ea8..96624b0 100644 --- a/columns.html +++ b/columns.html @@ -36,7 +36,10 @@
+ + + +
+

Dashboard

+
Your SunScope journal.
+ +
+
+
+
+ + +
+
+ + +
+ + +
+ New here? Create an account +
+ +
+

+ 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. +

+
+ + +
+ + + + + + + diff --git a/data/tracking.json b/data/tracking.json index c66e8b0..9bf3f1e 100644 --- a/data/tracking.json +++ b/data/tracking.json @@ -205,5 +205,35 @@ "profiles": { "vehicle": 1 } + }, + "2026-08-26": { + "visits": 99, + "profiles": { + "basic": 15, + "home": 13, + "vehicle": 11, + "pets": 8, + "alltemps": 5, + "showall": 4, + "custom": 1 + } + }, + "2026-08-27": { + "visits": 82, + "profiles": { + "basic": 8, + "showall": 3, + "alltemps": 11, + "vehicle": 9, + "home": 10, + "pets": 13, + "farming": 2 + } + }, + "2026-08-30": { + "visits": 17, + "profiles": { + "showall": 1 + } } } \ No newline at end of file diff --git a/db/001_init.sql b/db/001_init.sql new file mode 100644 index 0000000..2c47f84 --- /dev/null +++ b/db/001_init.sql @@ -0,0 +1,43 @@ +-- SunScope Dashboard — initial schema. +-- Minimal v1: accounts (email + password) and journal entries. +-- No Stripe/entitlement linking yet — that stays in the front-end +-- localStorage Pro check for now, same as the rest of the site. + +CREATE TABLE IF NOT EXISTS schema_migrations ( + version VARCHAR(32) NOT NULL PRIMARY KEY, + applied_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +CREATE TABLE IF NOT EXISTS users ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY, + public_id CHAR(22) NOT NULL, + email VARCHAR(255) NOT NULL, + password_hash VARCHAR(255) NOT NULL, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + UNIQUE KEY uq_users_public_id (public_id), + UNIQUE KEY uq_users_email (email) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +CREATE TABLE IF NOT EXISTS sessions ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY, + user_id BIGINT UNSIGNED NOT NULL, + token_hash BINARY(32) NOT NULL, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + expires_at DATETIME NOT NULL, + KEY ix_sessions_user (user_id), + UNIQUE KEY uq_sessions_token_hash (token_hash), + CONSTRAINT fk_sessions_user FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +CREATE TABLE IF NOT EXISTS journal_entries ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY, + user_id BIGINT UNSIGNED NOT NULL, + entry_date DATE NOT NULL, + note TEXT NULL, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + KEY ix_journal_user_date (user_id, entry_date), + CONSTRAINT fk_journal_user FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +INSERT IGNORE INTO schema_migrations (version) VALUES ('001_init'); diff --git a/db/002_add_reading.sql b/db/002_add_reading.sql new file mode 100644 index 0000000..e5791f1 --- /dev/null +++ b/db/002_add_reading.sql @@ -0,0 +1,14 @@ +-- Adds an optional computed reading to a journal entry, captured the same +-- way the day tabs derive their headline number: peak value of the active +-- profile's main field across that day's hourly forecast rows. Only +-- possible for dates inside the 14-day forecast window, so it's nullable - +-- older/out-of-range entries stay note-only. + +ALTER TABLE journal_entries + ADD COLUMN profile VARCHAR(32) NULL AFTER note, + ADD COLUMN reading DECIMAL(5,1) NULL AFTER profile, + ADD COLUMN location_name VARCHAR(255) NULL AFTER reading, + ADD COLUMN lat DECIMAL(8,5) NULL AFTER location_name, + ADD COLUMN lon DECIMAL(8,5) NULL AFTER lat; + +INSERT IGNORE INTO schema_migrations (version) VALUES ('002_add_reading'); diff --git a/db/003_add_solar_env.sql b/db/003_add_solar_env.sql new file mode 100644 index 0000000..fa17d7d --- /dev/null +++ b/db/003_add_solar_env.sql @@ -0,0 +1,8 @@ +-- The solar model (environment modifier) changes the SunSoak reading just +-- as much as the profile does, so a reading needs both stored to mean +-- anything when filtering the journal/graph by "what's currently selected". + +ALTER TABLE journal_entries + ADD COLUMN solar_env VARCHAR(16) NULL AFTER profile; + +INSERT IGNORE INTO schema_migrations (version) VALUES ('003_add_solar_env'); diff --git a/db/004_add_end_date.sql b/db/004_add_end_date.sql new file mode 100644 index 0000000..4c7bea8 --- /dev/null +++ b/db/004_add_end_date.sql @@ -0,0 +1,8 @@ +-- A note can now span a period rather than one day (e.g. a holiday week). +-- entry_date remains the start of the range; end_date is NULL for a +-- single-day entry, keeping every existing row valid with no backfill. + +ALTER TABLE journal_entries + ADD COLUMN end_date DATE NULL AFTER entry_date; + +INSERT IGNORE INTO schema_migrations (version) VALUES ('004_add_end_date'); diff --git a/faq.html b/faq.html index bd48dbf..7ecb883 100644 --- a/faq.html +++ b/faq.html @@ -72,7 +72,10 @@