From 541bed3a8aca36fef7b6bf133cede05f0487c186 Mon Sep 17 00:00:00 2001 From: Ryan Kegel Date: Tue, 21 Jul 2026 14:53:31 -0400 Subject: [PATCH] feat: enhance tutorial functionality by disabling inputs and buttons during active steps --- .opencode/agents/developer.md | 2 +- .opencode/skills/bugfix/SKILL.md | 27 ++++++ frontend/e2e/.auth/user-cc.json | 12 +-- frontend/e2e/.auth/user-delete.json | 12 +-- frontend/e2e/.auth/user-tutorial.json | 12 +-- frontend/e2e/.auth/user.json | 12 +-- .../e2e/mode_parent/tutorial/tutorial.spec.ts | 64 +++++++++++++ .../components/shared/ChildrenListView.vue | 7 +- .../src/components/shared/EntityEditForm.vue | 17 ++-- .../shared/FloatingActionButton.vue | 15 ++- .../shared/__tests__/EntityEditForm.spec.ts | 66 ++++++++++++- .../__tests__/FloatingActionButton.spec.ts | 26 +++++ frontend/src/components/utils/ImagePicker.vue | 54 +++++++---- .../utils/__tests__/ImagePicker.spec.ts | 96 +++++++++++++++++++ frontend/src/tutorial/TutorialOverlay.vue | 22 +++++ frontend/src/tutorial/controller.ts | 3 +- frontend/test-results/.last-run.json | 6 +- opencode.json | 24 +++++ 18 files changed, 418 insertions(+), 59 deletions(-) create mode 100644 .opencode/skills/bugfix/SKILL.md create mode 100644 frontend/src/components/shared/__tests__/FloatingActionButton.spec.ts create mode 100644 frontend/src/components/utils/__tests__/ImagePicker.spec.ts create mode 100644 opencode.json diff --git a/.opencode/agents/developer.md b/.opencode/agents/developer.md index 43307f8..26fdd32 100644 --- a/.opencode/agents/developer.md +++ b/.opencode/agents/developer.md @@ -2,7 +2,7 @@ name: Developer description: Implements core application features across Python backends and Vue frontends. mode: subagent -model: deepseek/deepseek-v4-pro +model: moonshotai/kimi-k2.7-code temperature: 0.2 maxSteps: 50 permission: diff --git a/.opencode/skills/bugfix/SKILL.md b/.opencode/skills/bugfix/SKILL.md new file mode 100644 index 0000000..c2e56c8 --- /dev/null +++ b/.opencode/skills/bugfix/SKILL.md @@ -0,0 +1,27 @@ +--- +name: bugfix +description: "Executes a bugfix pipeline on one or more gitea issues: Developer -> Tester -> Reviewer" +--- + +## What I do + +I orchestrate a sequential bugfix and verification pipeline - I will retrieve issues(s) from Gitea. I will then forward information from the issues to the respective subagents. + +Use gitea-mcp-server to interact with Gitea. Verify that the server is running and accessible. + +If an issue is not provided, ask the user for the issue number(s). + +1. **Developer**: Provides a code fix for each issue. +2. **Reviewer**: Audits the code and architectural soundness. +3. **Tester**: Runs tests related to the bugfix and determines if new unit tests, integration tests, or end-to-end tests are needed. If so, implement. Verify by running the test suite. + +## Execution Rules + +- Stop and ask the user for clarification if a step fails or is ambiguous. +- Use the `@` mention to trigger the respective subagents sequentially. +- Pass the context from the previous stage to the next stage to ensure consistency. +- Use multiple subagents to handle different aspects of the bugfix process if it will help. + +## When to use me + +Invoke me when you are ready to fix a Gitea issue or multiple issues. diff --git a/frontend/e2e/.auth/user-cc.json b/frontend/e2e/.auth/user-cc.json index 0326907..d74372e 100644 --- a/frontend/e2e/.auth/user-cc.json +++ b/frontend/e2e/.auth/user-cc.json @@ -2,20 +2,20 @@ "cookies": [ { "name": "refresh_token", - "value": "QRDDJrwVfrRB_5Z--i4k48Q4-0kktQnZJelqd9JABBQ", + "value": "qYJH5LMH5O4UK92tdAp7R49rzGACFvTMVj29S-Pzygc", "domain": "localhost", "path": "/api/auth", - "expires": 1791758861.201632, + "expires": 1792435594.088715, "httpOnly": true, "secure": true, "sameSite": "Strict" }, { "name": "access_token", - "value": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJlbWFpbCI6ImUyZS1jY0B0ZXN0LmNvbSIsInVzZXJfaWQiOiIyNjU1MGUxZS00NTBiLTRmMzMtOGI3MC02NjA1MGU2MzZkNzgiLCJ0b2tlbl92ZXJzaW9uIjowLCJleHAiOjE3ODM5OTM2NjF9.pZegL2iwonftDyVlaADPhHeTPrx7VKkoi8TtDY3Byuk", + "value": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJlbWFpbCI6ImUyZS1jY0B0ZXN0LmNvbSIsInVzZXJfaWQiOiI2NmNlMzgzMy0zMGQ3LTRjMTItYjRhMi1iNmIxMjcxYTI1YzEiLCJ0b2tlbl92ZXJzaW9uIjowLCJleHAiOjE3ODQ2NzAzOTR9.jkyiFTxBQ0oatY6jCAWFG88E8e6mCrDDlN_8I56RMBA", "domain": "localhost", "path": "/", - "expires": 1783993661.201585, + "expires": 1784670394.088677, "httpOnly": true, "secure": true, "sameSite": "Lax" @@ -27,11 +27,11 @@ "localStorage": [ { "name": "authSyncEvent", - "value": "{\"type\":\"logout\",\"at\":1783982861064}" + "value": "{\"type\":\"logout\",\"at\":1784659593888}" }, { "name": "parentAuth", - "value": "{\"expiresAt\":1784155661355}" + "value": "{\"expiresAt\":1784832394248}" } ] } diff --git a/frontend/e2e/.auth/user-delete.json b/frontend/e2e/.auth/user-delete.json index 32dab55..31084a3 100644 --- a/frontend/e2e/.auth/user-delete.json +++ b/frontend/e2e/.auth/user-delete.json @@ -2,20 +2,20 @@ "cookies": [ { "name": "refresh_token", - "value": "QCQAmUFxc_xNvuLjIGYO4HL49nrDnWW2K54jTZT3ZBw", + "value": "PylNoNcxDjRuoocNg4ZmApg_7OIxp9Jfs7ttsX3C5uA", "domain": "localhost", "path": "/api/auth", - "expires": 1791758861.100584, + "expires": 1792435594.088028, "httpOnly": true, "secure": true, "sameSite": "Strict" }, { "name": "access_token", - "value": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJlbWFpbCI6ImUyZS1kZWxldGVAdGVzdC5jb20iLCJ1c2VyX2lkIjoiZjkyN2FjMzItZDE3ZC00MjhhLWI5YTgtYTQ5NjM5YjE4YTgzIiwidG9rZW5fdmVyc2lvbiI6MCwiZXhwIjoxNzgzOTkzNjYxfQ.GE6KZ4PXLwLsF8HYZckYwvBruIBMNqVxY-Yg94_QBcU", + "value": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJlbWFpbCI6ImUyZS1kZWxldGVAdGVzdC5jb20iLCJ1c2VyX2lkIjoiN2UzNjdjMGItMDU3ZS00MWEzLThlNzYtZWZmNDUyMmFmOTU2IiwidG9rZW5fdmVyc2lvbiI6MCwiZXhwIjoxNzg0NjcwMzk0fQ.cVkoiVTtH1O7TFqW5yQ9BEBlTq7hR8YEya7FW2b_Yh8", "domain": "localhost", "path": "/", - "expires": 1783993661.100535, + "expires": 1784670394.08796, "httpOnly": true, "secure": true, "sameSite": "Lax" @@ -27,11 +27,11 @@ "localStorage": [ { "name": "authSyncEvent", - "value": "{\"type\":\"logout\",\"at\":1783982860894}" + "value": "{\"type\":\"logout\",\"at\":1784659593889}" }, { "name": "parentAuth", - "value": "{\"expiresAt\":1784155661256}" + "value": "{\"expiresAt\":1784832394248}" } ] } diff --git a/frontend/e2e/.auth/user-tutorial.json b/frontend/e2e/.auth/user-tutorial.json index 585b268..6e6c776 100644 --- a/frontend/e2e/.auth/user-tutorial.json +++ b/frontend/e2e/.auth/user-tutorial.json @@ -2,20 +2,20 @@ "cookies": [ { "name": "refresh_token", - "value": "xQQnxda8ujhjlZDBQ2Rl4Xxlh4X2_Kt1e80YSWniAXA", + "value": "LZA3Bk15HR3io_q8vfyqD3wlRoyV-GJwY-MjWvtUQzg", "domain": "localhost", "path": "/api/auth", - "expires": 1791760814.863417, + "expires": 1792435594.060453, "httpOnly": true, "secure": true, "sameSite": "Strict" }, { "name": "access_token", - "value": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJlbWFpbCI6ImUyZS10dXRvcmlhbEB0ZXN0LmNvbSIsInVzZXJfaWQiOiIzNWI3ZGVjMS01ODNmLTQ5M2YtYTQ5ZS1mNWM0NzM3YTQ5NzgiLCJ0b2tlbl92ZXJzaW9uIjowLCJleHAiOjE3ODM5OTU2MTR9.KWJqO7mVswb5IoHsJOOdZSm8iCmYoNVXwixDsQ8lbmI", + "value": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJlbWFpbCI6ImUyZS10dXRvcmlhbEB0ZXN0LmNvbSIsInVzZXJfaWQiOiJjYjQyMzZkNC1kNDRlLTQ0YjMtOTYzYi0xYTUwMWEzZjM2NTMiLCJ0b2tlbl92ZXJzaW9uIjowLCJleHAiOjE3ODQ2NzAzOTR9.pPd0JYgaAyn4_0YPOlxKoX1B9Ka4Vq2M3CpNcl7EDAU", "domain": "localhost", "path": "/", - "expires": 1783995614.863369, + "expires": 1784670394.06041, "httpOnly": true, "secure": true, "sameSite": "Lax" @@ -27,11 +27,11 @@ "localStorage": [ { "name": "authSyncEvent", - "value": "{\"type\":\"logout\",\"at\":1783984814749}" + "value": "{\"type\":\"logout\",\"at\":1784659593886}" }, { "name": "parentAuth", - "value": "{\"expiresAt\":1784157614996}" + "value": "{\"expiresAt\":1784832394232}" } ] } diff --git a/frontend/e2e/.auth/user.json b/frontend/e2e/.auth/user.json index bca56c6..60b346b 100644 --- a/frontend/e2e/.auth/user.json +++ b/frontend/e2e/.auth/user.json @@ -2,20 +2,20 @@ "cookies": [ { "name": "refresh_token", - "value": "zFYZnbIZmC4uH9DcQ3PO8WPPAy_IOts57gvbCMERHvw", + "value": "MYMeAyxeWQy7LVHrJsvF-10FozU5Ko_QoEvmV3bHwPc", "domain": "localhost", "path": "/api/auth", - "expires": 1791760813.448801, + "expires": 1792435592.056481, "httpOnly": true, "secure": true, "sameSite": "Strict" }, { "name": "access_token", - "value": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJlbWFpbCI6ImUyZUB0ZXN0LmNvbSIsInVzZXJfaWQiOiIzNDkyZjAyMi0yMDdiLTRjMjMtYWFlYy1lMmNjNzc0N2IxYjAiLCJ0b2tlbl92ZXJzaW9uIjowLCJleHAiOjE3ODM5OTU2MTN9.PM5XaijvAuzEPSY3Mz1DB1jvYTJDeaMhGZnrYukUJ0Q", + "value": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJlbWFpbCI6ImUyZUB0ZXN0LmNvbSIsInVzZXJfaWQiOiJiODJhZDU2Mi03MjMwLTRkN2EtODU3NS04N2FhNzE1ZWNhMjQiLCJ0b2tlbl92ZXJzaW9uIjowLCJleHAiOjE3ODQ2NzAzOTJ9.iEbCtV3csb9AUWoXL4MJ-GLL1yiJDumvYmKr-vyuwBg", "domain": "localhost", "path": "/", - "expires": 1783995613.448755, + "expires": 1784670392.056434, "httpOnly": true, "secure": true, "sameSite": "Lax" @@ -27,11 +27,11 @@ "localStorage": [ { "name": "authSyncEvent", - "value": "{\"type\":\"logout\",\"at\":1783984813329}" + "value": "{\"type\":\"logout\",\"at\":1784659591903}" }, { "name": "parentAuth", - "value": "{\"expiresAt\":1784157613579}" + "value": "{\"expiresAt\":1784832392197}" } ] } diff --git a/frontend/e2e/mode_parent/tutorial/tutorial.spec.ts b/frontend/e2e/mode_parent/tutorial/tutorial.spec.ts index 1b6573c..6b15b2c 100644 --- a/frontend/e2e/mode_parent/tutorial/tutorial.spec.ts +++ b/frontend/e2e/mode_parent/tutorial/tutorial.spec.ts @@ -377,4 +377,68 @@ test.describe('Tutorial system', () => { await page.reload() await expect(getTutorialCard(page)).not.toBeVisible({ timeout: 3000 }) }) + + test('add-child FAB is disabled while the create-child tutorial is showing', async ({ + page, + request, + }) => { + await deleteAllChildren(request) + await page.goto('/parent') + await expectTutorialCard(page, 'Add your child') + + const fab = page.locator('.fab') + await expect(fab).toBeVisible() + await expect(fab).toBeDisabled() + + // The page should still be on the children list and the tutorial visible. + await expect(page).toHaveURL('/parent') + await expect(getTutorialCard(page)).toBeVisible() + }) + + test('child form inputs are disabled while the edit-child-name tutorial is showing', async ({ + page, + }) => { + await page.goto('/parent/children/create') + await expect(page).toHaveURL('/parent/children/create') + await expectTutorialCard(page, "Child's Name") + + await expect(page.locator('input#name')).toBeDisabled() + await expect(page.locator('input#age')).toBeDisabled() + + // The tutorial card should remain visible after checking the inputs. + await expect(getTutorialCard(page)).toBeVisible() + }) + + test('clicking the highlighted Points area does not navigate while child-points tutorial is showing', async ({ + page, + request, + }) => { + const childName = 'TutorialPointsKid' + await ensureChild(request, childName, 7) + await page.goto('/parent') + await expect(page).toHaveURL('/parent') + + // Wait for the child card to render so the children list has finished + // loading before we assert on the Points tutorial. + await expect(page.getByText(childName, { exact: true }).first()).toBeVisible({ timeout: 10000 }) + + await expectTutorialCard(page, 'Points') + + // Small resilience delay so the spotlight blocker is positioned before the + // synthetic click reaches it. + await page.waitForTimeout(50) + + const points = page.locator('.card .points').first() + await expect(points).toBeVisible() + const box = await points.boundingBox() + if (!box) throw new Error('Could not resolve points element bounding box') + + // Click the center of the highlighted points area. The tutorial spotlight + // blocker should intercept the click, preventing the card click handler + // from navigating to the child detail page. + await page.mouse.click(box.x + box.width / 2, box.y + box.height / 2) + + await expect(page).toHaveURL('/parent') + await expect(getTutorialCard(page)).toBeVisible() + }) }) diff --git a/frontend/src/components/shared/ChildrenListView.vue b/frontend/src/components/shared/ChildrenListView.vue index 0d93878..5e5a5e6 100644 --- a/frontend/src/components/shared/ChildrenListView.vue +++ b/frontend/src/components/shared/ChildrenListView.vue @@ -8,6 +8,7 @@ import { maybeShow as tutorialMaybeShow, markStepSeen as tutorialMark, tutorialReady, + isTutorialActive, } from '@/tutorial/controller' import type { Child, @@ -141,10 +142,12 @@ const fetchChildren = async (): Promise => { return Promise.resolve() }), ) + children.value = childList return childList } catch (err) { error.value = err instanceof Error ? err.message : 'Failed to fetch children' console.error('Error fetching children:', err) + children.value = [] return [] } finally { loading.value = false @@ -182,8 +185,7 @@ onMounted(async () => { eventBus.on('child_reward_triggered', handleChildRewardTriggered) const listPromise = fetchChildren() - listPromise.then((list) => { - children.value = list + listPromise.then(() => { maybeTriggerCreateChildTutorial() }) // listen for outside clicks to auto-close any open kebab menu @@ -387,6 +389,7 @@ onBeforeUnmount(() => { diff --git a/frontend/src/components/shared/EntityEditForm.vue b/frontend/src/components/shared/EntityEditForm.vue index cc5f680..b82a06f 100644 --- a/frontend/src/components/shared/EntityEditForm.vue +++ b/frontend/src/components/shared/EntityEditForm.vue @@ -9,7 +9,7 @@ :label="field.label" :modelValue="formData[field.name]" @update:modelValue="(val: boolean) => (formData[field.name] = val)" - :disabled="field.disabled" + :disabled="field.disabled || isTutorialActive" :description="field.description" :error="props.fieldErrors?.[field.name]" /> @@ -29,6 +29,7 @@ type="text" :required="field.required" :maxlength="field.maxlength" + :disabled="isTutorialActive" /> @@ -81,7 +85,7 @@ import { ref, onMounted, nextTick, watch, computed } from 'vue' import ImagePicker from '@/components/utils/ImagePicker.vue' import ToggleField from './ToggleField.vue' -import { useRouter } from 'vue-router' +import { isTutorialActive } from '@/tutorial/controller' import '@/assets/styles.css' type Field = { @@ -118,7 +122,6 @@ const props = withDefaults( const emit = defineEmits(['submit', 'cancel', 'add-image']) -const router = useRouter() const formData = ref>({ ...props.initialData }) const baselineData = ref>({ ...props.initialData }) const formRef = ref(null) diff --git a/frontend/src/components/shared/FloatingActionButton.vue b/frontend/src/components/shared/FloatingActionButton.vue index 7d21203..59619b3 100644 --- a/frontend/src/components/shared/FloatingActionButton.vue +++ b/frontend/src/components/shared/FloatingActionButton.vue @@ -1,5 +1,5 @@