From f303415d94aff004de3c077ca8cf2536aea7b9fb Mon Sep 17 00:00:00 2001 From: Mircea Lungu Date: Wed, 27 May 2026 13:16:10 +0200 Subject: [PATCH 01/15] Daily audio lesson: fresh lesson waiting, just listen (Listen redesign) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reworks the Listen tab from a vending machine (pick type + topic + tap Generate every day) into a daily delivery: the user configures a daily lesson once and finds a fresh one waiting each day. - TodayAudio: new state machine — setup CTA (no preference) -> episode card (lesson ready, instant play) -> auto-generate via the existing streaming progress UI (first day / cron miss) -> listened/replay. Removes the inline type picker + orange Generate button from the main flow. - DailyLessonSettingsDialog: type/topic config moved into a dialog opened by 'Change daily topic'; saves the preference and regenerates today's lesson on demand (or 'save for tomorrow'). - TodayEpisodeCard: Design-B header (TODAY . date, type chip, title, duration . subject . word count) wrapping the reused LessonPlaybackView (now takes header/footer props). - useDailyLessonPreference: per-language preference, server as source of truth. - SuggestionSelector: now a pure controlled component (no localStorage). - PastLessons: type filter chips; shared lessonTypeChip module. Depends on the backend preference keys in zeeguu/api (separate PR). Co-Authored-By: Claude Opus 4.7 (1M context) --- src/dailyAudio/DailyLessonSettingsDialog.js | 100 ++++ src/dailyAudio/LessonPlaybackView.js | 16 +- src/dailyAudio/PastLessons.js | 80 +-- src/dailyAudio/SuggestionSelector.js | 85 ++-- src/dailyAudio/TodayAudio.js | 536 +++++++++----------- src/dailyAudio/TodayEpisodeCard.js | 85 ++++ src/dailyAudio/_DailyAudioRouter.js | 15 +- src/dailyAudio/audioUtils.js | 17 + src/dailyAudio/lessonTypeChip.js | 36 ++ src/hooks/useDailyLessonPreference.js | 59 +++ 10 files changed, 638 insertions(+), 391 deletions(-) create mode 100644 src/dailyAudio/DailyLessonSettingsDialog.js create mode 100644 src/dailyAudio/TodayEpisodeCard.js create mode 100644 src/dailyAudio/lessonTypeChip.js create mode 100644 src/hooks/useDailyLessonPreference.js diff --git a/src/dailyAudio/DailyLessonSettingsDialog.js b/src/dailyAudio/DailyLessonSettingsDialog.js new file mode 100644 index 000000000..d8b39753b --- /dev/null +++ b/src/dailyAudio/DailyLessonSettingsDialog.js @@ -0,0 +1,100 @@ +import React, { useEffect, useState } from "react"; +import { Dialog } from "../components/DialogWrapper"; +import SuggestionSelector, { pillToBackend, backendToPill } from "./SuggestionSelector"; +import { SubtleTextButton } from "./LessonView.sc"; +import { BannerButton } from "./SharedLessonView.sc"; + +/** + * Setup / "Change daily topic" dialog. Hosts the lesson-type selector and + * persists the per-language daily-lesson preference. The dialog never + * generates audio itself — it hands the chosen (type, suggestion) back to the + * parent via onSubmit, which owns the generation state machine and progress UI. + * + * onSubmit(backendType, verbatimSuggestion, regenerate): + * regenerate=true → apply now (parent regenerates today's lesson) + * regenerate=false → save for tomorrow only (keeps today's existing lesson) + */ +export default function DailyLessonSettingsDialog({ + api, + initialType, + initialSuggestion, + todaysLessonExists, + onSubmit, + onDismiss, +}) { + const [pillType, setPillType] = useState(initialType ? backendToPill(initialType) : "topic"); + const [suggestion, setSuggestion] = useState(initialSuggestion || ""); + const [autoDisabled, setAutoDisabled] = useState(false); + + // Vocabulary needs enough study words; disable the pill when there aren't. + useEffect(() => { + api.checkDailyLessonFeasibility( + (data) => { + setAutoDisabled(!data.feasible); + if (!data.feasible) setPillType((t) => (t === "auto" ? "topic" : t)); + }, + () => setAutoDisabled(false), + ); + }, [api]); + + const needsSubject = pillType === "topic" || pillType === "situation"; + const canSubmit = !needsSubject || suggestion.trim().length > 0; + + const submit = (regenerate) => { + if (!canSubmit) return; + // Store the subject exactly as typed; Vocabulary carries no subject. + onSubmit(pillToBackend(pillType), needsSubject ? suggestion : "", regenerate); + }; + + return ( + +

+ Your daily lesson +

+

+ Every morning we'll have a fresh one ready for you. What kind? +

+ + + +
+ submit(true)} + disabled={!canSubmit} + style={{ + width: "100%", + padding: "12px", + fontSize: "1rem", + fontWeight: 600, + opacity: canSubmit ? 1 : 0.5, + cursor: canSubmit ? "pointer" : "not-allowed", + }} + > + {todaysLessonExists ? "Generate today's lesson" : "Start my daily lessons"} + + + {todaysLessonExists && ( + submit(false)} disabled={!canSubmit}> + Save for tomorrow instead + + )} +
+
+ ); +} diff --git a/src/dailyAudio/LessonPlaybackView.js b/src/dailyAudio/LessonPlaybackView.js index 13034ab6a..907310341 100644 --- a/src/dailyAudio/LessonPlaybackView.js +++ b/src/dailyAudio/LessonPlaybackView.js @@ -21,11 +21,15 @@ export default function LessonPlaybackView({ listeningSession, currentPlaybackTime, setCurrentPlaybackTime, + header, + footer, }) { const [openFeedback, setOpenFeedback] = useState(false); - return ( - + // Callers (e.g. the daily episode card) can replace the default title block + // with their own header; otherwise we show the plain title + topic line. + const defaultHeader = ( + <> {lessonData.title} {lessonData.is_completed && <> } @@ -33,6 +37,12 @@ export default function LessonPlaybackView({ {lessonData.canonical_suggestion && ( {lessonData.lesson_type === "situation" ? "Situation" : "Topic"}: {lessonData.canonical_suggestion} )} + + ); + + return ( + + {header || defaultHeader} {error &&
{error}
} @@ -145,6 +155,8 @@ export default function LessonPlaybackView({ )} + {footer} + (chipPalette[$type] || chipPalette.topic).bg}; - color: ${({ $type }) => (chipPalette[$type] || chipPalette.topic).color}; - - [data-theme="dark"] & { - background: ${({ $type }) => (chipPalette[$type] || chipPalette.topic).darkBg}; - color: ${({ $type }) => (chipPalette[$type] || chipPalette.topic).darkColor}; - } -`; - -const chipLabel = (lessonType) => { - if (lessonType === "situation") return "Situation"; - if (lessonType === "three_words_lesson") return "Vocabulary"; - return "Topic"; -}; +// Filter the back catalogue by lesson type. value=null means "All". +const TYPE_FILTERS = [ + { label: "All", value: null }, + { label: "Vocabulary", value: "three_words_lesson" }, + { label: "Topic", value: "topic" }, + { label: "Situation", value: "situation" }, +]; const lessonProgressSeconds = (lesson) => lesson.pause_position_seconds || lesson.position_seconds || lesson.progress_seconds || 0; @@ -112,6 +88,7 @@ export default function PastLessons() { const [hasMore, setHasMore] = useState(true); const [offset, setOffset] = useState(0); const [openLessonId, setOpenLessonId] = useState(null); + const [typeFilter, setTypeFilter] = useState(null); const limit = 10; useEffect(() => { @@ -205,20 +182,51 @@ export default function PastLessons() { const toggleLesson = (lessonId) => setOpenLessonId((current) => (current === lessonId ? null : lessonId)); + // Client-side filter over the lessons loaded so far. Note: with pagination + // this only filters the pages already fetched, so a rarely-used type may + // show fewer than exist until more pages are loaded. (Follow-up: push the + // lesson_type filter down to the past_daily_lessons endpoint.) + const visibleLessons = typeFilter + ? pastLessons.filter((lesson) => lesson.lesson_type === typeFilter) + : pastLessons; + return (
{error &&
{error}
} + {pastLessons.length > 0 && ( + + {TYPE_FILTERS.map(({ label, value }) => ( + setTypeFilter(value)} + > + {label} + + ))} + + )} + {pastLessons.length === 0 && !isLoading && !error && (

There are no past lessons to display.

)} - {pastLessons.length > 0 && ( + {pastLessons.length > 0 && visibleLessons.length === 0 && ( +
+

No {chipLabel(typeFilter).toLowerCase()} lessons yet.

+
+ )} + + {visibleLessons.length > 0 && (
- {pastLessons.map((lesson, idx) => { - const prev = idx > 0 ? pastLessons[idx - 1] : null; + {visibleLessons.map((lesson, idx) => { + const prev = idx > 0 ? visibleLessons[idx - 1] : null; const categoryChanged = !prev || prev.lesson_type !== lesson.lesson_type || diff --git a/src/dailyAudio/SuggestionSelector.js b/src/dailyAudio/SuggestionSelector.js index dde1f481a..3d168ae53 100644 --- a/src/dailyAudio/SuggestionSelector.js +++ b/src/dailyAudio/SuggestionSelector.js @@ -8,66 +8,76 @@ import { InputArea, } from "./SuggestionSelector.sc"; -const MAX_SUGGESTION_LENGTH = 80; +export const MAX_SUGGESTION_LENGTH = 80; -const SUGGESTION_TYPES = { +export const SUGGESTION_TYPES = { auto: { label: "Vocabulary", - description: "A listening lesson focused on three words from your study list, each in a short dialogue.", + description: "A new lesson built from words on your study list, each in a short dialogue.", placeholder: null, }, topic: { label: "Topic", - description: "A listening lesson with a conversation about a topic of your choice.", + description: "A daily conversation about a subject you care about.", placeholder: "e.g. cooking, sports", }, situation: { label: "Situation", - description: "A listening lesson with a conversation simulating a real-world situation of your choice.", + description: "A daily conversation that role-plays a real-world situation.", placeholder: "e.g. at a restaurant, job interview", }, }; -const SELECTED_LESSON_TYPE = "audio_lesson_lesson_type_"; -export const suggestionKey = (type, lang) => `audio_lesson_suggestion_${type}_${lang}`; +// The UI pills use friendly keys ("auto"); the backend / preference store +// the canonical lesson_type ("three_words_lesson"). Convert at the boundary. +const PILL_TO_BACKEND = { auto: "three_words_lesson", topic: "topic", situation: "situation" }; -export function getSavedSuggestionType(lang) { - return localStorage.getItem(SELECTED_LESSON_TYPE + lang) || "auto"; -} +export const pillToBackend = (pillKey) => PILL_TO_BACKEND[pillKey] || "three_words_lesson"; -export function getSavedSuggestion(lang) { - return localStorage.getItem(suggestionKey(getSavedSuggestionType(lang), lang)) || ""; -} +export const backendToPill = (lessonType) => + lessonType === "topic" || lessonType === "situation" ? lessonType : "auto"; + +/** + * Pure controlled selector. The parent owns the (suggestionType, suggestion) + * state and is responsible for persistence — this component just renders the + * pills + description + input and reports changes upward. + */ +export default function SuggestionSelector({ + suggestionType, + setSuggestionType, + suggestion, + setSuggestion, + autoDisabled, +}) { + const selectType = (key) => { + if (suggestionType === key) return; + if (key === "auto" && autoDisabled) return; + setSuggestionType(key); + // Switching to Vocabulary clears the (now irrelevant) subject; switching + // between Topic/Situation keeps whatever the user already typed. + if (key === "auto") setSuggestion(""); + }; -export default function SuggestionSelector({ suggestionType, setSuggestionType, suggestion, setSuggestion, lang, autoDisabled }) { return ( - - - {Object.entries(SUGGESTION_TYPES).map(([key, { label }]) => { - return ( + + {Object.entries(SUGGESTION_TYPES).map(([key, { label }]) => ( { - if (suggestionType === key) return; - setSuggestionType(key); - localStorage.setItem(SELECTED_LESSON_TYPE + lang, key); - setSuggestion(key === "auto" ? "" : localStorage.getItem(suggestionKey(key, lang)) || ""); - }} + disabled={key === "auto" && autoDisabled} + style={key === "auto" && autoDisabled ? { opacity: 0.5, cursor: "not-allowed" } : undefined} + onClick={() => selectType(key)} > {label} - ); - })} + ))} - - {SUGGESTION_TYPES[suggestionType].description} - + {SUGGESTION_TYPES[suggestionType].description} { - const val = e.target.value.replace(/\n/g, " "); - setSuggestion(val); - const key = suggestionKey(suggestionType, lang); - if (val.trim()) { - localStorage.setItem(key, val); - } else { - localStorage.removeItem(key); - } - }} - onClear={() => { - setSuggestion(""); - localStorage.removeItem(suggestionKey(suggestionType, lang)); - }} + onChange={(e) => setSuggestion(e.target.value.replace(/\n/g, " "))} + onClear={() => setSuggestion("")} /> - ); } diff --git a/src/dailyAudio/TodayAudio.js b/src/dailyAudio/TodayAudio.js index a3ec82a78..ecad4715f 100644 --- a/src/dailyAudio/TodayAudio.js +++ b/src/dailyAudio/TodayAudio.js @@ -1,18 +1,17 @@ -import React, { useContext, useEffect, useState } from "react"; +import React, { useContext, useEffect, useRef, useState } from "react"; import { useHistory } from "react-router-dom"; import { Capacitor } from "@capacitor/core"; import { orange500, zeeguuOrange } from "../components/colors"; import { APIContext } from "../contexts/APIContext"; import { UserContext } from "../contexts/UserContext"; import LoadingAnimation from "../components/LoadingAnimation"; -import EmptyState from "../components/EmptyState"; -import FullWidthErrorMsg from "../components/FullWidthErrorMsg.sc"; import useListeningSession from "../hooks/useListeningSession"; +import useDailyLessonPreference from "../hooks/useDailyLessonPreference"; import { AUDIO_STATUS, GENERATION_PROGRESS } from "./AudioLessonConstants"; -import { GenerateView, GenerateButton } from "./GenerateButton.sc"; -import SuggestionSelector, { getSavedSuggestion, getSavedSuggestionType, suggestionKey } from "./SuggestionSelector"; -import LessonPlaybackView from "./LessonPlaybackView"; +import TodayEpisodeCard from "./TodayEpisodeCard"; +import DailyLessonSettingsDialog from "./DailyLessonSettingsDialog"; import { SubtleTextButton } from "./LessonView.sc"; +import { BannerButton } from "./SharedLessonView.sc"; import { wordsAsTile, shortDate } from "./audioUtils"; // Shown rotating during the backend phases that don't emit sub-step @@ -30,33 +29,125 @@ const PLACEHOLDER_PROGRESS_MESSAGES = [ ]; const PLACEHOLDER_ROTATION_MS = 2500; -export default function TodayAudio({ setShowTabs }) { +export default function TodayAudio() { const api = useContext(APIContext); const { userDetails, setUserDetails } = useContext(UserContext); const lang = userDetails?.learned_language || ""; + + const { dailyType, dailySuggestion, prefLoaded, isConfigured, saveDailyLesson } = + useDailyLessonPreference(api, lang); + const [isLoading, setIsLoading] = useState(false); const [isGenerating, setIsGenerating] = useState(false); const [generationProgress, setGenerationProgress] = useState(null); const [placeholderIndex, setPlaceholderIndex] = useState(0); - const [suggestionType, setSuggestionType] = useState( - () => getSavedSuggestionType(lang), - ); - const [suggestion, setSuggestion] = useState(() => { - return getSavedSuggestion(lang); - }); + // What the in-progress generation is about, so the progress screen can name + // it. { type: backendLessonType, suggestion } + const [generatingLabel, setGeneratingLabel] = useState(null); + const [lessonData, setLessonData] = useState(null); + const [noLesson, setNoLesson] = useState(false); // confirmed: nothing for today yet + const [error, setError] = useState(null); + const [settingsOpen, setSettingsOpen] = useState(false); + const [currentPlaybackTime, setCurrentPlaybackTime] = useState(0); + const history = useHistory(); + + // Guards against re-triggering auto-generation every render once we've + // already kicked it off (or are explicitly driving generation ourselves). + const autoGenAttemptedRef = useRef(false); + + const generatingKey = `zeeguu_generating_lesson_${lang}_${new Date().toDateString()}`; + const failedKey = `zeeguu_generation_failed_${lang}_${new Date().toDateString()}`; + + // Listening session tracking via hook + const listeningSession = useListeningSession(lessonData?.lesson_id); + const words = lessonData?.words || []; - // Re-initialize state when language changes + // Re-initialize when the learned language changes useEffect(() => { - setSuggestionType(getSavedSuggestionType(lang)); - setSuggestion(getSavedSuggestion(lang)); setLessonData(null); setError(null); - setCanGenerateLesson(null); + setNoLesson(false); + autoGenAttemptedRef.current = false; }, [lang]); + // Kick off generation for a given (backend lesson type, raw subject). Used by + // the auto-generate fallback and by the settings dialog (first day / change + // topic). Reuses the existing background-generation + streaming-progress flow. + function startGeneration(backendType, rawSuggestion) { + const isVocab = backendType === "three_words_lesson"; + const trimmedSuggestion = isVocab ? null : (rawSuggestion || "").trim() || null; + const apiLessonType = isVocab ? null : backendType; + + setGeneratingLabel({ type: backendType, suggestion: trimmedSuggestion || "" }); + setIsGenerating(true); + setNoLesson(false); + setError(null); + setGenerationProgress(null); + setUserDetails((prev) => ({ ...prev, daily_audio_status: AUDIO_STATUS.GENERATING })); + localStorage.setItem(generatingKey, "true"); + + api.generateDailyLesson( + (data) => { + if (data.status === AUDIO_STATUS.GENERATING) { + // Generation started in background — polling will deliver the lesson + return; + } + // Existing lesson returned directly + localStorage.removeItem(generatingKey); + localStorage.removeItem(failedKey); + setIsGenerating(false); + setGenerationProgress(null); + setLessonData(data); + setUserDetails((prev) => ({ + ...prev, + daily_audio_status: data.is_completed ? AUDIO_STATUS.COMPLETED : AUDIO_STATUS.READY, + })); + }, + (err) => { + // Generation already running — keep the flag and let polling continue + if (err.message && err.message.toLowerCase().includes("already being generated")) { + return; + } + localStorage.removeItem(generatingKey); + setIsGenerating(false); + setGenerationProgress(null); + setUserDetails((prev) => ({ ...prev, daily_audio_status: null })); + + const msg = err.message || "Couldn't prepare today's lesson. Please try again."; + // Remember the failure for the rest of the day so we don't auto-retry + // on every refresh; the user can change the topic to try again. + localStorage.setItem(failedKey, msg); + setError(msg); + }, + trimmedSuggestion, + apiLessonType, + ); + } + + // Saving from the settings dialog. regenerate=true applies the change to + // today's lesson now (delete + regenerate); regenerate=false only stores the + // preference for tomorrow's lesson. + function handleConfigured(backendType, suggestion, regenerate) { + saveDailyLesson(backendType, suggestion); + setSettingsOpen(false); + if (!regenerate) return; + + localStorage.removeItem(failedKey); + setError(null); + autoGenAttemptedRef.current = true; // we're driving generation explicitly + if (lessonData) { + const afterDelete = () => { + setLessonData(null); + startGeneration(backendType, suggestion); + }; + api.deleteTodaysLesson(afterDelete, afterDelete); + } else { + startGeneration(backendType, suggestion); + } + } + // Poll for progress when generating useEffect(() => { - const generatingKey = `zeeguu_generating_lesson_${lang}_${new Date().toDateString()}`; const hasLocalStorageFlag = localStorage.getItem(generatingKey); // Start polling if either: localStorage flag is set (page reload) or isGenerating is true (button click/409) @@ -71,7 +162,6 @@ export default function TodayAudio({ setShowTabs }) { return; // Effect will re-run with isGenerating=true } - // Helper to stop polling and reset state let pollInterval; const stopPolling = () => { @@ -90,8 +180,10 @@ export default function TodayAudio({ setShowTabs }) { if (data && data.lesson_id) { stopPolling(); setLessonData(data); - // Update context to show lesson is ready - setUserDetails((prev) => ({ ...prev, daily_audio_status: data.is_completed ? AUDIO_STATUS.COMPLETED : AUDIO_STATUS.READY })); + setUserDetails((prev) => ({ + ...prev, + daily_audio_status: data.is_completed ? AUDIO_STATUS.COMPLETED : AUDIO_STATUS.READY, + })); lessonRetryCount = 0; } else if (data && data.error) { // Lesson exists but has an error (e.g., audio file not ready yet) @@ -99,7 +191,6 @@ export default function TodayAudio({ setShowTabs }) { if (lessonRetryCount >= MAX_LESSON_RETRIES) { handleError(data.error); } - // Otherwise, keep polling - the file might still be writing } }; @@ -108,7 +199,6 @@ export default function TodayAudio({ setShowTabs }) { setError(message); }; - // Check if lesson is ready (used in multiple places) const checkForLesson = () => { api.getTodaysLesson(handleLessonReady, () => {}); }; @@ -126,12 +216,10 @@ export default function TodayAudio({ setShowTabs }) { handleError(progress.message || "Lesson generation failed. Please try again."); } } else { - // No progress record - might be a brief gap (e.g., lesson was - // regenerated and progress hasn't appeared yet). Retry a few - // times before giving up. + // No progress record - might be a brief gap. Retry a few times. noProgressCount++; if (noProgressCount <= MAX_NO_PROGRESS_RETRIES) { - return; // keep polling + return; } // Exhausted retries — check if a lesson appeared, otherwise stop api.getTodaysLesson( @@ -140,12 +228,12 @@ export default function TodayAudio({ setShowTabs }) { handleLessonReady(data); } else { stopPolling(); - checkLessonGenerationFeasibility(); + setNoLesson(true); } }, () => { stopPolling(); - checkLessonGenerationFeasibility(); + setNoLesson(true); }, ); } @@ -155,7 +243,6 @@ export default function TodayAudio({ setShowTabs }) { ); }; - // Poll for generation progress pollInterval = setInterval(pollForProgress, 1500); // Browsers throttle setInterval for background tabs, so check @@ -187,7 +274,6 @@ export default function TodayAudio({ setShowTabs }) { })(); } - // Cleanup on unmount return () => { stopPolling(); document.removeEventListener("visibilitychange", onVisibilityChange); @@ -196,12 +282,8 @@ export default function TodayAudio({ setShowTabs }) { }; }, [api, isGenerating]); - // Rotate placeholder messages only during the early/long phases where - // the backend message sits static for many seconds with no sub-step - // progress (no record yet, "pending", "generating_script"). Once we - // hit "synthesizing_audio" the per-step counter takes over, and - // "combining_audio" / "done" are end states where we want the real - // message + a full bar rather than a fresh rotation cycle. + // Rotate placeholder messages only during the early/long phases where the + // backend message sits static with no sub-step progress. const showRealMessage = generationProgress?.status === GENERATION_PROGRESS.SYNTHESIZING_AUDIO || generationProgress?.status === GENERATION_PROGRESS.COMBINING_AUDIO || @@ -217,199 +299,80 @@ export default function TodayAudio({ setShowTabs }) { return () => clearInterval(id); }, [isGenerating, showRealMessage]); - const [openFeedback, setOpenFeedback] = useState(false); - const [currentPlaybackTime, setCurrentPlaybackTime] = useState(0); - const [lessonData, setLessonData] = useState(null); - const [error, setError] = useState(null); - const [canGenerateLesson, setCanGenerateLesson] = useState(null); // null = checking, true = can generate, false = cannot - const history = useHistory(); - - // Listening session tracking via hook - const listeningSession = useListeningSession(lessonData?.lesson_id); - - let words = lessonData?.words || []; - - // Control tab visibility - hide tabs when showing empty state - useEffect(() => { - if (setShowTabs) { - // Hide tabs only when we know user can't generate a lesson and has no lesson - setShowTabs(true); - } - }, [canGenerateLesson, lessonData, setShowTabs]); - // Update page title and playback time when lessonData changes useEffect(() => { if (lessonData && lessonData.words) { document.title = shortDate() + " Daily Audio: " + wordsAsTile(words); - - // Initialize playback time from lesson data - const initialTime = lessonData.pause_position_seconds || lessonData.position_seconds || lessonData.progress_seconds || 0; + const initialTime = + lessonData.pause_position_seconds || lessonData.position_seconds || lessonData.progress_seconds || 0; setCurrentPlaybackTime(initialTime); } else { document.title = "Zeeguu: Audio Lesson"; } }, [lessonData]); - const failedKey = `zeeguu_generation_failed_${lang}_${new Date().toDateString()}`; - - // Check if lesson generation is possible - const checkLessonGenerationFeasibility = () => { - // If generation already failed this session, don't retry on refresh - const previousError = localStorage.getItem(failedKey); - if (previousError) { - setCanGenerateLesson(false); - setError(previousError); - return; - } - - api.checkDailyLessonFeasibility( - (data) => { - setCanGenerateLesson(data.feasible); - if (!data.feasible) { - setError("Not enough words for a vocabulary lesson. Try a Topic or Situation instead!"); - // If user had auto selected, switch to topic - if (suggestionType === "auto") { - setSuggestionType("topic"); - } - } - }, - (error) => { - // If the API endpoint doesn't exist, we'll assume generation is possible - // and let the generation attempt handle the error - setCanGenerateLesson(true); - } - ); - }; - - // Fetch lesson data on mount - but check for active generation first + // On mount: is something generating? else is there a lesson? else nothing yet. useEffect(() => { setIsLoading(true); - // First, check if there's an active generation in progress + const onLesson = (data) => { + setIsLoading(false); + if (data) { + setLessonData(data); + setNoLesson(false); + } else { + setNoLesson(true); + } + }; + const onLessonError = () => { + setIsLoading(false); + setLessonData(null); + setUserDetails((prev) => ({ ...prev, daily_audio_status: null })); + setNoLesson(true); + }; + api.getAudioLessonGenerationProgress( (progress) => { if (progress && ![GENERATION_PROGRESS.DONE, GENERATION_PROGRESS.ERROR].includes(progress.status)) { - // Generation in progress - let polling handle it setIsLoading(false); setIsGenerating(true); setGenerationProgress(progress); - // Update context so navigation dot shows generating state setUserDetails((prev) => ({ ...prev, daily_audio_status: AUDIO_STATUS.GENERATING })); return; } - - // No active generation - check for existing lesson - api.getTodaysLesson( - (data) => { - setIsLoading(false); - setLessonData(data); - - // If no lesson exists, check if we can generate one - if (!data) { - checkLessonGenerationFeasibility(); - } - }, - (error) => { - setIsLoading(false); - setLessonData(null); - // Reset status on error - setUserDetails((prev) => ({ ...prev, daily_audio_status: null })); - - // Don't show technical errors (e.g., "Audio file not found") — just let user regenerate - checkLessonGenerationFeasibility(); - }, - ); - }, - () => { - // Progress API error - fall back to checking lesson - api.getTodaysLesson( - (data) => { - setIsLoading(false); - setLessonData(data); - if (!data) { - checkLessonGenerationFeasibility(); - } - }, - (error) => { - setIsLoading(false); - setLessonData(null); - // Reset status on error - setUserDetails((prev) => ({ ...prev, daily_audio_status: null })); - checkLessonGenerationFeasibility(); - }, - ); + api.getTodaysLesson(onLesson, onLessonError); }, + () => api.getTodaysLesson(onLesson, onLessonError), ); }, [api, lang]); - const handleGenerateLesson = () => { - const generatingKey = `zeeguu_generating_lesson_${lang}_${new Date().toDateString()}`; - - setIsGenerating(true); - setError(null); - setGenerationProgress(null); - - // Update context so navigation dot shows generating state - setUserDetails((prev) => ({ ...prev, daily_audio_status: AUDIO_STATUS.GENERATING })); - - // Set localStorage flag to track generation across page reloads - localStorage.setItem(generatingKey, "true"); - - const trimmedSuggestion = suggestion.trim() || null; - const suggestionTypeToSend = trimmedSuggestion && suggestionType !== "auto" ? suggestionType : null; - api.generateDailyLesson( - (data) => { - if (data.status === AUDIO_STATUS.GENERATING) { - // Generation started in background — polling will deliver the lesson - return; - } - // Existing lesson returned directly - localStorage.removeItem(generatingKey); - localStorage.removeItem(failedKey); - setIsGenerating(false); - setGenerationProgress(null); - setLessonData(data); - setUserDetails((prev) => ({ ...prev, daily_audio_status: AUDIO_STATUS.READY })); - }, - (error) => { - // Check if generation is already in progress (409 Conflict) - if (error.message && error.message.toLowerCase().includes("already being generated")) { - // Don't clear the flag - keep polling for the existing generation - return; - } - - // Clear the localStorage flag on error - localStorage.removeItem(generatingKey); - setIsGenerating(false); - setGenerationProgress(null); - - // Reset status back to available on error - setUserDetails((prev) => ({ ...prev, daily_audio_status: null })); - - // Check if the error is a topic rejection (user can try a different topic) - const isSuggestionRejection = error.message && error.message.toLowerCase().includes("can't generate a lesson for this"); - if (isSuggestionRejection) { - setError(error.message); - return; - } - - setCanGenerateLesson(false); + // No lesson for today yet: if the daily lesson is configured, generate it now + // (cron miss / first day / timezone). Otherwise the setup CTA is shown. + useEffect(() => { + if (!prefLoaded || !noLesson || isGenerating || lessonData) return; + if (!isConfigured || autoGenAttemptedRef.current) return; + const previousFailure = localStorage.getItem(failedKey); + if (previousFailure) { + setError(previousFailure); + return; + } + autoGenAttemptedRef.current = true; + startGeneration(dailyType, dailySuggestion); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [prefLoaded, noLesson, isGenerating, lessonData, isConfigured, dailyType, dailySuggestion]); - // Check if the error is related to no words in learning - let errorMsg; - if (error.message && error.message.toLowerCase().includes("not enough words")) { - errorMsg = "Not enough words for a vocabulary lesson. Try a Topic or Situation instead!"; - } else { - errorMsg = error.message || "Failed to generate daily lesson. Please try again."; - } - setError(errorMsg); - }, - trimmedSuggestion, - suggestionTypeToSend, - ); - }; + const settingsDialog = settingsOpen && ( + setSettingsOpen(false)} + /> + ); - if (isLoading) { + if (isLoading || !prefLoaded) { return (
@@ -420,38 +383,37 @@ export default function TodayAudio({ setShowTabs }) { } if (isGenerating) { - // Build progress detail (e.g., "Word 2/3: Synthesizing man voice") let progressDetail = PLACEHOLDER_PROGRESS_MESSAGES[placeholderIndex % PLACEHOLDER_PROGRESS_MESSAGES.length]; - let progressPercent = 1; // Start at 1% so the bar is visible immediately + let progressPercent = 1; - // Always derive percent from the backend's segment/step counters - // when present — otherwise the bar would collapse back to 1% the - // moment status leaves "synthesizing_audio" (e.g. into combining - // or done), which looks like the lesson restarted. if (generationProgress?.total_segments > 0) { const segmentsCompleted = Math.max(0, generationProgress.current_segment - 1); let stepsInCurrentSegment = 0; if (generationProgress.total_steps > 0) { stepsInCurrentSegment = generationProgress.current_step / generationProgress.total_steps; } - progressPercent = Math.max(1, ((segmentsCompleted + stepsInCurrentSegment) / generationProgress.total_segments) * 100); + progressPercent = Math.max( + 1, + ((segmentsCompleted + stepsInCurrentSegment) / generationProgress.total_segments) * 100, + ); } if (showRealMessage) { progressDetail = generationProgress.message || progressDetail; } + const label = generatingLabel || {}; let subtitle; let bigTitle; - if (suggestionType === "topic") { - subtitle = "Generating a lesson on the topic"; - bigTitle = suggestion; - } else if (suggestionType === "situation") { - subtitle = "Generating a lesson for the situation"; - bigTitle = suggestion; + if (label.type === "topic") { + subtitle = "Preparing today's lesson on"; + bigTitle = label.suggestion; + } else if (label.type === "situation") { + subtitle = "Preparing today's lesson for"; + bigTitle = label.suggestion; } else { - subtitle = "Generating a lesson with"; + subtitle = "Preparing today's lesson with"; bigTitle = "Three of Your Study Words"; } @@ -466,24 +428,10 @@ export default function TodayAudio({ setShowTabs }) { }} >
-

+

{subtitle}

-

+

{bigTitle}

@@ -536,88 +484,82 @@ export default function TodayAudio({ setShowTabs }) { }} >

- This can take a while.
- Feel free to browse — you'll find it here when it's ready. + This can take a moment.
+ Feel free to browse — your lesson will be here when it's ready.

-
); } - if (!lessonData) { - if (canGenerateLesson !== null) { - const autoDisabled = canGenerateLesson === false; - const canGenerate = suggestionType !== "auto" || !autoDisabled; - - const errorMessage = error && ( -

- {error} -

- ); - - const generateAction = ( - <> - {errorMessage} - - Generate -
- Lesson -
- - ); - - const cantGenerateMessage = ( -

- {error || "Not enough words for a vocabulary lesson. Try a Topic or Situation instead!"} -

- ); - - return ( - - - {canGenerate ? generateAction : cantGenerateMessage} -
- history.push("/daily-audio/past-lessons")}> - See past lessons → - -
-
- ); - } + if (lessonData) { + return ( + <> + setSettingsOpen(true)} + onSeePastLessons={() => history.push("/daily-audio/past-lessons")} + /> + {settingsDialog} + + ); + } - // Still checking feasibility + // No lesson + configured + no error: auto-generation is about to fire. + if (isConfigured && !error) { return (
-

Checking...

+

Preparing today's lesson...

); } - + // First-run setup, or a generation error that the user can retry by changing + // the topic. return ( - + <> +
+
🎧
+

+ {error ? "Let's try a different topic" : "Your daily listening lesson"} +

+

+ {error || + "Pick a topic you care about and we'll have a fresh lesson ready for you every morning. Just open and listen."} +

+ setSettingsOpen(true)} style={{ padding: "12px 24px", fontSize: "1rem" }}> + {isConfigured ? "Change daily topic" : "Choose your daily lesson"} + +
+ history.push("/daily-audio/past-lessons")}> + See past lessons → + +
+
+ {settingsDialog} + ); } diff --git a/src/dailyAudio/TodayEpisodeCard.js b/src/dailyAudio/TodayEpisodeCard.js new file mode 100644 index 000000000..71fbfafde --- /dev/null +++ b/src/dailyAudio/TodayEpisodeCard.js @@ -0,0 +1,85 @@ +import React from "react"; +import LessonPlaybackView from "./LessonPlaybackView"; +import { LessonTitle, LessonMetadata, CompletionCheck, SubtleTextButton } from "./LessonView.sc"; +import { LessonTypeChip, chipLabel } from "./lessonTypeChip"; +import { todayDateLabel, formatDurationMinutes } from "./audioUtils"; + +// Design-B "episode card" header: reads like today's episode of a show that +// renews every morning — TODAY · date, a type chip, the title, and a compact +// metadata line. +function EpisodeHeader({ lessonData, words }) { + const hasSubject = + lessonData.lesson_type === "topic" || lessonData.lesson_type === "situation"; + + const metaParts = [ + formatDurationMinutes(lessonData.duration_seconds), + hasSubject ? lessonData.canonical_suggestion : null, + words && words.length > 0 ? `${words.length} words` : null, + ].filter(Boolean); + + return ( + <> +
+ + Today · {todayDateLabel()} + + + {chipLabel(lessonData.lesson_type)} + +
+ + + {lessonData.title} + {lessonData.is_completed && <> } + + + {metaParts.length > 0 && 🎧 {metaParts.join(" · ")}} + + ); +} + +function freshLine(lessonData) { + if (lessonData.lesson_type === "topic" || lessonData.lesson_type === "situation") { + return `A fresh ${lessonData.canonical_suggestion} lesson, every morning.`; + } + return "A fresh vocabulary lesson, every morning."; +} + +/** + * The Today view's main state: today's pre-generated lesson, ready to play. + * Reuses LessonPlaybackView for all the player/words/feedback plumbing and + * injects the Design-B header + footer. + */ +export default function TodayEpisodeCard({ onChangeTopic, onSeePastLessons, ...playbackProps }) { + const { lessonData, words } = playbackProps; + + const footer = ( +
+

+ {lessonData.is_completed + ? "You're done for today — a fresh lesson arrives tomorrow 🌅" + : freshLine(lessonData)} +

+
+ Change daily topic + See past lessons → +
+
+ ); + + return ( + } + footer={footer} + /> + ); +} diff --git a/src/dailyAudio/_DailyAudioRouter.js b/src/dailyAudio/_DailyAudioRouter.js index 0d99c4429..b80af73c0 100644 --- a/src/dailyAudio/_DailyAudioRouter.js +++ b/src/dailyAudio/_DailyAudioRouter.js @@ -3,7 +3,7 @@ import { PrivateRoute } from "../PrivateRoute"; import * as s from "../components/ColumnWidth.sc"; import TopTabs from "../components/TopTabs"; import strings from "../i18n/definitions"; -import { Switch, useLocation } from "react-router-dom"; +import { Switch } from "react-router-dom"; import TodayAudio from "./TodayAudio"; import PastLessons from "./PastLessons"; import { APIContext } from "../contexts/APIContext"; @@ -16,9 +16,7 @@ export default function DailyAudioRouter() { const api = useContext(APIContext); const { userDetails } = useContext(UserContext); const learnedLanguage = userDetails?.learned_language; - const location = useLocation(); const [pastLessonsCount, setPastLessonsCount] = useState(0); - const [showTabs, setShowTabs] = useState(true); const swipeRef = useTabbedRoute(TAB_PATHS); @@ -40,13 +38,6 @@ export default function DailyAudioRouter() { ); }, [api, learnedLanguage]); - // Always show tabs on past-lessons page - useEffect(() => { - if (location.pathname === "/daily-audio/past-lessons") { - setShowTabs(true); - } - }, [location.pathname]); - let tabsAndLinks = [ { text: strings.today, @@ -62,10 +53,10 @@ export default function DailyAudioRouter() { return ( - {showTabs && } +
- +
diff --git a/src/dailyAudio/audioUtils.js b/src/dailyAudio/audioUtils.js index b5aaccbf8..b1b9e8219 100644 --- a/src/dailyAudio/audioUtils.js +++ b/src/dailyAudio/audioUtils.js @@ -10,3 +10,20 @@ export function wordsAsTile(words) { export function shortDate() { return `[${new Date().toLocaleDateString("en-US", { month: "short", day: "numeric" })}]`; } + +// "Wed, May 27" — shown next to the TODAY label on the episode card so the +// daily lesson reads like a dated episode that renews each day. +export function todayDateLabel() { + return new Date().toLocaleDateString("en-US", { + weekday: "short", + month: "short", + day: "numeric", + }); +} + +// Compact, human duration for the episode card: "4 min". Rounds up so a +// 30-second clip still reads as "1 min" rather than "0 min". +export function formatDurationMinutes(seconds) { + if (!seconds || seconds <= 0) return ""; + return `${Math.max(1, Math.round(seconds / 60))} min`; +} diff --git a/src/dailyAudio/lessonTypeChip.js b/src/dailyAudio/lessonTypeChip.js new file mode 100644 index 000000000..1e06cc884 --- /dev/null +++ b/src/dailyAudio/lessonTypeChip.js @@ -0,0 +1,36 @@ +import styled from "styled-components"; + +// Small colored pill that surfaces a lesson's category. Colors mirror +// SessionHistory's activity chips (Reading/Browsing/Audio) so the visual +// language is consistent across activity & lessons. Each palette has a +// dark-mode variant: pale pastels disappear on dark cards, so we swap to a +// deeper background with brighter text. +// +// Shared between the past-lessons list and today's episode card. +export const chipPalette = { + topic: { bg: "#e3f2fd", color: "#1565c0", darkBg: "#1e3a52", darkColor: "#90caf9" }, // blue + situation: { bg: "#e8f5e9", color: "#2e7d32", darkBg: "#1f3d24", darkColor: "#a5d6a7" }, // green + three_words_lesson: { bg: "#f3e5f5", color: "#7b1fa2", darkBg: "#3a1f3e", darkColor: "#ce93d8" }, // purple +}; + +export const LessonTypeChip = styled.span` + display: inline-block; + padding: 2px 10px; + border-radius: 999px; + font-size: 0.75rem; + font-weight: 500; + margin-bottom: 6px; + background: ${({ $type }) => (chipPalette[$type] || chipPalette.topic).bg}; + color: ${({ $type }) => (chipPalette[$type] || chipPalette.topic).color}; + + [data-theme="dark"] & { + background: ${({ $type }) => (chipPalette[$type] || chipPalette.topic).darkBg}; + color: ${({ $type }) => (chipPalette[$type] || chipPalette.topic).darkColor}; + } +`; + +export const chipLabel = (lessonType) => { + if (lessonType === "situation") return "Situation"; + if (lessonType === "three_words_lesson") return "Vocabulary"; + return "Topic"; +}; diff --git a/src/hooks/useDailyLessonPreference.js b/src/hooks/useDailyLessonPreference.js new file mode 100644 index 000000000..a47f8e9ea --- /dev/null +++ b/src/hooks/useDailyLessonPreference.js @@ -0,0 +1,59 @@ +import { useCallback, useEffect, useState } from "react"; + +// Per-language daily audio lesson preference. The daily lesson is stored and +// queried per learned-language on the backend, so the preference is keyed per +// language too (e.g. "daily_audio_lesson_type_da"). The suggestion is stored +// verbatim — exactly what the user typed — and only canonicalized at +// generation time. +const typeKeyFor = (lang) => `daily_audio_lesson_type_${lang}`; +const suggestionKeyFor = (lang) => `daily_audio_lesson_suggestion_${lang}`; + +export default function useDailyLessonPreference(api, lang) { + // dailyType is the canonical backend value: three_words_lesson | topic | situation, + // or null when the user hasn't set up a daily lesson for this language. + const [dailyType, setDailyType] = useState(null); + const [dailySuggestion, setDailySuggestion] = useState(""); + const [prefLoaded, setPrefLoaded] = useState(false); + + useEffect(() => { + if (!lang) return; + let cancelled = false; + setPrefLoaded(false); + api + .getUserPreferences() + .then((prefs) => { + if (cancelled) return; + setDailyType(prefs[typeKeyFor(lang)] || null); + setDailySuggestion(prefs[suggestionKeyFor(lang)] || ""); + setPrefLoaded(true); + }) + .catch(() => { + if (!cancelled) setPrefLoaded(true); + }); + return () => { + cancelled = true; + }; + }, [api, lang]); + + const saveDailyLesson = useCallback( + (lessonType, suggestion) => { + // Optimistic local update so the UI reflects the choice immediately. + setDailyType(lessonType); + const verbatim = lessonType === "three_words_lesson" ? "" : suggestion || ""; + setDailySuggestion(verbatim); + api.saveUserPreferences({ + [typeKeyFor(lang)]: lessonType, + [suggestionKeyFor(lang)]: verbatim, + }); + }, + [api, lang], + ); + + return { + dailyType, + dailySuggestion, + prefLoaded, + isConfigured: !!dailyType, + saveDailyLesson, + }; +} From 514721ed72a3fe07f28ba10317f4797e52db1c55 Mon Sep 17 00:00:00 2001 From: Mircea Lungu Date: Wed, 27 May 2026 13:56:06 +0200 Subject: [PATCH 02/15] Daily audio: device-test polish (onboarding copy, card & dialog, dot) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Onboarding: clearer 'set up once, a new lesson daily, starting today' copy on the setup CTA and the settings dialog. - Episode card: type chip + subject under the title (no headphones, no duration — the player shows time); dropped redundant 'Today .' and the 'See past lessons' link; one check per listen (shared completionChecks, bumped live on replay); recurrence promise moved into the dialog. - Share/Feedback actions now sit below the config pill; 'Change your daily lesson type' rendered as a theme-aware config pill (light + dark). - Dialog reads as a real modal: darker+blurred backdrop, border + shadow, orange title, narrower; light-mode safe. - Notification dot only shows for a ready/generating lesson, not the persistent 'available' state. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/components/DailyAudioNotificationDot.js | 9 +- src/components/DialogWrapper.js | 4 +- src/dailyAudio/DailyLessonSettingsDialog.js | 15 ++- src/dailyAudio/LessonPlaybackView.js | 7 +- src/dailyAudio/PastLessons.js | 10 +- src/dailyAudio/TodayAudio.js | 7 +- src/dailyAudio/TodayEpisodeCard.js | 107 ++++++++++++-------- src/dailyAudio/audioUtils.js | 20 ++-- 8 files changed, 104 insertions(+), 75 deletions(-) diff --git a/src/components/DailyAudioNotificationDot.js b/src/components/DailyAudioNotificationDot.js index 6b4262759..658ed7f7a 100644 --- a/src/components/DailyAudioNotificationDot.js +++ b/src/components/DailyAudioNotificationDot.js @@ -43,8 +43,13 @@ const Dot = styled.div` `; export default function DailyAudioNotificationDot({ status, isActive, sidebar }) { - // Only show for generating (spinner) or ready (new lesson waiting) - if (!status || status === AUDIO_STATUS.COMPLETED || status === AUDIO_STATUS.IN_PROGRESS) return null; + // Show the dot only when there's something actionable: a fresh unlistened + // lesson waiting (ready) or one being generated (spinner). Everything else — + // no lesson yet ("available", the backend default before generation), + // already listened (completed), or mid-listen (in_progress) — shows nothing. + // Listing the two positive cases (rather than excluding a few) keeps stray + // backend statuses like "available" from leaking through the default branch. + if (status !== AUDIO_STATUS.GENERATING && status !== AUDIO_STATUS.READY) return null; return ; } diff --git a/src/components/DialogWrapper.js b/src/components/DialogWrapper.js index ac93849d1..6237b58cc 100644 --- a/src/components/DialogWrapper.js +++ b/src/components/DialogWrapper.js @@ -3,7 +3,9 @@ import * as RadixDialog from "@radix-ui/react-dialog"; import styled from "styled-components"; const Overlay = styled(RadixDialog.Overlay)` - background: hsla(0, 0%, 0%, 0.33); + background: hsla(0, 0%, 0%, 0.6); + backdrop-filter: blur(2px); + -webkit-backdrop-filter: blur(2px); position: fixed; top: 0; right: 0; diff --git a/src/dailyAudio/DailyLessonSettingsDialog.js b/src/dailyAudio/DailyLessonSettingsDialog.js index d8b39753b..6e0d5cbbe 100644 --- a/src/dailyAudio/DailyLessonSettingsDialog.js +++ b/src/dailyAudio/DailyLessonSettingsDialog.js @@ -3,6 +3,7 @@ import { Dialog } from "../components/DialogWrapper"; import SuggestionSelector, { pillToBackend, backendToPill } from "./SuggestionSelector"; import { SubtleTextButton } from "./LessonView.sc"; import { BannerButton } from "./SharedLessonView.sc"; +import { zeeguuOrange } from "../components/colors"; /** * Setup / "Change daily topic" dialog. Hosts the lesson-type selector and @@ -54,15 +55,19 @@ export default function DailyLessonSettingsDialog({ background: "var(--card-bg)", color: "var(--text-primary)", borderRadius: "16px", - width: "min(90vw, 360px)", + width: "min(86vw, 330px)", padding: "1.5rem", + border: "1px solid rgba(255, 255, 255, 0.14)", + boxShadow: "0 16px 48px rgba(0, 0, 0, 0.6)", }} > -

- Your daily lesson +

+ {todaysLessonExists ? "Change your daily lesson" : "Set up your daily lessons"}

- Every morning we'll have a fresh one ready for you. What kind? + {todaysLessonExists + ? "A new lesson, ready for you daily. Pick what you'd like instead — we'll make today's right away." + : "A new lesson, ready for you daily. Pick what kind — we'll make your first one right now."}

- {todaysLessonExists ? "Generate today's lesson" : "Start my daily lessons"} + {todaysLessonExists ? "Generate today's lesson" : "Start today's lesson"} {todaysLessonExists && ( diff --git a/src/dailyAudio/LessonPlaybackView.js b/src/dailyAudio/LessonPlaybackView.js index 907310341..a0f070faa 100644 --- a/src/dailyAudio/LessonPlaybackView.js +++ b/src/dailyAudio/LessonPlaybackView.js @@ -79,6 +79,9 @@ export default function LessonPlaybackView({ setLessonData((prev) => ({ ...prev, is_completed: true, + // One ✓ per listen — bump locally so replays show immediately, + // mirroring the backend's per-completion increment. + listened_count: (prev.listened_count || 0) + 1, last_completed_at: new Date().toISOString(), })); setUserDetails((prev) => ({ ...prev, daily_audio_status: AUDIO_STATUS.COMPLETED })); @@ -128,6 +131,8 @@ export default function LessonPlaybackView({
)} + {footer} + {lessonData.lesson_id && ( - {footer} - const lessonTitleText = (lesson) => lesson.title || "Past Audio Lesson"; -// Render the completion check(s). For replays we draw one ✓ per listen, -// up to 7; beyond that we use ✓✓✓…✓ so the row doesn't grow without bound. -const renderChecks = (count) => { - if (count <= 7) return "✓".repeat(count); - return "✓✓✓…✓"; -}; - function CollapsedProgressBar({ lesson }) { const duration = lesson.duration_seconds || 0; const pct = duration > 0 @@ -72,7 +66,7 @@ const titleWithDate = (lesson) => { {head} {tail} - {checkCount > 0 && <> {renderChecks(checkCount)}} + {checkCount > 0 && <> {completionChecks(checkCount)}} ); diff --git a/src/dailyAudio/TodayAudio.js b/src/dailyAudio/TodayAudio.js index ecad4715f..91ff48307 100644 --- a/src/dailyAudio/TodayAudio.js +++ b/src/dailyAudio/TodayAudio.js @@ -509,7 +509,6 @@ export default function TodayAudio() { currentPlaybackTime={currentPlaybackTime} setCurrentPlaybackTime={setCurrentPlaybackTime} onChangeTopic={() => setSettingsOpen(true)} - onSeePastLessons={() => history.push("/daily-audio/past-lessons")} /> {settingsDialog} @@ -544,14 +543,14 @@ export default function TodayAudio() { >
🎧

- {error ? "Let's try a different topic" : "Your daily listening lesson"} + {error ? "Let's try a different topic" : "A new lesson, daily"}

{error || - "Pick a topic you care about and we'll have a fresh lesson ready for you every morning. Just open and listen."} + "Choose what you'd like to listen to. We'll make you a fresh lesson on it daily — starting with today's."}

setSettingsOpen(true)} style={{ padding: "12px 24px", fontSize: "1rem" }}> - {isConfigured ? "Change daily topic" : "Choose your daily lesson"} + {isConfigured ? "Change daily topic" : "Set up my daily lessons"}
history.push("/daily-audio/past-lessons")}> diff --git a/src/dailyAudio/TodayEpisodeCard.js b/src/dailyAudio/TodayEpisodeCard.js index 71fbfafde..5054bcd85 100644 --- a/src/dailyAudio/TodayEpisodeCard.js +++ b/src/dailyAudio/TodayEpisodeCard.js @@ -1,77 +1,98 @@ import React from "react"; +import styled from "styled-components"; +import SettingsRoundedIcon from "@mui/icons-material/SettingsRounded"; import LessonPlaybackView from "./LessonPlaybackView"; -import { LessonTitle, LessonMetadata, CompletionCheck, SubtleTextButton } from "./LessonView.sc"; +import { LessonTitle, LessonMetadata, CompletionCheck } from "./LessonView.sc"; import { LessonTypeChip, chipLabel } from "./lessonTypeChip"; -import { todayDateLabel, formatDurationMinutes } from "./audioUtils"; +import { todayDateLabel, completionChecks } from "./audioUtils"; -// Design-B "episode card" header: reads like today's episode of a show that -// renews every morning — TODAY · date, a type chip, the title, and a compact -// metadata line. +// Config pill, styled like the Discover screen's "Topics: … ⚙" control but with +// theme tokens (not hardcoded white) so it renders as a pill in light AND dark. +const ConfigPill = styled.button` + display: inline-flex; + align-items: center; + gap: 0.4rem; + padding: 0.4rem 0.9rem; + border-radius: 999px; + border: 1px solid var(--border-light); + background: var(--bg-secondary); + color: var(--text-secondary); + font-family: inherit; + font-size: 0.85rem; + cursor: pointer; + -webkit-tap-highlight-color: transparent; + transition: border-color 0.15s, color 0.15s; + + &:active { + transform: scale(0.97); + } +`; + +// Design-B "episode card" header: a date line, the title (with one ✓ per +// listen), then a metadata line that leads with the type chip and the subject. function EpisodeHeader({ lessonData, words }) { const hasSubject = lessonData.lesson_type === "topic" || lessonData.lesson_type === "situation"; - const metaParts = [ - formatDurationMinutes(lessonData.duration_seconds), + // Type chip lives on the metadata line now; duration is omitted (the player + // already shows total time) and there's no headphones glyph. + const metaText = [ hasSubject ? lessonData.canonical_suggestion : null, words && words.length > 0 ? `${words.length} words` : null, - ].filter(Boolean); + ] + .filter(Boolean) + .join(" · "); return ( <> -
- - Today · {todayDateLabel()} - - - {chipLabel(lessonData.lesson_type)} - -
+ + {todayDateLabel()} + {lessonData.title} - {lessonData.is_completed && <> } + {lessonData.is_completed && ( + <> {completionChecks(Math.max(1, lessonData.listened_count || 1))} + )} - {metaParts.length > 0 && 🎧 {metaParts.join(" · ")}} + + + {chipLabel(lessonData.lesson_type)} + + {metaText && {metaText}} + ); } -function freshLine(lessonData) { - if (lessonData.lesson_type === "topic" || lessonData.lesson_type === "situation") { - return `A fresh ${lessonData.canonical_suggestion} lesson, every morning.`; - } - return "A fresh vocabulary lesson, every morning."; -} - /** * The Today view's main state: today's pre-generated lesson, ready to play. * Reuses LessonPlaybackView for all the player/words/feedback plumbing and * injects the Design-B header + footer. */ -export default function TodayEpisodeCard({ onChangeTopic, onSeePastLessons, ...playbackProps }) { +export default function TodayEpisodeCard({ onChangeTopic, ...playbackProps }) { const { lessonData, words } = playbackProps; + // Just the daily-lesson config pill (styled like the Discover screen's + // "Topics: … ⚙" control), above the Share/Feedback utility actions. The + // "new lesson every day" promise now lives in the settings dialog, and + // completion is already acknowledged by the player's banner — so the footer + // stays quiet to keep the screen uncluttered. const footer = (
-

- {lessonData.is_completed - ? "You're done for today — a fresh lesson arrives tomorrow 🌅" - : freshLine(lessonData)} -

-
- Change daily topic - See past lessons → -
+ + Change your daily lesson type + +
); diff --git a/src/dailyAudio/audioUtils.js b/src/dailyAudio/audioUtils.js index b1b9e8219..29ea3ec51 100644 --- a/src/dailyAudio/audioUtils.js +++ b/src/dailyAudio/audioUtils.js @@ -11,19 +11,19 @@ export function shortDate() { return `[${new Date().toLocaleDateString("en-US", { month: "short", day: "numeric" })}]`; } -// "Wed, May 27" — shown next to the TODAY label on the episode card so the -// daily lesson reads like a dated episode that renews each day. +// Completion check(s): one ✓ per listen, capped so the row can't grow without +// bound (✓✓✓…✓ beyond 7). Shared by today's episode card and the past-lessons +// list so they show completion the same way. +export function completionChecks(count) { + if (count <= 7) return "✓".repeat(count); + return "✓✓✓…✓"; +} + +// "May 27" — shown on the episode card so the daily lesson reads like a dated +// episode. Weekday omitted to keep the (busy) header compact. export function todayDateLabel() { return new Date().toLocaleDateString("en-US", { - weekday: "short", month: "short", day: "numeric", }); } - -// Compact, human duration for the episode card: "4 min". Rounds up so a -// 30-second clip still reads as "1 min" rather than "0 min". -export function formatDurationMinutes(seconds) { - if (!seconds || seconds <= 0) return ""; - return `${Math.max(1, Math.round(seconds / 60))} min`; -} From 1f6dbc32cc0345bc2c76ce72e0184f11cca20b8d Mon Sep 17 00:00:00 2001 From: Mircea Lungu Date: Wed, 27 May 2026 14:03:06 +0200 Subject: [PATCH 03/15] Daily audio: inline type chip; 'Save settings' with regenerate-or-tomorrow prompt - Episode card: the type chip now flows inline at the start of the subject line instead of sitting on its own row. - Settings dialog: primary button is now 'Save settings'. Saving a *changed* setting while today's lesson already exists asks whether to regenerate today's lesson or start from tomorrow; otherwise it just saves (generating immediately only when there's no lesson for today yet). Co-Authored-By: Claude Opus 4.7 (1M context) --- src/dailyAudio/DailyLessonSettingsDialog.js | 62 ++++++++++++++------- src/dailyAudio/TodayEpisodeCard.js | 9 ++- 2 files changed, 47 insertions(+), 24 deletions(-) diff --git a/src/dailyAudio/DailyLessonSettingsDialog.js b/src/dailyAudio/DailyLessonSettingsDialog.js index 6e0d5cbbe..3b2a59ce7 100644 --- a/src/dailyAudio/DailyLessonSettingsDialog.js +++ b/src/dailyAudio/DailyLessonSettingsDialog.js @@ -26,6 +26,7 @@ export default function DailyLessonSettingsDialog({ const [pillType, setPillType] = useState(initialType ? backendToPill(initialType) : "topic"); const [suggestion, setSuggestion] = useState(initialSuggestion || ""); const [autoDisabled, setAutoDisabled] = useState(false); + const [confirmRegen, setConfirmRegen] = useState(false); // Vocabulary needs enough study words; disable the pill when there aren't. useEffect(() => { @@ -47,6 +48,44 @@ export default function DailyLessonSettingsDialog({ onSubmit(pillToBackend(pillType), needsSubject ? suggestion : "", regenerate); }; + // Did the user actually pick something different from their current setup? + const changed = + pillToBackend(pillType) !== initialType || + (needsSubject ? suggestion.trim() : "") !== (initialSuggestion || "").trim(); + + // Saving a *changed* setting while today's lesson already exists asks whether + // to apply it now or from tomorrow. Otherwise just save — generating right + // away when there's no lesson for today yet (first run / cron miss). + const handleSaveClick = () => { + if (!canSubmit) return; + if (todaysLessonExists && changed) setConfirmRegen(true); + else submit(!todaysLessonExists); + }; + + const buttonStyle = { width: "100%", padding: "12px", fontSize: "1rem", fontWeight: 600 }; + + const saveButton = ( + + Save settings + + ); + + const confirmStep = ( + <> +

+ Apply this to today's lesson, or start from tomorrow? +

+ submit(true)} style={buttonStyle}> + Regenerate today's lesson + + submit(false)}>Start from tomorrow + + ); + return (

