Compare commits
5
Commits
a1adfa0ef1
..
beta
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
03c3dd72fe | ||
|
|
30a7e8a6cb | ||
|
|
a11adf8ad2 | ||
|
|
e2cd66386b | ||
|
|
597f996474 |
No files matched your search
+1
-1
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "siteforge",
|
||||
"private": true,
|
||||
"version": "2.45.0",
|
||||
"version": "2.46.1",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
|
||||
@@ -44,6 +44,7 @@ import {
|
||||
scheduleReengagementEmails,
|
||||
scheduleWeeklyDigestEmails,
|
||||
backfillCompletedStudies,
|
||||
backfillReminderDefault,
|
||||
} from './server/study-helpers.js'
|
||||
import { recordVisitor } from './server/routes/analytics.js'
|
||||
|
||||
@@ -210,6 +211,11 @@ Promise.all([
|
||||
.catch(err => {
|
||||
console.error('[study] completed-study backfill failed:', err)
|
||||
})
|
||||
.then(() => backfillReminderDefault())
|
||||
.then(updated => { if (updated > 0) console.log(`[study] turned lesson reminders on for ${updated} existing accounts`) })
|
||||
.catch(err => {
|
||||
console.error('[study] reminder-default backfill failed:', err)
|
||||
})
|
||||
.finally(() => {
|
||||
createBackupSnapshot('startup').catch(() => {})
|
||||
setInterval(() => {
|
||||
|
||||
@@ -1421,6 +1421,7 @@ export function sanitizeStudyUsers(value) {
|
||||
remindersPausedUntil: typeof item?.remindersPausedUntil === 'string' ? item.remindersPausedUntil : null,
|
||||
reminderFrequency: item?.reminderFrequency === 'weekly' ? 'weekly' : 'immediate',
|
||||
weeklyDigestSentAt: typeof item?.weeklyDigestSentAt === 'string' ? item.weeklyDigestSentAt : null,
|
||||
remindersDefaultedAt: typeof item?.remindersDefaultedAt === 'string' ? item.remindersDefaultedAt : null,
|
||||
})
|
||||
}
|
||||
return out.slice(0, MAX_STUDY_USERS)
|
||||
|
||||
+11
-6
@@ -4,7 +4,7 @@ import { loadPodcastWorkflow, laneSeasonOf } from './routes/podcast-workflow.js'
|
||||
// The Spotify (Anchor) RSS feed only lists episodes that are live, so a season + episode number
|
||||
// showing up there means the episode has been published. That drives two things on the public site:
|
||||
// - highlights marked "hold until released" stay hidden until their episode is in the feed
|
||||
// - study lessons with no audio of their own pick it up from the matching feed episode
|
||||
// - highlights and study lessons with no audio of their own pick it up from the matching feed episode
|
||||
|
||||
const SNAPSHOT_TTL_MS = 5 * 60 * 1000
|
||||
|
||||
@@ -44,7 +44,7 @@ export async function applyFeedRelease(content) {
|
||||
|
||||
const links = Array.isArray(content.podcastFeaturedLinks) ? content.podcastFeaturedLinks : []
|
||||
const studies = Array.isArray(content.studies) ? content.studies : []
|
||||
if (!links.some(l => l?.holdUntilReleased) && studies.length === 0) return content
|
||||
if (links.length === 0 && studies.length === 0) return content
|
||||
|
||||
const [index, wf] = await Promise.all([getFeedIndex(), loadPodcastWorkflow()])
|
||||
const seasonA = laneSeasonOf('A', wf, content)
|
||||
@@ -52,11 +52,16 @@ export async function applyFeedRelease(content) {
|
||||
|
||||
const nextLinks = links.flatMap(link => {
|
||||
// General highlights aren't tied to a series, so there's no season to match against
|
||||
if (!link?.holdUntilReleased || link.lane === 'general') return [link]
|
||||
if (!link || link.lane === 'general') return [link]
|
||||
const season = link.lane === 'second' ? seasonB : seasonA
|
||||
const item = season && link.episodeNumber ? index.get(`${season}-${link.episodeNumber}`) : null
|
||||
if (!item) return []
|
||||
return [{ ...link, url: link.url || item.link || '' }]
|
||||
const item = season && link.episodeNumber ? index.get(`${season}-${String(link.episodeNumber).trim()}`) : null
|
||||
if (!item) return link.holdUntilReleased ? [] : [link]
|
||||
// Highlights with no audio of their own pick it up from the matching feed episode
|
||||
return [{
|
||||
...link,
|
||||
url: link.url || item.link || '',
|
||||
embedUrl: link.embedUrl?.trim() ? link.embedUrl : (item.audioUrl || link.embedUrl),
|
||||
}]
|
||||
})
|
||||
|
||||
const studyLane = study => {
|
||||
|
||||
@@ -117,7 +117,8 @@ export function register(app) {
|
||||
passwordHash: hashStudyPassword(password),
|
||||
displayName,
|
||||
subscribeNewsletter: subscribe,
|
||||
studyRemindersEnabled: false,
|
||||
studyRemindersEnabled: true,
|
||||
remindersDefaultedAt: now,
|
||||
pendingEmailChange: null,
|
||||
emailVerified: false,
|
||||
pendingEmailVerification: {
|
||||
|
||||
@@ -736,6 +736,23 @@ export function extractResendMessageId(result) {
|
||||
return ''
|
||||
}
|
||||
|
||||
// One-off pass: lesson reminders are now on by default, so switch them on for accounts that
|
||||
// predate the default. Each account is marked as it is handled, so a later opt-out sticks.
|
||||
export async function backfillReminderDefault() {
|
||||
const now = new Date().toISOString()
|
||||
let updated = 0
|
||||
for (const user of state.studyUsers) {
|
||||
if (user.remindersDefaultedAt) continue
|
||||
if (user.studyRemindersEnabled !== true) {
|
||||
user.studyRemindersEnabled = true
|
||||
updated++
|
||||
}
|
||||
user.remindersDefaultedAt = now
|
||||
}
|
||||
if (state.studyUsers.some(user => user.remindersDefaultedAt === now)) queueStudyUsersWrite()
|
||||
return updated
|
||||
}
|
||||
|
||||
// ── Study reminder scheduler ───────────────────────────────────────────────
|
||||
|
||||
export async function scheduleStudyReminders(sendStudyReminderEmail) {
|
||||
|
||||
+14
-1
@@ -13275,7 +13275,20 @@ a.study-track-card:hover {
|
||||
}
|
||||
|
||||
.header-nav--open {
|
||||
max-height: 660px;
|
||||
max-height: calc(100dvh - var(--site-header-h, 64px));
|
||||
overflow-y: auto;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
}
|
||||
|
||||
.nav-dropdown-label {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.nav-dropdown-caret {
|
||||
min-width: 44px;
|
||||
min-height: 44px;
|
||||
margin: -0.8rem 0;
|
||||
font-size: 0.9em;
|
||||
}
|
||||
|
||||
.nav-dropdown-trigger {
|
||||
|
||||
+8
-4
@@ -472,7 +472,7 @@ function SiteHeader({ content }: { content: SiteContent }) {
|
||||
{/* Episodes dropdown */}
|
||||
<div ref={dropdownRef} className={`nav-dropdown${episodesOpen ? ' nav-dropdown--open' : ''}`}>
|
||||
<div className="nav-dropdown-trigger">
|
||||
<NavLink to="/episodes" className={`nav-dropdown-label${episodesActive ? ' header-nav-link--active' : ''}`} onClick={() => { setMenuOpen(false); setEpisodesOpen(false) }}>Episodes</NavLink>
|
||||
<NavLink to="/episodes" className={`nav-dropdown-label${episodesActive ? ' header-nav-link--active' : ''}`} onClick={e => { if (window.matchMedia('(max-width: 900px)').matches) { e.preventDefault(); setEpisodesOpen(o => !o) } else { setMenuOpen(false); setEpisodesOpen(false) } }}>Episodes</NavLink>
|
||||
<button type="button" className="nav-dropdown-caret" onClick={() => setEpisodesOpen(o => !o)} aria-expanded={episodesOpen} aria-label="Episodes menu">
|
||||
<span aria-hidden="true">{episodesOpen ? '▴' : '▾'}</span>
|
||||
</button>
|
||||
@@ -489,7 +489,7 @@ function SiteHeader({ content }: { content: SiteContent }) {
|
||||
{/* Studies dropdown */}
|
||||
<div ref={studiesDropdownRef} className={`nav-dropdown${studiesOpen ? ' nav-dropdown--open' : ''}`}>
|
||||
<div className="nav-dropdown-trigger">
|
||||
<NavLink to="/study" className={`nav-dropdown-label${studiesActive ? ' header-nav-link--active' : ''}`} onClick={() => { setMenuOpen(false); setStudiesOpen(false) }}>Studies</NavLink>
|
||||
<NavLink to="/study" className={`nav-dropdown-label${studiesActive ? ' header-nav-link--active' : ''}`} onClick={e => { if (window.matchMedia('(max-width: 900px)').matches) { e.preventDefault(); setStudiesOpen(o => !o) } else { setMenuOpen(false); setStudiesOpen(false) } }}>Studies</NavLink>
|
||||
<button type="button" className="nav-dropdown-caret" onClick={() => setStudiesOpen(o => !o)} aria-expanded={studiesOpen} aria-label="Studies menu">
|
||||
<span aria-hidden="true">{studiesOpen ? '▴' : '▾'}</span>
|
||||
</button>
|
||||
@@ -829,7 +829,8 @@ function PodcastHighlightsSection({ content }: { content: SiteContent }) {
|
||||
{ key: 'primary', title: content.seriesTitle, links: links.filter(link => laneOf(link) === 'primary') },
|
||||
{ key: 'second', title: content.secondSeries?.title?.trim() || 'Second Series', links: links.filter(link => laneOf(link) === 'second') },
|
||||
{ key: 'general', title: 'More Highlights', links: links.filter(link => laneOf(link) === 'general') },
|
||||
].filter(group => group.links.length > 0)
|
||||
].filter(group => group.key === 'primary' || group.links.length > 0)
|
||||
// Always open on the current series, even if it has no highlights yet
|
||||
const active = groups.find(group => group.key === activeKey) ?? groups[0]
|
||||
const isExpanded = expanded[active.key] === true
|
||||
const shown = isExpanded ? active.links : active.links.slice(0, HIGHLIGHTS_PREVIEW_COUNT)
|
||||
@@ -858,6 +859,7 @@ function PodcastHighlightsSection({ content }: { content: SiteContent }) {
|
||||
</div>
|
||||
)}
|
||||
<div className="episode-list">
|
||||
{shown.length === 0 && <p className="finished-books-empty">No highlights for this series yet.</p>}
|
||||
{shown.map(link => <HighlightCard key={link.id} link={link} />)}
|
||||
</div>
|
||||
{active.links.length > HIGHLIGHTS_PREVIEW_COUNT && (
|
||||
@@ -2239,7 +2241,9 @@ function EpisodesPage({ content }: { content: SiteContent }) {
|
||||
)
|
||||
const { episodes, loading } = useAllEpisodes()
|
||||
const [query, setQuery] = useState('')
|
||||
const [season, setSeason] = useState('all')
|
||||
// Until a chip is picked, browse the current series' season (all episodes if none is set)
|
||||
const [pickedSeason, setSeason] = useState<string | null>(null)
|
||||
const season = pickedSeason ?? (content.seriesSeason ? String(content.seriesSeason) : 'all')
|
||||
const [expanded, setExpanded] = useState(false)
|
||||
const [openKey, setOpenKey] = useState<string | null>(null)
|
||||
|
||||
|
||||
Reference in new issue
Block a user