import { useEffect, useRef, useState } from 'react';
import { useApp } from '../context/AppContext.js';
import { filterStudies, groupBySeries, isTagQuery, seriesList, tagCounts } from '../utils/studyFilter.js';
import BookCover from '../components/BookCover.jsx';
import WeeklyRecall from '../components/WeeklyRecall.jsx';
import RecallHistory from '../components/RecallHistory.jsx';
import IqDailyCard from '../components/iq/IqDailyCard.jsx';
import IqNarratorOptions, { useLazyNarrators } from '../components/iq/IqNarratorOptions.jsx';
import PageShell from '../components/study/PageShell.jsx';
import AudioPlayer from '../components/AudioPlayer.jsx';
import {
bookOptions,
formatChunkReference,
formatRelativeDate,
loadProjectIndex,
} from '../utils/bibleUtils.jsx';
import { backupAge, lastBackupAt } from '../utils/backup.js';
import { loadProjectById } from '../utils/projectStorage.js';
import { compactPassages } from '../utils/passageSummary.js';
import { getPlan, getPlanSchedule, formatDayReadings, nextUnreadDayIndex, computeStreak } from '../utils/readingPlans.js';
export default function HomePage() {
const {
autoRestoredCount,
staleLocalProjects,
sharedStudies,
projectIndex,
planProgress,
readingActivity,
openReadingPlans,
openPlanReading,
studyReadingPlanDay,
studyBuildBusy,
readingPlanSyncStatus,
readingPlanSyncError,
retryReadingPlanSync,
audioBook, setAudioBook,
audioNarrator, setAudioNarrator,
audioState,
audioProgress,
openImportProject,
setCurrentPage,
openBackup,
openNewProject,
pullLatestFromServer,
resumeProject,
renameProjectInStorage,
setProjectSeries,
deleteProject,
handlePlayBookAudio,
handleStopBookAudio,
handleToggleBookAudioPause,
handleSkipBookChapter,
handleSeekAudio,
handleSeekAudioTo,
} = useApp();
const narrators = useLazyNarrators(audioNarrator);
const [homeSearch, setHomeSearch] = useState('');
const [homeSort, setHomeSort] = useState(() => {
try { return localStorage.getItem('homeSort') || 'recent'; } catch { return 'recent'; }
}); // 'recent' | 'title' | 'passage'
const [homeTags, setHomeTags] = useState([]); // tag chips switched on: a study must have all of them
const [showAllTags, setShowAllTags] = useState(false);
const [seriesFilter, setSeriesFilter] = useState(''); // '' = all, '__none' = not in a series, or a series name
const [seriesEditingId, setSeriesEditingId] = useState(null);
const [seriesValue, setSeriesValue] = useState('');
const toggleTag = (tag) => setHomeTags((cur) => (cur.includes(tag) ? cur.filter((t) => t !== tag) : [...cur, tag]));
const [homeView, setHomeView] = useState(() => {
try { return localStorage.getItem('homeView') === 'list' ? 'list' : 'grid'; } catch { return 'grid'; }
}); // 'grid' | 'list'
useEffect(() => {
try { localStorage.setItem('homeView', homeView); } catch {}
}, [homeView]);
const [homeFullTextResults, setHomeFullTextResults] = useState(null); // null=idle, []=no match, [...]= results
const fullTextTimerRef = useRef(null);
const [renamingId, setRenamingId] = useState(null);
const [renameValue, setRenameValue] = useState('');
useEffect(() => {
try { localStorage.setItem('homeSort', homeSort); } catch {}
}, [homeSort]);
// Full-text search across all project notes (debounced, synchronous localStorage read)
useEffect(() => {
clearTimeout(fullTextTimerRef.current);
const q = homeSearch.trim().toLowerCase();
if (q.length < 3 || isTagQuery(q)) { setHomeFullTextResults(null); return; }
fullTextTimerRef.current = window.setTimeout(() => {
const index = loadProjectIndex();
const results = [];
for (const entry of index) {
const proj = loadProjectById(entry.id);
if (!proj) continue;
const matches = [];
for (const [chIdx, ch] of (proj.chapters ?? []).entries()) {
for (const chunk of (ch.chunks ?? [])) {
for (const field of ['observation', 'interpretation', 'application', 'generalNotes', 'finalScript']) {
const text = (chunk[field] ?? '').toLowerCase();
if (!text.includes(q)) continue;
const idx = text.indexOf(q);
const raw = chunk[field] ?? '';
const start = Math.max(0, idx - 40);
const snippet = (start > 0 ? '…' : '') + raw.slice(start, idx + q.length + 60) + (idx + q.length + 60 < raw.length ? '…' : '');
matches.push({ chunkId: chunk.id, ref: formatChunkReference(proj, chIdx, chunk, '–'), field, snippet });
break; // one match per chunk
}
}
}
if (matches.length > 0) results.push({ projectId: entry.id, projectTitle: entry.title, matches });
}
setHomeFullTextResults(results);
}, 300);
}, [homeSearch]);
const tagList = tagCounts(projectIndex);
const shownTags = showAllTags ? tagList : tagList.slice(0, 12);
const seriesNames = seriesList(projectIndex);
const visible = filterStudies(projectIndex, { query: homeSearch, tags: homeTags, series: seriesFilter })
.sort((a, b) => {
if (homeSort === 'title') return (a.title ?? '').localeCompare(b.title ?? '');
if (homeSort === 'passage') return (a.chapterSummary ?? '').localeCompare(b.chapterSummary ?? '');
return (b.lastEdited ?? 0) - (a.lastEdited ?? 0);
});
// With series in use (and no single series chosen) the studies are grouped under their series name
const sections = seriesFilter === '' && seriesNames.length > 0 ? groupBySeries(visible) : [{ name: '', items: visible, flat: true }];
const fieldCls = 'rounded-lg border border-[var(--r-line)] bg-[var(--r-bar)] px-2.5 py-1.5 text-sm text-[var(--r-text)] focus:outline-none focus:ring-2 focus:ring-[var(--r-line)]';
const linkBtn = 'rounded-lg px-3 py-1.5 text-sm text-[var(--r-sub)] hover:bg-black/5';
const notice = 'mb-4 rounded-xl border border-[var(--r-line)] bg-[var(--r-bar)] px-4 py-3 text-sm';
return (
>
)}
>
{projectIndex.length > 0 && (() => {
const age = backupAge(lastBackupAt());
return (
{age.text}.
);
})()}
{autoRestoredCount !== null && (
Synced {autoRestoredCount} project{autoRestoredCount > 1 ? 's' : ''} from another device.
)}
{staleLocalProjects.length > 0 && (
{staleLocalProjects.length} project{staleLocalProjects.length > 1 ? 's have' : ' has'} a newer version on the server:
{staleLocalProjects.map((entry) => (
))}
)}
{readingPlanSyncStatus === 'error' && (
Couldn’t sync your reading plans to the server. Changes are only saved on this device until this is fixed.
)}
{projectIndex.length === 0 ? (
No projects yet
Start a new Bible study to get going.
) : (
<>
setHomeSearch(e.target.value)} placeholder="Search projects by title, passage or #tag…"
className={`${fieldCls} w-full max-w-sm`} />
{seriesNames.length > 0 && (
)}
{[['grid', 'Grid'], ['list', 'List']].map(([id, label]) => (
))}
{tagList.length > 0 && (
Tags
{shownTags.map(({ tag, count }) => {
const on = homeTags.includes(tag);
return (
);
})}
{tagList.length > 12 && (
)}
{homeTags.length > 0 && }
)}
{visible.length === 0 && (
No studies match{homeTags.length ? ` ${homeTags.map((t) => `“${t}”`).join(' + ')}` : ''}{homeSearch.trim() ? ` “${homeSearch.trim()}”` : ''}.{' '}
)}
{/* Full-text note search results */}
{homeFullTextResults !== null && (
{homeFullTextResults.length === 0
? 'No notes match your search.'
: `Notes matching "${homeSearch.trim()}" — ${homeFullTextResults.reduce((n, r) => n + r.matches.length, 0)} result${homeFullTextResults.reduce((n, r) => n + r.matches.length, 0) !== 1 ? 's' : ''}`}
{homeFullTextResults.map(({ projectId, projectTitle, matches }) => (
{matches.map(({ chunkId, ref, field, snippet }) => (
{ref} · {field}
{snippet}
))}
))}
)}
{sections.map((section) => (
{!section.flat && (
{section.name || 'Not in a series'} {section.items.length}
)}
{section.items.map((entry) => (
{homeView === 'list'
?
:
}
{renamingId === entry.id ? (
setRenameValue(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') { e.preventDefault(); const t = renameValue.trim(); if (t) renameProjectInStorage(entry.id, t); setRenamingId(null); }
else if (e.key === 'Escape') setRenamingId(null);
}}
className={`${fieldCls} flex-1`} />
) : (
{entry.title}
{sharedStudies?.[entry.id] !== undefined && (
Shared{sharedStudies[entry.id] ? ` by ${sharedStudies[entry.id]}` : ''}
)}
)}
{seriesEditingId === entry.id && (
setSeriesValue(e.target.value)}
onKeyDown={(e) => { if (e.key === 'Enter') { e.preventDefault(); setProjectSeries(entry.id, seriesValue); setSeriesEditingId(null); } else if (e.key === 'Escape') setSeriesEditingId(null); }}
className={`${fieldCls} flex-1`} />
)}
{entry.chapterSummary &&
{compactPassages(entry.chapterSummary)}
}
{entry.tags?.length > 0 && (
// A few tags and a "+N" for the rest (hover for all; the Tag filter above finds any of them)
3 ? entry.tags.join(', ') : undefined}>
{entry.tags.slice(0, 3).map((tag) => (
))}
{entry.tags.length > 3 && +{entry.tags.length - 3} more}
)}
{formatRelativeDate(entry.lastEdited)}
))}
))}
>
)}
);
}
// Today's reading across your active plans, each opening straight into the Reader.
function TodayCard({ planProgress, readingActivity, openReadingPlans, openPlanReading, studyReadingPlanDay, studyBuildBusy }) {
const active = planProgress.filter((p) => !p.completedAt);
const streak = computeStreak(readingActivity);
const streakText = streak.current > 0 && {streak.current} day{streak.current !== 1 ? 's' : ''} in a row;
if (active.length === 0) {
return (
Reading Plans
{streakText}
Read through the Bible, the Gospels, Psalms, and more — one day at a time.
);
}
const rows = active.map((progress) => {
const plan = getPlan(progress.planId);
if (!plan) return null;
const dayIndex = nextUnreadDayIndex(plan, progress.completedDays);
const refs = dayIndex != null ? getPlanSchedule(progress.planId)[dayIndex] : null;
return { progress, plan, dayIndex, refs, pct: plan.days > 0 ? Math.round((progress.completedDays.length / plan.days) * 100) : 0 };
}).filter(Boolean);
return (
Today’s Reading
{streakText}
{rows.map(({ progress, plan, dayIndex, refs, pct }) => (
{plan.icon ? `${plan.icon} ` : ''}{plan.title}
{refs ? formatDayReadings(refs) : `${progress.completedDays.length} / ${plan.days} days`}
{refs && (
)}
))}
);
}