Add Insights, Observe-first, compare, handout layouts, richer search, backup nudge

- Commentary and study aids wait for 15 words of your own Observe notes.
- Insights page: this week's own work, themes across studies, words you keep
  studying, what slipped in recalls, open follow-ups.
- Recall can record what slipped; session notes (teach it back, who came,
  group notes, follow-ups); "Pray about this" from Apply.
- Compare any two passages side by side.
- Handout layouts: standard, questions only, large print, note-taking.
- Search understands "in <book>", "last week/month/year" and a year.
- Home shows the last backup age and nudges after two weeks.
- Phone: the stage bar scrolls instead of clipping Script and Export.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
nmemmertandClaude Sonnet 5.5 committed 2026-10-14 12:42:11 -04:00
1 parent 2ddc4e1ff6
commit d8e522dd06
24 files changed
+540 -28

No files matched your search

+41 -4
View File
@@ -13,18 +13,55 @@ const snippetAround = (raw, at, len) => {
return (start > 0 ? '…' : '') + raw.slice(start, end).replace(/\s+/g, ' ') + (end < raw.length ? '…' : '');
};
const DAY = 86400000;
// "grace in Romans last year" -> { text: 'grace', book: 'Romans', since, until }. Words it understands:
// "in <book>", "last|this week|month|year" (last = the past 7 / 30 / 365 days; this year = since 1 January) and a
// four-digit year. Whatever is left is the text to look for.
export function parseSearchQuery(query, now = Date.now()) {
let text = ` ${query} `;
let book = null;
let since = null;
let until = null;
for (const b of [...bookOptions].sort((x, y) => y.name.length - x.name.length)) {
const re = new RegExp(`\\s(?:in|from)\\s+${b.name.toLowerCase().replace(/\s+/g, '\\s+')}(?=\\s)`, 'i');
if (re.test(text)) { book = b.name; text = text.replace(re, ' '); break; }
}
const rel = text.match(/\s(last|this)\s+(week|month|year)(?=\s)/i);
if (rel) {
const [, which, unit] = rel;
const span = { week: 7, month: 30, year: 365 }[unit.toLowerCase()];
since = which.toLowerCase() === 'last' ? now - span * DAY : (unit.toLowerCase() === 'year' ? new Date(new Date(now).getFullYear(), 0, 1).getTime() : now - span * DAY);
text = text.replace(rel[0], ' ');
}
const year = text.match(/\s(20\d\d)(?=\s)/);
if (year) {
since = new Date(Number(year[1]), 0, 1).getTime();
until = new Date(Number(year[1]) + 1, 0, 1).getTime();
text = text.replace(year[0], ' ');
}
return { text: text.replace(/\s+/g, ' ').trim(), book, since, until };
}
// projects: full project objects. -> [{ projectId, projectTitle, chunkId, ref, field, snippet }], at most
// `limit`, one hit per chunk (the first field that matches).
export function searchNotes(projects, query, { limit = 20 } = {}) {
const q = query.trim().toLowerCase();
if (q.length < 3) return [];
export function searchNotes(projects, query, { limit = 20, now = Date.now() } = {}) {
const parsed = parseSearchQuery(query, now);
const q = parsed.text.toLowerCase();
const filtered = !!(parsed.book || parsed.since);
if (q.length < 3 && !(filtered && q.length === 0)) return [];
const hits = [];
for (const project of projects) {
for (const [chapterIndex, chapter] of (project.chapters ?? []).entries()) {
if (parsed.book && chapter.book !== parsed.book) continue;
for (const chunk of chapter.chunks ?? []) {
const when = chunk.editedAt ?? project.lastEdited ?? 0;
if (parsed.since && when < parsed.since) continue;
if (parsed.until && when >= parsed.until) continue;
for (const [field, label] of NOTE_FIELDS) {
const raw = chunk[field] ?? '';
const at = raw.toLowerCase().indexOf(q);
if (!q && !raw.trim()) continue; // a passage/date search lists chunks that have something written
const at = q ? raw.toLowerCase().indexOf(q) : 0;
if (at === -1) continue;
hits.push({
projectId: project.id, projectTitle: project.title, chunkId: chunk.id,