Skip to content
Open

Dev #58

Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
8e58fb5
feat: show dashboard version in a global footer #86exqazkq
navidshad Jun 19, 2026
ea095dc
docs: require ClickUp #<taskId> in commit subject and PR title #86exq…
navidshad Jun 19, 2026
7228660
ci: gate dashboard release on unit tests before publishing #86exqazkq
navidshad Jun 19, 2026
5a8e246
docs: correct stale "release automation not wired up" note in CLAUDE.…
navidshad Jun 19, 2026
2dfd8ea
Merge pull request #52 from codebridger/CU-86exqazkq_Show-versions-on…
navidshad Jun 19, 2026
29e67bc
chore(release): 1.0.0-dev.1 [skip ci]
semantic-release-bot Jun 19, 2026
2856a3b
feat(leitner): return confirmed_chunk + source_sentence on review ite…
navidshad Jul 2, 2026
19c52c9
Merge pull request #53 from codebridger/CU-86exnxph5_Update-get-revie…
navidshad Jul 3, 2026
e5728bc
chore(release): 1.0.0-dev.2 [skip ci]
semantic-release-bot Jul 3, 2026
f6ebc1d
feat(pool): Pool encode queue, Forgot×2 routing, age-out, and board/s…
navidshad Jul 3, 2026
d5e35bc
style(settings): tabbed Study settings page + study nav terminology
navidshad Jul 3, 2026
b63ad1d
Merge pull request #54 from codebridger/CU-86exnxp4t_Pool-system-Forg…
navidshad Jul 3, 2026
2465adc
chore(release): 1.0.0-dev.3 [skip ci]
semantic-release-bot Jul 3, 2026
9543401
fix(leitner): use real review RPCs and replace end-of-session alert w…
navidshad Jul 4, 2026
bf105e1
Merge pull request #55 from codebridger/CU-86exnxphy_Replace-Leitner-…
navidshad Jul 4, 2026
9145112
chore(release): 1.0.0-dev.4 [skip ci]
semantic-release-bot Jul 4, 2026
bf81701
add .playwright-mcp dir into gitignore
navidshad Jul 5, 2026
5f49c7c
feat: wire account_created, first-save & pricing-page analytics + enr…
navidshad Jul 8, 2026
71cf5e0
fix: guard pricing-page_viewed track so a token-less Mixpanel can't a…
navidshad Jul 8, 2026
13c6cf5
Merge pull request #56 from codebridger/CU-86exkh107_Step-5-Instrumen…
navidshad Jul 10, 2026
a8bc192
chore(release): 1.0.0-dev.5 [skip ci]
semantic-release-bot Jul 10, 2026
32c224c
chore: trigger CI for release PR #58
navidshad Jul 10, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
51 changes: 51 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
name: Release

on:
push:
branches:
- main
- dev

permissions:
contents: write
issues: write
pull-requests: write

concurrency:
group: release-${{ github.ref }}
cancel-in-progress: false

jobs:
release:
name: Release
runs-on: ubuntu-latest
defaults:
run:
working-directory: frontend
steps:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 0
persist-credentials: false

- name: Setup Node
uses: actions/setup-node@v4
with:
node-version: 22
cache: yarn
cache-dependency-path: frontend/yarn.lock

- name: Install dependencies
run: yarn install --frozen-lockfile

# Gate the release: a push to main/dev with failing unit tests must not
# cut a version. (E2E is omitted here — it needs Playwright browsers and
# a live backend; PRs already run the full suite via test.yml.)
- name: Unit tests
run: yarn test

- name: Release
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: yarn release
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -5,3 +5,4 @@

# Agent E2E run reports (the specs are tracked; the generated run logs are not)
agent-tests/_runs/
.playwright-mcp
4 changes: 3 additions & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -166,7 +166,9 @@ This repo uses **semantic versioning**, and commit titles follow **Conventional

Don't dress a real feature as `refactor`/`chore` (it would skip a release) or inflate a refactor into `feat` (it over-bumps). If you squash-merge a PR, the **PR title** becomes the commit message, so it must follow the same convention.

