Add streaks, today view, custom plan editing/chapter ranges, and ntfy reminders
Build and Push Docker Image / build-and-push (push) Failing after 21s

Reading plans now track a cross-plan activity log for streaks, surface a
combined "today's reading" view on both Home and the Reading Plans page,
and let custom plans be edited after creation (including narrowing a book
to a specific chapter range). The reader auto-marks a plan's day read after
a dwell period, active plans that fall behind their pace get a "catch me
up" banner, and a new Settings section wires up daily reading reminders
via ntfy.sh, checked once a minute by a small server-side scheduler.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
nmemmertandClaude Sonnet 5 committed 2026-09-23 13:16:27 -04:00
1 parent 600b3d3ab9
commit 1dd6aef6a5
10 files changed
+711 -141

No files matched your search

+64 -3
View File
@@ -12,15 +12,28 @@ export const PENTATEUCH = ['GEN', 'EXO', 'LEV', 'NUM', 'DEU'];
export const WISDOM_BOOKS = ['JOB', 'PSA', 'PRO', 'ECC', 'SNG'];
// Flattens an ordered list of book abbreviations into one chapter-by-chapter list.
function chaptersFor(bookAbbrevs) {
// `chapterRanges` (optional) is { [bookAbbrev]: { start, end } } — a custom plan's
// per-book override to read only part of a book instead of the whole thing.
function chaptersFor(bookAbbrevs, chapterRanges) {
const list = [];
for (const book of bookAbbrevs) {
const total = CHAPTER_COUNTS[book] ?? 0;
for (let chapter = 1; chapter <= total; chapter += 1) list.push({ book, chapter });
const range = chapterRanges?.[book];
const start = Math.max(1, range?.start ?? 1);
const end = Math.min(total, range?.end ?? total);
for (let chapter = start; chapter <= end; chapter += 1) list.push({ book, chapter });
}
return list;
}
// Chapter count a book contributes to a plan, honoring a chapter-range override.
export function bookChapterCount(bookAbbrev, chapterRanges) {
const total = CHAPTER_COUNTS[bookAbbrev] ?? 0;
const range = chapterRanges?.[bookAbbrev];
if (!range) return total;
return Math.max(0, Math.min(total, range.end) - Math.max(1, range.start) + 1);
}
// Splits a flat chapter list into `days` nearly-equal, contiguous slices.
function distributeIntoDays(chapters, days) {
const total = chapters.length;
@@ -187,7 +200,7 @@ export function getPlanSchedule(planId) {
if (scheduleCache.has(planId)) return scheduleCache.get(planId);
const plan = getPlan(planId);
if (!plan) return [];
const schedule = distributeIntoDays(chaptersFor(plan.books), plan.days);
const schedule = distributeIntoDays(chaptersFor(plan.books, plan.chapterRanges), plan.days);
scheduleCache.set(planId, schedule);
return schedule;
}
@@ -233,3 +246,51 @@ export function findDayIndexForRef(schedule, bookAbbrev, chapter) {
}
return -1;
}
// ---------------------------------------------------------------------------
// Streaks & pace — driven by a plain list of 'YYYY-MM-DD' local-date strings
// (one per calendar day something was marked read, across all plans).
// ---------------------------------------------------------------------------
export function localDateString(ts = Date.now()) {
const d = new Date(ts);
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
}
// { current, longest } consecutive-calendar-day streak. "current" stays alive
// through today even if today hasn't been logged yet (so it doesn't drop to 0
// the moment you wake up) but breaks once a full day is skipped.
export function computeStreak(activityDates) {
if (!activityDates || activityDates.length === 0) return { current: 0, longest: 0 };
const daySet = new Set(activityDates);
const DAY_MS = 86400000;
const sorted = Array.from(daySet).sort();
let longest = 0;
let run = 0;
let prevTime = null;
for (const ds of sorted) {
const t = new Date(`${ds}T00:00:00`).getTime();
run = prevTime != null && t - prevTime === DAY_MS ? run + 1 : 1;
longest = Math.max(longest, run);
prevTime = t;
}
let current = 0;
const cursor = new Date();
cursor.setHours(0, 0, 0, 0);
if (!daySet.has(localDateString(cursor.getTime()))) cursor.setDate(cursor.getDate() - 1);
while (daySet.has(localDateString(cursor.getTime()))) {
current += 1;
cursor.setDate(cursor.getDate() - 1);
}
return { current, longest };
}
// How many days a plan's pace has fallen behind, assuming one plan-day per
// calendar day since it was started. Purely informational — nothing enforces it.
export function daysBehindPace(plan, progress) {
const elapsedDays = Math.floor((Date.now() - progress.startedAt) / 86400000);
const expectedDayIndex = Math.min(plan.days - 1, Math.max(0, elapsedDays));
return Math.max(0, expectedDayIndex - progress.completedDays.length);
}