Fix plan days with no chapters, add a way out of ink mode, harden ntfy reminders
Build and Push Docker Image / build-and-push (push) Failing after 19s
Build and Push Docker Image / build-and-push (push) Failing after 19s
Plans can no longer have more days than chapters (existing ones heal), and opening a plan never strands the Reader on an empty day. The Reader shows Done drawing while inking (Escape also exits). Reminders use the user's own timezone, send within a 3 hour grace window, and Settings has a Send test notification button. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
ecb1c701fa
commit
297f2c7e84
14 files changed
+335
-58
No files matched your search
+8
-5
@@ -1,5 +1,5 @@
|
||||
import { lazy, Suspense, useEffect, useRef, useState } from 'react';
|
||||
import { getPlanSchedule } from './utils/readingPlans.js';
|
||||
import { getPlanSchedule, nearestReadingDay } from './utils/readingPlans.js';
|
||||
import { AppContext, useApp } from './context/AppContext.js';
|
||||
import { useAudio } from './hooks/useAudio.js';
|
||||
import { useAuth } from './hooks/useAuth.js';
|
||||
@@ -140,11 +140,14 @@ const App = () => {
|
||||
const openPlanReading = (progressId, dayIndex, ref) => {
|
||||
const progress = readingPlans.planProgress.find((p) => p.id === progressId);
|
||||
if (!progress) return;
|
||||
const refs = getPlanSchedule(progress.planId)[dayIndex] ?? [];
|
||||
const done = progress.completedDays.includes(dayIndex);
|
||||
const read = new Set(done ? [] : progress.chaptersRead?.[dayIndex] ?? []);
|
||||
const schedule = getPlanSchedule(progress.planId);
|
||||
// A day with nothing scheduled would leave the Reader where it was — use the nearest real day.
|
||||
const day = schedule[dayIndex]?.length ? dayIndex : Math.max(0, nearestReadingDay(schedule, dayIndex));
|
||||
const refs = schedule[day] ?? [];
|
||||
const done = progress.completedDays.includes(day);
|
||||
const read = new Set(done ? [] : progress.chaptersRead?.[day] ?? []);
|
||||
const target = ref ?? refs.find((r) => !read.has(`${r.book}.${r.chapter}`)) ?? refs[0];
|
||||
setReaderPlan({ progressId, dayIndex });
|
||||
setReaderPlan({ progressId, dayIndex: day });
|
||||
if (target) {
|
||||
reader.setReaderBookAbbrev(target.book);
|
||||
reader.setReaderChapter(target.chapter);
|
||||
|
||||
@@ -950,3 +950,19 @@ describe('Study layout', () => {
|
||||
expect(within(screen.getByRole('region', { name: 'Scripture' })).queryByRole('textbox')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Reading plan with more days than chapters', () => {
|
||||
test('Continue reading opens the plan on a real chapter instead of stranding the Reader', async () => {
|
||||
localStorage.setItem('readingPlanProgress', JSON.stringify({
|
||||
customPlans: [{ id: 'custom-luke', title: 'Luke', days: 90, books: ['LUK'], custom: true }],
|
||||
progress: [{ id: 'rp1', planId: 'custom-luke', startedAt: Date.now(), completedDays: [0, 1], chaptersRead: {}, completedAt: null, lastActivityAt: Date.now() }],
|
||||
activity: [],
|
||||
}));
|
||||
const user = userEvent.setup();
|
||||
await renderApp();
|
||||
await user.click(await screen.findByRole('button', { name: /continue reading/i }));
|
||||
expect(await screen.findByText(/Luke 3 · BSB/)).toBeInTheDocument();
|
||||
expect(screen.getAllByText(/Luke · Day 3/).length).toBeGreaterThan(0);
|
||||
expect(screen.queryByText(/back to today.s reading/i)).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -3,7 +3,7 @@
|
||||
import { useRef, useState } from 'react';
|
||||
import { bookOptions, CHAPTER_COUNTS } from '../../utils/bibleUtils.jsx';
|
||||
import {
|
||||
bookChapterCount, ALL_BOOKS, OT_BOOKS, NT_BOOKS, GOSPELS, PENTATEUCH, WISDOM_BOOKS,
|
||||
bookChapterCount, clampPlanDays, ALL_BOOKS, OT_BOOKS, NT_BOOKS, GOSPELS, PENTATEUCH, WISDOM_BOOKS,
|
||||
} from '../../utils/readingPlans.js';
|
||||
|
||||
// Downscales a picked image file to a reasonable cover-photo size and returns
|
||||
@@ -201,8 +201,13 @@ export default function CustomPlanBuilder({ initialValues, onSubmit, onCancel })
|
||||
</div>
|
||||
<p className="mt-2 text-xs text-[var(--r-sub)]">
|
||||
{books.length} book{books.length !== 1 ? 's' : ''} selected · {totalChapters} chapters
|
||||
{days > 0 && totalChapters > 0 && ` · ~${(totalChapters / days).toFixed(1)} ch/day`}
|
||||
{days > 0 && totalChapters > 0 && ` · ~${(totalChapters / Math.min(days, totalChapters)).toFixed(1)} ch/day`}
|
||||
</p>
|
||||
{totalChapters > 0 && Number(days) > totalChapters && (
|
||||
<p className="mt-1 text-xs text-[var(--r-accent)]">
|
||||
That’s more days than chapters, so the plan will be {totalChapters} days, one chapter a day.
|
||||
</p>
|
||||
)}
|
||||
{isEditing && (
|
||||
books.join() !== initialValues.books.join()
|
||||
|| Number(days) !== initialValues.days
|
||||
@@ -218,7 +223,7 @@ export default function CustomPlanBuilder({ initialValues, onSubmit, onCancel })
|
||||
<button
|
||||
type="button"
|
||||
disabled={!canCreate}
|
||||
onClick={() => onSubmit({ title, description, days, books, chapterRanges, image })}
|
||||
onClick={() => onSubmit({ title, description, days: clampPlanDays(books, chapterRanges, days), books, chapterRanges, image })}
|
||||
className="rounded-xl bg-[var(--r-accent)] px-4 py-2 text-sm font-semibold text-[var(--r-on)] transition hover:opacity-90 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
{isEditing ? 'Save changes' : 'Create plan'}
|
||||
|
||||
@@ -5,7 +5,7 @@ import { BookmarkIcon } from '../../utils/bibleUtils.jsx';
|
||||
|
||||
const iconBtn = 'flex h-9 w-9 items-center justify-center rounded-lg text-[var(--r-sub)] transition hover:bg-black/5 disabled:opacity-30 disabled:hover:bg-transparent';
|
||||
|
||||
export function TopBar({ title, onBack, onOpenNavigator, onSearch, onBookmarks, bookmarkCount, onSettings }) {
|
||||
export function TopBar({ title, onBack, onOpenNavigator, onSearch, onBookmarks, bookmarkCount, onSettings, onDoneDrawing }) {
|
||||
return (
|
||||
<div className="sticky top-0 z-30 border-b border-[var(--r-line)] bg-[var(--r-bar)] font-sans">
|
||||
<div className="mx-auto flex h-12 max-w-4xl items-center gap-1 px-2">
|
||||
@@ -14,11 +14,20 @@ export function TopBar({ title, onBack, onOpenNavigator, onSearch, onBookmarks,
|
||||
className="mx-auto flex min-w-0 items-center gap-1.5 rounded-lg px-3 py-1.5 text-sm font-medium text-[var(--r-text)] hover:bg-black/5">
|
||||
<span className="truncate">{title}</span><ChevronDown />
|
||||
</button>
|
||||
<button type="button" onClick={onSearch} className={iconBtn} aria-label="Search"><SearchIcon /></button>
|
||||
<button type="button" onClick={onBookmarks} className={`${iconBtn} relative ${bookmarkCount ? 'text-[var(--r-accent)]' : ''}`} aria-label="Bookmarks">
|
||||
<BookmarkIcon filled={bookmarkCount > 0} />
|
||||
</button>
|
||||
<button type="button" onClick={onSettings} className={`${iconBtn} w-10 text-sm font-medium`} aria-label="Reading settings">Aa</button>
|
||||
{onDoneDrawing ? (
|
||||
<button type="button" onClick={onDoneDrawing}
|
||||
className="mr-1 flex items-center gap-1.5 rounded-full bg-[var(--r-accent)] px-4 py-1.5 text-xs font-medium text-[var(--r-on)]">
|
||||
<CheckIcon />Done drawing
|
||||
</button>
|
||||
) : (
|
||||
<>
|
||||
<button type="button" onClick={onSearch} className={iconBtn} aria-label="Search"><SearchIcon /></button>
|
||||
<button type="button" onClick={onBookmarks} className={`${iconBtn} relative ${bookmarkCount ? 'text-[var(--r-accent)]' : ''}`} aria-label="Bookmarks">
|
||||
<BookmarkIcon filled={bookmarkCount > 0} />
|
||||
</button>
|
||||
<button type="button" onClick={onSettings} className={`${iconBtn} w-10 text-sm font-medium`} aria-label="Reading settings">Aa</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -4,9 +4,9 @@
|
||||
// Saved locally right away; synced to the server via an effect that watches
|
||||
// `state` (see below) so sync success/failure can actually be tracked and
|
||||
// shown, instead of being a fire-and-forget call buried inside setState.
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { getRemoteReadingProgress, saveRemoteReadingProgress } from '../syncService.js';
|
||||
import { localDateString, registerCustomPlans } from '../utils/readingPlans.js';
|
||||
import { clampPlanDays, localDateString, normalizeCustomPlan, registerCustomPlans } from '../utils/readingPlans.js';
|
||||
|
||||
const STORAGE_KEY = 'readingPlanProgress';
|
||||
|
||||
@@ -46,6 +46,8 @@ export function useReadingPlans() {
|
||||
// Keeps the readingPlans.js lookup (getPlan/getPlanSchedule) in sync with
|
||||
// this user's custom plans before any consumer renders this commit.
|
||||
registerCustomPlans(state.customPlans);
|
||||
// What the rest of the app sees: plans with more days than chapters are clamped.
|
||||
const customPlans = useMemo(() => state.customPlans.map(normalizeCustomPlan), [state.customPlans]);
|
||||
|
||||
// Persist locally + sync to the server whenever state changes. Runs as a
|
||||
// real effect (not inside the setState updater) so the network call has a
|
||||
@@ -198,7 +200,7 @@ export function useReadingPlans() {
|
||||
category: 'My Plans',
|
||||
icon: '✨',
|
||||
image: image || undefined,
|
||||
days: Math.max(1, Math.round(days)),
|
||||
days: clampPlanDays(books, chapterRanges, days),
|
||||
books,
|
||||
chapterRanges: chapterRanges && Object.keys(chapterRanges).length > 0 ? chapterRanges : undefined,
|
||||
custom: true,
|
||||
@@ -279,7 +281,7 @@ export function useReadingPlans() {
|
||||
|
||||
return {
|
||||
planProgress: state.progress,
|
||||
customPlans: state.customPlans,
|
||||
customPlans,
|
||||
readingActivity: state.activity,
|
||||
readingPlanSyncStatus: syncStatus,
|
||||
readingPlanSyncError: syncError,
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import { useState, useEffect, useRef, useMemo, useDeferredValue } from 'react';
|
||||
import { useApp } from '../context/AppContext.js';
|
||||
import { bookOptions } from '../utils/bibleUtils.jsx';
|
||||
import { getPlan, getPlanSchedule, findDayIndexForRef, formatDayReadings, nextUnreadDayIndex, computeStreak } from '../utils/readingPlans.js';
|
||||
import { getPlan, getPlanSchedule, findDayIndexForRef, formatDayReadings, nextUnreadDayIndex, computeStreak, nearestReadingDay } from '../utils/readingPlans.js';
|
||||
import DrawCanvas from './DrawCanvas.jsx';
|
||||
import { READER_TRANSLATIONS } from '../hooks/useBibleReader.js';
|
||||
import { useBibleSearch } from '../hooks/useBibleSearch.js';
|
||||
@@ -162,11 +162,15 @@ export default function ReaderPage() {
|
||||
};
|
||||
const goToRef = (r) => goTo(r.book, r.chapter);
|
||||
|
||||
// Opens a day of the context plan on its first unread chapter.
|
||||
// Opens a day of the context plan on its first unread chapter (or the nearest day that has one).
|
||||
const goDay = (progressId, dayIndex) => {
|
||||
const info = planInfo(planProgress.find((p) => p.id === progressId), dayIndex);
|
||||
const progress = planProgress.find((p) => p.id === progressId);
|
||||
if (!progress) return;
|
||||
const schedule = getPlanSchedule(progress.planId);
|
||||
const day = schedule[dayIndex]?.length ? dayIndex : nearestReadingDay(schedule, dayIndex);
|
||||
const info = day >= 0 ? planInfo(progress, day) : null;
|
||||
if (!info) return;
|
||||
setReaderPlan({ progressId, dayIndex });
|
||||
setReaderPlan({ progressId, dayIndex: day });
|
||||
const target = info.refs.find((r) => !info.readSet.has(`${r.book}.${r.chapter}`)) ?? info.refs[0];
|
||||
if (target) goToRef(target);
|
||||
};
|
||||
@@ -232,7 +236,7 @@ export default function ReaderPage() {
|
||||
if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA' || e.target.isContentEditable) return;
|
||||
if (e.key === 'ArrowLeft') readerGoToPreviousChapter();
|
||||
else if (e.key === 'ArrowRight') readerGoToNextChapter();
|
||||
else if (e.key === 'Escape') { if (sheet) closeSheet(); else setReaderSelectedVerse(null); }
|
||||
else if (e.key === 'Escape') { if (sheet) closeSheet(); else if (readerDrawMode) setReaderDrawMode(false); else setReaderSelectedVerse(null); }
|
||||
};
|
||||
window.addEventListener('keydown', handleKey);
|
||||
return () => window.removeEventListener('keydown', handleKey);
|
||||
@@ -336,6 +340,7 @@ export default function ReaderPage() {
|
||||
onBookmarks={() => openSheet('bookmarks')}
|
||||
bookmarkCount={bookmarkEntries.length}
|
||||
onSettings={() => openSheet('settings')}
|
||||
onDoneDrawing={readerDrawMode ? () => setReaderDrawMode(false) : undefined}
|
||||
/>
|
||||
)}
|
||||
{!chromeVisible && (
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
changePassword,
|
||||
updateProfile,
|
||||
updateReminderSettings,
|
||||
sendReminderTest,
|
||||
} from '../syncService.js';
|
||||
import { exportJournalDocx, printJournalPdf } from '../utils/journalExport.js';
|
||||
|
||||
@@ -120,11 +121,20 @@ export default function SettingsPage() {
|
||||
window.setTimeout(() => setPodcastNameSaved(false), 2500);
|
||||
};
|
||||
|
||||
const [reminderTest, setReminderTest] = useState({ busy: false, ok: false, message: '' });
|
||||
const testReminder = async () => {
|
||||
setReminderTest({ busy: true, ok: false, message: '' });
|
||||
const result = await sendReminderTest({ ntfyTopic: reminderForm.ntfyTopic, ntfyUrl: reminderForm.ntfyUrl });
|
||||
setReminderTest(result.ok
|
||||
? { busy: false, ok: true, message: 'Sent. Check your ntfy app or the topic page in a browser.' }
|
||||
: { busy: false, ok: false, message: result.error ?? 'Could not send the test.' });
|
||||
};
|
||||
|
||||
const submitReminderSettings = async (e) => {
|
||||
e.preventDefault();
|
||||
setReminderError('');
|
||||
setReminderBusy(true);
|
||||
const result = await updateReminderSettings(reminderForm);
|
||||
const result = await updateReminderSettings({ ...reminderForm, reminderTz: Intl.DateTimeFormat().resolvedOptions().timeZone });
|
||||
setReminderBusy(false);
|
||||
if (!result.ok) {
|
||||
setReminderError(result.error ?? 'Failed to save reminder settings.');
|
||||
@@ -334,13 +344,26 @@ export default function SettingsPage() {
|
||||
</label>
|
||||
{reminderError && <p className="text-sm text-[var(--r-danger,#b42318)]">{reminderError}</p>}
|
||||
{reminderSaved && <p className="text-sm text-[var(--r-accent)]">Saved.</p>}
|
||||
<button
|
||||
type="submit"
|
||||
disabled={reminderBusy}
|
||||
className="rounded-full bg-[var(--r-accent)] px-5 py-2 text-sm font-medium text-[var(--r-on)] transition hover:opacity-90 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
{reminderBusy ? 'Saving…' : 'Save'}
|
||||
</button>
|
||||
{reminderTest.message && (
|
||||
<p role="status" className={`text-sm ${reminderTest.ok ? 'text-[var(--r-accent)]' : 'text-[var(--r-danger,#b42318)]'}`}>{reminderTest.message}</p>
|
||||
)}
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<button
|
||||
type="submit"
|
||||
disabled={reminderBusy}
|
||||
className="rounded-full bg-[var(--r-accent)] px-5 py-2 text-sm font-medium text-[var(--r-on)] transition hover:opacity-90 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
{reminderBusy ? 'Saving…' : 'Save'}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={testReminder}
|
||||
disabled={reminderTest.busy || !reminderForm.ntfyTopic.trim()}
|
||||
className="rounded-full border border-[var(--r-line)] px-5 py-2 text-sm font-medium transition hover:bg-black/5 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
{reminderTest.busy ? 'Sending…' : 'Send test notification'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
|
||||
@@ -159,6 +159,11 @@ export async function changePassword(currentPassword, newPassword) {
|
||||
return request('POST', '/auth/change-password', { currentPassword, newPassword });
|
||||
}
|
||||
|
||||
/** Sends a test notification to the given ntfy topic/server (or the saved ones). */
|
||||
export async function sendReminderTest(settings) {
|
||||
return request('POST', '/auth/reminder-test', settings);
|
||||
}
|
||||
|
||||
/** Sets the daily reading reminder sent via ntfy.sh ({ reminderEnabled, ntfyUrl, ntfyTopic, reminderTime }). */
|
||||
export async function updateReminderSettings(settings) {
|
||||
return request('PATCH', '/auth/reminder-settings', settings);
|
||||
|
||||
@@ -180,6 +180,26 @@ export function getPlan(planId) {
|
||||
return PLAN_BY_ID[planId] ?? null;
|
||||
}
|
||||
|
||||
// A plan can't have more days than it has chapters (the extra days would be empty, with
|
||||
// nothing to read). Existing custom plans with too many days heal to one chapter a day.
|
||||
export function clampPlanDays(books, chapterRanges, days) {
|
||||
const total = (books ?? []).reduce((sum, b) => sum + bookChapterCount(b, chapterRanges), 0);
|
||||
const wanted = Math.max(1, Math.round(Number(days) || 1));
|
||||
return total > 0 ? Math.min(wanted, total) : wanted;
|
||||
}
|
||||
|
||||
export function normalizeCustomPlan(plan) {
|
||||
const days = clampPlanDays(plan.books, plan.chapterRanges, plan.days);
|
||||
return days === plan.days ? plan : { ...plan, days };
|
||||
}
|
||||
|
||||
// Nearest day at or after `from` that has something to read, else the nearest one before it.
|
||||
export function nearestReadingDay(schedule, from) {
|
||||
for (let i = Math.max(0, from); i < schedule.length; i += 1) if (schedule[i]?.length) return i;
|
||||
for (let i = Math.min(from, schedule.length) - 1; i >= 0; i -= 1) if (schedule[i]?.length) return i;
|
||||
return -1;
|
||||
}
|
||||
|
||||
// Registers/updates/removes user-created plans in the lookup used by getPlan/getPlanSchedule.
|
||||
// Called by useReadingPlans() whenever the custom-plans list changes.
|
||||
export function registerCustomPlans(customPlans) {
|
||||
@@ -190,7 +210,7 @@ export function registerCustomPlans(customPlans) {
|
||||
}
|
||||
}
|
||||
for (const cp of customPlans) {
|
||||
PLAN_BY_ID[cp.id] = { category: 'My Plans', icon: '✨', ...cp, custom: true };
|
||||
PLAN_BY_ID[cp.id] = { category: 'My Plans', icon: '✨', ...normalizeCustomPlan(cp), custom: true };
|
||||
scheduleCache.delete(cp.id);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
import { test, expect } from 'vitest';
|
||||
import {
|
||||
clampPlanDays, getPlan, getPlanSchedule, nearestReadingDay, normalizeCustomPlan, registerCustomPlans,
|
||||
} from './readingPlans.js';
|
||||
|
||||
const luke = (days) => ({ id: `custom-luke-${days}`, title: 'Luke', days, books: ['LUK'], custom: true });
|
||||
|
||||
test('a plan can never have more days than chapters', () => {
|
||||
expect(clampPlanDays(['LUK'], undefined, 90)).toBe(24);
|
||||
expect(clampPlanDays(['LUK'], undefined, 24)).toBe(24);
|
||||
expect(clampPlanDays(['LUK'], undefined, 10)).toBe(10);
|
||||
expect(clampPlanDays(['LUK'], { LUK: { start: 1, end: 5 } }, 30)).toBe(5); // honours chapter ranges
|
||||
expect(clampPlanDays(['LUK'], undefined, 0)).toBe(1);
|
||||
});
|
||||
|
||||
test('normalizeCustomPlan leaves a fine plan alone and heals one with too many days', () => {
|
||||
const fine = luke(12);
|
||||
expect(normalizeCustomPlan(fine)).toBe(fine);
|
||||
expect(normalizeCustomPlan(luke(90)).days).toBe(24);
|
||||
});
|
||||
|
||||
test('a 90-day Luke plan registers as 24 days with a chapter on every day (no empty days)', () => {
|
||||
registerCustomPlans([luke(90)]);
|
||||
const plan = getPlan('custom-luke-90');
|
||||
expect(plan.days).toBe(24);
|
||||
const schedule = getPlanSchedule('custom-luke-90');
|
||||
expect(schedule).toHaveLength(24);
|
||||
expect(schedule.every((day) => day.length === 1)).toBe(true);
|
||||
expect(schedule[2]).toEqual([{ book: 'LUK', chapter: 3 }]); // "Day 3" opens Luke 3
|
||||
});
|
||||
|
||||
test('nearestReadingDay skips empty days, looking forward first and then back', () => {
|
||||
const schedule = [[{ book: 'LUK', chapter: 1 }], [], [], [{ book: 'LUK', chapter: 2 }], []];
|
||||
expect(nearestReadingDay(schedule, 1)).toBe(3);
|
||||
expect(nearestReadingDay(schedule, 3)).toBe(3);
|
||||
expect(nearestReadingDay(schedule, 4)).toBe(3);
|
||||
expect(nearestReadingDay([[], []], 0)).toBe(-1);
|
||||
});
|
||||
Reference in new issue
Block a user