> Release automation is **not** wired up in this repo yet (no `semantic-release`, no tags, `server/package.json` is `0.0.0`) — the convention currently records the *intended* bump. The sibling **subturtle-extension-apps** repo enforces the identical mapping automatically via `semantic-release`.
**Link the ClickUp task:** when the work has a task id, append it as `#<taskId>` to the commit subject — and to the **PR title** so it survives a squash-merge — e.g. `feat: show dashboard version in a global footer #86exqazkq`. Use the bare id (not the `CU-` branch prefix). The type prefix still drives the version bump; the `#<taskId>` just keeps `git log` greppable and linkable back to ClickUp.

> Release automation is wired up for the **frontend** via `semantic-release` ([frontend/release.config.cjs](frontend/release.config.cjs), [.github/workflows/release.yml](.github/workflows/release.yml)) — it owns the version in [frontend/package.json](frontend/package.json) and cuts a tagged release on pushes to `dev`/`main` that contain releasable commits. The **server** has no release pipeline yet (`server/package.json` stays `0.0.0`). The sibling **subturtle-extension-apps** repo enforces the identical mapping via its own `semantic-release` setup.

## Gotchas

Expand Down
255 changes: 255 additions & 0 deletions frontend/CHANGELOG-DEV.md

Large diffs are not rendered by default.

4 changes: 2 additions & 2 deletions frontend/components/FeatureLocked.vue
Original file line number Diff line number Diff line change
Expand Up @@ -63,9 +63,9 @@

// Primary CTA pre-selects the required tier on the pricing page; secondary lists all.
function goUpgrade() {
router.push(`/settings/subscription?suggest=${props.requiredTier}`);
router.push(`/settings/subscription?suggest=${props.requiredTier}&from=cap-hit-banner`);
}
function goToPlans() {
router.push('/settings/subscription');
router.push('/settings/subscription?from=cap-hit-banner');
}
</script>
143 changes: 143 additions & 0 deletions frontend/components/Pool/PoolSettings.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,143 @@
<template>
<Card class="rounded-2xl border border-gray-100 shadow-sm dark:border-gray-700">
<div class="flex flex-col gap-8 p-8">
<!-- Header -->
<div class="flex items-center gap-4">
<div class="flex h-12 w-12 items-center justify-center rounded-xl bg-secondary/10 text-secondary">
<Icon name="iconify solar--inbox-in-bold-duotone" class="!h-6 !w-6" />
</div>
<div>
<h3 class="text-xl font-bold text-gray-900 dark:text-white">{{ $t('pool.settings') }}</h3>
<p class="max-w-lg text-sm text-gray-500 dark:text-gray-400">{{ $t('pool.settings_desc') }}</p>
</div>
</div>

<!-- Rows -->
<div class="divide-y divide-gray-100 border-t border-gray-100 dark:divide-gray-700/60 dark:border-gray-700/60">
<!-- Age cut-off -->
<div class="flex items-center justify-between gap-6 py-5">
<div class="flex items-start gap-3">
<Icon name="iconify solar--hourglass-line-duotone" class="mt-0.5 !h-5 !w-5 shrink-0 text-secondary/50" />
<div>
<div class="font-bold text-gray-800 dark:text-gray-200">{{ $t('pool.age_cutoff') }}</div>
<p class="text-xs text-gray-500 dark:text-gray-400">{{ $t('pool.age_cutoff_desc') }}</p>
</div>
</div>
<div class="flex shrink-0 items-center gap-2">
<input v-model.number="local.poolAgeCutoffDays" type="number" min="1" max="90"
class="form-input w-20 rounded-lg border-gray-200 bg-gray-50 px-3 py-2 text-center text-sm font-bold transition-all focus:border-primary-500 focus:bg-white focus:ring-4 focus:ring-primary/10 dark:border-gray-600 dark:bg-gray-700" />
<span class="w-10 text-xs font-bold uppercase text-gray-400">{{ $t('smart_review.days') }}</span>
</div>
</div>