{todaysLessonExists - ? "A new lesson, ready for you daily. Pick what you'd like instead — we'll make today's right away." + ? "A new lesson, ready for you daily. Pick what you'd like instead." : "A new lesson, ready for you daily. Pick what kind — we'll make your first one right now."}

@@ -79,26 +118,7 @@ export default function DailyLessonSettingsDialog({ />
- submit(true)} - disabled={!canSubmit} - style={{ - width: "100%", - padding: "12px", - fontSize: "1rem", - fontWeight: 600, - opacity: canSubmit ? 1 : 0.5, - cursor: canSubmit ? "pointer" : "not-allowed", - }} - > - {todaysLessonExists ? "Generate today's lesson" : "Start today's lesson"} - - - {todaysLessonExists && ( - submit(false)} disabled={!canSubmit}> - Save for tomorrow instead - - )} + {confirmRegen ? confirmStep : saveButton}
); diff --git a/src/dailyAudio/TodayEpisodeCard.js b/src/dailyAudio/TodayEpisodeCard.js index 5054bcd85..2768e3538 100644 --- a/src/dailyAudio/TodayEpisodeCard.js +++ b/src/dailyAudio/TodayEpisodeCard.js @@ -64,11 +64,14 @@ function EpisodeHeader({ lessonData, words }) { )} - - + + {chipLabel(lessonData.lesson_type)} - {metaText && {metaText}} + {metaText} ); From c20473a884fb8797ceea0e9a2883a97723ba813e Mon Sep 17 00:00:00 2001 From: Mircea Lungu Date: Wed, 27 May 2026 14:06:22 +0200 Subject: [PATCH 04/15] Daily audio: config pill spans width with gear aligned right Co-Authored-By: Claude Opus 4.7 (1M context) --- src/dailyAudio/TodayEpisodeCard.js | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/src/dailyAudio/TodayEpisodeCard.js b/src/dailyAudio/TodayEpisodeCard.js index 2768e3538..eb71c3c2f 100644 --- a/src/dailyAudio/TodayEpisodeCard.js +++ b/src/dailyAudio/TodayEpisodeCard.js @@ -9,10 +9,13 @@ import { todayDateLabel, completionChecks } from "./audioUtils"; // Config pill, styled like the Discover screen's "Topics: … ⚙" control but with // theme tokens (not hardcoded white) so it renders as a pill in light AND dark. const ConfigPill = styled.button` - display: inline-flex; + display: flex; align-items: center; + justify-content: space-between; gap: 0.4rem; - padding: 0.4rem 0.9rem; + width: min(100%, 320px); + margin: 0 auto; + padding: 0.5rem 0.95rem; border-radius: 999px; border: 1px solid var(--border-light); background: var(--bg-secondary); From fbf03742d534c3eab0747a2ce2732d267809232b Mon Sep 17 00:00:00 2001 From: Mircea Lungu Date: Wed, 27 May 2026 14:13:50 +0200 Subject: [PATCH 05/15] Daily audio: keep wrapped subject text out from under the type chip Chip and subject now sit in separate flex columns (no wrap) so the subject wraps within its own column instead of flowing back under the chip, while still sharing the first line. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/dailyAudio/TodayEpisodeCard.js | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/dailyAudio/TodayEpisodeCard.js b/src/dailyAudio/TodayEpisodeCard.js index eb71c3c2f..a55160c14 100644 --- a/src/dailyAudio/TodayEpisodeCard.js +++ b/src/dailyAudio/TodayEpisodeCard.js @@ -67,14 +67,14 @@ function EpisodeHeader({ lessonData, words }) { )} - + {chipLabel(lessonData.lesson_type)} - {metaText} + {metaText && {metaText}} ); From f57c72955609bb08386e2bf1f54e2462719e56be Mon Sep 17 00:00:00 2001 From: Mircea Lungu Date: Wed, 27 May 2026 14:16:17 +0200 Subject: [PATCH 06/15] Daily audio: simplify settings dialog to 'Daily lesson type' title, drop redundant subtitle The per-type description in the selector already explains each option. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/dailyAudio/DailyLessonSettingsDialog.js | 9 ++------- 1 file changed, 2 insertions(+), 7 deletions(-) diff --git a/src/dailyAudio/DailyLessonSettingsDialog.js b/src/dailyAudio/DailyLessonSettingsDialog.js index 3b2a59ce7..01e613705 100644 --- a/src/dailyAudio/DailyLessonSettingsDialog.js +++ b/src/dailyAudio/DailyLessonSettingsDialog.js @@ -100,14 +100,9 @@ export default function DailyLessonSettingsDialog({ boxShadow: "0 16px 48px rgba(0, 0, 0, 0.6)", }} > -

