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
This commit is contained in:
fraxle
2026-10-08 09:53:41 +01:00
parent 71860b9dd9
commit 862f631beb
22 changed files with 2169 additions and 429 deletions
+34
View File
@@ -504,8 +504,11 @@ export function useAppState() {
const variantCols = OUTDOORS_VARIANTS[v]?.cols;
if (variantCols) {
setVisibleCols({ ...variantCols });
// Indoors state is owned here (and persisted) so Quick, Detailed and
// Table all agree - callers must not override it afterwards.
const hasIndoor = variantCols['indoorT'] || variantCols['managedT'];
setIndoorModeAndSave(hasIndoor ? 'on' : 'off');
setIndoorManagedAndSave(false);
}
// Auto-set UTCI environment modifier to match the selected variant.
const defaultEnv = VARIANT_DEFAULT_ENV[v] ?? 'open';
@@ -516,6 +519,14 @@ export function useAppState() {
setVehicleTypeAndSave('truck');
setVehicleSpeedAndSave('aroad');
}
// Camping shows the Indoor column as the tent's overnight temperature.
if (v === 'camping') {
setBuildingTypeAndSave('tent');
}
// Office models the Indoor column on an office building.
if (v === 'office') {
setBuildingTypeAndSave('office');
}
};
const activeCols = activeProfile === 'outdoors'
@@ -621,11 +632,21 @@ export function useAppState() {
}
}, []);
const closeWelcome = () => {
// First visit only: hand the new user straight on to the profile picker
// so they start from a profile that matches them rather than the default.
// Reopening the popup later from the footer link doesn't re-trigger it.
let firstVisit = false;
try { firstVisit = localStorage.getItem('sunscope_welcome_seen') !== '1'; } catch (e) { /* ignore */ }
try { localStorage.setItem('sunscope_welcome_seen', '1'); } catch (e) { /* ignore */ }
setWelcomeOpen(false);
if (firstVisit) setProfilePickPending(true);
};
const openWelcome = () => setWelcomeOpen(true);
// Set when the welcome popup is dismissed on a first visit; cleared by the
// effect below that actually opens the picker.
const [profilePickPending, setProfilePickPending] = useState(false);
// ── Weekly subscribe nudge (free users only) ─────────────────────────
// At most once every 7 days, and never on the first visit - the welcome
// popup owns that moment. The last-shown date lives in localStorage;
@@ -660,6 +681,16 @@ export function useAppState() {
const openPanel = () => setPanelOpen(true);
const closePanel = () => setPanelOpen(false);
// Completes the first-visit hand-off from the welcome popup, but only once
// the forecast is in: the panel anchors itself to the active-profile row,
// which doesn't exist until the forecast has rendered.
useEffect(() => {
if (!profilePickPending || !forecast) return;
setProfilePickPending(false);
setPanelOpen(true);
track('profile_picker_first_visit');
}, [profilePickPending, forecast]);
const [showUnits, setShowUnits] = useState(() => {
try {
const s = localStorage.getItem('sunscope_show_units');
@@ -778,6 +809,7 @@ export function useAppState() {
handleEventTagClick, handleEventTagEnter, handleEventTagLeave,
handleEventTagPopupEnter, handleEventTagPopupLeave,
closePopup, closeEventTagPopup,
weekPopup, weekPopupRef, handleWeekRowClick, closeWeekPopup,
} = useColumnPopup();
const {
@@ -791,6 +823,7 @@ export function useAppState() {
// The rotated table is a single table with CSS-only sticky edges, so
// none of this hook's dual-table syncing applies to it.
rotated: tableRotated,
tableInterval,
});
// ── 8. GEOCODING SEARCH ───────────────────────────────────────────────
@@ -953,6 +986,7 @@ export function useAppState() {
handleEventTagClick, handleEventTagEnter, handleEventTagLeave,
handleEventTagPopupEnter, handleEventTagPopupLeave,
closePopup, closeEventTagPopup,
weekPopup, weekPopupRef, handleWeekRowClick, closeWeekPopup,
// table scroll
tableCanScrollLeft, tableCanScrollRight, handleBodyScroll,
// computation
+68 -9
View File
@@ -23,7 +23,10 @@ export function useColumnPopup() {
const closeTimerRef = useRef(null);
// --- Event tag popup ------------------------------------------------
// Holds { events[], slideIndex, x, y, arrowLeft, below }
// Holds { events[], date, slideIndex, x, y, arrowLeft, below }
// `date` is the YYYY-MM-DD of the row the tag was on - the popup's Quick
// Record button dates its journal entry to the day being looked at, which
// is not necessarily today (the table can be showing any of the 14 days).
// When multiple events are in the popup they auto-cycle with a crossfade.
const [eventTagPopup, setEventTagPopup] = useState(null);
const [evSlideIndex, setEvSlideIndex] = useState(0);
@@ -168,12 +171,12 @@ export function useColumnPopup() {
}, [eventTagPopup, evSlideTo]);
// --- Event tag popup handlers ----------------------------------------
const openEventTagPopup = (events, spanEl, startIndex = 0) => {
const openEventTagPopup = (events, spanEl, startIndex = 0, date = null) => {
clearInterval(evSlideTimerRef.current);
setEvSlideIndex(startIndex);
prevSlideIndexRef.current = startIndex;
setEvTransition(null);
setEventTagPopup({ events, ...calcEventPopupPos(spanEl) });
setEventTagPopup({ events, date, ...calcEventPopupPos(spanEl) });
};
const closeEventTagPopup = () => {
@@ -185,8 +188,9 @@ export function useColumnPopup() {
clearInterval(evSlideTimerRef.current);
};
// rowEvents = all events for that row (passed in from app.js)
const handleEventTagClick = (rowEvents, startIndex, e) => {
// rowEvents = all events for that row, dateISO = that row's YYYY-MM-DD
// (both passed in from app.js)
const handleEventTagClick = (rowEvents, startIndex, dateISO, e) => {
e.stopPropagation();
clearTimeout(evHoverTimerRef.current);
clearTimeout(evCloseTimerRef.current);
@@ -194,19 +198,27 @@ export function useColumnPopup() {
if (eventTagPopup && JSON.stringify(eventTagPopup.events.map(ev => ev.id)) === JSON.stringify(rowEvents.map(ev => ev.id))) {
closeEventTagPopup(); return;
}
openEventTagPopup(rowEvents, e.currentTarget, startIndex);
openEventTagPopup(rowEvents, e.currentTarget, startIndex, dateISO);
};
const handleEventTagEnter = (rowEvents, startIndex, e) => {
const handleEventTagEnter = (rowEvents, startIndex, dateISO, e) => {
clearTimeout(evHoverTimerRef.current);
clearTimeout(evCloseTimerRef.current);
const spanEl = e.currentTarget;
evHoverTimerRef.current = setTimeout(() => openEventTagPopup(rowEvents, spanEl, startIndex), 700);
evHoverTimerRef.current = setTimeout(() => openEventTagPopup(rowEvents, spanEl, startIndex, dateISO), 700);
};
const handleEventTagLeave = () => clearTimeout(evHoverTimerRef.current);
const handleEventTagPopupEnter = () => clearTimeout(evCloseTimerRef.current);
// Moving into the popup stops the carousel for good (leaving it closes the
// popup anyway, so there is nothing to restart). It has a Quick Record
// button in it now: an event that slides out from under the cursor mid-reach
// would file the wrong one.
const handleEventTagPopupEnter = () => {
clearTimeout(evCloseTimerRef.current);
clearInterval(evSlideTimerRef.current);
};
const handleEventTagPopupLeave = () => { evCloseTimerRef.current = setTimeout(closeEventTagPopup, 200); };
useEffect(() => {
@@ -239,12 +251,59 @@ export function useColumnPopup() {
}
}, [eventTagPopup]);
// --- Week Ahead row popup ------------------------------------------
// Click-driven only: the Week Ahead rows used to carry their
// explanation in `title=` tooltips, which never surface on touch.
// Holds { key, title, sub, rows:[{icon,text}], ...pos }.
const [weekPopup, setWeekPopup] = useState(null);
const weekPopupRef = useRef(null);
const closeWeekPopup = () => setWeekPopup(null);
const handleWeekRowClick = (payload, e) => {
e.stopPropagation();
if (weekPopup?.key === payload.key) { closeWeekPopup(); return; }
setWeekPopup({ ...payload, ...calcPopupPos(e.currentTarget) });
};
useEffect(() => {
if (!weekPopup) return;
const onClickOutside = (e) => {
if (weekPopupRef.current && !weekPopupRef.current.contains(e.target)) closeWeekPopup();
};
const onScrollOrResize = () => closeWeekPopup();
document.addEventListener('mousedown', onClickOutside);
window.addEventListener('scroll', onScrollOrResize, { passive: true, capture: true });
window.addEventListener('resize', onScrollOrResize, { passive: true });
return () => {
document.removeEventListener('mousedown', onClickOutside);
window.removeEventListener('scroll', onScrollOrResize, { capture: true });
window.removeEventListener('resize', onScrollOrResize);
};
}, [weekPopup]);
// Post-render clamp (same logic as the col popup above).
useEffect(() => {
if (!weekPopup || !weekPopupRef.current) return;
const rect = weekPopupRef.current.getBoundingClientRect();
const MARGIN = 8;
if (!weekPopup.below && rect.top < MARGIN) {
const delta = MARGIN - rect.top;
if (delta > 1) setWeekPopup(prev => prev ? { ...prev, y: prev.y + delta } : prev);
} else if (weekPopup.below && rect.bottom > window.innerHeight - MARGIN) {
const delta = rect.bottom - (window.innerHeight - MARGIN);
if (delta > 1) setWeekPopup(prev => prev ? { ...prev, y: prev.y - delta } : prev);
}
}, [weekPopup]);
return {
// column-header popup
colPopup, colPopupRef,
handleThClick, handleThEnter, handleThLeave,
handlePopupEnter, handlePopupLeave,
closePopup,
// week-ahead row popup
weekPopup, weekPopupRef, handleWeekRowClick, closeWeekPopup,
// event-tag popup
eventTagPopup, eventTagPopupRef,
evSlideIndex, evTransition, evSlideTo,
+46 -6
View File
@@ -44,6 +44,8 @@ export function useTableScroll({
// are a sticky header table over a horizontally scrolling body table.
// It is a dependency purely so the widths re-sync on the flip.
rotated = false,
// Row bucket size (1-4h). Changes the number of rows/columns, so widths must re-sync.
tableInterval = 1,
}) {
// --- SCROLL INDICATORS ---------------------------------------------
const [tableCanScrollLeft, setTableCanScrollLeft] = useState(false);
@@ -167,6 +169,10 @@ export function useTableScroll({
bodyCells.forEach(c => { c.style.width = ''; c.style.minWidth = ''; c.style.maxWidth = ''; });
headTable.style.width = 'max-content';
bodyTable.style.width = 'max-content';
// Drop the CSS min-width:100% while measuring, or auto layout stretches the
// columns to fill the container and the "natural" widths come out inflated.
headTable.style.minWidth = '0';
bodyTable.style.minWidth = '0';
headTable.style.tableLayout = 'auto';
bodyTable.style.tableLayout = 'auto';
@@ -176,7 +182,15 @@ export function useTableScroll({
let naturalTotal = 0;
for (let i = 0; i < n; i++) {
const headW = headCells[i].getBoundingClientRect().width;
const bodyW = bodyCells[i].getBoundingClientRect().width;
let bodyW = bodyCells[i].getBoundingClientRect().width;
// Rotated: the first body row is an empty group-label row, so take
// the widest cell in this column across every body row instead.
if (rotated) {
bodyTable.querySelectorAll('tbody tr').forEach(tr => {
const c = tr.children[i];
if (c) bodyW = Math.max(bodyW, c.getBoundingClientRect().width);
});
}
const w = Math.max(Math.ceil(headW), Math.ceil(bodyW));
naturalW[i] = w;
naturalTotal += w;
@@ -186,14 +200,40 @@ export function useTableScroll({
const containerW = bodyScroll.clientWidth;
const finalW = new Array(n);
let totalWidth;
if (naturalTotal > 0 && naturalTotal < containerW) {
// Rotated: pinned label column keeps its natural width and the hour
// columns are sized EQUALLY to exactly fill the scroller - both
// spreading out when there are few and shrinking (down to the 92px
// cell minimum) so they never run off the right edge. Only when even
// that floor doesn't fit does it fall through to natural widths + scroll.
const eqW = rotated && n > 1 ? Math.floor((containerW - naturalW[0]) / (n - 1)) : 0;
if (rotated && n > 1 && eqW >= 92) {
let running = naturalW[0];
finalW[0] = naturalW[0];
for (let i = 1; i < n - 1; i++) { finalW[i] = eqW; running += eqW; }
finalW[n - 1] = containerW - running; // absorb rounding
totalWidth = containerW;
} else if (naturalTotal > 0 && naturalTotal < containerW) {
// Spare space - distribute proportionally across columns so they
// fan out to fill the scroller (no awkward right-hand gap).
const scale = containerW / naturalTotal;
let running = 0;
for (let i = 0; i < n - 1; i++) {
finalW[i] = Math.floor(naturalW[i] * scale);
running += finalW[i];
if (rotated && n > 1) {
// Rotated: keep the pinned label column at its natural width and
// share the spare space EQUALLY across the hour columns, so 2h/3h/4h
// spread evenly instead of scaling each column by its own width.
const spare = containerW - naturalTotal;
const share = Math.floor(spare / (n - 1));
finalW[0] = naturalW[0];
running = finalW[0];
for (let i = 1; i < n - 1; i++) {
finalW[i] = naturalW[i] + share;
running += finalW[i];
}
} else {
for (let i = 0; i < n - 1; i++) {
finalW[i] = Math.floor(naturalW[i] * scale);
running += finalW[i];
}
}
// Absorb sub-pixel rounding into the last column so the total
// exactly matches the container width.
@@ -251,7 +291,7 @@ export function useTableScroll({
if (ro) ro.disconnect();
window.removeEventListener('resize', sync);
};
}, [forecast, visibleCols, selectedDay, skinType, vehicleType, indoorMode, indoorManaged, showDecimals, showUnits, rotated]);
}, [forecast, visibleCols, selectedDay, skinType, vehicleType, indoorMode, indoorManaged, showDecimals, showUnits, rotated, tableInterval]);
return {
tableCanScrollLeft,