<!-- Chunk size -->
<div class="flex items-center justify-between gap-6 py-5">
<div class="flex items-start gap-3">
<Icon name="iconify solar--layers-minimalistic-bold-duotone" class="mt-0.5 !h-5 !w-5 shrink-0 text-secondary/50" />
<div>
<div class="font-bold text-gray-800 dark:text-gray-200">{{ $t('pool.chunk_size') }}</div>
<p class="text-xs text-gray-500 dark:text-gray-400">{{ $t('pool.chunk_size_desc') }}</p>
</div>
</div>
<div class="flex shrink-0 items-center gap-2">
<input v-model.number="local.poolChunkSize" type="number" min="1" max="100"
class="form-input w-20 rounded-lg border-gray-200 bg-gray-50 px-3 py-2 text-center text-sm font-bold transition-all focus:border-primary-500 focus:bg-white focus:ring-4 focus:ring-primary/10 dark:border-gray-600 dark:bg-gray-700" />
<span class="w-10 text-xs font-bold uppercase text-gray-400">{{ $t('smart_review.items') }}</span>
</div>
</div>

<!-- Daily review time (shared with Smart Review) -->
<div class="flex items-center justify-between gap-6 py-5">
<div class="flex items-start gap-3">
<Icon name="iconify solar--clock-circle-bold-duotone" class="mt-0.5 !h-5 !w-5 shrink-0 text-secondary/50" />
<div>
<div class="font-bold text-gray-800 dark:text-gray-200">{{ $t('pool.daily_review_time') }}</div>
<p class="text-xs text-gray-500 dark:text-gray-400">{{ $t('pool.daily_review_time_desc') }}</p>
</div>
</div>
<div class="shrink-0">
<HourSelector v-model="local.reviewHour" />
</div>
</div>
</div>

<!-- Footer / save -->
<div class="flex items-center justify-between gap-3 border-t border-gray-100 pt-6 dark:border-gray-700/60">
<div class="flex items-center gap-2">
<span class="h-2 w-2 rounded-full" :class="dirty ? 'bg-warning animate-pulse' : 'bg-success'" />
<span class="text-xs font-bold text-gray-500 dark:text-gray-400">
{{ dirty ? $t('smart_review.unsaved_changes') : $t('smart_review.settings_synced') }}
</span>
</div>
<Button color="primary" rounded="xl" :disabled="!dirty || saving" :is-loading="saving"
class="px-8 shadow-lg shadow-primary/20" @click="save">
<template #icon>
<Icon name="IconSave" class="!h-4 !w-4" />
</template>
{{ $t('pool.save') }}
</Button>
</div>
</div>
</Card>
</template>

<script setup lang="ts">
import { ref, watch } from 'vue';
import { Button, Card, Icon } from 'pilotui/elements';
import { toastSuccess, toastError } from 'pilotui/toast';
import HourSelector from '~/components/material/HourSelector.vue';
import { functionProvider } from '@modular-rest/client';
import { useProfileStore } from '~/stores/profile';
import { storeToRefs } from 'pinia';
import { useI18n } from 'vue-i18n';

const { t } = useI18n();
const profileStore = useProfileStore();
const { authUser } = storeToRefs(profileStore);

const props = defineProps<{
stats: any;
}>();

const emit = defineEmits<{ (e: 'saved'): void }>();

const local = ref({ poolAgeCutoffDays: 7, poolChunkSize: 10, reviewHour: 9 });
const dirty = ref(false);
const saving = ref(false);

// Seed from the merged settings whenever they load/refresh.
watch(
() => props.stats,
(newVal) => {
if (newVal?.settings) {
local.value = {
poolAgeCutoffDays: newVal.settings.poolAgeCutoffDays ?? 7,
poolChunkSize: newVal.settings.poolChunkSize ?? 10,
reviewHour: newVal.settings.reviewHour ?? 9,
};
dirty.value = false;
}
},
{ immediate: true, deep: true }
);

watch(local, () => { dirty.value = true; }, { deep: true });

