wip(mobile): checkpoint engine and experience round three in progress

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01VVYRAqiQj9Y1V9ZfG1RcDH
This commit is contained in:
Claude committed 2026-10-23 07:29:27 +00:00
1 parent a6f98ce3c1
commit a720b10913
83 files changed
+50005 -416

No files matched your search

+485
View File
@@ -0,0 +1,485 @@
/**
* Today (DESIGN.md §12.3): the daily brief above the feed.
*
* - Since you last looked: the three biggest changes (timeline() and a
* situations diff against a stored last-visit time).
* - Today's Moment: one replay a day, playing inline on its own canvas.
* - Overhead tonight: the next ISS pass and the next launch (real data).
* - Your sky now: a mini sky dome; opens the full sheet.
* - Your watch: followed places and things, and whether anything is unusual.
*/
import { icon } from './icons.js';
import { $, esc, ago, haptic, esriThumb, reducedMotion, store } from './dom.js';
import { bandOf, utc, cardinal } from './bands.js';
import { trackAt } from './stubMoments.js';
import { domeSvg } from './skySheet.js';
const LAST_KEY = 'gev.lastVisit';
const SEEN_KEY = 'gev.seenIds';
function readLast() {
try {
const v = Number(localStorage.getItem(LAST_KEY));
if (Number.isFinite(v) && v > 0) return v;
} catch {
/* ignore */
}
return Date.now() - 8 * 3600e3;
}
function readSeen() {
try {
return new Set(JSON.parse(localStorage.getItem(SEEN_KEY) || '[]'));
} catch {
return new Set();
}
}
export function createToday(
slot,
{
getSituations,
watch,
getHome,
onOpenSituation,
onOpenEntity,
onOpenMoment,
onSky,
onWatch,
},
) {
const lastVisit = readLast();
const seen = readSeen();
let globe = null;
let sits = [];
let moment = null;
let sky = null;
let raf = 0;
let visible = true;
slot.innerHTML = `
<section class="brief" aria-label="Your brief">
<div class="bf-since glass-card" aria-label="Since you last looked">
<header class="bf-h"><h3>Since you last looked</h3><span class="tele" data-since></span></header>
<div class="bf-rows" data-changes><div class="bf-row bf-ph"><i></i><span></span></div><div class="bf-row bf-ph"><i></i><span></span></div><div class="bf-row bf-ph"><i></i><span></span></div></div>
</div>
<button class="bf-moment" type="button" aria-label="Play today's Moment">
<img class="bf-mimg" alt="" decoding="async"/>
<canvas class="bf-mcanvas" aria-hidden="true"></canvas>
<span class="bf-mshade"></span>
<span class="bf-mtop tele"><span class="bf-mk">TODAY’S MOMENT</span><span class="bf-mtag"></span></span>
<span class="bf-mbody"><b class="bf-mtitle">Loading today’s Moment…</b><small class="bf-msub"></small></span>
<span class="bf-mplay">${icon('play')}</span>
<span class="bf-mclock tele"></span>
</button>
<button class="bf-sky glass-card" type="button" aria-label="Your sky now">
<span class="bf-dome" data-dome></span>
<span class="bf-skytxt"><span class="bf-skyk tele"><span class="live-dot"></span>YOUR SKY NOW</span><b class="bf-skyh" data-skyh>Looking up…</b><small data-skyn></small></span>
${icon('chevronRight', { cls: 'chev' })}
</button>
<div class="bf-tonight glass-card"><header class="bf-h"><h3>Overhead tonight</h3><span class="tele" data-tonight-where></span></header><div data-tonight><div class="bf-row bf-ph"><i></i><span></span></div><div class="bf-row bf-ph"><i></i><span></span></div></div></div>
<div class="bf-watch glass-card"><header class="bf-h"><h3>Your watch</h3><button class="link-btn" type="button" data-manage>Manage</button></header><div data-watch></div></div>
</section>`;
const mBtn = $('.bf-moment', slot);
const mCanvas = $('.bf-mcanvas', slot);
const mctx = mCanvas.getContext('2d');
/* ---------- since you last looked ---------- */
function changes() {
const now = Date.now();
const out = [];
const used = new Set();
let evts = [];
try {
evts = globe ? globe.timeline({ from: lastVisit, to: now }) : [];
} catch {
evts = [];
}
const ranked = [...evts].sort(
(a, b) => (b.severity || 0) - (a.severity || 0) || b.t - a.t,
);
for (const e of ranked) {
const key = e.momentId || e.situationId || e.entityId || e.title;
if (used.has(key)) continue;
used.add(key);
const s = e.situationId
? sits.find((x) => String(x.id) === String(e.situationId))
: null;
out.push({
t: e.t,
title: s?.title || e.title,
sub: s?.place || (e.momentId ? 'Moment · replay it' : e.kind),
band: e.band || (s ? bandOf(s) : 'ground'),
momentId:
e.kind === 'moment' || e.kind === 'capture' || e.kind === 'circling'
? e.momentId
: null,
situationId: e.situationId,
entityId: e.entityId,
sev: e.severity || 0,
});
if (out.length >= 3) break;
}
// New situations since the last visit fill the rest.
if (out.length < 3)
for (const s of [...sits].sort(
(a, b) => (b.severity || 0) - (a.severity || 0),
)) {
if (out.length >= 3) break;
if (
used.has(s.id) ||
used.has(s.momentId) ||
(seen.size && seen.has(String(s.id)))
)
continue;
used.add(s.id);
out.push({
t: +new Date(s.updatedAt),
title: s.title,
sub: s.place,
band: bandOf(s),
situationId: s.id,
sev: s.severity || 0,
isNew: !seen.has(String(s.id)),
});
}
return out;
}
function renderChanges() {
const list = changes();
$('[data-since]', slot).textContent = `${ago(lastVisit).toUpperCase()}`;
$('[data-changes]', slot).innerHTML =
list
.map(
(c, i) =>
`<button class="bf-row" type="button" data-ch="${i}" data-band="${c.band}"><i class="bd"></i><span class="bf-main"><b>${esc(c.title)}</b><small>${esc(c.sub || '')}</small></span><span class="bf-when tele">${esc(c.t > Date.now() ? 'SOON' : ago(c.t).replace(' ago', '').toUpperCase())}</span></button>`,
)
.join('') ||
`<div class="bf-calm">${icon('checkmark')}<span><b>Nothing unusual</b> since you last looked.</span></div>`;
slot._changes = list;
}
/* ---------- today's moment ---------- */
let geom = null;
function setMoment(m) {
moment = m;
if (!m) return;
const span = 0.02;
const img = $('.bf-mimg', slot);
const r = mBtn.getBoundingClientRect();
const w = Math.max(320, Math.round(r.width || 400));
const hgt = Math.max(200, Math.round(r.height || 260));
img.src = esriThumb(
m.lon,
m.lat,
span,
Math.min(1024, w * 2),
Math.min(1024, Math.round((hgt / w) * Math.min(1024, w * 2))),
);
img.onload = () => img.classList.add('loaded');
const dLon = span / Math.cos((m.lat * Math.PI) / 180);
const dLat = span * (hgt / w);
geom = { lon0: m.lon - dLon / 2, lat1: m.lat + dLat / 2, dLon, dLat };
$('.bf-mtitle', slot).textContent = m.title;
const dur = Math.round((m.time.end - m.time.start) / 60e3);
$('.bf-msub', slot).textContent =
`${m.place} · ${dur} min in 20 s · ${(m.tracks || []).length} helicopters · ${(m.captures || []).length} sealed captures`;
$('.bf-mtag', slot).innerHTML = m.illustrative
? '<span class="tag-ill">Illustrative</span>'
: '<span class="tag-real">Recorded</span>';
mBtn.classList.add('ready');
startLoop();
}
const TRACK_COL = ['#a3d8ff', '#ffd27a', '#ff9db0', '#9be15d'];
function drawMoment(ts) {
raf = 0;
if (!moment || !geom) return;
const r = mCanvas.getBoundingClientRect();
const dpr = Math.min(2, window.devicePixelRatio || 1);
const W = r.width;
const H = r.height;
if (mCanvas.width !== Math.round(W * dpr)) {
mCanvas.width = Math.round(W * dpr);
mCanvas.height = Math.round(H * dpr);
}
const ctx = mctx;
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
ctx.clearRect(0, 0, W, H);
const m = moment;
const loop = 20000;
const k = reducedMotion() ? 0.55 : (ts % loop) / loop;
const t = m.time.start + k * (m.time.end - m.time.start);
const px = (lon, lat) => [
((lon - geom.lon0) / geom.dLon) * W,
((geom.lat1 - lat) / geom.dLat) * H,
];
(m.tracks || []).forEach((tr, i) => {
const head = trackAt(tr, t);
if (!head) return;
const col = TRACK_COL[i % TRACK_COL.length];
const pts = tr.points;
for (let j = Math.max(1, head.i - 24); j <= head.i; j += 1) {
const a = px(pts[j - 1].lon, pts[j - 1].lat);
const b = px(pts[j].lon, pts[j].lat);
const f = 1 - (head.i - j) / 24;
ctx.strokeStyle = col;
ctx.globalAlpha = 0.1 + 0.8 * f * f;
ctx.lineWidth = 1 + 1.8 * f;
ctx.lineCap = 'round';
ctx.beginPath();
ctx.moveTo(a[0], a[1]);
ctx.lineTo(b[0], b[1]);
ctx.stroke();
}
ctx.globalAlpha = 1;
const [x, y] = px(head.lon, head.lat);
ctx.save();
ctx.translate(x, y);
ctx.rotate(((head.headingDeg || 0) * Math.PI) / 180);
ctx.fillStyle = col;
ctx.beginPath();
ctx.moveTo(0, -6);
ctx.lineTo(4, 4.5);
ctx.lineTo(0, 2.4);
ctx.lineTo(-4, 4.5);
ctx.closePath();
ctx.fill();
ctx.restore();
});
for (const c of m.captures || []) {
if (c.t > t) continue;
const [x, y] = px(c.lon, c.lat);
const age = (t - c.t) / 60e3;
const f = Math.max(0, 1 - age / 1.2);
const hd = ((c.headingDeg || 0) - 90) * (Math.PI / 180);
const fov = ((c.fovDeg || 52) * Math.PI) / 360;
const len = 46;
const g = ctx.createRadialGradient(x, y, 1, x, y, len);
g.addColorStop(0, `rgba(235,199,119,${0.45 + f * 0.4})`);
g.addColorStop(1, 'rgba(235,199,119,0)');
ctx.fillStyle = g;
ctx.beginPath();
ctx.moveTo(x, y);
ctx.arc(x, y, len, hd - fov, hd + fov);
ctx.closePath();
ctx.fill();
ctx.fillStyle = '#ebc777';
ctx.beginPath();
ctx.arc(x, y, 3 + f * 3, 0, Math.PI * 2);
ctx.fill();
}
const s = Math.round((t - m.time.start) / 1000);
const day =
new Date(m.time.start).toDateString() === new Date().toDateString()
? 'TODAY'
: new Date(m.time.start)
.toLocaleDateString('en-GB', { day: 'numeric', month: 'short' })
.toUpperCase();
$('.bf-mclock', slot).textContent =
`${day} · ${utc(t)} · +${Math.floor(s / 60)}:${String(s % 60).padStart(2, '0')}`;
if (!reducedMotion()) startLoop();
}
function startLoop() {
if (!raf && visible && !document.hidden)
raf = requestAnimationFrame(drawMoment);
}
new IntersectionObserver((es) => {
visible = es.some((e) => e.isIntersecting);
if (visible) startLoop();
}).observe(mBtn);
document.addEventListener('visibilitychange', startLoop);
/* ---------- overhead tonight ---------- */
const hm = (t) =>
new Date(t).toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' });
function tMinus(t) {
const d = t - Date.now();
if (d < 0) return 'NOW';
const hrs = Math.floor(d / 3600e3);
const mins = Math.round((d % 3600e3) / 60e3);
return `T−${hrs ? `${hrs} H ` : ''}${mins} MIN`;
}
function renderTonight() {
const rows = [];
const np = sky?.iss?.nextPass;
if (sky?.iss?.visibleNow)
rows.push(
`<button class="bf-row" type="button" data-sky data-band="orbit"><i class="bd"></i><span class="bf-main"><b>ISS is overhead now</b><small>${Math.round(sky.iss.elevationDeg)}° up, ${esc(cardinal(sky.iss.azimuthDeg))}</small></span></button>`,
);
else if (np)
rows.push(
`<button class="bf-row" type="button" data-sky data-band="orbit"><i class="bd"></i><span class="bf-main"><b>International Space Station</b><small>${np.maxElevationDeg != null ? `Rises to ${np.maxElevationDeg}° · ` : ''}visible ${Math.max(1, Math.round(((np.end || np.start + 360e3) - np.start) / 60e3))} min</small></span><span class="bf-when tele">${esc(hm(np.start).toUpperCase())}</span></button>`,
);
const launches = sits
.filter((s) => /launch/.test(s.kind || ''))
.map((s) => {
const ev = (s.events || []).find((e) => +new Date(e.at) > Date.now());
return { s, at: ev ? +new Date(ev.at) : +new Date(s.time?.start || 0) };
})
.filter((x) => x.at > Date.now())
.sort((a, b) => a.at - b.at);
if (launches[0]) {
const { s, at } = launches[0];
rows.push(
`<button class="bf-row" type="button" data-sit="${esc(s.id)}" data-band="orbit"><i class="bd"></i><span class="bf-main"><b>${esc(s.title)}</b><small>${esc(s.place || '')}</small></span><span class="bf-when tele">${esc(tMinus(at))}</span></button>`,
);
}
$('[data-tonight]', slot).innerHTML =
rows.join('') ||
'<div class="bf-calm"><span>Clear skies, nothing scheduled.</span></div>';
}
function renderSky() {
if (!sky) return;
$('[data-dome]', slot).innerHTML = domeSvg(sky, {
size: 120,
labels: false,
});
const home = getHome();
const ac = (sky.aircraft || []).length;
const sats = (sky.satellites || []).length;
const top = (sky.aircraft || [])[0];
$('[data-skyh]', slot).textContent = sats
? `${ac} aircraft and ${sats} satellite${sats === 1 ? '' : 's'} above you`
: `${ac} aircraft above you right now`;
$('[data-skyn]', slot).textContent = top
? `Highest: ${top.title}, ${Math.round(top.elevationDeg)}° up ${cardinal(top.azimuthDeg)} · ${home.label || home.name}`
: `A quiet sky over ${home.label || home.name}`;
$('[data-tonight-where]', slot).textContent = (
home.label || home.name
).toUpperCase();
}
/* ---------- your watch ---------- */
async function renderWatch() {
const home = getHome();
const rules = watch.rules().filter((r) => r.on && r.of === 'home');
const hits = rules.flatMap((r) => watch.hits(r.id).map((x) => ({ r, x })));
const rows = [];
rows.push(
hits.length
? `<button class="bf-row" type="button" data-hit="0" data-band="${esc(bandOf(hits[0].x))}"><span class="bf-ic">${icon('home')}</span><span class="bf-main"><b>${esc(home.name)} · ${hits.length} on watch</b><small>${esc(hits[0].x.title)}</small></span><span class="bf-when tele">${esc(hits[0].x.updatedAt ? ago(hits[0].x.updatedAt).replace(' ago', '').toUpperCase() : '')}</span></button>`
: `<div class="bf-row"><span class="bf-ic">${icon('home')}</span><span class="bf-main"><b>${esc(home.name)} · quiet</b><small>Nothing unusual within ${Math.max(...rules.map((r) => r.within || 0), 200).toLocaleString()} km</small></span><span class="bf-ok">${icon('checkmark')}</span></div>`,
);
slot._hits = hits;
const followed = store.get('followed') || [];
const things = (
await Promise.all(
followed
.slice(0, 3)
.map((id) => watch.statusOf(id, sits).catch(() => null)),
)
).filter(Boolean);
for (const t of things)
rows.push(
`<button class="bf-row" type="button" data-thing="${esc(t.id)}" data-band="${t.band}"><span class="bf-ic">${icon(t.icon)}</span><span class="bf-main"><b>${esc(t.title)}</b><small>${esc(t.status)}</small></span>${icon('chevronRight', { cls: 'chev' })}</button>`,
);
if (!things.length)
rows.push(
`<button class="bf-row bf-add" type="button" data-manage>${icon('plus')}<span>Follow a place, a tail number, a ship or the ISS</span></button>`,
);
$('[data-watch]', slot).innerHTML = rows.join('');
}
/* ---------- input ---------- */
slot.addEventListener('click', (e) => {
const ch = e.target.closest('[data-ch]');
if (ch) {
haptic('light');
const c = slot._changes[Number(ch.dataset.ch)];
if (c.momentId) return onOpenMoment(c.momentId);
if (c.situationId) return onOpenSituation(c.situationId);
if (c.entityId) return onOpenEntity(c.entityId);
return;
}
if (e.target.closest('.bf-moment')) {
haptic('light');
if (moment) onOpenMoment(moment.id);
return;
}
if (e.target.closest('.bf-sky, [data-sky]')) {
haptic('light');
return onSky();
}
const sit = e.target.closest('[data-sit]');
if (sit) return onOpenSituation(sit.dataset.sit);
const th = e.target.closest('[data-thing]');
if (th) return onOpenEntity(th.dataset.thing);
const hit = e.target.closest('[data-hit]');
if (hit && slot._hits?.[0]) return onOpenSituation(slot._hits[0].x.id);
if (e.target.closest('[data-manage]')) onWatch();
});
/* ---------- lifecycle ---------- */
function persistVisit() {
try {
localStorage.setItem(LAST_KEY, String(Date.now()));
localStorage.setItem(
SEEN_KEY,
JSON.stringify(sits.map((s) => String(s.id)).slice(0, 200)),
);
} catch {
/* ignore */
}
}
document.addEventListener(
'visibilitychange',
() => document.visibilityState === 'hidden' && persistVisit(),
);
window.addEventListener('pagehide', persistVisit);
async function refreshSky() {
if (!globe) return;
const home = getHome();
sky = await globe
.skyNow({ lon: home.lon, lat: home.lat })
.catch(() => null);
renderSky();
renderTonight();
}
watch.onChange(() => renderWatch());
setInterval(() => {
if (!document.hidden && slot.offsetParent) {
refreshSky();
renderChanges();
}
}, 15000);
return {
setSituations(list) {
sits = list || [];
renderChanges();
renderTonight();
renderWatch();
},
/** Before the globe: the bundled hero paints the card at once. */
setMoment(m) {
if (!moment && m) setMoment(m);
},
async setGlobe(g) {
globe = g;
renderChanges();
const m = await g.momentOfTheDay();
if (
m &&
(!moment || m.id !== moment.id || m.time?.start !== moment.time?.start)
)
setMoment(m);
await refreshSky();
renderWatch();
},
refresh() {
renderChanges();
refreshSky();
renderWatch();
},
get moment() {
return moment;
},
lastVisit,
};
}