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/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 new file mode 100644 index 000000000..071e1c4a5 --- /dev/null +++ b/src/dailyAudio/DailyLessonSettingsDialog.js @@ -0,0 +1,138 @@ +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"; +import { zeeguuOrange } from "../components/colors"; + +/** + * 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"); + // 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); + + // 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( + (data) => { + setAutoDisabled(!data.feasible); + if (!data.feasible) setPillType((t) => (t === "auto" ? "topic" : t)); + }, + () => setAutoDisabled(false), + ); + }, [api]); + + const needsSubject = pillType === "topic" || pillType === "situation"; + 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 ? 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 || 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). + const handleSaveClick = () => { + if (!canSave) return; + if (todaysLessonExists) setConfirmRegen(true); + else submit(true); + }; + + 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 ( + +

+ Daily lesson settings +

+ + + +
+ {confirmRegen ? confirmStep : saveButton} +
+
+ ); +} diff --git a/src/dailyAudio/LessonPlaybackView.js b/src/dailyAudio/LessonPlaybackView.js index 13034ab6a..10096ff88 100644 --- a/src/dailyAudio/LessonPlaybackView.js +++ b/src/dailyAudio/LessonPlaybackView.js @@ -8,12 +8,11 @@ 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, setLessonData, - words, error, api, userDetails, @@ -21,11 +20,16 @@ export default function LessonPlaybackView({ listeningSession, currentPlaybackTime, setCurrentPlaybackTime, + header, + footer, }) { const [openFeedback, setOpenFeedback] = useState(false); + const words = lessonData.words || []; - 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}
} @@ -69,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 })); @@ -82,7 +95,9 @@ export default function LessonPlaybackView({ maxWidth: "600px", margin: "0 auto 20px auto", }} - /> + > + + {lessonData.is_completed && (
)} + {footer} + - {lessonData.lesson_id && ( - shareLessonLink(api, lessonData.lesson_id, lessonData.title)} - > - Share - - )} setOpenFeedback(true)}> Feedback diff --git a/src/dailyAudio/PastLessons.js b/src/dailyAudio/PastLessons.js index f37724474..6bd625303 100644 --- a/src/dailyAudio/PastLessons.js +++ b/src/dailyAudio/PastLessons.js @@ -1,5 +1,4 @@ import React, { useState, useContext, useEffect } from "react"; -import styled from "styled-components"; import { APIContext } from "../contexts/APIContext"; import { UserContext } from "../contexts/UserContext"; import LoadingAnimation from "../components/LoadingAnimation"; @@ -7,59 +6,26 @@ import useListeningSession from "../hooks/useListeningSession"; import CustomAudioPlayer from "../components/CustomAudioPlayer"; import { SubtleTextButton, LessonTitle, CompletionCheck } from "./LessonView.sc"; import { SubtleLessonCard, ProgressBarTrack, ProgressBarFill } from "./SharedLessonView.sc"; -import { shareLessonLink } from "./shareLessonLink"; +import { PillRow, SelectablePill } from "./SuggestionSelector.sc"; +import { LessonTypeChip, chipLabel } from "./lessonTypeChip"; +import { completionChecks, formatShortDate } from "./audioUtils"; +import ShareLessonButton from "./ShareLessonButton"; -// Small colored pill that surfaces the lesson category in the card preview. -// 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. -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 -}; - -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}; - } -`; - -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; -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"; -// 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 @@ -96,7 +62,7 @@ const titleWithDate = (lesson) => { {head} {tail} - {checkCount > 0 && <> {renderChecks(checkCount)}} + {checkCount > 0 && <> {completionChecks(checkCount)}} ); @@ -112,6 +78,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(() => { @@ -174,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, ), ); @@ -205,20 +179,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 || @@ -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, + }; +}