async function save() {
saving.value = true;
try {
await functionProvider.run({
name: 'update-settings',
args: { userId: authUser.value?.id, settings: local.value },
});
dirty.value = false;
toastSuccess(t('pool.saved_ok'));
emit('saved');
} catch (e: any) {
console.error(e);
toastError(e?.error || e?.message || t('pool.save_failed'));
} finally {
saving.value = false;
}
}
</script>
99 changes: 99 additions & 0 deletions frontend/components/PoolCard.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
<template>
<!-- Count-adaptive Pool card. Renders nothing when the pool is empty. -->
<div v-if="poolCount > 0" class="group h-full">
<Card
class="relative h-full flex flex-col overflow-hidden rounded-[2.5rem] bg-white dark:bg-gray-800 shadow-[0_4px_20px_rgba(0,0,0,0.03)] hover:shadow-[0_40px_80px_rgba(var(--primary-rgb),0.15)] transition-all duration-700 hover:-translate-y-3 cursor-default p-0 border-0">

<!-- Accent header -->
<div class="h-32 w-full relative overflow-hidden shrink-0 rounded-t-[2rem]">
<div
class="absolute inset-0 bg-gradient-to-br from-secondary via-primary to-primary-dark opacity-100 group-hover:scale-110 transition-transform duration-1000">
</div>
<div
class="absolute inset-0 opacity-10 bg-[radial-gradient(#fff_1px,transparent_1px)] [background-size:16px_16px]">
</div>
<div class="absolute -left-6 -bottom-10 opacity-10 scale-150 group-hover:rotate-12 transition-transform duration-1000">
<Icon name="iconify solar--inbox-archive-bold-duotone" class="w-32 h-32 text-white" />
</div>
</div>

<!-- Content -->
<div
class="p-10 pb-12 flex flex-col flex-grow relative -mt-8 bg-white dark:bg-gray-800 rounded-t-[3rem] z-10 border-t border-white/10">
<div class="absolute -top-12 left-10 h-24 w-24">
<div
class="h-24 w-24 bg-white dark:bg-gray-900 rounded-[2rem] shadow-2xl flex items-center justify-center border border-gray-50 dark:border-gray-800 ring-8 ring-white/10 dark:ring-gray-900/10 group-hover:scale-105 transition-transform duration-500">
<Icon name="iconify solar--inbox-in-bold-duotone" class="text-4xl text-secondary" />
</div>
</div>

<div class="mt-10 mb-8">
<div class="flex items-center gap-2 mb-3">
<span class="text-[10px] font-bold text-secondary uppercase tracking-widest">{{ $t('pool.module_label') }}</span>
<div class="h-px flex-grow bg-gray-100 dark:bg-gray-700/50"></div>
</div>
<h3 class="text-2xl font-black text-gray-900 dark:text-white mb-3 tracking-tight group-hover:text-secondary transition-colors">
{{ $t('pool.title') }}
</h3>
<p class="text-base font-medium text-gray-500 dark:text-gray-400 leading-relaxed flex items-center gap-2">
<Icon name="iconify solar--layers-minimalistic-bold-duotone" class="w-5 h-5 opacity-60 shrink-0" />
<span>{{ cardCopy }}</span>
</p>
</div>

<!-- Actions -->
<div class="mt-auto pt-8 flex flex-col gap-3">
<Button @click="start()" block size="lg"
class="h-16 rounded-2xl font-black bg-gray-900 dark:bg-white text-white dark:text-gray-900 hover:bg-secondary dark:hover:bg-secondary hover:text-white dark:hover:text-white transition-all duration-300 active:scale-95 shadow-xl">
<div class="flex items-center justify-center gap-3">
<Icon name="iconify solar--play-circle-bold" class="w-6 h-6" />
{{ $t('pool.start') }}
</div>
</Button>
<Button v-if="poolCount > chunkSize" @click="doNext()" block size="lg" :outline="true" color="secondary"
class="h-14 rounded-2xl font-bold">
{{ $t('pool.do_next', { chunk: chunkSize }) }}
</Button>
</div>
</div>
</Card>
</div>
</template>

<script setup lang="ts">
import { usePoolStore } from '~/stores/pool';
import { useProfileStore } from '~/stores/profile';
import { storeToRefs } from 'pinia';
import { Card, Button, Icon } from 'pilotui/elements';

