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.
101 lines
2.6 KiB
JavaScript
101 lines
2.6 KiB
JavaScript
/* Pure helpers for streaks, totals, and the goal-window countdown. */
|
|
|
|
const DAY_MS = 86_400_000;
|
|
|
|
export function todayISO(d = new Date()) {
|
|
const y = d.getFullYear();
|
|
const m = String(d.getMonth() + 1).padStart(2, '0');
|
|
const day = String(d.getDate()).padStart(2, '0');
|
|
return `${y}-${m}-${day}`;
|
|
}
|
|
|
|
export function isoToDate(iso) {
|
|
const [y, m, d] = iso.split('-').map(Number);
|
|
return new Date(y, m - 1, d);
|
|
}
|
|
|
|
export function addDays(iso, n) {
|
|
const d = isoToDate(iso);
|
|
d.setDate(d.getDate() + n);
|
|
return todayISO(d);
|
|
}
|
|
|
|
export function eachDay(startISO, endISO) {
|
|
const out = [];
|
|
let cur = startISO;
|
|
while (cur <= endISO) {
|
|
out.push(cur);
|
|
cur = addDays(cur, 1);
|
|
}
|
|
return out;
|
|
}
|
|
|
|
export function clampWindowEnd(goal, refISO = todayISO()) {
|
|
return goal.end_date < refISO ? goal.end_date : refISO;
|
|
}
|
|
|
|
export function daysRemaining(endISO, refISO = todayISO()) {
|
|
if (endISO < refISO) return 0;
|
|
return Math.round((isoToDate(endISO) - isoToDate(refISO)) / DAY_MS);
|
|
}
|
|
|
|
/**
|
|
* Build a Set of completed ISO dates for (user, goal) from a flat completions array.
|
|
*/
|
|
export function completedSet(completions, user, goalId) {
|
|
const set = new Set();
|
|
for (const c of completions) {
|
|
if (c.user === user && c.goal_id === goalId && c.completed) set.add(c.date);
|
|
}
|
|
return set;
|
|
}
|
|
|
|
/**
|
|
* Current streak ending at (or just before) `refISO`, walking back day-by-day
|
|
* while completions are present, never crossing the goal start.
|
|
*/
|
|
export function currentStreak(completedSetForUser, goal, refISO = todayISO()) {
|
|
const start = goal.start_date;
|
|
const end = clampWindowEnd(goal, refISO);
|
|
if (refISO < start) return 0;
|
|
|
|
let cur = end;
|
|
let n = 0;
|
|
while (cur >= start && completedSetForUser.has(cur)) {
|
|
n += 1;
|
|
cur = addDays(cur, -1);
|
|
}
|
|
return n;
|
|
}
|
|
|
|
export function longestStreak(completedSetForUser, goal, refISO = todayISO()) {
|
|
const start = goal.start_date;
|
|
const end = clampWindowEnd(goal, refISO);
|
|
if (end < start) return 0;
|
|
let best = 0;
|
|
let run = 0;
|
|
for (const d of eachDay(start, end)) {
|
|
if (completedSetForUser.has(d)) {
|
|
run += 1;
|
|
if (run > best) best = run;
|
|
} else {
|
|
run = 0;
|
|
}
|
|
}
|
|
return best;
|
|
}
|
|
|
|
export function totalCompleted(completedSetForUser, goal, refISO = todayISO()) {
|
|
const start = goal.start_date;
|
|
const end = clampWindowEnd(goal, refISO);
|
|
if (end < start) return 0;
|
|
let n = 0;
|
|
for (const d of eachDay(start, end)) {
|
|
if (completedSetForUser.has(d)) n += 1;
|
|
}
|
|
return n;
|
|
}
|
|
|
|
export function windowLength(goal) {
|
|
return eachDay(goal.start_date, goal.end_date).length;
|
|
}
|