Vanilla HTML/JS/CSS PWA with Dexie offline-first sync, Hono+SQLite backend, served via nginx reverse-proxy. Two seed goals (no-sugar, no-social-media) for users ray and cer. Local dev runs at https://raycer.test via the shared Traefik proxy. Production deploys to https://raycer.altweb.me on cool2026/personal via docker-compose.coolify.yaml — see deploy/COOLIFY.md.
380 lines
10 KiB
JavaScript
380 lines
10 KiB
JavaScript
import {
|
|
db,
|
|
hydrateFromServer,
|
|
startSync,
|
|
setCompletion,
|
|
onChange,
|
|
} 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');
|
|
|
|
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 || '';
|
|
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'));
|
|
}
|
|
|
|
/* ---------- 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();
|
|
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>`;
|
|
});
|