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

+68 -31
View File
@@ -1,21 +1,25 @@
// Tracks which reading plans (see utils/readingPlans.js) a user has started,
// which days they've checked off, which plans they've finished, and any plans
// they've created themselves. Saved locally right away and synced to the
// server in the background — same "local wins immediately, server wins at
// sign-in" pattern as reader ink.
// which days they've checked off, which plans they've finished, any plans
// they've created themselves, and a cross-plan activity log (for streaks).
// Saved locally right away and synced to the server in the background — same
// "local wins immediately, server wins at sign-in" pattern as reader ink.
import { useCallback, useState } from 'react';
import { saveRemoteReadingProgress } from '../syncService.js';
import { registerCustomPlans } from '../utils/readingPlans.js';
import { localDateString, registerCustomPlans } from '../utils/readingPlans.js';
const STORAGE_KEY = 'readingPlanProgress';
function loadLocal() {
try {
const raw = JSON.parse(localStorage.getItem(STORAGE_KEY) || 'null');
if (Array.isArray(raw)) return { progress: raw, customPlans: [] }; // pre-custom-plans shape
return { progress: raw?.progress ?? [], customPlans: raw?.customPlans ?? [] };
if (Array.isArray(raw)) return { progress: raw, customPlans: [], activity: [] }; // pre-custom-plans shape
return {
progress: raw?.progress ?? [],
customPlans: raw?.customPlans ?? [],
activity: raw?.activity ?? [],
};
} catch {
return { progress: [], customPlans: [] };
return { progress: [], customPlans: [], activity: [] };
}
}
@@ -32,21 +36,21 @@ export function useReadingPlans() {
// this user's custom plans before any consumer renders this commit.
registerCustomPlans(state.customPlans);
const updateProgress = useCallback((updater) => {
const updateState = useCallback((updater) => {
setState((prev) => {
const next = { ...prev, progress: updater(prev.progress) };
const next = updater(prev);
persist(next);
return next;
});
}, []);
const updateProgress = useCallback((updater) => {
updateState((prev) => ({ ...prev, progress: updater(prev.progress) }));
}, [updateState]);
const updateCustomPlans = useCallback((updater) => {
setState((prev) => {
const next = { ...prev, customPlans: updater(prev.customPlans) };
persist(next);
return next;
});
}, []);
updateState((prev) => ({ ...prev, customPlans: updater(prev.customPlans) }));
}, [updateState]);
const startPlan = useCallback((planId) => {
updateProgress((prev) => {
@@ -64,16 +68,26 @@ export function useReadingPlans() {
}, [updateProgress]);
const toggleDay = useCallback((progressId, dayIndex, totalDays) => {
updateProgress((prev) => prev.map((p) => {
if (p.id !== progressId) return p;
const already = p.completedDays.includes(dayIndex);
const completedDays = already
? p.completedDays.filter((d) => d !== dayIndex)
: [...p.completedDays, dayIndex].sort((a, b) => a - b);
const completedAt = completedDays.length >= totalDays ? (p.completedAt ?? Date.now()) : null;
return { ...p, completedDays, completedAt, lastActivityAt: Date.now() };
}));
}, [updateProgress]);
updateState((prev) => {
const target = prev.progress.find((p) => p.id === progressId);
if (!target) return prev;
const wasAlready = target.completedDays.includes(dayIndex);
const progress = prev.progress.map((p) => {
if (p.id !== progressId) return p;
const completedDays = wasAlready
? p.completedDays.filter((d) => d !== dayIndex)
: [...p.completedDays, dayIndex].sort((a, b) => a - b);
const completedAt = completedDays.length >= totalDays ? (p.completedAt ?? Date.now()) : null;
return { ...p, completedDays, completedAt, lastActivityAt: Date.now() };
});
// Only marking a day *done* extends the streak; unchecking one doesn't erase it.
const today = localDateString();
const activity = !wasAlready && !prev.activity.includes(today)
? [...prev.activity, today]
: prev.activity;
return { ...prev, progress, activity };
});
}, [updateState]);
const restartPlan = useCallback((progressId) => {
updateProgress((prev) => prev.map((p) => (
@@ -87,8 +101,18 @@ export function useReadingPlans() {
updateProgress((prev) => prev.filter((p) => p.id !== progressId));
}, [updateProgress]);
// { title, description, days, books: [abbrev, ...], image?: dataUrl } -> new custom plan id
const addCustomPlan = useCallback(({ title, description, days, books, image }) => {
// Resets the pace clock so a plan that's fallen behind schedule stops being
// flagged as "behind" — doesn't touch which days are actually marked done.
const recalibratePace = useCallback((progressId) => {
updateProgress((prev) => prev.map((p) => (
p.id === progressId
? { ...p, startedAt: Date.now() - p.completedDays.length * 86400000, lastActivityAt: Date.now() }
: p
)));
}, [updateProgress]);
// { title, description, days, books: [abbrev, ...], chapterRanges?, image?: dataUrl } -> new custom plan id
const addCustomPlan = useCallback(({ title, description, days, books, chapterRanges, image }) => {
const id = `custom-${makeId()}`;
updateCustomPlans((prev) => [
{
@@ -100,6 +124,7 @@ export function useReadingPlans() {
image: image || undefined,
days: Math.max(1, Math.round(days)),
books,
chapterRanges: chapterRanges && Object.keys(chapterRanges).length > 0 ? chapterRanges : undefined,
custom: true,
createdAt: Date.now(),
},
@@ -113,10 +138,19 @@ export function useReadingPlans() {
updateProgress((prev) => prev.filter((p) => p.planId !== planId));
}, [updateCustomPlans, updateProgress]);
// Patches an existing custom plan (used for setting/changing/removing its cover image).
const updateCustomPlan = useCallback((planId, patch) => {
// Patches an existing custom plan. Pass resetProgress: true when the edit
// changes its books/days (day indices from before the edit no longer mean
// anything), which restarts any in-progress runs of it from day 1.
const updateCustomPlan = useCallback((planId, patch, { resetProgress = false } = {}) => {
updateCustomPlans((prev) => prev.map((p) => (p.id === planId ? { ...p, ...patch } : p)));
}, [updateCustomPlans]);
if (resetProgress) {
updateProgress((prev) => prev.map((p) => (
p.planId === planId
? { ...p, completedDays: [], completedAt: null, startedAt: Date.now(), lastActivityAt: Date.now() }
: p
)));
}
}, [updateCustomPlans, updateProgress]);
// Server wins over whatever's local at sign-in.
const mergeServerReadingProgress = useCallback((serverData) => {
@@ -124,6 +158,7 @@ export function useReadingPlans() {
const next = {
progress: Array.isArray(serverData.progress) ? serverData.progress : [],
customPlans: Array.isArray(serverData.customPlans) ? serverData.customPlans : [],
activity: Array.isArray(serverData.activity) ? serverData.activity : [],
};
try { localStorage.setItem(STORAGE_KEY, JSON.stringify(next)); } catch {}
setState(next);
@@ -132,10 +167,12 @@ export function useReadingPlans() {
return {
planProgress: state.progress,
customPlans: state.customPlans,
readingActivity: state.activity,
startPlan,
toggleDay,
restartPlan,
removePlanProgress,
recalibratePace,
addCustomPlan,
removeCustomPlan,
updateCustomPlan,