-
-
-
-
-
+
+
+
+
+ {{ t('live-session.repeat-last') }}
+
+
+
-
+
diff --git a/frontend/tests/unit/utils/bundleReviewState.test.ts b/frontend/tests/unit/utils/bundleReviewState.test.ts
new file mode 100644
index 00000000..e642ea92
--- /dev/null
+++ b/frontend/tests/unit/utils/bundleReviewState.test.ts
@@ -0,0 +1,54 @@
+import { describe, it, expect } from 'vitest';
+import { bundlePhraseIds, dueCountFor, isNeverPractised } from '~/utils/bundleReviewState';
+
+describe('bundlePhraseIds', () => {
+ it('reads raw id arrays', () => {
+ expect(bundlePhraseIds({ phrases: ['a', 'b'] })).toEqual(['a', 'b']);
+ });
+
+ it('reads populated phrase documents', () => {
+ expect(bundlePhraseIds({ phrases: [{ _id: 'a' }, { _id: 'b' }] })).toEqual(['a', 'b']);
+ });
+
+ it('survives a bundle with no phrases at all', () => {
+ expect(bundlePhraseIds({})).toEqual([]);
+ expect(bundlePhraseIds(null)).toEqual([]);
+ });
+});
+
+describe('dueCountFor', () => {
+ const due = new Set(['a', 'c', 'z']);
+
+ it('counts only this bundle’s due phrases', () => {
+ expect(dueCountFor({ phrases: ['a', 'b', 'c', 'd'] }, due)).toBe(2);
+ });
+
+ it('is zero when nothing in the bundle is due', () => {
+ expect(dueCountFor({ phrases: ['b', 'd'] }, due)).toBe(0);
+ });
+
+ // The RPC can fail (it is best-effort) — the tiles must then read "N phrases", not "0 due".
+ it('is zero when review state never loaded', () => {
+ expect(dueCountFor({ phrases: ['a', 'c'] }, new Set())).toBe(0);
+ });
+
+ it('matches ObjectId-shaped and populated phrases alike', () => {
+ expect(dueCountFor({ phrases: [{ _id: 'a' }, { _id: 'b' }] }, due)).toBe(1);
+ });
+});
+
+describe('isNeverPractised', () => {
+ const tracked = new Set(['a', 'b']);
+
+ it('is false once any phrase has entered the review system', () => {
+ expect(isNeverPractised({ phrases: ['b', 'x'] }, tracked)).toBe(false);
+ });
+
+ it('is true when none of them have', () => {
+ expect(isNeverPractised({ phrases: ['x', 'y'] }, tracked)).toBe(true);
+ });
+
+ it('treats an empty bundle as never practised', () => {
+ expect(isNeverPractised({ phrases: [] }, tracked)).toBe(true);
+ });
+});
diff --git a/frontend/utils/bundleReviewState.ts b/frontend/utils/bundleReviewState.ts
new file mode 100644
index 00000000..8c91884f
--- /dev/null
+++ b/frontend/utils/bundleReviewState.ts
@@ -0,0 +1,32 @@
+/**
+ * Per-bundle review state for the Start-a-session picker: the "N due" count on each tile
+ * and the Due today / Never practised filters.
+ *
+ * `leitner_system` is an owner-access collection, so the client cannot read review state
+ * directly; `get-phrase-management-info` hands back the two id sets these functions
+ * intersect with a bundle's own phrase ids.
+ */
+
+/**
+ * A bundle's phrase ids as strings, whether the document stores raw ObjectIds or populated
+ * phrase documents. Both shapes occur: the picker's list query returns ids, while some
+ * detail queries populate.
+ */
+export function bundlePhraseIds(bundle: { phrases?: any[] } | null | undefined): string[] {
+ return (bundle?.phrases ?? []).map((p: any) => String(p?._id ?? p)).filter(Boolean);
+}
+
+/** How many of the bundle's phrases are due for review right now. */
+export function dueCountFor(bundle: { phrases?: any[] } | null | undefined, duePhraseIds: Set
): number {
+ if (!duePhraseIds.size) return 0;
+ return bundlePhraseIds(bundle).filter((id) => duePhraseIds.has(id)).length;
+}
+
+/**
+ * True when no phrase in the bundle has ever entered the review system.
+ *
+ * An empty bundle counts as never practised — there is nothing in it that could have been.
+ */
+export function isNeverPractised(bundle: { phrases?: any[] } | null | undefined, practisedPhraseIds: Set): boolean {
+ return !bundlePhraseIds(bundle).some((id) => practisedPhraseIds.has(id));
+}
diff --git a/server/src/modules/leitner_box/service.ts b/server/src/modules/leitner_box/service.ts
index 1a510e6f..a7d901dc 100644
--- a/server/src/modules/leitner_box/service.ts
+++ b/server/src/modules/leitner_box/service.ts
@@ -521,9 +521,16 @@ export class LeitnerService {
console.log('[PhraseManagementInfo] Getting info for userId:', userId);
const system = await this.getSystem(userId);
const phraseToBoxMap: Record = {};
+ // Phrases whose next review has come round. The Start-a-session bundle picker
+ // intersects these with each bundle's phrase ids to show its "N due" count and
+ // to drive the Due today / Never practised filters — `leitner_system` itself is
+ // owner-access, so the client cannot derive this on its own.
+ const duePhraseIds: string[] = [];
+ const now = new Date();
if (system && system.items) {
system.items.forEach(i => {
phraseToBoxMap[i.phraseId.toString()] = i.boxLevel;
+ if (new Date(i.nextReviewDate) <= now) duePhraseIds.push(i.phraseId.toString());
});
}
@@ -539,6 +546,8 @@ export class LeitnerService {
return {
phraseToBoxMap,
+ // Additive: existing callers (the Leitner phrase picker) ignore it.
+ duePhraseIds,
bundles: (bundles || []).map((b: any) => ({ _id: b._id.toString(), title: b.title }))
};
}
diff --git a/ui/scripts/build-icons.mjs b/ui/scripts/build-icons.mjs
index 75401f28..641b5821 100644
--- a/ui/scripts/build-icons.mjs
+++ b/ui/scripts/build-icons.mjs
@@ -18,7 +18,7 @@ import { fileURLToPath } from 'node:url';
const here = dirname(fileURLToPath(import.meta.url));
const root = resolve(here, '..');
-/** Every icon the shell, the Progress screen, the Login screen and the profile menu use. Keep sorted by set, then name. */
+/** Every icon the migrated screens, the shell and the profile menu use. Keep sorted by set, then name. */
const ICONS = [
'logos:google-icon',
'solar:add-circle-bold',
@@ -34,6 +34,8 @@ const ICONS = [
'solar:calendar-minimalistic-linear',
'solar:card-2-bold-duotone',
'solar:chart-2-bold-duotone',
+ 'solar:chat-round-line-bold',
+ 'solar:chat-round-line-bold-duotone',
'solar:check-circle-bold',
'solar:check-circle-bold-duotone',
'solar:clock-circle-bold',
@@ -48,13 +50,16 @@ const ICONS = [
'solar:download-minimalistic-bold',
'solar:fire-bold',
'solar:fire-bold-duotone',
+ 'solar:global-linear',
'solar:hamburger-menu-linear',
'solar:history-2-bold-duotone',
'solar:inbox-in-bold-duotone',
'solar:layers-minimalistic-bold-duotone',
+ 'solar:lock-keyhole-bold',
'solar:lock-keyhole-minimalistic-bold-duotone',
'solar:logout-2-linear',
'solar:magnifer-linear',
+ 'solar:microphone-3-bold',
'solar:microphone-3-bold-duotone',
'solar:monitor-bold-duotone',
'solar:moon-bold-duotone',
@@ -67,9 +72,11 @@ const ICONS = [
'solar:rocket-2-bold-duotone',
'solar:settings-bold-duotone',
'solar:settings-linear',
+ 'solar:shuffle-bold',
'solar:sun-2-bold-duotone',
'solar:tuning-2-bold',
'solar:user-linear',
+ 'solar:user-speak-rounded-bold-duotone',
];
const setCache = new Map();
diff --git a/ui/src/elements/StProgressBar.vue b/ui/src/elements/StProgressBar.vue
new file mode 100644
index 00000000..e96c24c3
--- /dev/null
+++ b/ui/src/elements/StProgressBar.vue
@@ -0,0 +1,57 @@
+
+
+
+
+
{{ Math.round(pct) }}%
+
+
+
+
diff --git a/ui/src/elements/StSegmentedControl.vue b/ui/src/elements/StSegmentedControl.vue
new file mode 100644
index 00000000..4de7aacf
--- /dev/null
+++ b/ui/src/elements/StSegmentedControl.vue
@@ -0,0 +1,74 @@
+
+
+
+
+
+
+
diff --git a/ui/src/icon/icons.generated.ts b/ui/src/icon/icons.generated.ts
index 5d88fcb6..c2b51739 100644
--- a/ui/src/icon/icons.generated.ts
+++ b/ui/src/icon/icons.generated.ts
@@ -21,6 +21,8 @@ export const icons: Record = {
"solar:calendar-minimalistic-linear": { body: "", width: 24, height: 24 },
"solar:card-2-bold-duotone": { body: "", width: 24, height: 24 },
"solar:chart-2-bold-duotone": { body: "", width: 24, height: 24 },
+ "solar:chat-round-line-bold": { body: "", width: 24, height: 24 },
+ "solar:chat-round-line-bold-duotone": { body: "", width: 24, height: 24 },
"solar:check-circle-bold": { body: "", width: 24, height: 24 },
"solar:check-circle-bold-duotone": { body: "", width: 24, height: 24 },
"solar:clock-circle-bold": { body: "", width: 24, height: 24 },
@@ -35,13 +37,16 @@ export const icons: Record = {
"solar:download-minimalistic-bold": { body: "", width: 24, height: 24 },
"solar:fire-bold": { body: "", width: 24, height: 24 },
"solar:fire-bold-duotone": { body: "", width: 24, height: 24 },
+ "solar:global-linear": { body: "", width: 24, height: 24 },
"solar:hamburger-menu-linear": { body: "", width: 24, height: 24 },
"solar:history-2-bold-duotone": { body: "", width: 24, height: 24 },
"solar:inbox-in-bold-duotone": { body: "", width: 24, height: 24 },
"solar:layers-minimalistic-bold-duotone": { body: "", width: 24, height: 24 },
+ "solar:lock-keyhole-bold": { body: "", width: 24, height: 24 },
"solar:lock-keyhole-minimalistic-bold-duotone": { body: "", width: 24, height: 24 },
"solar:logout-2-linear": { body: "", width: 24, height: 24 },
"solar:magnifer-linear": { body: "", width: 24, height: 24 },
+ "solar:microphone-3-bold": { body: "", width: 24, height: 24 },
"solar:microphone-3-bold-duotone": { body: "", width: 24, height: 24 },
"solar:monitor-bold-duotone": { body: "", width: 24, height: 24 },
"solar:moon-bold-duotone": { body: "", width: 24, height: 24 },
@@ -54,9 +59,11 @@ export const icons: Record = {
"solar:rocket-2-bold-duotone": { body: "", width: 24, height: 24 },
"solar:settings-bold-duotone": { body: "", width: 24, height: 24 },
"solar:settings-linear": { body: "", width: 24, height: 24 },
+ "solar:shuffle-bold": { body: "", width: 24, height: 24 },
"solar:sun-2-bold-duotone": { body: "", width: 24, height: 24 },
"solar:tuning-2-bold": { body: "", width: 24, height: 24 },
"solar:user-linear": { body: "", width: 24, height: 24 },
+ "solar:user-speak-rounded-bold-duotone": { body: "", width: 24, height: 24 },
};
export type IconName = keyof typeof icons;
diff --git a/ui/src/index.ts b/ui/src/index.ts
index a327cc21..c34cc876 100644
--- a/ui/src/index.ts
+++ b/ui/src/index.ts
@@ -20,6 +20,8 @@ export { default as StEmptyState } from './elements/StEmptyState.vue';
export { default as StIconButton } from './elements/StIconButton.vue';
export { default as StInput } from './elements/StInput.vue';
export { default as StModal } from './elements/StModal.vue';
+export { default as StProgressBar } from './elements/StProgressBar.vue';
+export { default as StSegmentedControl } from './elements/StSegmentedControl.vue';
export { default as StSkeleton } from './elements/StSkeleton.vue';
export { default as StSwitch } from './elements/StSwitch.vue';
export { default as StTextarea } from './elements/StTextarea.vue';
@@ -34,4 +36,5 @@ export { default as StAppShell } from './shell/StAppShell.vue';
export { default as StProfileMenu } from './shell/StProfileMenu.vue';
export { default as StSidebarNav } from './shell/StSidebarNav.vue';
+export type { StSegment } from './elements/StSegmentedControl.vue';
export type { StNavGroup, StNavItem, StProfileMenuItem, StTheme, StTone, StSize, StPadding, StElevation } from './types';