2026-04-23 14:45:06 +00:00
|
|
|
/* 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;
|
2026-04-25 11:29:43 +00:00
|
|
|
let end = clampWindowEnd(goal, refISO);
|
2026-04-23 14:45:06 +00:00
|
|
|
if (refISO < start) return 0;
|
|
|
|
|
|
2026-04-25 11:29:43 +00:00
|
|
|
// If today isn't completed yet and the goal is still active,
|
|
|
|
|
// start from yesterday — the day isn't over so don't break the streak.
|
|
|
|
|
if (end === refISO && !completedSetForUser.has(end)) {
|
|
|
|
|
end = addDays(end, -1);
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-23 14:45:06 +00:00
|
|
|
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;
|
|
|
|
|
}
|