mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-06 03:14:00 +00:00
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:
83 files changed
+50005
-416
No files matched your search
@@ -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,
|
||||
};
|
||||
}
|
||||
Reference in new issue
Block a user