Add streaks, today view, custom plan editing/chapter ranges, and ntfy reminders
Build and Push Docker Image / build-and-push (push) Failing after 21s
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:
1 parent
600b3d3ab9
commit
1dd6aef6a5
10 files changed
+711
-141
No files matched your search
@@ -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);
|
||||
}
|
||||
Reference in new issue
Block a user