+ )}
+
{pastLessons.length === 0 && !isLoading && !error && (
)}
- {pastLessons.length > 0 && (
+ {pastLessons.length > 0 && 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 ||
@@ -380,13 +385,7 @@ function InlineLessonPlayer({ lesson, api, userDetails, onLessonCompleted, onPro
}}
onError={() => {}}
/>
- {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..4dee300db
--- /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 dde1f481a..93b49a172 100644
--- a/src/dailyAudio/SuggestionSelector.js
+++ b/src/dailyAudio/SuggestionSelector.js
@@ -8,66 +8,75 @@ 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;
+ // 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);
+ };
-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..f7a5787f0 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,143 @@ 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()}`;
- // Re-initialize state when language changes
+ // Listening session tracking via hook
+ const listeningSession = useListeningSession(lessonData?.lesson_id);
+ const words = lessonData?.words || [];
+
+ // Re-initialize when the learned language changes
useEffect(() => {
- setSuggestionType(getSavedSuggestionType(lang));
- setSuggestion(getSavedSuggestion(lang));
setLessonData(null);
setError(null);
- setCanGenerateLesson(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]);
+ // 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 }));
+
+ // "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);
+ 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) {
+ 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);
+ }
+ }
+
// 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 +180,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 +198,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 +209,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 +217,16 @@ export default function TodayAudio({ setShowTabs }) {
setError(message);
};
- // Check if lesson is ready (used in multiple places)
+ // 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, () => {});
};
@@ -126,12 +244,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(
@@ -139,14 +255,10 @@ export default function TodayAudio({ setShowTabs }) {
if (data && data.lesson_id) {
handleLessonReady(data);
} else {
- stopPolling();
- checkLessonGenerationFeasibility();
+ handleExhausted();
}
},
- () => {
- stopPolling();
- checkLessonGenerationFeasibility();
- },
+ handleExhausted,
);
}
},
@@ -155,7 +267,6 @@ export default function TodayAudio({ setShowTabs }) {
);
};
- // Poll for generation progress
pollInterval = setInterval(pollForProgress, 1500);
// Browsers throttle setInterval for background tabs, so check
@@ -187,7 +298,6 @@ export default function TodayAudio({ setShowTabs }) {
})();
}
- // Cleanup on unmount
return () => {
stopPolling();
document.removeEventListener("visibilitychange", onVisibilityChange);
@@ -196,12 +306,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 +323,83 @@ 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);
-
- // 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,
- );
- };
+ // 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]);
+
+ // 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)}
+ />
+ );
- if (isLoading) {
+ if (isLoading || !prefLoaded) {
return (
@@ -420,38 +410,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 +455,10 @@ export default function TodayAudio({ setShowTabs }) {
}}
>
-
+
{subtitle}
-
+
{bigTitle}
@@ -536,88 +511,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)}
+ />
+ {settingsDialog}
+ >
+ );
+ }
- // Still checking feasibility
+ // No lesson + configured + no error, and auto-gen hasn't fired yet: it's
+ // about to. Once it HAS fired, never sit on this spinner forever — fall
+ // through to the actionable view below (e.g. after a failed/exhausted run).
+ if (isConfigured && !error && !autoGenAttemptedRef.current) {
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" : "A new lesson, daily"}
+
+
+ {error ||
+ "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" : "Set up my daily lessons"}
+
+
+ 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..e909d32a7
--- /dev/null
+++ b/src/dailyAudio/TodayEpisodeCard.js
@@ -0,0 +1,100 @@
+import React from "react";
+import styled from "styled-components";
+import SettingsRoundedIcon from "@mui/icons-material/SettingsRounded";
+import LessonPlaybackView from "./LessonPlaybackView";
+import { LessonTitle, LessonMetadata, CompletionCheck } from "./LessonView.sc";
+import { LessonTypeChip, chipLabel } from "./lessonTypeChip";
+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;
+ align-items: center;
+ gap: 0.5rem;
+ padding: 0.5rem 0.95rem;
+ 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 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 (
+ <>
+
+ {lessonData.paused ? "⏸ Paused" : todayDateLabel()}
+
+
+
+ {lessonData.title}
+ {lessonData.is_completed && (
+ <> {completionChecks(Math.max(1, lessonData.listened_count || 1))}>
+ )}
+
+
+
+
+ {chipLabel(lessonData.lesson_type)}
+ {lessonData.canonical_suggestion ? `: ${lessonData.canonical_suggestion}` : ""}
+
+
+ >
+ );
+}
+
+/**
+ * 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, ...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
+ // "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.paused && (
+
+ New daily lessons are paused. Listen to this one and they'll start again tomorrow.
+
+ )}
+
+ Daily lesson settings
+
+
+
+ );
+
+ 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..037098a8e 100644
--- a/src/dailyAudio/audioUtils.js
+++ b/src/dailyAudio/audioUtils.js
@@ -7,6 +7,26 @@ 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
+// 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" 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 formatShortDate(new Date());
}
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..13e70a92f
--- /dev/null
+++ b/src/hooks/useDailyLessonPreference.js
@@ -0,0 +1,64 @@
+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
+// 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);
+ // 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],
+ );
+
+ return {
+ dailyType,
+ dailySuggestion,
+ prefLoaded,
+ isConfigured: !!dailyType,
+ saveDailyLesson,
+ };
+}