From 02e7f927a460a47dd81171471c0edd885673527f Mon Sep 17 00:00:00 2001 From: Morpheus Date: Sat, 1 Nov 2025 15:01:08 +0100 Subject: [PATCH 01/10] Fix daily rewards practice and lab CTA navigation --- components/dailyRewards/Card.vue | 336 +++++++++++++++++++++++++++++++ 1 file changed, 336 insertions(+) create mode 100644 components/dailyRewards/Card.vue diff --git a/components/dailyRewards/Card.vue b/components/dailyRewards/Card.vue new file mode 100644 index 00000000..2ce6f768 --- /dev/null +++ b/components/dailyRewards/Card.vue @@ -0,0 +1,336 @@ + + + + + From 6fc0b8b494eb63254e09bb5e5275c725350aafd8 Mon Sep 17 00:00:00 2001 From: Morpheus Date: Sat, 1 Nov 2025 15:02:59 +0100 Subject: [PATCH 02/10] Daily Rewards --- components/dailyRewards/CategoryRow.vue | 259 ++++++++++++++++++ composables/dailyRewards.ts | 340 ++++++++++++++++++++++++ locales/de.json | 59 ++++ locales/en-US.json | 59 ++++ pages/dashboard/index.vue | 4 + 5 files changed, 721 insertions(+) create mode 100644 components/dailyRewards/CategoryRow.vue create mode 100644 composables/dailyRewards.ts diff --git a/components/dailyRewards/CategoryRow.vue b/components/dailyRewards/CategoryRow.vue new file mode 100644 index 00000000..2445cd0d --- /dev/null +++ b/components/dailyRewards/CategoryRow.vue @@ -0,0 +1,259 @@ + + + + + diff --git a/composables/dailyRewards.ts b/composables/dailyRewards.ts new file mode 100644 index 00000000..8316700e --- /dev/null +++ b/composables/dailyRewards.ts @@ -0,0 +1,340 @@ +import { refreshNuxtData, useAsyncData, useState } from "#app"; +import { useIntervalFn, useEventListener, useNow } from "@vueuse/core"; +import { computed, onBeforeUnmount, ref, watch } from "vue"; +import { GET, POST } from "./fetch"; + +const DAILY_REWARDS_KEY = "daily-rewards"; +const TEN_MINUTES_MS = 10 * 60 * 1000; + +export type RewardCategory = "arrival" | "lecture" | "practice" | "lab"; +export type RewardStatus = "pending" | "ready" | "claimed" | "unavailable"; + +export interface DailyReward { + category: RewardCategory; + coins: number; + status: RewardStatus; + claimable_since: string | null; + last_detected_at: string | null; + claimed_at: string | null; + activity_sample?: Record | null; + unavailable_reason?: string | null; +} + +export interface DailyRewardsPayload { + date: string; + feature_enabled: boolean; + rewards: DailyReward[]; + claim_totals?: { + available: number; + claimed_today: number; + }; +} + +export interface ClaimAllResponse { + status: "ok"; + claimed_categories: RewardCategory[]; + skipped_categories: { category: RewardCategory; reason: string }[]; +} + +interface ClaimOutcome { + ok: boolean; + category: RewardCategory; + error?: string; +} + +interface ClaimAllOutcome { + ok: boolean; + claimed: RewardCategory[]; + skipped: { category: RewardCategory; reason: string }[]; + error?: string; +} + +const categories: RewardCategory[] = ["arrival", "lecture", "practice", "lab"]; + +export function useDailyRewards() { + const ariaAnnouncement = useState( + "daily-rewards-aria-announcement", + () => null, + ); + + const claimBusy = ref>( + categories.reduce( + (acc, category) => { + acc[category] = false; + return acc; + }, + {} as Record, + ), + ); + + const claimAllBusy = ref(false); + + const { data, pending, error, refresh } = useAsyncData( + DAILY_REWARDS_KEY, + async () => { + const response = await GET("/daily-rewards"); + return response as DailyRewardsPayload; + }, + { + server: true, + watch: [], + }, + ); + + const rewards = computed(() => data.value?.rewards ?? []); + + const availableCoins = computed(() => + rewards.value + .filter((reward) => reward.status === "ready") + .reduce((total, reward) => total + (reward.coins ?? 0), 0), + ); + + const claimedToday = computed(() => data.value?.claim_totals?.claimed_today ?? 0); + + const isClient = typeof window !== "undefined"; + const currentTime = isClient ? useNow({ interval: 60_000 }) : ref(new Date()); + + const needsResetRefresh = computed(() => { + if (!data.value?.date) return true; + const dayStart = new Date(`${data.value.date}T00:00:00Z`); + if (Number.isNaN(dayStart.getTime())) return true; + const nextDay = new Date(dayStart); + nextDay.setUTCDate(nextDay.getUTCDate() + 1); + return currentTime.value.getTime() >= nextDay.getTime(); + }); + + const shouldPoll = computed( + () => + needsResetRefresh.value || + rewards.value.some((reward) => reward.status !== "claimed"), + ); + + const countdownLabel = computed(() => + formatResetCountdown(data.value?.date, currentTime.value), + ); + + const { pause: pausePolling, resume: resumePolling } = useIntervalFn( + () => refreshNuxtData(DAILY_REWARDS_KEY), + TEN_MINUTES_MS, + { + immediate: false, + immediateCallback: false, + }, + ); + + if (!isClient) { + pausePolling(); + } + + if (isClient) { + watch( + shouldPoll, + (active) => { + if (active && !document.hidden) { + resumePolling(); + } else { + pausePolling(); + } + }, + { immediate: true }, + ); + + useEventListener( + () => document, + "visibilitychange", + () => { + if (document.hidden) { + pausePolling(); + return; + } + if (shouldPoll.value) { + resumePolling(); + } + }, + ); + } + + onBeforeUnmount(() => { + if (isClient) { + pausePolling(); + } + }); + + const mutateReward = ( + category: RewardCategory, + updater: (reward: DailyReward) => DailyReward, + ) => { + if (!data.value) return; + data.value = { + ...data.value, + rewards: data.value.rewards.map((reward) => + reward.category === category ? updater({ ...reward }) : reward, + ), + }; + }; + + const resetAnnouncement = () => { + ariaAnnouncement.value = ""; + }; + + const claimCategory = async (category: RewardCategory): Promise => { + if (claimBusy.value[category]) { + return { ok: false, category, error: "busy" }; + } + + claimBusy.value = { ...claimBusy.value, [category]: true }; + + const originalReward = rewards.value.find((reward) => reward.category === category); + const optimisticTimestamp = new Date().toISOString(); + + if (originalReward && originalReward.status === "ready") { + mutateReward(category, (reward) => ({ + ...reward, + status: "claimed", + claimed_at: optimisticTimestamp, + })); + } + + try { + await POST(`/daily-rewards/${category}/claim`); + resetAnnouncement(); + ariaAnnouncement.value = buildClaimAnnouncement(category, originalReward?.coins ?? 0); + await refreshNuxtData(DAILY_REWARDS_KEY); + return { ok: true, category }; + } catch (err: any) { + if (originalReward) { + mutateReward(category, () => ({ ...originalReward })); + } + return { + ok: false, + category, + error: normalizeError(err), + }; + } finally { + claimBusy.value = { ...claimBusy.value, [category]: false }; + } + }; + + const claimAll = async (): Promise => { + if (claimAllBusy.value) { + return { ok: false, claimed: [], skipped: [], error: "busy" }; + } + + claimAllBusy.value = true; + const readyRewards = rewards.value.filter((reward) => reward.status === "ready"); + const optimisticTimestamp = new Date().toISOString(); + const snapshot = data.value ? structuredClone(data.value) : null; + + if (readyRewards.length > 0) { + data.value = { + ...(data.value as DailyRewardsPayload), + rewards: rewards.value.map((reward) => + reward.status === "ready" + ? { ...reward, status: "claimed", claimed_at: optimisticTimestamp } + : reward, + ), + }; + } + + try { + const response = (await POST("/daily-rewards/claim-all")) as ClaimAllResponse; + if (response?.claimed_categories?.length) { + const totalCoins = readyRewards + .filter((reward) => response.claimed_categories.includes(reward.category)) + .reduce((sum, reward) => sum + reward.coins, 0); + resetAnnouncement(); + ariaAnnouncement.value = buildClaimAllAnnouncement( + response.claimed_categories, + totalCoins, + ); + } + await refreshNuxtData(DAILY_REWARDS_KEY); + return { + ok: true, + claimed: response?.claimed_categories ?? [], + skipped: response?.skipped_categories ?? [], + }; + } catch (err: any) { + if (snapshot) { + data.value = snapshot; + } + return { + ok: false, + claimed: [], + skipped: [], + error: normalizeError(err), + }; + } finally { + claimAllBusy.value = false; + } + }; + + return { + data, + rewards, + availableCoins, + claimedToday, + countdownLabel, + featureEnabled: computed(() => !!data.value?.feature_enabled), + pending, + error, + refresh: () => refreshNuxtData(DAILY_REWARDS_KEY), + claimCategory, + claimAll, + claimBusy, + claimAllBusy, + ariaAnnouncement, + resetAnnouncement, + }; +} + +function formatResetCountdown(dateIso?: string | null, nowReference?: Date) { + if (!dateIso) return ""; + const start = new Date(`${dateIso}T00:00:00Z`); + if (Number.isNaN(start.getTime())) return ""; + + const nextDay = new Date(start); + nextDay.setUTCDate(nextDay.getUTCDate() + 1); + + const reference = nowReference ?? new Date(); + const diffMs = nextDay.getTime() - reference.getTime(); + if (diffMs <= 0) return ""; + + const diffMinutes = Math.round(diffMs / (1000 * 60)); + + if (diffMinutes >= 60) { + const hours = Math.round(diffMinutes / 60); + return new Intl.RelativeTimeFormat(undefined, { numeric: "auto" }).format( + hours, + "hour", + ); + } + + return new Intl.RelativeTimeFormat(undefined, { numeric: "auto" }).format( + diffMinutes || 1, + "minute", + ); +} + +function buildClaimAnnouncement(category: RewardCategory, coins: number) { + return `${capitalize(category)} reward claimed for ${coins} coins.`; +} + +function buildClaimAllAnnouncement(categories: RewardCategory[], coins: number) { + if (!categories.length) return ""; + return `Claimed ${categories.length} rewards for ${coins} coins.`; +} + +function capitalize(value: string) { + return value.charAt(0).toUpperCase() + value.slice(1); +} + +function normalizeError(error: any) { + if (!error) return "unknown"; + const detail = + error?.data?.detail ?? + error?.response?._data?.detail ?? + error?.message ?? + error?.toString(); + return typeof detail === "string" ? detail : "unknown"; +} diff --git a/locales/de.json b/locales/de.json index 49d569cb..bbdb5542 100644 --- a/locales/de.json +++ b/locales/de.json @@ -387,6 +387,65 @@ "MyAccount": "Mein Konto", "SelectSkill": "Skill auswählen" }, + "DailyRewards": { + "Heading": "T\u00e4gliche Belohnungen", + "Subheading": "Melde dich an, bleib dran und sammle deine Coins ein.", + "Coins": "Coins", + "Countdown": "Reset {relative}", + "Error": "Belohnungen konnten nicht geladen werden. Versuch es gleich nochmal.", + "Disabled": "Daily Rewards sind noch nicht aktiv.", + "Footer": "Heute bereits eingesackt: {coins} Coins", + "Buttons": { + "Claim": "Einsacken", + "ClaimAll": "Alles einsacken", + "Retry": "Nochmal versuchen", + "Resume": { + "arrival": "Check-in starten", + "lecture": "Vorlesung fortsetzen", + "practice": "Jetzt ueben", + "lab": "Lab starten" + } + }, + "Categories": { + "arrival": "Daily Check-in", + "lecture": "Lecture Run", + "practice": "Practice Grind", + "lab": "Lab Breakthrough" + }, + "Status": { + "pending": "Ausstehend", + "ready": "Bereit", + "claimed": "Eingeloest", + "unavailable": "Nicht verfuegbar" + }, + "StatusLabel": { + "ClaimedAt": "Eingeloest um {time}" + }, + "Unavailable": { + "no_recommendation": "Noch keine Empfehlung.", + "default": "Gerade nicht verfuegbar." + }, + "DefaultHint": { + "arrival": "Hol dir den taeglichen Check-in fuer direkte Coins.", + "lecture": "Schliesse heute eine Vorlesung ab, um das zu triggern.", + "practice": "Quiz oder Matching loesen und die Leiste fuellen.", + "lab": "Ein Lab abschliessen, dann blinkt die Belohnung." + }, + "Toast": { + "ClaimSuccess": "Belohnung gesichert!", + "ClaimError": "Claim fehlgeschlagen", + "ClaimAllSuccess": "Loot eingesackt!", + "ClaimAllPartial": "Teilweise eingesackt", + "ClaimAllError": "Claim-all fehlgeschlagen" + }, + "ToastBody": { + "ClaimSingle": "{category} hat dir {coins} Coins eingebracht.", + "ClaimError": "{category} konnte nicht eingesackt werden. Versuch es erneut.", + "ClaimAllSuccess": "Du hast {coins} Coins eingesackt.", + "ClaimAllPartial": "Noch offen: {categories}. Schnapp sie dir!", + "ClaimAllError": "Neu laden oder spaeter wieder probieren." + } + }, "Body": { "DashboardIntro": "Steig direkt wieder in deine Kurse oder Challenges ein.", "SkillTreeOverview": "Behalte deine Root-Skills im Blick und öffne die Details, wenn du tiefer einsteigen möchtest.", diff --git a/locales/en-US.json b/locales/en-US.json index d0db6ef0..c6e7b46e 100644 --- a/locales/en-US.json +++ b/locales/en-US.json @@ -388,6 +388,65 @@ "MyAccount": "My Account", "SelectSkill": "Select Skill" }, + "DailyRewards": { + "Heading": "Daily Rewards", + "Subheading": "Sign in, keep learning, and collect your coins each day.", + "Coins": "coins", + "Countdown": "Resets {relative}", + "Error": "We couldn't load your rewards. Try again in a moment.", + "Disabled": "Daily rewards are warming up and will unlock soon.", + "Footer": "Coins claimed today: {coins}", + "Buttons": { + "Claim": "Claim", + "ClaimAll": "Claim all", + "Retry": "Try again", + "Resume": { + "arrival": "Check in", + "lecture": "Jump into a lecture", + "practice": "Practice now", + "lab": "Launch a lab" + } + }, + "Categories": { + "arrival": "Daily Check-in", + "lecture": "Lecture Run", + "practice": "Practice Grind", + "lab": "Lab Breakthrough" + }, + "Status": { + "pending": "Pending", + "ready": "Ready", + "claimed": "Claimed", + "unavailable": "Unavailable" + }, + "StatusLabel": { + "ClaimedAt": "Claimed at {time}" + }, + "Unavailable": { + "no_recommendation": "No fresh recommendation yet.", + "default": "Not available right now." + }, + "DefaultHint": { + "arrival": "Claim your daily check-in for instant coins.", + "lecture": "Finish a lecture today to light this up.", + "practice": "Clear a quiz or matching task to charge the meter.", + "lab": "Ship a lab solution to trigger this reward." + }, + "Toast": { + "ClaimSuccess": "Reward secured!", + "ClaimError": "Claim failed", + "ClaimAllSuccess": "Loot haul!", + "ClaimAllPartial": "Partial haul", + "ClaimAllError": "Claim all failed" + }, + "ToastBody": { + "ClaimSingle": "{category} dropped {coins} coins into your bank.", + "ClaimError": "Couldn't claim {category}. Try again.", + "ClaimAllSuccess": "You grabbed {coins} coins.", + "ClaimAllPartial": "Still pending: {categories}. Knock those out!", + "ClaimAllError": "Reload or try again in a bit." + } + }, "Body": { "DashboardIntro": "Jump back into your courses or challenges to keep the momentum going.", "SkillTreeOverview": "Browse every root skill and open the details whenever you want to go deeper.", diff --git a/pages/dashboard/index.vue b/pages/dashboard/index.vue index 2911113b..2c15c529 100644 --- a/pages/dashboard/index.vue +++ b/pages/dashboard/index.vue @@ -9,6 +9,10 @@

+
+ +
+
From facfc601d1a7fe42fcbc1daface696b243d03034 Mon Sep 17 00:00:00 2001 From: Morpheus Date: Sat, 1 Nov 2025 15:08:13 +0100 Subject: [PATCH 03/10] style: fix daily rewards formatting --- components/dailyRewards/Card.vue | 50 +++++++++++++++---------- components/dailyRewards/CategoryRow.vue | 32 +++++++--------- composables/dailyRewards.ts | 50 +++++++++---------------- 3 files changed, 62 insertions(+), 70 deletions(-) diff --git a/components/dailyRewards/Card.vue b/components/dailyRewards/Card.vue index 2ce6f768..1f86a8e8 100644 --- a/components/dailyRewards/Card.vue +++ b/components/dailyRewards/Card.vue @@ -1,6 +1,6 @@