raycer/frontend/public/js/app.js

438 lines
12 KiB
JavaScript
Raw Normal View History

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>`;
});