beforeinstallprompt fires asynchronously after renderLanding() runs, so the click handler was never attached when the prompt arrived late. Always attach the handler and check deferredInstallPrompt at click time.
437 lines
12 KiB
JavaScript
437 lines
12 KiB
JavaScript
import {
|
|
db,
|
|
hydrateFromServer,
|
|
startSync,
|
|
setCompletion,
|
|
onChange,
|
|
getMeta,
|
|
setMeta,
|
|
} from './db.js';
|
|
import { initTheme, cycleTheme, currentTheme } from './theme.js';
|
|
import { renderCalendar } from './calendar.js';
|
|
import {
|
|
todayISO,
|
|
daysRemaining,
|
|
completedSet,
|
|
currentStreak,
|
|
longestStreak,
|
|
totalCompleted,
|
|
} from './stats.js';
|
|
|
|
const VALID_USERS = new Set(['ray', 'cer']);
|
|
const app = document.getElementById('app');
|
|
|
|
/* ---------- PWA install prompt ---------- */
|
|
|
|
let deferredInstallPrompt = null;
|
|
const isIOS = /iphone|ipad|ipod/i.test(navigator.userAgent);
|
|
const isStandalone =
|
|
window.matchMedia('(display-mode: standalone)').matches ||
|
|
('standalone' in navigator && navigator.standalone === true);
|
|
|
|
window.addEventListener('beforeinstallprompt', (e) => {
|
|
e.preventDefault();
|
|
deferredInstallPrompt = e;
|
|
const btn = document.querySelector('[data-install-wrap]');
|
|
if (btn) btn.hidden = false;
|
|
});
|
|
|
|
window.addEventListener('appinstalled', () => {
|
|
deferredInstallPrompt = null;
|
|
const wrap = document.querySelector('[data-install-wrap]');
|
|
if (wrap) wrap.hidden = true;
|
|
});
|
|
|
|
const state = {
|
|
route: '/',
|
|
user: '',
|
|
goals: [],
|
|
completions: [],
|
|
view: { year: new Date().getFullYear(), month: new Date().getMonth() },
|
|
};
|
|
|
|
/* ---------- Router ---------- */
|
|
|
|
function parseRoute(pathname) {
|
|
if (pathname === '/' || pathname === '') return { route: '/', user: '' };
|
|
if (pathname === '/ray') return { route: '/ray', user: 'ray' };
|
|
if (pathname === '/cer') return { route: '/cer', user: 'cer' };
|
|
return { route: '/', user: '' };
|
|
}
|
|
|
|
function navigate(path, { replace = false } = {}) {
|
|
if (replace) history.replaceState({}, '', path);
|
|
else history.pushState({}, '', path);
|
|
handleRoute();
|
|
}
|
|
|
|
function handleRoute() {
|
|
const { route, user } = parseRoute(location.pathname);
|
|
state.route = route;
|
|
state.user = user;
|
|
document.body.dataset.route = route;
|
|
document.body.dataset.user = user || '';
|
|
if (user) setMeta('last_route', route).catch(() => {});
|
|
render();
|
|
}
|
|
|
|
window.addEventListener('popstate', handleRoute);
|
|
|
|
document.addEventListener('click', (e) => {
|
|
const link = e.target.closest('a[data-link]');
|
|
if (!link) return;
|
|
const href = link.getAttribute('href');
|
|
if (!href || href.startsWith('http') || link.target === '_blank') return;
|
|
e.preventDefault();
|
|
navigate(href);
|
|
});
|
|
|
|
/* ---------- Data load ---------- */
|
|
|
|
async function loadFromDexie() {
|
|
const [goals, completions] = await Promise.all([
|
|
db.goals.orderBy('start_date').toArray(),
|
|
db.completions.toArray(),
|
|
]);
|
|
state.goals = goals;
|
|
state.completions = completions;
|
|
}
|
|
|
|
/* ---------- Templates ---------- */
|
|
|
|
function tpl(id) {
|
|
const t = document.getElementById(id);
|
|
return t.content.cloneNode(true);
|
|
}
|
|
|
|
/* ---------- Render: landing ---------- */
|
|
|
|
function renderLanding() {
|
|
app.innerHTML = '';
|
|
app.appendChild(tpl('tpl-landing'));
|
|
|
|
if (isStandalone) return;
|
|
|
|
const wrap = app.querySelector('[data-install-wrap]');
|
|
const btn = app.querySelector('[data-install-btn]');
|
|
const hint = app.querySelector('[data-install-hint]');
|
|
|
|
// Show immediately if we already have the prompt or are on iOS
|
|
if (deferredInstallPrompt || isIOS) wrap.hidden = false;
|
|
|
|
// Always attach handler — check state at click time (handles async beforeinstallprompt)
|
|
btn.addEventListener('click', async () => {
|
|
if (deferredInstallPrompt) {
|
|
deferredInstallPrompt.prompt();
|
|
const { outcome } = await deferredInstallPrompt.userChoice;
|
|
if (outcome === 'accepted') {
|
|
deferredInstallPrompt = null;
|
|
wrap.hidden = true;
|
|
}
|
|
} else if (isIOS) {
|
|
hint.hidden = !hint.hidden;
|
|
}
|
|
});
|
|
}
|
|
|
|
/* ---------- Render: route page ---------- */
|
|
|
|
async function renderRoute() {
|
|
app.innerHTML = '';
|
|
app.appendChild(tpl('tpl-route'));
|
|
|
|
// Topbar
|
|
const themeBtn = app.querySelector('[data-theme-toggle]');
|
|
const themeIcon = app.querySelector('[data-theme-icon]');
|
|
themeIcon.textContent = await currentTheme();
|
|
themeBtn.addEventListener('click', async () => {
|
|
themeIcon.textContent = await cycleTheme();
|
|
});
|
|
|
|
// Countdown - use earliest end_date among active goals
|
|
const cdEl = app.querySelector('[data-countdown]');
|
|
const today = todayISO();
|
|
const activeGoals = state.goals.filter((g) => g.end_date >= today);
|
|
if (activeGoals.length) {
|
|
const minEnd = activeGoals
|
|
.map((g) => g.end_date)
|
|
.sort()[0];
|
|
const remain = daysRemaining(minEnd);
|
|
cdEl.textContent = remain === 0 ? 'last day' : `${remain}d left`;
|
|
} else {
|
|
cdEl.textContent = 'window closed';
|
|
}
|
|
|
|
renderStats();
|
|
renderActionbar();
|
|
renderCalendarRoot();
|
|
}
|
|
|
|
/* ---------- Render: stats row ---------- */
|
|
|
|
function renderStats() {
|
|
const root = app.querySelector('[data-stats]');
|
|
if (!root) return;
|
|
root.innerHTML = '';
|
|
|
|
const today = todayISO();
|
|
const orderedGoals = [...state.goals].sort((a, b) =>
|
|
a.start_date === b.start_date ? a.id.localeCompare(b.id) : a.start_date.localeCompare(b.start_date)
|
|
);
|
|
|
|
for (const g of orderedGoals) {
|
|
const card = document.createElement('div');
|
|
card.className = 'stat';
|
|
|
|
const label = document.createElement('div');
|
|
label.className = 'stat__label';
|
|
label.textContent = g.name;
|
|
card.appendChild(label);
|
|
|
|
for (const user of ['ray', 'cer']) {
|
|
const set = completedSet(state.completions, user, g.id);
|
|
const cur = currentStreak(set, g, today);
|
|
const long = longestStreak(set, g, today);
|
|
const total = totalCompleted(set, g, today);
|
|
const row = document.createElement('div');
|
|
row.className = 'stat__row';
|
|
row.innerHTML = `
|
|
<span class="stat__user stat__user--${user}">
|
|
<span class="stat__user-tag">${user}</span>
|
|
<span class="stat__num">${cur}</span>
|
|
<span class="stat__num-sub">streak / ${long} best / ${total} total</span>
|
|
</span>
|
|
`;
|
|
card.appendChild(row);
|
|
}
|
|
|
|
root.appendChild(card);
|
|
}
|
|
}
|
|
|
|
/* ---------- Render: action bar ---------- */
|
|
|
|
function renderActionbar() {
|
|
const root = app.querySelector('[data-actionbar]');
|
|
if (!root) return;
|
|
root.innerHTML = '';
|
|
const today = todayISO();
|
|
|
|
const orderedGoals = [...state.goals].sort((a, b) =>
|
|
a.start_date === b.start_date ? a.id.localeCompare(b.id) : a.start_date.localeCompare(b.start_date)
|
|
);
|
|
|
|
for (const g of orderedGoals) {
|
|
const inWindow = today >= g.start_date && today <= g.end_date;
|
|
const existing = state.completions.find(
|
|
(c) => c.user === state.user && c.goal_id === g.id && c.date === today
|
|
);
|
|
const done = !!existing?.completed;
|
|
|
|
const btn = document.createElement('button');
|
|
btn.type = 'button';
|
|
btn.className = 'action' + (done ? ' action--done' : '');
|
|
btn.disabled = !inWindow;
|
|
btn.innerHTML = `
|
|
<span class="action__label">${state.user} • today</span>
|
|
<span class="action__title">${g.name}</span>
|
|
<span class="action__state">${done ? 'completed — tap to undo' : inWindow ? 'tap to mark done' : 'outside goal window'}</span>
|
|
`;
|
|
btn.addEventListener('click', async () => {
|
|
await setCompletion(state.user, g.id, today, !done);
|
|
// optimistic UI update via subscription, but also re-render now
|
|
await loadFromDexie();
|
|
renderStats();
|
|
renderActionbar();
|
|
renderCalendarRoot();
|
|
});
|
|
root.appendChild(btn);
|
|
}
|
|
}
|
|
|
|
/* ---------- Render: calendar ---------- */
|
|
|
|
function renderCalendarRoot() {
|
|
const root = app.querySelector('[data-calendar]');
|
|
if (!root) return;
|
|
|
|
renderCalendar(root, {
|
|
year: state.view.year,
|
|
month: state.view.month,
|
|
goals: state.goals,
|
|
completions: state.completions,
|
|
activeUser: state.user,
|
|
onPrev: () => {
|
|
const m = state.view.month - 1;
|
|
if (m < 0) {
|
|
state.view.month = 11;
|
|
state.view.year -= 1;
|
|
} else {
|
|
state.view.month = m;
|
|
}
|
|
renderCalendarRoot();
|
|
},
|
|
onNext: () => {
|
|
const m = state.view.month + 1;
|
|
if (m > 11) {
|
|
state.view.month = 0;
|
|
state.view.year += 1;
|
|
} else {
|
|
state.view.month = m;
|
|
}
|
|
renderCalendarRoot();
|
|
},
|
|
onJumpToday: (y, m) => {
|
|
state.view.year = y;
|
|
state.view.month = m;
|
|
renderCalendarRoot();
|
|
},
|
|
onDayClick: ({ iso }) => openDayPopover(iso),
|
|
});
|
|
}
|
|
|
|
/* ---------- Popover ---------- */
|
|
|
|
function openDayPopover(iso) {
|
|
const root = app.querySelector('[data-popover]');
|
|
if (!root) return;
|
|
root.hidden = false;
|
|
root.innerHTML = '';
|
|
|
|
const card = document.createElement('div');
|
|
card.className = 'popover';
|
|
|
|
const head = document.createElement('div');
|
|
head.className = 'popover__head';
|
|
head.innerHTML = `
|
|
<div class="popover__title">${iso} • ${state.user}</div>
|
|
<button class="popover__close" data-close aria-label="Close">close</button>
|
|
`;
|
|
card.appendChild(head);
|
|
|
|
const list = document.createElement('div');
|
|
list.className = 'popover__list';
|
|
const orderedGoals = [...state.goals].sort((a, b) =>
|
|
a.start_date === b.start_date ? a.id.localeCompare(b.id) : a.start_date.localeCompare(b.start_date)
|
|
);
|
|
for (const g of orderedGoals) {
|
|
const inWindow = iso >= g.start_date && iso <= g.end_date;
|
|
if (!inWindow) continue;
|
|
const existing = state.completions.find(
|
|
(c) => c.user === state.user && c.goal_id === g.id && c.date === iso
|
|
);
|
|
const done = !!existing?.completed;
|
|
const row = document.createElement('button');
|
|
row.type = 'button';
|
|
row.className = 'popover__row' + (done ? ' popover__row--on' : '');
|
|
row.innerHTML = `
|
|
<span class="popover__row__name">${g.name}</span>
|
|
<span class="popover__row__state">${done ? 'done • tap to undo' : 'tap to mark done'}</span>
|
|
`;
|
|
row.addEventListener('click', async () => {
|
|
await setCompletion(state.user, g.id, iso, !done);
|
|
await loadFromDexie();
|
|
renderStats();
|
|
renderActionbar();
|
|
renderCalendarRoot();
|
|
openDayPopover(iso); // refresh popover state
|
|
});
|
|
list.appendChild(row);
|
|
}
|
|
|
|
if (!list.children.length) {
|
|
const empty = document.createElement('div');
|
|
empty.className = 'popover__hint';
|
|
empty.textContent = 'no goals active on this date';
|
|
list.appendChild(empty);
|
|
}
|
|
|
|
card.appendChild(list);
|
|
|
|
const hint = document.createElement('div');
|
|
hint.className = 'popover__hint';
|
|
hint.textContent = 'changes only affect the active user';
|
|
card.appendChild(hint);
|
|
|
|
root.appendChild(card);
|
|
|
|
const close = () => {
|
|
root.hidden = true;
|
|
root.innerHTML = '';
|
|
};
|
|
head.querySelector('[data-close]').addEventListener('click', close);
|
|
root.addEventListener('click', (e) => {
|
|
if (e.target === root) close();
|
|
});
|
|
document.addEventListener(
|
|
'keydown',
|
|
function onKey(e) {
|
|
if (e.key === 'Escape') {
|
|
close();
|
|
document.removeEventListener('keydown', onKey);
|
|
}
|
|
},
|
|
{ once: true }
|
|
);
|
|
}
|
|
|
|
/* ---------- Top-level render ---------- */
|
|
|
|
function render() {
|
|
if (state.route === '/' || !VALID_USERS.has(state.user)) {
|
|
renderLanding();
|
|
} else {
|
|
renderRoute();
|
|
}
|
|
}
|
|
|
|
/* ---------- Boot ---------- */
|
|
|
|
async function boot() {
|
|
await initTheme();
|
|
await loadFromDexie();
|
|
|
|
// Restore last visited user route if opening at root
|
|
const curPath = location.pathname;
|
|
if (curPath === '/' || curPath === '') {
|
|
const saved = await getMeta('last_route', null);
|
|
if (saved && VALID_USERS.has(saved.slice(1))) {
|
|
history.replaceState({}, '', saved);
|
|
}
|
|
}
|
|
|
|
handleRoute();
|
|
|
|
// Hydrate from server (non-blocking) and start sync
|
|
hydrateFromServer().then(async () => {
|
|
await loadFromDexie();
|
|
if (state.route !== '/') render();
|
|
else if (state.goals.length) {
|
|
// landing has no data dependency, but we still re-render to no-op safely
|
|
}
|
|
});
|
|
startSync();
|
|
|
|
onChange(async () => {
|
|
await loadFromDexie();
|
|
if (state.route !== '/') {
|
|
renderStats();
|
|
renderActionbar();
|
|
renderCalendarRoot();
|
|
}
|
|
});
|
|
|
|
// Register service worker
|
|
if ('serviceWorker' in navigator) {
|
|
try {
|
|
await navigator.serviceWorker.register('/sw.js');
|
|
} catch (e) {
|
|
console.warn('[sw] register failed:', e);
|
|
}
|
|
}
|
|
}
|
|
|
|
boot().catch((e) => {
|
|
console.error('[boot] failed:', e);
|
|
app.innerHTML = `<pre style="padding:24px;color:var(--fg)">boot failed: ${e?.message || e}</pre>`;
|
|
});
|