From db1a3fc08f2579c2d6c958a2e8c289c95e592339 Mon Sep 17 00:00:00 2001 From: Navid Shad Date: Mon, 25 May 2026 19:25:35 +0300 Subject: [PATCH 1/3] feat(practice-now): voice session config + dashboard deep-link Fill in the Practice now ConsoleCrane page: an avatar voice picker (voices fetched from the server), an inline save-first flow, and a Start button that opens the dashboard live-session gate via the login_with_token deep-link. Free users see their remaining AI-session allocation (read from profile) and are prompted to upgrade once used up. Co-Authored-By: Claude Opus 4.7 --- src/common/services/live-voices.service.ts | 54 +++++ src/common/static/global.ts | 10 +- .../components/SaveWordSectionV2.vue | 9 + src/console-crane/components/VoicePicker.vue | 55 +++++ .../modules/practice-config/deep-link.ts | 50 +++++ .../modules/practice-config/index.vue | 203 +++++++++++++++++- .../modules/word-detail/index.vue | 4 + tests/live-voices.service.test.ts | 50 +++++ tests/practice-config.test.ts | 187 ++++++++++++++++ tests/practice-now-deep-link.test.ts | 71 ++++++ 10 files changed, 679 insertions(+), 14 deletions(-) create mode 100644 src/common/services/live-voices.service.ts create mode 100644 src/console-crane/components/VoicePicker.vue create mode 100644 src/console-crane/modules/practice-config/deep-link.ts create mode 100644 tests/live-voices.service.test.ts create mode 100644 tests/practice-config.test.ts create mode 100644 tests/practice-now-deep-link.test.ts diff --git a/src/common/services/live-voices.service.ts b/src/common/services/live-voices.service.ts new file mode 100644 index 0000000..3b92151 --- /dev/null +++ b/src/common/services/live-voices.service.ts @@ -0,0 +1,54 @@ +import { functionProvider } from "@modular-rest/client"; + +/** + * AI-coach voice as returned by the server's `get-live-session-voices` + * function. Kept structurally in sync with the dashboard's `CoachVoice` + * (server: live_session/voices.ts) — the two repos build separately so the + * shape is mirrored, not imported. + */ +export interface CoachVoice { + name: string; + label: string; + description?: string; + gender?: "female" | "male"; + avatarColor?: string; + avatarUrl?: string | null; +} + +/** + * Cached fetch of the coach voices. Singleton so every Practice now mount + * shares one network call (mirrors BundleSuggestionService / TranslateService). + * + * No offline fallback: if the server can't return voices, the dashboard live + * session can't run anyway, so an empty list is the honest result. + */ +export class LiveVoicesService { + private static _instance: LiveVoicesService | null = null; + + static get instance(): LiveVoicesService { + if (!this._instance) this._instance = new LiveVoicesService(); + return this._instance; + } + + private cache: CoachVoice[] | null = null; + private inflight: Promise | null = null; + + async getVoices(): Promise { + if (this.cache) return this.cache; + if (this.inflight) return this.inflight; + + this.inflight = functionProvider + .run({ name: "get-live-session-voices", args: {} }) + .then((res) => { + this.cache = res || []; + return this.cache; + }) + // Don't cache failures, so a transient error retries on the next open. + .catch(() => [] as CoachVoice[]) + .finally(() => { + this.inflight = null; + }); + + return this.inflight; + } +} diff --git a/src/common/static/global.ts b/src/common/static/global.ts index 3baa52d..cf784a2 100644 --- a/src/common/static/global.ts +++ b/src/common/static/global.ts @@ -4,10 +4,14 @@ export const VERSION = require("../../../package.json").version; export const SUBTURTLE_DASHBOARD_URL = process.env.SUBTURTLE_DASHBOARD_URL; -export function getSubturtleDashboardUrlWithToken() { +export function getSubturtleDashboardUrlWithToken(redirectPath?: string) { const token = authentication.getToken; - const url = `${process.env.SUBTURTLE_DASHBOARD_URL}/#/auth/login_with_token?token=${token}`; - console.log("Subturtle dashboard url", url); + let url = `${process.env.SUBTURTLE_DASHBOARD_URL}/#/auth/login_with_token?token=${token}`; + // The dashboard's login_with_token page reads `redirect`, validates same-origin, + // and pushes it after auth — so a deep-link survives the token handoff. + if (redirectPath) { + url += `&redirect=${encodeURIComponent(redirectPath)}`; + } return url; } diff --git a/src/console-crane/components/SaveWordSectionV2.vue b/src/console-crane/components/SaveWordSectionV2.vue index 32da0ec..5a8a692 100644 --- a/src/console-crane/components/SaveWordSectionV2.vue +++ b/src/console-crane/components/SaveWordSectionV2.vue @@ -70,6 +70,11 @@ const props = defineProps<{ chunks?: Chunk[]; }>(); +const emit = defineEmits<{ + /** Fired after a successful save, carrying the created phrase document. */ + saved: [PhraseType]; +}>(); + const selectBundleRef = ref(); const selectedBundles = ref([]); const existingBundles = ref([]); @@ -352,6 +357,10 @@ async function savePhrase() { await loadExistingBundles(); await profileStore.fetchSubscription(); + if (existedPhrase.value) { + emit("saved", existedPhrase.value); + } + if (selectBundleRef.value?.closeDropdown) { selectBundleRef.value.closeDropdown(); } diff --git a/src/console-crane/components/VoicePicker.vue b/src/console-crane/components/VoicePicker.vue new file mode 100644 index 0000000..abc2969 --- /dev/null +++ b/src/console-crane/components/VoicePicker.vue @@ -0,0 +1,55 @@ + + + diff --git a/src/console-crane/modules/practice-config/deep-link.ts b/src/console-crane/modules/practice-config/deep-link.ts new file mode 100644 index 0000000..731af59 --- /dev/null +++ b/src/console-crane/modules/practice-config/deep-link.ts @@ -0,0 +1,50 @@ +/** + * "Practice now" -> dashboard deep-link. + * + * Practice now is a single-phrase voice session. It hands the saved phrase to + * the dashboard's single live-session gate (`/practice/live-session`) as a + * base64-encoded LiveSessionRequest with a `phrases` source — the same + * descriptor the dashboard's bundle review builds (with a `bundle` source). + * The phrase is always saved first, so it's referenced by id; all values are + * ASCII, so plain base64 is enough. + */ + +/** The eight Gemini voices, mirroring the dashboard's voice list. */ +export const PRACTICE_NOW_VOICES = [ + "Kore", + "Puck", + "Charon", + "Fenrir", + "Aoede", + "Leda", + "Orus", + "Zephyr", +] as const; + +export const DEFAULT_PRACTICE_VOICE = "Kore"; + +export interface PracticeNowOptions { + /** The saved phrase's `_id`. */ + phraseId: string; + /** The chosen coach voice (one of {@link PRACTICE_NOW_VOICES}). */ + voiceName?: string; +} + +/** + * The dashboard-relative path the new tab lands on after the token handoff: + * the unified live-session gate, carrying a single-phrase request. + */ +export function buildPracticeNowPath({ + phraseId, + voiceName, +}: PracticeNowOptions): string { + const request = { + aiCharacter: voiceName || DEFAULT_PRACTICE_VOICE, + source: { kind: "phrases", phraseIds: [phraseId] }, + returnTo: "/board", + }; + const session = btoa(JSON.stringify(request)); + const params = new URLSearchParams(); + params.set("session", session); + return `/practice/live-session?${params.toString()}`; +} diff --git a/src/console-crane/modules/practice-config/index.vue b/src/console-crane/modules/practice-config/index.vue index 666a6a0..db7c228 100644 --- a/src/console-crane/modules/practice-config/index.vue +++ b/src/console-crane/modules/practice-config/index.vue @@ -1,17 +1,198 @@ - + diff --git a/src/console-crane/modules/word-detail/index.vue b/src/console-crane/modules/word-detail/index.vue index fce1222..ae38d92 100644 --- a/src/console-crane/modules/word-detail/index.vue +++ b/src/console-crane/modules/word-detail/index.vue @@ -338,8 +338,12 @@ function startPracticeWithAI() { "practice-config", { phrase: cleanText(getProps().word || ""), + translation: cleanText(wordData.value?.translation?.phrase || ""), context: context.value, chunks: wordData.value?.chunks || [], + direction: wordData.value?.direction, + language_info: wordData.value?.language_info, + linguistic_data: wordData.value?.linguistic_data, }, true ); diff --git a/tests/live-voices.service.test.ts b/tests/live-voices.service.test.ts new file mode 100644 index 0000000..b7b41fd --- /dev/null +++ b/tests/live-voices.service.test.ts @@ -0,0 +1,50 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; + +// Controllable functionProvider mock. The service is a module-level singleton, +// so each test re-imports a fresh module via vi.resetModules(). +const { runMock } = vi.hoisted(() => ({ runMock: vi.fn() })); +vi.mock("@modular-rest/client", () => ({ + functionProvider: { run: (...args: any[]) => runMock(...args) }, +})); + +async function loadService() { + const mod = await import("../src/common/services/live-voices.service"); + return mod.LiveVoicesService; +} + +beforeEach(() => { + vi.resetModules(); + runMock.mockReset(); +}); + +describe("LiveVoicesService", () => { + it("fetches once and caches the server list", async () => { + runMock.mockResolvedValueOnce([{ name: "Kore", label: "Kore" }]); + const Service = await loadService(); + + const first = await Service.instance.getVoices(); + const second = await Service.instance.getVoices(); + + expect(first).toEqual([{ name: "Kore", label: "Kore" }]); + expect(second).toBe(first); // served from cache + expect(runMock).toHaveBeenCalledTimes(1); + expect(runMock).toHaveBeenCalledWith({ + name: "get-live-session-voices", + args: {}, + }); + }); + + it("returns an empty list on failure and retries on the next call", async () => { + runMock.mockRejectedValueOnce(new Error("network")); + const Service = await loadService(); + + const first = await Service.instance.getVoices(); + expect(first).toEqual([]); + + // Failures aren't cached — a later success populates the list. + runMock.mockResolvedValueOnce([{ name: "Kore", label: "Kore" }]); + const second = await Service.instance.getVoices(); + expect(second).toEqual([{ name: "Kore", label: "Kore" }]); + expect(runMock).toHaveBeenCalledTimes(2); + }); +}); diff --git a/tests/practice-config.test.ts b/tests/practice-config.test.ts new file mode 100644 index 0000000..e1b0c5e --- /dev/null +++ b/tests/practice-config.test.ts @@ -0,0 +1,187 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { mount, flushPromises } from "@vue/test-utils"; +import { defineComponent } from "vue"; +import { encodeRouteParams } from "../src/console-crane/route-params"; + +// Drive the route :data payload via a hoisted holder (same approach as +// flashcard-preview.test.ts), plus holders for the saved-phrase lookup, the +// freemium flag, and window.open calls. +const { routeHolder, savedHolder, profileHolder, openHolder } = vi.hoisted( + () => ({ + routeHolder: { data: "" }, + savedHolder: { value: null as { _id: string } | null }, + profileHolder: { + isFreemium: false, + freemiumAllocation: null as any, + fetchSubscription: () => Promise.resolve(), + }, + openHolder: { calls: [] as string[] }, + }) +); + +vi.mock("vue-router", () => ({ + useRoute: () => ({ params: { data: routeHolder.data } }), +})); +vi.mock("../src/common/services/phrase.service", () => ({ + findSavedPhrase: vi.fn(async () => savedHolder.value), +})); +vi.mock("../src/stores/profile", () => ({ + useProfileStore: () => profileHolder, +})); +vi.mock("../src/common/static/global", () => ({ + getSubturtleDashboardUrlWithToken: (path?: string) => `OPEN:${path ?? ""}`, +})); + +// Pilotui Button → minimal stub that re-emits click (mirrors word-detail.test.ts). +vi.mock("pilotui/elements", () => ({ + Button: defineComponent({ + name: "PilotButton", + props: { label: { type: String, default: "" } }, + emits: ["click"], + template: + '', + }), +})); + +// Heavy children → light stubs. The save stub re-emits `saved` with a phrase doc. +vi.mock("../src/console-crane/components/SaveWordSectionV2.vue", () => ({ + default: defineComponent({ + name: "SaveWordSectionV2Stub", + emits: ["saved"], + template: + "", + }), +})); +vi.mock("../src/console-crane/components/VoicePicker.vue", () => ({ + default: defineComponent({ + name: "VoicePickerStub", + props: { modelValue: { type: String, default: "" } }, + template: '
', + }), +})); + +import PracticeConfig from "../src/console-crane/modules/practice-config/index.vue"; + +function mountWith(payload: Record) { + routeHolder.data = encodeRouteParams(payload); + return mount(PracticeConfig); +} + +beforeEach(() => { + savedHolder.value = null; + profileHolder.isFreemium = false; + profileHolder.freemiumAllocation = null; + openHolder.calls = []; + vi.spyOn(window, "open").mockImplementation((url?: string | URL) => { + openHolder.calls.push(String(url)); + return null; + }); +}); + +describe("PracticeConfig page (voice-only)", () => { + it("prompts to save first and reveals the save widget on demand", async () => { + const w = mountWith({ phrase: "had to" }); + await flushPromises(); + // Step 1: a prompt + "Let's save it" — no save widget, no config card yet. + expect(w.text()).toContain("Let's save it"); + expect(w.find(".save-stub").exists()).toBe(false); + expect(w.text()).not.toContain("Choose a coach voice"); + // Step 2: clicking it reveals the real save widget. + await w.find("button.pilot-btn").trigger("click"); + expect(w.find(".save-stub").exists()).toBe(true); + w.unmount(); + }); + + it("reveals the voice config card after an inline save", async () => { + const w = mountWith({ phrase: "had to" }); + await flushPromises(); + await w.find("button.pilot-btn").trigger("click"); // Let's save it + await w.find(".save-stub").trigger("click"); // save → emits saved + await flushPromises(); + expect(w.text()).toContain("Choose a coach voice"); + expect(w.find(".voice-picker-stub").exists()).toBe(true); + expect(w.text()).toContain("Start"); + expect(w.find(".save-stub").exists()).toBe(false); + w.unmount(); + }); + + it("shows the voice config card immediately when the phrase is already saved", async () => { + savedHolder.value = { _id: "PID123" }; + const w = mountWith({ phrase: "had to" }); + await flushPromises(); + expect(w.text()).toContain("Choose a coach voice"); + expect(w.find(".save-stub").exists()).toBe(false); + w.unmount(); + }); + + it("Start (premium) opens a dashboard tab for the saved phrase with the chosen voice", async () => { + savedHolder.value = { _id: "PID123" }; + const w = mountWith({ phrase: "had to" }); + await flushPromises(); + + await w.find("button.pilot-btn").trigger("click"); + + expect(openHolder.calls).toHaveLength(1); + // The link carries a base64 LiveSessionRequest with a single-phrase source. + const session = new URLSearchParams( + openHolder.calls[0].split("?")[1] + ).get("session") as string; + const req = JSON.parse(atob(session)); + expect(req.source).toEqual({ kind: "phrases", phraseIds: ["PID123"] }); + expect(req.aiCharacter).toBe("Kore"); + w.unmount(); + }); + + it("free users with remaining allocation can start (shows sessions left)", async () => { + savedHolder.value = { _id: "PID123" }; + profileHolder.isFreemium = true; + profileHolder.freemiumAllocation = { + allowed_lived_sessions: 3, + allowed_lived_sessions_used: 1, + }; + const w = mountWith({ phrase: "had to" }); + await flushPromises(); + + expect(w.text()).toContain("2 of 3 free AI sessions left"); + expect(w.text()).not.toContain("Upgrade"); + + await w.find("button.pilot-btn").trigger("click"); + expect(openHolder.calls).toHaveLength(1); + const session = new URLSearchParams( + openHolder.calls[0].split("?")[1] + ).get("session") as string; + expect(JSON.parse(atob(session)).source.phraseIds).toEqual(["PID123"]); + w.unmount(); + }); + + it("free users at their session limit see Upgrade (no session launch)", async () => { + savedHolder.value = { _id: "PID123" }; + profileHolder.isFreemium = true; + profileHolder.freemiumAllocation = { + allowed_lived_sessions: 3, + allowed_lived_sessions_used: 3, + }; + const w = mountWith({ phrase: "had to" }); + await flushPromises(); + + expect(w.text()).toContain("Upgrade to Premium"); + expect(w.text()).toContain("used all 3 free AI sessions"); + + await w.find("button.pilot-btn").trigger("click"); + // Upgrade opens the dashboard root, not a practice session. + expect(openHolder.calls).toHaveLength(1); + expect(openHolder.calls[0]).not.toContain("session="); + w.unmount(); + }); + + it("premium users just see Start (no allocation note, no upgrade)", async () => { + savedHolder.value = { _id: "PID123" }; + profileHolder.isFreemium = false; + const w = mountWith({ phrase: "had to" }); + await flushPromises(); + expect(w.text()).toContain("Start"); + expect(w.text()).not.toContain("Premium"); + expect(w.text()).not.toContain("free AI sessions"); + w.unmount(); + }); +}); diff --git a/tests/practice-now-deep-link.test.ts b/tests/practice-now-deep-link.test.ts new file mode 100644 index 0000000..e312c49 --- /dev/null +++ b/tests/practice-now-deep-link.test.ts @@ -0,0 +1,71 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { + buildPracticeNowPath, + PRACTICE_NOW_VOICES, + DEFAULT_PRACTICE_VOICE, +} from "../src/console-crane/modules/practice-config/deep-link"; + +function decodeSession(path: string) { + const params = new URLSearchParams(path.split("?")[1]); + return JSON.parse(atob(params.get("session") as string)); +} + +// Practice now hands a single-phrase voice session to the unified live-session +// gate as a base64 LiveSessionRequest (phrases source). +describe("buildPracticeNowPath", () => { + it("targets the unified /practice/live-session gate", () => { + const path = buildPracticeNowPath({ phraseId: "abc123", voiceName: "Puck" }); + expect(path.startsWith("/practice/live-session?session=")).toBe(true); + }); + + it("encodes a single-phrase source with the chosen voice", () => { + const req = decodeSession( + buildPracticeNowPath({ phraseId: "abc123", voiceName: "Puck" }) + ); + expect(req.aiCharacter).toBe("Puck"); + expect(req.source).toEqual({ kind: "phrases", phraseIds: ["abc123"] }); + expect(req.returnTo).toBe("/board"); + }); + + it("defaults the voice to Kore when none is chosen", () => { + const req = decodeSession(buildPracticeNowPath({ phraseId: "x" })); + expect(req.aiCharacter).toBe("Kore"); + }); + + it("ships the eight Gemini voices with Kore as default", () => { + expect(PRACTICE_NOW_VOICES).toHaveLength(8); + expect(PRACTICE_NOW_VOICES).toContain("Kore"); + expect(DEFAULT_PRACTICE_VOICE).toBe("Kore"); + }); +}); + +describe("getSubturtleDashboardUrlWithToken redirect handoff", () => { + beforeEach(() => { + vi.resetModules(); + process.env.SUBTURTLE_DASHBOARD_URL = "https://dash.example"; + }); + + it("appends an encoded redirect when a path is given", async () => { + vi.doMock("@modular-rest/client", () => ({ + authentication: { getToken: "TEST_TOKEN" }, + })); + const { getSubturtleDashboardUrlWithToken } = await import( + "../src/common/static/global" + ); + const path = "/practice/live-session?session=eyJhYmMiOjF9"; + const url = getSubturtleDashboardUrlWithToken(path); + + expect(url).toContain("token=TEST_TOKEN"); + expect(url.endsWith("&redirect=" + encodeURIComponent(path))).toBe(true); + }); + + it("omits redirect when no path is given", async () => { + vi.doMock("@modular-rest/client", () => ({ + authentication: { getToken: "TEST_TOKEN" }, + })); + const { getSubturtleDashboardUrlWithToken } = await import( + "../src/common/static/global" + ); + expect(getSubturtleDashboardUrlWithToken()).not.toContain("redirect="); + }); +}); From 2f09e0577de43a55d995e9277446d313b058beef Mon Sep 17 00:00:00 2001 From: Navid Shad Date: Mon, 25 May 2026 20:11:57 +0300 Subject: [PATCH 2/3] feat(practice-now): open config to logged-out users + clearer CTAs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Show "Practice with AI" and "Preview flashcard" in word-detail regardless of login — flashcard preview has no auth guard, and the practice-config page now handles the login prompt itself. In practice-config the voice picker + duration hint are always shown; only the footer changes across states (logged out -> "Log in & save first"; logged in + unsaved -> save-first flow; saved -> Start/Upgrade). The picker stays mounted across saving so the chosen voice is kept. Point the free-tier Upgrade button at the dashboard /settings/subscription page. Co-Authored-By: Claude Opus 4.7 --- .../modules/practice-config/index.vue | 155 ++++++++++++------ .../modules/word-detail/index.vue | 6 +- tests/practice-config.test.ts | 49 +++++- tests/word-detail.test.ts | 8 +- 4 files changed, 153 insertions(+), 65 deletions(-) diff --git a/src/console-crane/modules/practice-config/index.vue b/src/console-crane/modules/practice-config/index.vue index db7c228..b676d86 100644 --- a/src/console-crane/modules/practice-config/index.vue +++ b/src/console-crane/modules/practice-config/index.vue @@ -25,33 +25,10 @@
- -
-
- To practice this phrase live with the AI coach, you need to save it first. -
- - - - - - -
- - +
@@ -67,34 +44,76 @@ Uses some of your monthly credits
- -

- {{ isAtFreeLimit - ? `You've used all ${sessionsTotal} free AI sessions this month.` - : `${freeSessionsLeft} of ${sessionsTotal} free AI sessions left` }} -

- - - - + + + + + + + +