Two concurrent series lanes, Complete studies, self-paced finished books; v2.9.0
Publish Container / build (push) Successful in 17m50s

- Study status 'complete' (manual or auto when all lessons released); stays enrollable and self-paced (no sequential lock, Resume button, Finished Studies hub section)
- Studies get lane (A/B) and expected start date; planned studies show "Starts <date>"; hub shows next lesson date instead of hardcoded pace
- Second concurrent series (homepage card, admin panel) with its own scheduled launch; transitions are per lane and flip the ending study to Complete and the incoming study to Active
- Finished books link to their study: "Study This Book" on playlist page, audio-only link from hub (playlist stays public, no account)
- Admin: bulk release scheduler (every N days), same-day collision warning, linked-study selects
- Weekly digest no longer dumps a finished study's backlog on late enrollees
- CI: build and tag the container as :beta on pushes to the beta branch

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
nmemmertandClaude Sonnet 5.5 committed 2026-10-08 10:14:02 -04:00
1 parent 2d8e4be5ea
commit ddcaec0ddf
12 files changed
+628 -105

No files matched your search

+275 -3
View File
@@ -3,7 +3,7 @@ import { DndContext, closestCenter, PointerSensor, useSensor, useSensors } from
import { arrayMove, SortableContext, verticalListSortingStrategy, useSortable } from '@dnd-kit/sortable'
import { CSS } from '@dnd-kit/utilities'
import { Link } from 'react-router-dom'
import type { SiteContent, CustomLink, CustomBlock, ColossiansStudySection, StudyProgram, RedirectRule, PodcastFeaturedLink, SeoSettings, LegalSettings, PodcastHost } from './content'
import type { SiteContent, CustomLink, CustomBlock, ColossiansStudySection, StudyProgram, RedirectRule, PodcastFeaturedLink, SeoSettings, LegalSettings, PodcastHost, SecondSeries } from './content'
import { DEFAULTS } from './content'
import { AnalyticsPanel } from './components/AnalyticsPanel'
import { AdminCollapsibleCard } from './components/AdminCollapsibleCard'
@@ -1613,6 +1613,9 @@ export default function AdminPage({ content, onSave, onLogout }: Props) {
const [endSeriesNewTitle, setEndSeriesNewTitle] = useState('')
const [endSeriesNewImage, setEndSeriesNewImage] = useState('')
const [endSeriesNewSeason, setEndSeriesNewSeason] = useState('')
const [bulkStart, setBulkStart] = useState('')
const [bulkInterval, setBulkInterval] = useState('14')
const [bulkOnlyEmpty, setBulkOnlyEmpty] = useState(false)
const [questions, setQuestions] = useState<Question[]>([])
const [contactSubmissions, setContactSubmissions] = useState<ContactSubmission[]>([])
@@ -2784,6 +2787,50 @@ const [dashboardNow, setDashboardNow] = useState(() => new Date())
}))
}
// Stamps release dates on a study's lessons in order: lesson N = start + N × interval days.
// Uses local-time date arithmetic so the time of day survives daylight-saving changes.
function applyBulkSchedule(studyId: string) {
if (!bulkStart) return
const interval = Math.max(1, parseInt(bulkInterval) || 14)
const base = new Date(bulkStart)
if (isNaN(base.getTime())) return
setForm(f => ({
...f,
studies: (f.studies ?? []).map(study => study.id !== studyId ? study : {
...study,
sections: (study.sections ?? []).map((section, index) => {
if (bulkOnlyEmpty && section.releasedAt) return section
const date = new Date(base)
date.setDate(date.getDate() + index * interval)
return { ...section, releasedAt: date.toISOString() }
}),
}),
}))
}
// Lessons from this study that land on the same calendar day as a lesson in another study.
function findReleaseCollisions(study: StudyProgram) {
const dayKey = (iso?: string) => {
if (!iso) return ''
const d = new Date(iso)
return isNaN(d.getTime()) ? '' : d.toLocaleDateString('en-CA')
}
const otherDays = new Map<string, string>()
for (const other of form.studies ?? []) {
if (other.id === study.id || other.status === 'planned') continue
for (const section of other.sections ?? []) {
const key = dayKey(section.releasedAt)
if (key) otherDays.set(key, other.title)
}
}
const hits: { date: string; title: string; other: string }[] = []
for (const section of study.sections ?? []) {
const key = dayKey(section.releasedAt)
if (key && otherDays.has(key)) hits.push({ date: key, title: section.title || section.reference, other: otherDays.get(key) ?? '' })
}
return hits
}
function updateStudySection(studyId: string, sectionId: string, field: keyof ColossiansStudySection, value: string | number | string[]) {
setForm(f => ({
...f,
@@ -4040,6 +4087,94 @@ const [dashboardNow, setDashboardNow] = useState(() => new Date())
)}
</div>
))}
<div className="admin-field">
<label htmlFor="series-study-slug">Linked Study (Lane A)</label>
<select id="series-study-slug" value={form.seriesStudySlug ?? ''} onChange={e => setForm(f => ({ ...f, seriesStudySlug: e.target.value }))}>
<option value="">None</option>
{(form.studies ?? []).map(st => <option key={st.id} value={st.slug}>{st.title}</option>)}
</select>
<p className="admin-field-help">Adds a &ldquo;Study Along&rdquo; button to the series card. When this series ends, its study is marked Complete.</p>
</div>
<div className="admin-field">
<label htmlFor="series-season">Podcast Season Number (Lane A)</label>
<input id="series-season" type="number" min="1" value={form.seriesSeason ?? ''} onChange={e => setForm(f => ({ ...f, seriesSeason: parseInt(e.target.value) || undefined }))} />
<p className="admin-field-help">Must match the season number you set on these episodes in Spotify for Creators. Used for the Finished Books playlist.</p>
</div>
{(() => {
const second = form.secondSeries
const patchSecond = (p: Partial<SecondSeries>) =>
setForm(f => ({ ...f, secondSeries: { label: 'Now Playing', title: '', description: '', imageUrl: '', listenUrl: '', listenBtnLabel: '', ...(f.secondSeries ?? {}), ...p } }))
return (
<div style={{ marginTop: '2rem' }}>
<hr style={{ border: 'none', borderTop: '1px solid #2a2518', marginBottom: '1.5rem' }} />
<h3 style={{ marginBottom: '0.35rem', fontSize: '1rem' }}>Second Series (Lane B)</h3>
<p className="admin-stats-note" style={{ marginBottom: '1rem' }}>
Running two books at once? Fill this in and the homepage shows a second Now Playing card. Leave the title blank to show only one series.
</p>
<div className="admin-field">
<label htmlFor="second-label">Label</label>
<input id="second-label" type="text" value={second?.label ?? 'Now Playing'} onChange={e => patchSecond({ label: e.target.value })} />
</div>
<div className="admin-field">
<label htmlFor="second-title">Series Title</label>
<input id="second-title" type="text" value={second?.title ?? ''} onChange={e => patchSecond({ title: e.target.value })} />
</div>
<div className="admin-field">
<label htmlFor="second-desc">Series Description</label>
<textarea id="second-desc" rows={3} value={second?.description ?? ''} onChange={e => patchSecond({ description: e.target.value })} />
</div>
<div className="admin-field">
<label htmlFor="second-image">Cover Image URL</label>
<input id="second-image" type="text" value={second?.imageUrl ?? ''} onChange={e => patchSecond({ imageUrl: e.target.value })} />
{renderImageAssetSelector(second?.imageUrl ?? '', value => patchSecond({ imageUrl: value }), 'second-image-asset')}
{renderImagePreview(second?.imageUrl ?? '', 'Second series preview')}
</div>
<div className="admin-field">
<label htmlFor="second-listen">Listen URL</label>
<input id="second-listen" type="text" placeholder="Leave blank to use the main Spotify link" value={second?.listenUrl ?? ''} onChange={e => patchSecond({ listenUrl: e.target.value })} />
</div>
<div className="admin-field">
<label htmlFor="second-study">Linked Study (Lane B)</label>
<select id="second-study" value={second?.studySlug ?? ''} onChange={e => patchSecond({ studySlug: e.target.value })}>
<option value="">None</option>
{(form.studies ?? []).map(st => <option key={st.id} value={st.slug}>{st.title}</option>)}
</select>
</div>
<div className="admin-field">
<label htmlFor="second-season">Podcast Season Number</label>
<input id="second-season" type="number" min="1" value={second?.season ?? ''} onChange={e => patchSecond({ season: parseInt(e.target.value) || undefined })} />
</div>
{second?.title?.trim() && (
<button
type="button"
className="btn-danger-outline"
onClick={() => {
if (!window.confirm(`Archive "${second.title}" to Finished Books and remove the second series card?`)) return
setForm(f => {
const cur = f.secondSeries
if (!cur?.title?.trim()) return f
const archived = {
id: Date.now().toString(36),
title: cur.title,
description: cur.description,
imageUrl: cur.imageUrl,
season: cur.season ?? 1,
...(cur.studySlug ? { studySlug: cur.studySlug } : {}),
}
return {
...f,
finishedBooks: [...(f.finishedBooks ?? []), archived],
studies: (f.studies ?? []).map(st => st.slug === cur.studySlug && st.status !== 'planned' ? { ...st, status: 'complete' as const } : st),
secondSeries: undefined,
}
})
}}
>End Second Series &amp; Archive</button>
)}
</div>
)
})()}
<p className="admin-stats-note">Companion study guide title, description, and Amazon link are now managed in Resources.</p>
{renderSaveStatus()}
<div className="admin-end-series-wrap">
@@ -4077,12 +4212,16 @@ const [dashboardNow, setDashboardNow] = useState(() => new Date())
title: form.seriesTitle,
description: form.seriesDescription,
imageUrl: form.seriesImageUrl,
season: parseInt(endSeriesNewSeason) - 1 || 1,
season: form.seriesSeason ?? (parseInt(endSeriesNewSeason) - 1 || 1),
...(form.seriesStudySlug ? { studySlug: form.seriesStudySlug } : {}),
}
setForm(f => ({
...f,
studies: (f.studies ?? []).map(st => st.slug === f.seriesStudySlug && st.status !== 'planned' ? { ...st, status: 'complete' as const } : st),
seriesStudySlug: '',
seriesSeason: parseInt(endSeriesNewSeason) || undefined,
finishedBooks: [...(f.finishedBooks ?? []), archivedBook],
podcastFeaturedLinks: [],
podcastFeaturedLinks: f.secondSeries?.title?.trim() ? f.podcastFeaturedLinks : [],
seriesTitle: endSeriesNewTitle.trim(),
seriesImageUrl: endSeriesNewImage.trim() || f.seriesImageUrl,
seriesLabel: 'Now Playing',
@@ -4149,6 +4288,14 @@ const [dashboardNow, setDashboardNow] = useState(() => new Date())
<label htmlFor="sched-new-season">New Season Number</label>
<input id="sched-new-season" type="number" min="1" placeholder="2" value={sched?.newSeason ?? ''} onChange={e => patch({ newSeason: parseInt(e.target.value) || 2 })} />
</div>
<div className="admin-field">
<label htmlFor="sched-new-study">Study to Open</label>
<select id="sched-new-study" value={sched?.newStudySlug ?? ''} onChange={e => patch({ newStudySlug: e.target.value })}>
<option value="">None</option>
{(form.studies ?? []).map(st => <option key={st.id} value={st.slug}>{st.title}</option>)}
</select>
<p className="admin-field-help">At launch this study switches from Planned to Active, and the ending series&apos; study is marked Complete.</p>
</div>
<p style={{ fontSize: '0.78rem', fontWeight: 600, letterSpacing: '0.08em', textTransform: 'uppercase', color: '#7a7060', margin: '1.25rem 0 0.6rem' }}>Start Here Section <span style={{ fontWeight: 400, textTransform: 'none' }}>(optional)</span></p>
<div className="admin-field">
@@ -4244,6 +4391,63 @@ const [dashboardNow, setDashboardNow] = useState(() => new Date())
</div>
)
})()}
{/* Schedule Auto-Transition — second series (Lane B) */}
{(() => {
const sched = form.scheduledSecondSeriesTransition
const patch = (p: Partial<NonNullable<typeof sched>>) =>
setForm(f => ({ ...f, scheduledSecondSeriesTransition: { ...(f.scheduledSecondSeriesTransition ?? { triggersAt: '', newTitle: '', newSeason: 2 }), ...p } }))
return (
<div style={{ marginTop: '2rem' }}>
<hr style={{ border: 'none', borderTop: '1px solid #2a2518', marginBottom: '1.5rem' }} />
<h3 style={{ marginBottom: '0.35rem', fontSize: '1rem' }}>Schedule Second Series (Lane B) Launch</h3>
<p className="admin-stats-note" style={{ marginBottom: '1rem' }}>
Same as above, but only the second series changes: its current book is archived to Finished Books and marked Complete, and the main series keeps playing. If no second series is running yet, nothing is archived and the new book simply appears.
</p>
{sched?.triggersAt && (
<div style={{ background: 'rgba(201,168,76,0.08)', border: '1px solid rgba(201,168,76,0.25)', borderRadius: '10px', padding: '0.8rem 1rem', marginBottom: '1rem' }}>
<p style={{ margin: 0, fontSize: '0.88rem', color: '#c9a84c' }}>
<strong>Scheduled:</strong> Launch &ldquo;{sched.newTitle}&rdquo; on{' '}
{new Date(sched.triggersAt).toLocaleString(undefined, { dateStyle: 'medium', timeStyle: 'short' })}
</p>
</div>
)}
<div className="admin-field">
<label htmlFor="sched2-triggers-at">Launch Date &amp; Time</label>
<input id="sched2-triggers-at" type="datetime-local" value={sched?.triggersAt ? toLocalDatetimeInput(sched.triggersAt) : ''} onChange={e => patch({ triggersAt: e.target.value ? new Date(e.target.value).toISOString() : '' })} />
</div>
<div className="admin-field">
<label htmlFor="sched2-new-title">Series Title</label>
<input id="sched2-new-title" type="text" value={sched?.newTitle ?? ''} onChange={e => patch({ newTitle: e.target.value })} />
</div>
<div className="admin-field">
<label htmlFor="sched2-new-desc">Series Description</label>
<textarea id="sched2-new-desc" rows={3} value={sched?.newDescription ?? ''} onChange={e => patch({ newDescription: e.target.value })} />
</div>
<div className="admin-field">
<label htmlFor="sched2-new-image">Series Image URL</label>
<input id="sched2-new-image" type="text" value={sched?.newImageUrl ?? ''} onChange={e => patch({ newImageUrl: e.target.value })} />
</div>
<div className="admin-field">
<label htmlFor="sched2-new-season">New Season Number</label>
<input id="sched2-new-season" type="number" min="1" value={sched?.newSeason ?? ''} onChange={e => patch({ newSeason: parseInt(e.target.value) || 2 })} />
</div>
<div className="admin-field">
<label htmlFor="sched2-new-study">Study to Open</label>
<select id="sched2-new-study" value={sched?.newStudySlug ?? ''} onChange={e => patch({ newStudySlug: e.target.value })}>
<option value="">None</option>
{(form.studies ?? []).map(st => <option key={st.id} value={st.slug}>{st.title}</option>)}
</select>
</div>
<div style={{ display: 'flex', gap: '0.75rem', marginTop: '1rem', flexWrap: 'wrap' }}>
<button type="button" className="btn-primary" disabled={!sched?.triggersAt || !sched?.newTitle?.trim()} onClick={() => handleSaveDraft()}>Save Schedule</button>
{sched?.triggersAt && (
<button type="button" className="btn-danger-outline" onClick={() => setForm(f => ({ ...f, scheduledSecondSeriesTransition: undefined }))}>Clear Schedule</button>
)}
</div>
</div>
)
})()}
</section>
)}
@@ -4420,6 +4624,14 @@ const [dashboardNow, setDashboardNow] = useState(() => new Date())
<label>Season Number</label>
<input type="number" value={book.season} onChange={e => setForm(f => ({ ...f, finishedBooks: (f.finishedBooks ?? []).map(b => b.id === book.id ? { ...b, season: parseInt(e.target.value) || 1 } : b) }))} />
</div>
<div className="admin-field">
<label>Linked Study</label>
<select value={book.studySlug ?? ''} onChange={e => setForm(f => ({ ...f, finishedBooks: (f.finishedBooks ?? []).map(b => b.id === book.id ? { ...b, studySlug: e.target.value } : b) }))}>
<option value="">None</option>
{(form.studies ?? []).map(st => <option key={st.id} value={st.slug}>{st.title}</option>)}
</select>
<p className="admin-field-help">Adds a &ldquo;Study This Book&rdquo; button to the playlist page and an audio-only link on the study hub.</p>
</div>
<div className="admin-field">
<label>Download URL <span style={{ fontWeight: 400, opacity: 0.7 }}>(ZIP — Google Drive, Dropbox, etc.)</span></label>
<input type="url" placeholder="https://drive.google.com/…" value={book.downloadUrl ?? ''} onChange={e => setForm(f => ({ ...f, finishedBooks: (f.finishedBooks ?? []).map(b => b.id === book.id ? { ...b, downloadUrl: e.target.value } : b) }))} />
@@ -5024,9 +5236,27 @@ const [dashboardNow, setDashboardNow] = useState(() => new Date())
<label htmlFor={`study-status-${activeStudy.id}`}>Status</label>
<select id={`study-status-${activeStudy.id}`} value={activeStudy.status} onChange={e => updateStudyProgram(activeStudy.id, 'status', e.target.value)}>
<option value="active">Active</option>
<option value="complete">Complete (finished — self-paced)</option>
<option value="planned">Planned</option>
</select>
<p className="admin-field-help">A study whose lessons have all been released is shown as Complete automatically. Complete studies stay open for enrollment.</p>
</div>
<div className="admin-field">
<label htmlFor={`study-lane-${activeStudy.id}`}>Release Lane</label>
<select id={`study-lane-${activeStudy.id}`} value={activeStudy.lane ?? ''} onChange={e => updateStudyProgram(activeStudy.id, 'lane', e.target.value)}>
<option value="">None</option>
<option value="A">Lane A (primary series)</option>
<option value="B">Lane B (second series)</option>
</select>
<p className="admin-field-help">Two series can air at once on opposite Mondays. Lane is a label for planning; dates come from each lesson.</p>
</div>
{activeStudy.status === 'planned' && (
<div className="admin-field">
<label htmlFor={`study-starts-at-${activeStudy.id}`}>Expected Start Date</label>
<input id={`study-starts-at-${activeStudy.id}`} type="datetime-local" value={activeStudy.startsAt ? toLocalInputValue(activeStudy.startsAt) : ''} onChange={e => updateStudyProgram(activeStudy.id, 'startsAt', fromLocalInputValue(e.target.value))} />
<p className="admin-field-help">Shown as &ldquo;Starts &lt;date&gt;&rdquo; on the study hub while the study is still Planned.</p>
</div>
)}
<div className="admin-field">
<label htmlFor={`study-description-${activeStudy.id}`}>Description</label>
<textarea id={`study-description-${activeStudy.id}`} rows={3} value={activeStudy.description} placeholder="Brief description for the study hub card" onChange={e => updateStudyProgram(activeStudy.id, 'description', e.target.value)} />
@@ -5060,6 +5290,48 @@ const [dashboardNow, setDashboardNow] = useState(() => new Date())
<h3>Lessons ({(activeStudy.sections ?? []).length})</h3>
</div>
{(activeStudy.sections ?? []).length > 0 && (
<details className="admin-collapsible-card" style={{ marginBottom: '1rem' }}>
<summary className="admin-collapsible-summary">
<div><strong>Bulk Schedule Releases</strong><p>Set every lesson&apos;s date at once (e.g. every other Monday)</p></div>
<span className="admin-collapsible-hint">Expand to edit</span>
</summary>
<div className="admin-collapsible-body">
<div className="admin-array-fields">
<div className="admin-field">
<label htmlFor="bulk-start">First lesson releases</label>
<input id="bulk-start" type="datetime-local" value={bulkStart} onChange={e => setBulkStart(e.target.value)} />
<p className="admin-field-help">Pick the Monday and time for lesson 1. Lane B should start the Monday after Lane A.</p>
</div>
<div className="admin-field">
<label htmlFor="bulk-interval">Days between lessons</label>
<input id="bulk-interval" type="number" min="1" value={bulkInterval} onChange={e => setBulkInterval(e.target.value)} />
<p className="admin-field-help">14 = every other week.</p>
</div>
<div className="admin-field" style={{ display: 'flex', alignItems: 'center', gap: '0.6rem' }}>
<input id="bulk-only-empty" type="checkbox" checked={bulkOnlyEmpty} onChange={e => setBulkOnlyEmpty(e.target.checked)} />
<label htmlFor="bulk-only-empty">Only fill lessons that have no date yet</label>
</div>
</div>
<button type="button" className="btn-primary" disabled={!bulkStart} onClick={() => applyBulkSchedule(activeStudy.id)}>Apply to {(activeStudy.sections ?? []).length} lessons</button>
<p className="admin-field-help" style={{ marginTop: '0.5rem' }}>Remember to Save Draft and Publish afterwards.</p>
</div>
</details>
)}
{(() => {
const hits = findReleaseCollisions(activeStudy)
if (hits.length === 0) return null
return (
<div style={{ background: 'rgba(211,47,47,0.1)', border: '1px solid rgba(211,47,47,0.4)', borderRadius: '10px', padding: '0.8rem 1rem', marginBottom: '1rem' }}>
<p style={{ margin: 0, fontSize: '0.88rem', color: '#ef9a9a' }}>
<strong>{hits.length} lesson{hits.length === 1 ? '' : 's'} release on the same day as another study:</strong>{' '}
{hits.slice(0, 4).map(h => `${h.date} (${h.title} · ${h.other})`).join('; ')}{hits.length > 4 ? '…' : ''}
</p>
</div>
)
})()}
{(activeStudy.sections ?? []).length === 0 && <p className="admin-stats-note">No lessons yet. Add one below.</p>}
<div className="study-lesson-list-header">