const { t } = useI18n();
const router = useRouter();
const poolStore = usePoolStore();
const profileStore = useProfileStore();
const { poolCount } = storeToRefs(poolStore);
const { userDetail } = storeToRefs(profileStore);

// Chunk size for "Do the next N" — user-configurable on the profile (default 10).
const chunkSize = computed(() => (userDetail.value as any)?.poolChunkSize || 10);

// ~15s per word, matching the spec's examples (8 words ≈ 2 min, 23 words ≈ 6 min).
const estMinutes = computed(() => Math.max(1, Math.round(poolCount.value * 0.25)));

// Copy adapts to how big the backlog is (spec's four ranges; 0 renders no card).
const cardCopy = computed(() => {
const count = poolCount.value;
if (count <= 15) return t('pool.card_small', { count, mins: estMinutes.value });
if (count <= 30) return t('pool.card_medium', { count, mins: estMinutes.value, chunk: chunkSize.value });
return t('pool.card_large', { count, chunk: chunkSize.value });
});

function start() {
poolStore.startSession();
router.push('/practice/pool');
}

function doNext() {
poolStore.startSession(chunkSize.value);
router.push('/practice/pool');
}
</script>
23 changes: 23 additions & 0 deletions frontend/components/PoolReviewItem.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
<template>
<!-- First-encounter (encode) card: the source sentence with the confirmed chunk
blanked out as a fill-in. We reuse WidgetFlashCard and force the fill-in mode
(which it normally reserves for Leitner L3+) by passing leitner-level 3; when a
phrase has no usable chunk it falls back to the recognition card automatically. -->
<WidgetFlashCard ref="flashCardRef" :phrase="phrase" :leitner-level="3" />
</template>

<script setup lang="ts">
import { type PhraseType } from '~/types/database.type';

defineProps<{
phrase?: PhraseType | null;
}>();

const flashCardRef = ref<any>(null);

function flipCard() {
flashCardRef.value?.flipCard();
}

defineExpose({ flipCard });
</script>
2 changes: 1 addition & 1 deletion frontend/components/TextChatCounter.vue
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,6 @@

// At the cap, point the user at Learner's unlimited text chat.
function findOutMore() {
router.push('/settings/subscription?suggest=learner');
router.push('/settings/subscription?suggest=learner&from=cap-hit-banner');
}
</script>
2 changes: 1 addition & 1 deletion frontend/components/VoiceCapBanner.vue
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,6 @@

// Learner's "Upgrade plan" highlights Coach; Coach has no tier above (button hidden).
function goUpgrade() {
router.push('/settings/subscription?from=voice-banner&suggest=coach');
router.push('/settings/subscription?from=cap-hit-banner&suggest=coach');
}
</script>
2 changes: 1 addition & 1 deletion frontend/components/freemium_alerts/LimitationModal.vue
Original file line number Diff line number Diff line change
Expand Up @@ -119,7 +119,7 @@ function handlePrimaryAction(toggleModal?: (value: boolean) => void) {

// Default behavior: redirect to subscription page (can be overridden by parent)
if (props.autoRedirectOnUpgrade) {
router.push('/settings/subscription');
router.push('/settings/subscription?from=cap-hit-banner');
analytic.track('upgrade-cta_clicked');
}
}
Expand Down
2 changes: 1 addition & 1 deletion frontend/components/freemium_alerts/UsageCapBanner.vue
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,6 @@ const state = computed<'hidden' | 'warning' | 'paused'>(() => {
});

function goToPlans() {
router.push('/settings/subscription');
router.push('/settings/subscription?from=cap-hit-banner');
}
</script>
14 changes: 14 additions & 0 deletions frontend/components/partial/AppVersionFooter.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
<template>
<div v-if="version" class="px-4 py-3 text-center text-xs text-gray-400 dark:text-gray-500">
{{ t('app.version', { version }) }}
</div>
</template>

<script setup lang="ts">
const { t } = useI18n();
const config = useRuntimeConfig();

// Baked in at build time from package.json (see nuxt.config.ts). Owned by
// semantic-release, so it reflects the latest released dashboard version.
const version = computed(() => config.public.APP_VERSION as string);
</script>
Loading
Loading