- {todaysLessonExists ? "Change your daily lesson" : "Set up your daily lessons"} +

+ Daily lesson type

-

- {todaysLessonExists - ? "A new lesson, ready for you daily. Pick what you'd like instead." - : "A new lesson, ready for you daily. Pick what kind — we'll make your first one right now."} -

Date: Wed, 27 May 2026 14:16:53 +0200 Subject: [PATCH 07/15] Daily audio: config pill label down to 'Daily lesson type' (gear implies change) Co-Authored-By: Claude Opus 4.7 (1M context) --- src/dailyAudio/TodayEpisodeCard.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/dailyAudio/TodayEpisodeCard.js b/src/dailyAudio/TodayEpisodeCard.js index a55160c14..bac51c8ba 100644 --- a/src/dailyAudio/TodayEpisodeCard.js +++ b/src/dailyAudio/TodayEpisodeCard.js @@ -96,7 +96,7 @@ export default function TodayEpisodeCard({ onChangeTopic, ...playbackProps }) { const footer = (
- Change your daily lesson type + Daily lesson type
From 8491c96360a84decdcc990d120118edff9f6c7e6 Mon Sep 17 00:00:00 2001 From: Mircea Lungu Date: Wed, 27 May 2026 16:27:06 +0200 Subject: [PATCH 08/15] Daily audio: 'Daily lesson settings' label (pill + dialog), content-width pill MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 'Settings' over 'type'/'config' — clearest for a non-technical learner. Pill hugs its content again instead of stretching to a bar. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/dailyAudio/DailyLessonSettingsDialog.js | 2 +- src/dailyAudio/TodayEpisodeCard.js | 11 ++++------- 2 files changed, 5 insertions(+), 8 deletions(-) diff --git a/src/dailyAudio/DailyLessonSettingsDialog.js b/src/dailyAudio/DailyLessonSettingsDialog.js index 01e613705..18e2a2ff1 100644 --- a/src/dailyAudio/DailyLessonSettingsDialog.js +++ b/src/dailyAudio/DailyLessonSettingsDialog.js @@ -101,7 +101,7 @@ export default function DailyLessonSettingsDialog({ }} >

- Daily lesson type + Daily lesson settings

- - Daily lesson type + + Daily lesson settings
From 8128240e7b1f2bd3c9a338978e667b1afd4f7c38 Mon Sep 17 00:00:00 2001 From: Mircea Lungu Date: Wed, 27 May 2026 16:30:16 +0200 Subject: [PATCH 09/15] Daily audio: single 'Type: subject' chip on the card, matching past lessons MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Subject now lives inside the colored chip (e.g. 'Situation: cafe') like the past-lessons category headers — consistent, and the subject wraps inside the pill instead of under it. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/dailyAudio/TodayEpisodeCard.js | 30 ++++++++---------------------- 1 file changed, 8 insertions(+), 22 deletions(-) diff --git a/src/dailyAudio/TodayEpisodeCard.js b/src/dailyAudio/TodayEpisodeCard.js index 89ce2f2cb..d687f321a 100644 --- a/src/dailyAudio/TodayEpisodeCard.js +++ b/src/dailyAudio/TodayEpisodeCard.js @@ -29,20 +29,9 @@ const ConfigPill = styled.button` `; // Design-B "episode card" header: a date line, the title (with one ✓ per -// listen), then a metadata line that leads with the type chip and the subject. -function EpisodeHeader({ lessonData, words }) { - const hasSubject = - lessonData.lesson_type === "topic" || lessonData.lesson_type === "situation"; - - // Type chip lives on the metadata line now; duration is omitted (the player - // already shows total time) and there's no headphones glyph. - const metaText = [ - hasSubject ? lessonData.canonical_suggestion : null, - words && words.length > 0 ? `${words.length} words` : null, - ] - .filter(Boolean) - .join(" · "); - +// listen), then a single category chip — "Type: subject" — exactly like the +// past-lessons list, so the subject lives inside the pill (no wrap-under). +function EpisodeHeader({ lessonData }) { return ( <> - - + + {chipLabel(lessonData.lesson_type)} + {lessonData.canonical_suggestion ? `: ${lessonData.canonical_suggestion}` : ""} - {metaText && {metaText}} ); @@ -83,7 +69,7 @@ function EpisodeHeader({ lessonData, words }) { * injects the Design-B header + footer. */ export default function TodayEpisodeCard({ onChangeTopic, ...playbackProps }) { - const { lessonData, words } = playbackProps; + const { lessonData } = playbackProps; // Just the daily-lesson config pill (styled like the Discover screen's // "Topics: … ⚙" control), above the Share/Feedback utility actions. The @@ -102,7 +88,7 @@ export default function TodayEpisodeCard({ onChangeTopic, ...playbackProps }) { return ( } + header={} footer={footer} /> ); From 1010c43d8bc651194572202d536a321b648da063 Mon Sep 17 00:00:00 2001 From: Mircea Lungu Date: Wed, 27 May 2026 16:35:06 +0200 Subject: [PATCH 10/15] Daily audio: Share under the player; settings dialog reflects current type/subject - Share moved to a centered link directly under the player (like past lessons); the faint bottom row is now just Feedback / Delete. - Settings dialog seeds from the saved preference, falling back to today's lesson's type + subject when no preference is stored yet, so the learner's current choice is always pre-selected. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/dailyAudio/LessonPlaybackView.js | 15 ++++++++------- src/dailyAudio/TodayAudio.js | 7 +++++-- 2 files changed, 13 insertions(+), 9 deletions(-) diff --git a/src/dailyAudio/LessonPlaybackView.js b/src/dailyAudio/LessonPlaybackView.js index a0f070faa..83f4d97d3 100644 --- a/src/dailyAudio/LessonPlaybackView.js +++ b/src/dailyAudio/LessonPlaybackView.js @@ -97,6 +97,14 @@ export default function LessonPlaybackView({ }} /> + {lessonData.lesson_id && ( +
+ shareLessonLink(api, lessonData.lesson_id, lessonData.title)}> + Share + +
+ )} + {lessonData.is_completed && (
- {lessonData.lesson_id && ( - shareLessonLink(api, lessonData.lesson_id, lessonData.title)} - > - Share - - )} setOpenFeedback(true)}> Feedback diff --git a/src/dailyAudio/TodayAudio.js b/src/dailyAudio/TodayAudio.js index 91ff48307..bb0d2d5ff 100644 --- a/src/dailyAudio/TodayAudio.js +++ b/src/dailyAudio/TodayAudio.js @@ -361,11 +361,14 @@ export default function TodayAudio() { // eslint-disable-next-line react-hooks/exhaustive-deps }, [prefLoaded, noLesson, isGenerating, lessonData, isConfigured, dailyType, dailySuggestion]); + // Seed the dialog with the saved preference; if none is stored yet (e.g. a + // lesson generated before preferences existed), fall back to today's lesson + // so the learner always sees their current type/subject pre-selected. const settingsDialog = settingsOpen && ( setSettingsOpen(false)} From a039679201c09e69d409b4b7211e4a6869cf4e17 Mon Sep 17 00:00:00 2001 From: Mircea Lungu Date: Wed, 27 May 2026 16:43:02 +0200 Subject: [PATCH 11/15] Daily audio: Share inside the player (shared control) + dialog fixes - Extract the history's Share into a shared ShareLessonButton used by both the episode card and the past-lessons list. CustomAudioPlayer gains a children slot so the card renders Share *inside* the player box (same as history, where the card wraps a transparent player + Share). - Settings dialog: per-type subject so switching pills (or to Vocabulary and back) no longer wipes/bleeds Topic vs Situation text. - Save settings stays disabled until something actually changes. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/components/CustomAudioPlayer.js | 3 ++ src/dailyAudio/DailyLessonSettingsDialog.js | 41 +++++++++++++-------- src/dailyAudio/LessonPlaybackView.js | 14 ++----- src/dailyAudio/PastLessons.js | 10 +---- src/dailyAudio/ShareLessonButton.js | 17 +++++++++ src/dailyAudio/SuggestionSelector.js | 5 +-- 6 files changed, 54 insertions(+), 36 deletions(-) create mode 100644 src/dailyAudio/ShareLessonButton.js diff --git a/src/components/CustomAudioPlayer.js b/src/components/CustomAudioPlayer.js index 2cd8dc14c..b40839b45 100644 --- a/src/components/CustomAudioPlayer.js +++ b/src/components/CustomAudioPlayer.js @@ -99,6 +99,7 @@ export default function CustomAudioPlayer({ title = "Audio Lesson", artist = "Zeeguu", autoPlay = false, + children, }) { const getStoredSpeed = () => { if (!language) return 1.0; @@ -804,6 +805,8 @@ export default function CustomAudioPlayer({ {formatTime(duration, true)}
+ + {children}
); } diff --git a/src/dailyAudio/DailyLessonSettingsDialog.js b/src/dailyAudio/DailyLessonSettingsDialog.js index 18e2a2ff1..118170559 100644 --- a/src/dailyAudio/DailyLessonSettingsDialog.js +++ b/src/dailyAudio/DailyLessonSettingsDialog.js @@ -24,7 +24,12 @@ export default function DailyLessonSettingsDialog({ onDismiss, }) { const [pillType, setPillType] = useState(initialType ? backendToPill(initialType) : "topic"); - const [suggestion, setSuggestion] = useState(initialSuggestion || ""); + // Per-type subject so switching pills — or to Vocabulary and back — never + // clobbers what was typed for Topic vs Situation. + const [subjectByType, setSubjectByType] = useState(() => ({ + topic: initialType === "topic" ? initialSuggestion || "" : "", + situation: initialType === "situation" ? initialSuggestion || "" : "", + })); const [autoDisabled, setAutoDisabled] = useState(false); const [confirmRegen, setConfirmRegen] = useState(false); @@ -40,26 +45,32 @@ export default function DailyLessonSettingsDialog({ }, [api]); const needsSubject = pillType === "topic" || pillType === "situation"; - const canSubmit = !needsSubject || suggestion.trim().length > 0; + const currentSubject = needsSubject ? subjectByType[pillType] : ""; + const setCurrentSubject = (val) => setSubjectByType((prev) => ({ ...prev, [pillType]: val })); + const canSubmit = !needsSubject || currentSubject.trim().length > 0; const submit = (regenerate) => { if (!canSubmit) return; // Store the subject exactly as typed; Vocabulary carries no subject. - onSubmit(pillToBackend(pillType), needsSubject ? suggestion : "", regenerate); + onSubmit(pillToBackend(pillType), needsSubject ? currentSubject : "", regenerate); }; // Did the user actually pick something different from their current setup? + // Save stays disabled until they do. + const initialSubject = + initialType === "topic" || initialType === "situation" ? (initialSuggestion || "").trim() : ""; const changed = - pillToBackend(pillType) !== initialType || - (needsSubject ? suggestion.trim() : "") !== (initialSuggestion || "").trim(); + pillToBackend(pillType) !== (initialType || null) || + (needsSubject ? currentSubject.trim() : "") !== initialSubject; + const canSave = canSubmit && changed; - // Saving a *changed* setting while today's lesson already exists asks whether - // to apply it now or from tomorrow. Otherwise just save — generating right - // away when there's no lesson for today yet (first run / cron miss). + // Saving a changed setting while today's lesson already exists asks whether to + // apply it now or from tomorrow. Otherwise just save — generating right away + // when there's no lesson for today yet (first run / cron miss). const handleSaveClick = () => { - if (!canSubmit) return; - if (todaysLessonExists && changed) setConfirmRegen(true); - else submit(!todaysLessonExists); + if (!canSave) return; + if (todaysLessonExists) setConfirmRegen(true); + else submit(true); }; const buttonStyle = { width: "100%", padding: "12px", fontSize: "1rem", fontWeight: 600 }; @@ -67,8 +78,8 @@ export default function DailyLessonSettingsDialog({ const saveButton = ( Save settings @@ -107,8 +118,8 @@ export default function DailyLessonSettingsDialog({ diff --git a/src/dailyAudio/LessonPlaybackView.js b/src/dailyAudio/LessonPlaybackView.js index 83f4d97d3..7be303b8d 100644 --- a/src/dailyAudio/LessonPlaybackView.js +++ b/src/dailyAudio/LessonPlaybackView.js @@ -8,7 +8,7 @@ import { AUDIO_STATUS } from "./AudioLessonConstants"; import { LessonWrapper, LessonTitle, LessonMetadata, CompletionCheck, SubtleTextButton, LessonActions } from "./LessonView.sc"; import { wordsAsTile } from "./audioUtils"; import { languageNames } from "../utils/languageDetection"; -import { shareLessonLink } from "./shareLessonLink"; +import ShareLessonButton from "./ShareLessonButton"; export default function LessonPlaybackView({ lessonData, @@ -95,15 +95,9 @@ export default function LessonPlaybackView({ maxWidth: "600px", margin: "0 auto 20px auto", }} - /> - - {lessonData.lesson_id && ( -
- shareLessonLink(api, lessonData.lesson_id, lessonData.title)}> - Share - -
- )} + > + + {lessonData.is_completed && (
{}} /> - {lesson.lesson_id && ( -
- shareLessonLink(api, lesson.lesson_id, lesson.title)}> - Share - -
- )} + ); } diff --git a/src/dailyAudio/ShareLessonButton.js b/src/dailyAudio/ShareLessonButton.js new file mode 100644 index 000000000..b2a2eb77c --- /dev/null +++ b/src/dailyAudio/ShareLessonButton.js @@ -0,0 +1,17 @@ +import React from "react"; +import { SubtleTextButton } from "./LessonView.sc"; +import { shareLessonLink } from "./shareLessonLink"; + +// The Share control shown under the player — shared by today's episode card and +// the past-lessons list so they stay identical. Renders nothing without a +// lesson id. +export default function ShareLessonButton({ api, lessonId, title }) { + if (!lessonId) return null; + return ( +
+ shareLessonLink(api, lessonId, title)}> + Share + +
+ ); +} diff --git a/src/dailyAudio/SuggestionSelector.js b/src/dailyAudio/SuggestionSelector.js index 3d168ae53..93b49a172 100644 --- a/src/dailyAudio/SuggestionSelector.js +++ b/src/dailyAudio/SuggestionSelector.js @@ -52,10 +52,9 @@ export default function SuggestionSelector({ const selectType = (key) => { if (suggestionType === key) return; if (key === "auto" && autoDisabled) return; + // Only switch the type — the parent keeps a per-type subject, so it supplies + // the right text for whichever pill is selected (and clears nothing). setSuggestionType(key); - // Switching to Vocabulary clears the (now irrelevant) subject; switching - // between Topic/Situation keeps whatever the user already typed. - if (key === "auto") setSuggestion(""); }; return ( From fec9b2671c902f0d21859ee19d59849b59368fe4 Mon Sep 17 00:00:00 2001 From: Mircea Lungu Date: Wed, 27 May 2026 16:43:47 +0200 Subject: [PATCH 12/15] Daily audio: tighten gap above the Share control Co-Authored-By: Claude Opus 4.7 (1M context) --- src/dailyAudio/ShareLessonButton.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/dailyAudio/ShareLessonButton.js b/src/dailyAudio/ShareLessonButton.js index b2a2eb77c..50a9dbf84 100644 --- a/src/dailyAudio/ShareLessonButton.js +++ b/src/dailyAudio/ShareLessonButton.js @@ -8,7 +8,7 @@ import { shareLessonLink } from "./shareLessonLink"; export default function ShareLessonButton({ api, lessonId, title }) { if (!lessonId) return null; return ( -
+
shareLessonLink(api, lessonId, title)}> Share From 2fd82288d24976948fa642d53db0d963a546c012 Mon Sep 17 00:00:00 2001 From: Mircea Lungu Date: Wed, 27 May 2026 16:44:21 +0200 Subject: [PATCH 13/15] Daily audio: pull Share up closer to the bar (-2px) Co-Authored-By: Claude Opus 4.7 (1M context) --- src/dailyAudio/ShareLessonButton.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/dailyAudio/ShareLessonButton.js b/src/dailyAudio/ShareLessonButton.js index 50a9dbf84..4dee300db 100644 --- a/src/dailyAudio/ShareLessonButton.js +++ b/src/dailyAudio/ShareLessonButton.js @@ -8,7 +8,7 @@ import { shareLessonLink } from "./shareLessonLink"; export default function ShareLessonButton({ api, lessonId, title }) { if (!lessonId) return null; return ( -
+
shareLessonLink(api, lessonId, title)}> Share From cb444c8ef0f213e9a80c3ef6e3d5c976489bfcf0 Mon Sep 17 00:00:00 2001 From: Mircea Lungu Date: Wed, 27 May 2026 17:07:15 +0200 Subject: [PATCH 14/15] Daily audio: address code-review findings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Correctness: - TodayAudio: exhausted polling no longer leaves a permanent 'Preparing…' spinner — it marks the attempt done and shows a recoverable error; the spinner branch is gated on !autoGenAttemptedRef. - TodayAudio: language switch mid-generation now tears down the stale poll/flag (lang effect resets isGenerating + generationProgress). - handleConfigured: deleteTodaysLesson failure surfaces an error instead of silently regenerating (which returns the old lesson). - startGeneration: 'not enough words' shows Topic/Situation guidance (restores the old feasibility hint the auto-gen path had dropped). - PastLessons.onLessonCompleted bumps listened_count so replay checkmarks update live, matching the today card. - DailyLessonSettingsDialog: reset confirmRegen when type/subject changes so the regenerate-or-tomorrow prompt can't strand or describe a stale choice. Cleanup: - Dedup the short-date formatter into audioUtils.formatShortDate (used by the card header and past-lessons rows). - Drop the redundant 'words' prop; LessonPlaybackView derives it from lessonData. - useDailyLessonPreference reports a failed save to Sentry. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/dailyAudio/DailyLessonSettingsDialog.js | 7 +++ src/dailyAudio/LessonPlaybackView.js | 2 +- src/dailyAudio/PastLessons.js | 17 ++++--- src/dailyAudio/TodayAudio.js | 55 +++++++++++++++------ src/dailyAudio/audioUtils.js | 17 ++++--- src/hooks/useDailyLessonPreference.js | 13 +++-- 6 files changed, 77 insertions(+), 34 deletions(-) diff --git a/src/dailyAudio/DailyLessonSettingsDialog.js b/src/dailyAudio/DailyLessonSettingsDialog.js index 118170559..071e1c4a5 100644 --- a/src/dailyAudio/DailyLessonSettingsDialog.js +++ b/src/dailyAudio/DailyLessonSettingsDialog.js @@ -33,6 +33,13 @@ export default function DailyLessonSettingsDialog({ const [autoDisabled, setAutoDisabled] = useState(false); const [confirmRegen, setConfirmRegen] = useState(false); + // Changing the type/subject after the regenerate-or-tomorrow prompt has shown + // would leave it describing a stale choice (and could strand the confirm + // buttons if the new subject is empty) — so drop back to "Save settings". + useEffect(() => { + setConfirmRegen(false); + }, [pillType, subjectByType]); + // Vocabulary needs enough study words; disable the pill when there aren't. useEffect(() => { api.checkDailyLessonFeasibility( diff --git a/src/dailyAudio/LessonPlaybackView.js b/src/dailyAudio/LessonPlaybackView.js index 7be303b8d..10096ff88 100644 --- a/src/dailyAudio/LessonPlaybackView.js +++ b/src/dailyAudio/LessonPlaybackView.js @@ -13,7 +13,6 @@ import ShareLessonButton from "./ShareLessonButton"; export default function LessonPlaybackView({ lessonData, setLessonData, - words, error, api, userDetails, @@ -25,6 +24,7 @@ export default function LessonPlaybackView({ footer, }) { const [openFeedback, setOpenFeedback] = useState(false); + const words = lessonData.words || []; // Callers (e.g. the daily episode card) can replace the default title block // with their own header; otherwise we show the plain title + topic line. diff --git a/src/dailyAudio/PastLessons.js b/src/dailyAudio/PastLessons.js index 16370b3e6..6bd625303 100644 --- a/src/dailyAudio/PastLessons.js +++ b/src/dailyAudio/PastLessons.js @@ -8,7 +8,7 @@ import { SubtleTextButton, LessonTitle, CompletionCheck } from "./LessonView.sc" import { SubtleLessonCard, ProgressBarTrack, ProgressBarFill } from "./SharedLessonView.sc"; import { PillRow, SelectablePill } from "./SuggestionSelector.sc"; import { LessonTypeChip, chipLabel } from "./lessonTypeChip"; -import { completionChecks } from "./audioUtils"; +import { completionChecks, formatShortDate } from "./audioUtils"; import ShareLessonButton from "./ShareLessonButton"; // Filter the back catalogue by lesson type. value=null means "All". @@ -22,11 +22,7 @@ const TYPE_FILTERS = [ const lessonProgressSeconds = (lesson) => lesson.pause_position_seconds || lesson.position_seconds || lesson.progress_seconds || 0; -const lessonDateLabel = (lesson) => - new Date(lesson.created_at).toLocaleDateString("en-US", { - month: "short", - day: "numeric", - }); +const lessonDateLabel = (lesson) => formatShortDate(new Date(lesson.created_at)); const lessonTitleText = (lesson) => lesson.title || "Past Audio Lesson"; @@ -145,7 +141,14 @@ export default function PastLessons() { setPastLessons((prev) => prev.map((l) => l.lesson_id === lessonId - ? { ...l, is_completed: true, last_completed_at: new Date().toISOString() } + ? { + ...l, + is_completed: true, + // One ✓ per listen — bump locally so replays add a check live, + // matching the today card (LessonPlaybackView does the same). + listened_count: (l.listened_count || 0) + 1, + last_completed_at: new Date().toISOString(), + } : l, ), ); diff --git a/src/dailyAudio/TodayAudio.js b/src/dailyAudio/TodayAudio.js index bb0d2d5ff..f7a5787f0 100644 --- a/src/dailyAudio/TodayAudio.js +++ b/src/dailyAudio/TodayAudio.js @@ -67,6 +67,11 @@ export default function TodayAudio() { setLessonData(null); setError(null); setNoLesson(false); + // Tear down any in-flight generation/polling tied to the previous language + // (the poll effect keys its localStorage flag on the old lang and doesn't + // depend on lang, so without this it keeps polling for the wrong language). + setIsGenerating(false); + setGenerationProgress(null); autoGenAttemptedRef.current = false; }, [lang]); @@ -113,7 +118,15 @@ export default function TodayAudio() { setGenerationProgress(null); setUserDetails((prev) => ({ ...prev, daily_audio_status: null })); - const msg = err.message || "Couldn't prepare today's lesson. Please try again."; + // "Not enough words" for a vocabulary lesson is actionable — steer to a + // Topic/Situation (restores the old proactive feasibility guidance, + // which the auto-generate path otherwise lost). + let msg; + if (err.message && err.message.toLowerCase().includes("not enough words")) { + msg = "Not enough words for a vocabulary lesson yet. Try a Topic or Situation instead."; + } else { + msg = err.message || "Couldn't prepare today's lesson. Please try again."; + } // Remember the failure for the rest of the day so we don't auto-retry // on every refresh; the user can change the topic to try again. localStorage.setItem(failedKey, msg); @@ -136,11 +149,16 @@ export default function TodayAudio() { setError(null); autoGenAttemptedRef.current = true; // we're driving generation explicitly if (lessonData) { - const afterDelete = () => { - setLessonData(null); - startGeneration(backendType, suggestion); - }; - api.deleteTodaysLesson(afterDelete, afterDelete); + api.deleteTodaysLesson( + () => { + setLessonData(null); + startGeneration(backendType, suggestion); + }, + // If the delete fails the backend still has today's lesson, so + // regenerating would silently hand back the old one — surface an error + // rather than pretend the change applied. + () => setError("Couldn't refresh today's lesson. Please try again."), + ); } else { startGeneration(backendType, suggestion); } @@ -199,6 +217,16 @@ export default function TodayAudio() { setError(message); }; + // Polling found neither a progress record nor a lesson — generation isn't + // actually running. Stop, mark it attempted (so the auto-gen effect won't + // relaunch), and show a recoverable error instead of an endless spinner. + const handleExhausted = () => { + stopPolling(); + setNoLesson(true); + autoGenAttemptedRef.current = true; + setError("We couldn't prepare today's lesson. Try again or pick a different topic."); + }; + const checkForLesson = () => { api.getTodaysLesson(handleLessonReady, () => {}); }; @@ -227,14 +255,10 @@ export default function TodayAudio() { if (data && data.lesson_id) { handleLessonReady(data); } else { - stopPolling(); - setNoLesson(true); + handleExhausted(); } }, - () => { - stopPolling(); - setNoLesson(true); - }, + handleExhausted, ); } }, @@ -503,7 +527,6 @@ export default function TodayAudio() { diff --git a/src/dailyAudio/audioUtils.js b/src/dailyAudio/audioUtils.js index 29ea3ec51..037098a8e 100644 --- a/src/dailyAudio/audioUtils.js +++ b/src/dailyAudio/audioUtils.js @@ -7,8 +7,14 @@ export function wordsAsTile(words) { return capitalized_comma_separated_words; } +// "May 27" — the one short-date format for lessons, shared by the episode card +// header and the past-lessons rows so they can never drift apart. +export function formatShortDate(date) { + return date.toLocaleDateString("en-US", { month: "short", day: "numeric" }); +} + export function shortDate() { - return `[${new Date().toLocaleDateString("en-US", { month: "short", day: "numeric" })}]`; + return `[${formatShortDate(new Date())}]`; } // Completion check(s): one ✓ per listen, capped so the row can't grow without @@ -19,11 +25,8 @@ export function completionChecks(count) { return "✓✓✓…✓"; } -// "May 27" — shown on the episode card so the daily lesson reads like a dated -// episode. Weekday omitted to keep the (busy) header compact. +// "May 27" for today — shown on the episode card so the daily lesson reads like +// a dated episode (weekday omitted to keep the header compact). export function todayDateLabel() { - return new Date().toLocaleDateString("en-US", { - month: "short", - day: "numeric", - }); + return formatShortDate(new Date()); } diff --git a/src/hooks/useDailyLessonPreference.js b/src/hooks/useDailyLessonPreference.js index a47f8e9ea..13e70a92f 100644 --- a/src/hooks/useDailyLessonPreference.js +++ b/src/hooks/useDailyLessonPreference.js @@ -1,4 +1,5 @@ import { useCallback, useEffect, useState } from "react"; +import * as Sentry from "@sentry/react"; // Per-language daily audio lesson preference. The daily lesson is stored and // queried per learned-language on the backend, so the preference is keyed per @@ -41,10 +42,14 @@ export default function useDailyLessonPreference(api, lang) { setDailyType(lessonType); const verbatim = lessonType === "three_words_lesson" ? "" : suggestion || ""; setDailySuggestion(verbatim); - api.saveUserPreferences({ - [typeKeyFor(lang)]: lessonType, - [suggestionKeyFor(lang)]: verbatim, - }); + // Optimistic; a failed save silently reverts on next load, so at least + // make the failure observable rather than fully silent. + api.saveUserPreferences( + { [typeKeyFor(lang)]: lessonType, [suggestionKeyFor(lang)]: verbatim }, + null, + (err) => + Sentry.captureException(err, { tags: { feature: "daily_audio_preference" } }), + ); }, [api, lang], ); From 2c4d5a8742ff7cb0b835072565a5d793a83685c9 Mon Sep 17 00:00:00 2001 From: Mircea Lungu Date: Wed, 27 May 2026 17:30:44 +0200 Subject: [PATCH 15/15] Daily audio: paused-state card UI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When generation is paused (last lesson < 50% listened), the backend returns that waiting lesson flagged `paused`. The card then drops the date for a '⏸ Paused' label and adds a one-line 'listen to resume' note. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/dailyAudio/TodayEpisodeCard.js | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/dailyAudio/TodayEpisodeCard.js b/src/dailyAudio/TodayEpisodeCard.js index d687f321a..e909d32a7 100644 --- a/src/dailyAudio/TodayEpisodeCard.js +++ b/src/dailyAudio/TodayEpisodeCard.js @@ -43,7 +43,7 @@ function EpisodeHeader({ lessonData }) { color: "var(--text-secondary)", }} > - {todayDateLabel()} + {lessonData.paused ? "⏸ Paused" : todayDateLabel()} @@ -78,6 +78,11 @@ export default function TodayEpisodeCard({ onChangeTopic, ...playbackProps }) { // stays quiet to keep the screen uncluttered. const footer = (
+ {lessonData.paused && ( +

+ New daily lessons are paused. Listen to this one and they'll start again tomorrow. +

+ )} Daily lesson settings