diff --git a/.opencode/agents/developer.md b/.opencode/agents/developer.md index f09f6ce..3ad494b 100644 --- a/.opencode/agents/developer.md +++ b/.opencode/agents/developer.md @@ -3,7 +3,6 @@ name: Developer description: Implements core application features across Python backends and Vue frontends. mode: subagent model: "deepseek/deepseek-v4-pro" -thinking: "enabled" maxSteps: 50 permission: edit: allow diff --git a/frontend/e2e/.auth/user-tutorial.json b/frontend/e2e/.auth/user-tutorial.json index 47a74a1..87d0fd4 100644 --- a/frontend/e2e/.auth/user-tutorial.json +++ b/frontend/e2e/.auth/user-tutorial.json @@ -2,20 +2,20 @@ "cookies": [ { "name": "refresh_token", - "value": "5gEK_iLdGl1BAGfetT8hEf6SJK2lQVbYxQjH0BdKWwU", + "value": "WGHWfbkdu5a4GEsBQ4exR8DG4W39iOm1b71weDg6RWU", "domain": "localhost", "path": "/api/auth", - "expires": 1792813397.168935, + "expires": 1793338810.330124, "httpOnly": true, "secure": true, "sameSite": "Strict" }, { "name": "access_token", - "value": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJlbWFpbCI6ImUyZS10dXRvcmlhbEB0ZXN0LmNvbSIsInVzZXJfaWQiOiJkODk0ZjA0OS0xODU3LTQwM2UtYjgzZC1jMTY2NTNmZDU2MmEiLCJ0b2tlbl92ZXJzaW9uIjowLCJleHAiOjE3ODUwNDgxOTd9.S7frWOrZwXC5xVgtG2RDioXGok04qK_jLCYWvfedD7s", + "value": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJlbWFpbCI6ImUyZS10dXRvcmlhbEB0ZXN0LmNvbSIsInVzZXJfaWQiOiJlMzIxNzNmNy1lODM5LTQxNjMtOGUyYS04YmRhZGY5ZTZiZjEiLCJ0b2tlbl92ZXJzaW9uIjowLCJleHAiOjE3ODU1NzM2MTB9.W08NeVvTAftfz4VQVopd8U38mOEiqUW9qb-EhbttWJk", "domain": "localhost", "path": "/", - "expires": 1785048197.167997, + "expires": 1785573610.329316, "httpOnly": true, "secure": true, "sameSite": "Lax" @@ -27,11 +27,11 @@ "localStorage": [ { "name": "authSyncEvent", - "value": "{\"type\":\"logout\",\"at\":1785037396813}" + "value": "{\"type\":\"logout\",\"at\":1785562810109}" }, { "name": "parentAuth", - "value": "{\"expiresAt\":1785210197388}" + "value": "{\"expiresAt\":1785735610529}" } ] } diff --git a/frontend/e2e/.auth/user.json b/frontend/e2e/.auth/user.json index 2f1c2f2..f889ffa 100644 --- a/frontend/e2e/.auth/user.json +++ b/frontend/e2e/.auth/user.json @@ -2,20 +2,20 @@ "cookies": [ { "name": "refresh_token", - "value": "Ke6qDj_0RYyrMSI1Z9C69DPtvYo26oUUiLZr_pDXZv4", + "value": "nT_T0nZ7ZXthfVaOIvMOa-9Z8IJAkT8hByHcv9yxyno", "domain": "localhost", "path": "/api/auth", - "expires": 1792813392.716776, + "expires": 1793338807.10011, "httpOnly": true, "secure": true, "sameSite": "Strict" }, { "name": "access_token", - "value": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJlbWFpbCI6ImUyZUB0ZXN0LmNvbSIsInVzZXJfaWQiOiI3NTYwZDBlNy1iMmVlLTRiMjktYTYwNi1lZTM3NmIzN2Y3ODciLCJ0b2tlbl92ZXJzaW9uIjowLCJleHAiOjE3ODUwNDgxOTJ9.QB4bXo88KC7jr94QSjJI3Y1eVWeU4KNKdHRi1_7zcD0", + "value": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJlbWFpbCI6ImUyZUB0ZXN0LmNvbSIsInVzZXJfaWQiOiJkZmQ4ZjI1NS0yMzkwLTRiOTgtODJmYi1jZGRmOTBjODYwYWMiLCJ0b2tlbl92ZXJzaW9uIjowLCJleHAiOjE3ODU1NzM2MDd9.Wn8RdLZGFifPe3LwAjljmHIoiFOOIzqeDCfQWvvy-_I", "domain": "localhost", "path": "/", - "expires": 1785048192.71579, + "expires": 1785573607.099308, "httpOnly": true, "secure": true, "sameSite": "Lax" @@ -27,11 +27,11 @@ "localStorage": [ { "name": "authSyncEvent", - "value": "{\"type\":\"logout\",\"at\":1785037392502}" + "value": "{\"type\":\"logout\",\"at\":1785562806880}" }, { "name": "parentAuth", - "value": "{\"expiresAt\":1785210192904}" + "value": "{\"expiresAt\":1785735607328}" } ] } diff --git a/frontend/src/components/child/ParentView.vue b/frontend/src/components/child/ParentView.vue index 808a072..e13524f 100644 --- a/frontend/src/components/child/ParentView.vue +++ b/frontend/src/components/child/ParentView.vue @@ -445,16 +445,8 @@ function openRoutineMenu(routineId: string, e: MouseEvent) { 'routine-kebab-menu', () => document.querySelector('.kebab-menu') as HTMLElement | null, ) - tutorialMaybeShow( - 'kebab-edit-points-cost', - () => document.querySelector('[data-tutorial~="kebab-edit-points-cost"]') as HTMLElement | null, - ) - tutorialMaybeShow( - 'routine-schedule', - () => document.querySelector('[data-tutorial="routine-schedule"]') as HTMLElement | null, - ) }) - const items = childRoutineListRef.value?.items ?? [] + const items: ChildRoutine[] = childRoutineListRef.value?.items ?? [] const routine = items.find((r) => r.id === routineId) if (routine) { if (isRoutineExpired(routine)) { @@ -740,14 +732,6 @@ function openChoreMenu(taskId: string, e: MouseEvent) { 'chore-kebab-menu', () => document.querySelector('.kebab-menu') as HTMLElement | null, ) - tutorialMaybeShow( - 'kebab-edit-points-cost', - () => document.querySelector('[data-tutorial~="kebab-edit-points-cost"]') as HTMLElement | null, - ) - tutorialMaybeShow( - 'chore-schedule', - () => document.querySelector('[data-tutorial="chore-schedule"]') as HTMLElement | null, - ) }) const items: ChildTask[] = childChoreListRef.value?.items ?? [] const task = items.find((t) => t.id === taskId) diff --git a/frontend/src/tutorial/__tests__/controller.spec.ts b/frontend/src/tutorial/__tests__/controller.spec.ts index e594584..f119ba3 100644 --- a/frontend/src/tutorial/__tests__/controller.spec.ts +++ b/frontend/src/tutorial/__tests__/controller.spec.ts @@ -1,17 +1,37 @@ -import { describe, it, expect, beforeEach } from 'vitest' +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest' import { shouldShowStep, clearChainProgress, tutorialEnabled, tutorialProgress, sessionSkipped, + maybeShow, + dismissActive, + activeStep, } from '../controller' +function setKebabAnchorHtml() { + document.body.innerHTML = ` +
+ + + ` +} + describe('tutorial controller - shouldShowStep', () => { beforeEach(() => { tutorialEnabled.value = true tutorialProgress.value = {} sessionSkipped.value = false + activeStep.value = null + document.body.innerHTML = '' + global.fetch = vi.fn().mockResolvedValue({ ok: true }) + }) + + afterEach(() => { + activeStep.value = null + document.body.innerHTML = '' + vi.restoreAllMocks() }) it('returns true when tips are enabled, the step is unseen, and the session is not skipped', () => { @@ -46,3 +66,103 @@ describe('tutorial controller - shouldShowStep', () => { expect(shouldShowStep('status-pending', true)).toBe(true) }) }) + +describe('tutorial controller - maybeShow', () => { + beforeEach(() => { + tutorialEnabled.value = true + tutorialProgress.value = {} + sessionSkipped.value = false + activeStep.value = null + setKebabAnchorHtml() + global.fetch = vi.fn().mockResolvedValue({ ok: true }) + }) + + afterEach(() => { + activeStep.value = null + document.body.innerHTML = '' + vi.restoreAllMocks() + }) + + it('promotes a step when the anchor is present', () => { + maybeShow('chore-kebab-menu') + expect(activeStep.value?.def.id).toBe('chore-kebab-menu') + }) + + it('does not promote a step that has already been seen', () => { + tutorialProgress.value = { 'chore-kebab-menu': true } + maybeShow('chore-kebab-menu') + expect(activeStep.value).toBeNull() + }) + + it('is idempotent for the currently active step', () => { + maybeShow('chore-kebab-menu') + expect(activeStep.value?.def.id).toBe('chore-kebab-menu') + + maybeShow('chore-kebab-menu') + expect(activeStep.value?.def.id).toBe('chore-kebab-menu') + }) + + it('drops a step whose anchor is missing', () => { + document.body.innerHTML = '' + maybeShow('chore-kebab-menu') + expect(activeStep.value).toBeNull() + }) +}) + +describe('tutorial controller - dismissActive', () => { + beforeEach(() => { + tutorialEnabled.value = true + tutorialProgress.value = {} + sessionSkipped.value = false + activeStep.value = null + setKebabAnchorHtml() + global.fetch = vi.fn().mockResolvedValue({ ok: true }) + }) + + afterEach(() => { + activeStep.value = null + document.body.innerHTML = '' + vi.restoreAllMocks() + }) + + it('marks the active step seen and chains the next step', () => { + maybeShow('chore-kebab-menu') + expect(activeStep.value?.def.id).toBe('chore-kebab-menu') + + dismissActive(true) + expect(tutorialProgress.value['chore-kebab-menu']).toBe(true) + expect(activeStep.value?.def.id).toBe('chore-edit-points') + }) + + it('does not duplicate a chained step that is already queued', () => { + // Set up: open the chore kebab menu, then queue the next step early. + maybeShow('chore-kebab-menu') + maybeShow('chore-edit-points') + expect(activeStep.value?.def.id).toBe('chore-kebab-menu') + + // Dismissing the menu should chain to chore-edit-points only once. + dismissActive(true) + expect(activeStep.value?.def.id).toBe('chore-edit-points') + + // If a duplicate had been added, we would still be on chore-edit-points. + dismissActive(true) + expect(activeStep.value?.def.id).toBe('chore-schedule') + }) + + it('does not re-chain a step that is currently active', () => { + maybeShow('chore-kebab-menu') + dismissActive(true) + expect(activeStep.value?.def.id).toBe('chore-edit-points') + + // Dismissing chore-edit-points should move to chore-schedule, not loop. + dismissActive(true) + expect(activeStep.value?.def.id).toBe('chore-schedule') + }) + + it('does not mark the step seen when markSeen is false', () => { + maybeShow('chore-kebab-menu') + dismissActive(false) + expect(tutorialProgress.value['chore-kebab-menu']).toBeUndefined() + expect(activeStep.value).toBeNull() + }) +}) diff --git a/frontend/src/tutorial/controller.ts b/frontend/src/tutorial/controller.ts index 5455a6e..4839e90 100644 --- a/frontend/src/tutorial/controller.ts +++ b/frontend/src/tutorial/controller.ts @@ -147,7 +147,7 @@ export function dismissActive(markSeen = true) { } if (current.def.next) { const nextDef = stepRegistry[current.def.next] - if (nextDef && shouldShowStep(nextDef.id)) { + if (nextDef && shouldShowStep(nextDef.id) && !queue.some((q) => q.def.id === nextDef.id)) { queue.unshift({ def: nextDef, anchor: null }) } } diff --git a/opencode.json b/opencode.json index 24ebdce..78657ad 100644 --- a/opencode.json +++ b/opencode.json @@ -5,20 +5,15 @@ "enabled": true, "type": "local", "command": [ - "docker", - "run", - "-i", - "--rm", - "-e", - "GITEA_ACCESS_TOKEN", - "-e", - "GITEA_HOST", - "docker.gitea.com/gitea-mcp-server" + "ssh", + "-T", + "devserver.lan", + "/home/ryan/gitea-mcp-wrapper.sh" ], "environment": { - "GITEA_HOST": "https://git.ryankegel.com", - "GITEA_ACCESS_TOKEN": "{env:GITEA_ACCESS_TOKEN}" - } + "GITEA_HOST": "https://git.ryankegel.com" + }, + "timeout": 30000 } } }