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
@@ -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,
|
||||
|
||||
Reference in new issue
Block a user