feat: add onboarding tutorial for new users
Chore App Build, Test, and Push Docker Images / build-and-push (push) Failing after 3m4s

- Introduced a modular tutorial layer to guide new parents through the app setup process.
- Implemented a 3-step forced intro after first sign-in (PIN setup → child creation → chore creation).
- Added just-in-time contextual hints for various features as users encounter them.
- Persisted user progress on the backend with new fields in the User model.
- Created a new tutorial controller and step registry in the frontend for managing tutorial states.
- Added Help button for easy access to tutorial tips and a restart option in the user profile.
- Ensured accessibility and mobile responsiveness for the tutorial overlay.
- Included tests for backend and frontend functionalities related to the tutorial.
This commit is contained in:
2026-06-19 17:27:35 -04:00
parent d147bd6f27
commit e2bb9cd6b9
24 changed files with 1764 additions and 627 deletions
+142 -20
View File
@@ -27,7 +27,11 @@ import {
triggerRoutineAsParent,
} from '@/common/api'
import { eventBus } from '@/common/eventBus'
import { maybeShow as tutorialMaybeShow } from '@/tutorial/controller'
import {
maybeShow as tutorialMaybeShow,
activeStep as tutorialActiveStep,
modalTutorialStepId,
} from '@/tutorial/controller'
import '@/assets/styles.css'
import type {
Task,
@@ -109,6 +113,9 @@ const selectedChoreId = ref<string | null>(null)
const menuPosition = ref({ top: 0, left: 0 })
const kebabBtnRefs = ref<Map<string, HTMLElement>>(new Map())
// Tutorial auto-demo state
const tutorialHighlightedItemId = ref<string | null>(null)
// Schedule modal
const showScheduleModal = ref(false)
const scheduleTarget = ref<ChildTask | null>(null)
@@ -427,10 +434,45 @@ function openRoutineMenu(routineId: string, e: MouseEvent) {
e.stopPropagation()
const btn = routineKebabBtnRefs.value.get(routineId)
if (btn) {
btn.scrollIntoView({ block: 'center', behavior: 'auto' })
const rect = btn.getBoundingClientRect()
menuPosition.value = { top: rect.bottom, left: rect.right - 140 }
}
activeRoutineMenuFor.value = routineId
nextTick(() => {
tutorialMaybeShow(
'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 routine = items.find((r) => r.id === routineId)
if (routine) {
if (isRoutineExpired(routine)) {
nextTick(() => {
tutorialMaybeShow(
'routine-extend-time',
() => document.querySelector('[data-tutorial="routine-extend-time"]') as HTMLElement | null,
)
})
}
if (isRoutineApprovedToday(routine)) {
nextTick(() => {
tutorialMaybeShow(
'routine-reset',
() => document.querySelector('[data-tutorial="routine-reset"]') as HTMLElement | null,
)
})
}
}
}
function closeRoutineMenu() {
@@ -666,7 +708,10 @@ const onDocClick = (e: MouseEvent) => {
node.classList.contains('kebab-menu')
)
})
if (!inside) {
const fromTutorial = path.some(
(n) => n instanceof HTMLElement && n.classList.contains('tutorial-root'),
)
if (!inside && !fromTutorial) {
activeMenuFor.value = null
activeRoutineMenuFor.value = null
selectedChoreId.value = null
@@ -684,14 +729,25 @@ function openChoreMenu(taskId: string, e: MouseEvent) {
e.stopPropagation()
const btn = kebabBtnRefs.value.get(taskId)
if (btn) {
btn.scrollIntoView({ block: 'center', behavior: 'auto' })
const rect = btn.getBoundingClientRect()
menuPosition.value = { top: rect.bottom, left: rect.right - 140 }
}
activeMenuFor.value = taskId
tutorialMaybeShow(
'chore-kebab',
() => document.querySelector('.kebab-menu') as HTMLElement | null,
)
nextTick(() => {
tutorialMaybeShow(
'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)
if (task) {
@@ -699,10 +755,7 @@ function openChoreMenu(taskId: string, e: MouseEvent) {
nextTick(() => {
tutorialMaybeShow(
'chore-kebab-extend-time',
() =>
Array.from(document.querySelectorAll('.kebab-menu .menu-item')).find((el) =>
/extend\s*time/i.test(el.textContent || ''),
) as HTMLElement | null,
() => document.querySelector('[data-tutorial="chore-extend-time"]') as HTMLElement | null,
)
})
}
@@ -710,10 +763,7 @@ function openChoreMenu(taskId: string, e: MouseEvent) {
nextTick(() => {
tutorialMaybeShow(
'chore-kebab-reset',
() =>
Array.from(document.querySelectorAll('.kebab-menu .menu-item')).find((el) =>
/^reset/i.test((el.textContent || '').trim()),
) as HTMLElement | null,
() => document.querySelector('[data-tutorial="chore-reset"]') as HTMLElement | null,
)
})
}
@@ -890,6 +940,13 @@ watch(showOverrideModal, async (newVal) => {
if (newVal) {
await nextTick()
document.getElementById('custom-value')?.focus()
modalTutorialStepId.value = 'point-editor-help'
tutorialMaybeShow(
'point-editor-help',
() => document.querySelector('input#custom-value') as HTMLElement | null,
)
} else {
modalTutorialStepId.value = null
}
})
@@ -1034,11 +1091,9 @@ onMounted(async () => {
tasks.value = data.tasks || []
rewards.value = data.rewards || []
// Fire the per-child overview tour (chains into assign-* steps).
// No anchor: this is a general overview so the card stays centered.
nextTick(() => {
tutorialMaybeShow(
'select-child',
() => document.querySelector('.assign-buttons') as HTMLElement | null,
)
tutorialMaybeShow('select-child')
})
}
loading.value = false
@@ -1096,6 +1151,66 @@ watch(
{ deep: true },
)
// When the generic kebab-overview step fires, scroll to an assigned item
// and select it so its kebab button is visible for the user to discover.
watch(
() => tutorialActiveStep.value?.def.id,
async (stepId, prevStepId) => {
if (stepId === 'item-kebab-overview') {
const chores: ChildTask[] = childChoreListRef.value?.items ?? []
const routines: ChildRoutine[] = childRoutineListRef.value?.items ?? []
if (chores.length > 0 && childChoreListRef.value) {
const first = chores[0]
childChoreListRef.value.scrollToItem(first.id)
selectedChoreId.value = first.id
tutorialHighlightedItemId.value = first.id
await nextTick()
const btn = kebabBtnRefs.value.get(first.id)
if (btn && tutorialActiveStep.value?.def.id === 'item-kebab-overview') {
tutorialActiveStep.value.anchor = () => btn
}
} else if (routines.length > 0 && childRoutineListRef.value) {
const first = routines[0]
childRoutineListRef.value.scrollToItem(first.id)
selectedRoutineId.value = first.id
tutorialHighlightedItemId.value = first.id
await nextTick()
const btn = routineKebabBtnRefs.value.get(first.id)
if (btn && tutorialActiveStep.value?.def.id === 'item-kebab-overview') {
tutorialActiveStep.value.anchor = () => btn
}
}
}
// Clean up the highlighted selection when the tutorial leaves kebab steps
const kebabStepIds = new Set([
'item-kebab-overview',
'chore-kebab-menu',
'chore-edit-points',
'chore-schedule',
'chore-kebab-extend-time',
'chore-kebab-reset',
'routine-kebab-menu',
'routine-edit',
'routine-edit-points',
'routine-schedule',
'routine-extend-time',
'routine-reset',
'kebab-edit-points-cost',
])
if (
prevStepId &&
kebabStepIds.has(prevStepId) &&
!kebabStepIds.has(stepId ?? '') &&
tutorialHighlightedItemId.value
) {
selectedChoreId.value = null
selectedRoutineId.value = null
tutorialHighlightedItemId.value = null
}
},
)
onUnmounted(() => {
eventBus.off('child_task_triggered', handleTaskTriggered)
eventBus.off('child_reward_triggered', handleRewardTriggered)
@@ -1372,11 +1487,12 @@ function goToAssignRoutines() {
@mousedown.stop.prevent
@click.stop
>
<button class="menu-item" @mousedown.stop.prevent @click="editChorePoints(item)">
<button class="menu-item" data-tutorial="chore-edit-points kebab-edit-points-cost" @mousedown.stop.prevent @click="editChorePoints(item)">
Edit Points
</button>
<button
class="menu-item"
data-tutorial="chore-schedule"
@mousedown.stop.prevent
@click="openScheduleModal(item, $event)"
>
@@ -1385,6 +1501,7 @@ function goToAssignRoutines() {
<button
v-if="isChoreExpired(item)"
class="menu-item"
data-tutorial="chore-extend-time"
@mousedown.stop.prevent
@click="doExtendTime(item, $event)"
>
@@ -1393,6 +1510,7 @@ function goToAssignRoutines() {
<button
v-if="isChoreCompletedToday(item)"
class="menu-item"
data-tutorial="chore-reset"
@mousedown.stop.prevent
@click="doResetChore(item, $event)"
>
@@ -1481,11 +1599,12 @@ function goToAssignRoutines() {
@mousedown.stop.prevent
@click.stop
>
<button class="menu-item" @mousedown.stop.prevent @click="editRoutine(item)">
<button class="menu-item" data-tutorial="routine-edit" @mousedown.stop.prevent @click="editRoutine(item)">
Edit Routine
</button>
<button
class="menu-item"
data-tutorial="routine-edit-points kebab-edit-points-cost"
@mousedown.stop.prevent
@click="editRoutinePoints(item)"
>
@@ -1493,6 +1612,7 @@ function goToAssignRoutines() {
</button>
<button
class="menu-item"
data-tutorial="routine-schedule"
@mousedown.stop.prevent
@click="openRoutineScheduleModal(item, $event)"
>
@@ -1501,6 +1621,7 @@ function goToAssignRoutines() {
<button
v-if="isRoutineExpired(item)"
class="menu-item"
data-tutorial="routine-extend-time"
@mousedown.stop.prevent
@click="doExtendRoutineTime(item, $event)"
>
@@ -1509,6 +1630,7 @@ function goToAssignRoutines() {
<button
v-if="isRoutineApprovedToday(item)"
class="menu-item"
data-tutorial="routine-reset"
@mousedown.stop.prevent
@click="doResetRoutine(item, $event)"
>