feat(tutorial): implement comprehensive tutorial system with step guidance
Chore App Build, Test, and Push Docker Images / build-and-push (push) Successful in 3m9s

- Added tutorial controller to manage tutorial state and progress.
- Introduced HelpButton component for contextual help throughout the application.
- Created various tutorial steps for onboarding and feature guidance.
- Integrated tutorial prompts in multiple components (ChildrenListView, LoginButton, ScheduleModal, etc.) to enhance user experience.
- Implemented logic to show tutorials based on user actions and state.
- Added functionality to dismiss and skip tutorial sessions.
- Established a mechanism to hydrate tutorial state from user profile.
This commit is contained in:
2026-05-26 16:54:45 -04:00
parent ec4912aa4a
commit d147bd6f27
23 changed files with 1338 additions and 5 deletions
@@ -27,6 +27,7 @@ import {
triggerRoutineAsParent,
} from '@/common/api'
import { eventBus } from '@/common/eventBus'
import { maybeShow as tutorialMaybeShow } from '@/tutorial/controller'
import '@/assets/styles.css'
import type {
Task,
@@ -687,6 +688,36 @@ function openChoreMenu(taskId: string, e: MouseEvent) {
menuPosition.value = { top: rect.bottom, left: rect.right - 140 }
}
activeMenuFor.value = taskId
tutorialMaybeShow(
'chore-kebab',
() => document.querySelector('.kebab-menu') as HTMLElement | null,
)
const items: ChildTask[] = childChoreListRef.value?.items ?? []
const task = items.find((t) => t.id === taskId)
if (task) {
if (isChoreExpired(task)) {
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,
)
})
}
if (isChoreCompletedToday(task)) {
nextTick(() => {
tutorialMaybeShow(
'chore-kebab-reset',
() =>
Array.from(document.querySelectorAll('.kebab-menu .menu-item')).find((el) =>
/^reset/i.test((el.textContent || '').trim()),
) as HTMLElement | null,
)
})
}
}
}
function closeChoreMenu() {
@@ -1002,6 +1033,13 @@ onMounted(async () => {
child.value = data
tasks.value = data.tasks || []
rewards.value = data.rewards || []
// Fire the per-child overview tour (chains into assign-* steps).
nextTick(() => {
tutorialMaybeShow(
'select-child',
() => document.querySelector('.assign-buttons') as HTMLElement | null,
)
})
}
loading.value = false
if (scrollToId) {
@@ -1028,6 +1066,36 @@ onMounted(async () => {
}
})
// Fire status-badge tutorials when those badges first render for any chore.
watch(
() => {
const items: ChildTask[] = childChoreListRef.value?.items ?? []
return items.map((t) => ({ id: t.id, expired: isChoreExpired(t), pending: isChorePending(t) }))
},
(list) => {
if (list.some((t) => t.expired)) {
nextTick(() => {
tutorialMaybeShow(
'status-too-late',
() =>
Array.from(document.querySelectorAll('.chore-stamp')).find(
(el) => (el.textContent || '').trim() === 'TOO LATE',
) as HTMLElement | null,
)
})
}
if (list.some((t) => t.pending)) {
nextTick(() => {
tutorialMaybeShow(
'status-pending',
() => document.querySelector('.chore-stamp.pending-stamp') as HTMLElement | null,
)
})
}
},
{ deep: true },
)
onUnmounted(() => {
eventBus.off('child_task_triggered', handleTaskTriggered)
eventBus.off('child_reward_triggered', handleRewardTriggered)
@@ -35,10 +35,11 @@
</template>
<script setup lang="ts">
import { ref, onMounted, onUnmounted } from 'vue'
import { ref, onMounted, onUnmounted, watch } from 'vue'
import { useRouter } from 'vue-router'
import ItemList from '../shared/ItemList.vue'
import MessageBlock from '../shared/MessageBlock.vue'
import { maybeShow as tutorialMaybeShow, tutorialReady } from '@/tutorial/controller'
import type {
PendingConfirmation,
Event,
@@ -87,6 +88,15 @@ function handleChoreConfirmation(event: Event) {
}
}
watch([notificationListCountRef, tutorialReady], ([count, ready]) => {
if (ready && typeof count === 'number' && count > 0) {
tutorialMaybeShow(
'notification-click',
() => document.querySelector('.notification-view .list-item') as HTMLElement | null,
)
}
})
onMounted(() => {
eventBus.on('child_reward_request', handleRewardRequest)
eventBus.on('child_chore_confirmation', handleChoreConfirmation)
@@ -95,6 +95,54 @@
<button class="btn btn-primary" @click="closeDeleteError">Close</button>
</div>
</ModalDialog>
<!-- Help / Tutorial section -->
<section class="help-section" aria-labelledby="help-heading">
<h3 id="help-heading" class="help-heading">Help</h3>
<label class="help-row">
<span class="help-row-text">
<span class="help-row-label">Show tutorial tips</span>
<span class="help-row-desc">Show helpful tips as I use the app.</span>
</span>
<input
type="checkbox"
class="help-toggle"
:checked="tutorialEnabled"
@change="onToggleTutorial"
aria-label="Show tutorial tips"
/>
</label>
<button type="button" class="btn-link btn-link-space" @click="openRestartConfirm">
Restart tutorial
</button>
</section>
<!-- Restart confirmation -->
<ModalDialog
v-if="showRestartConfirm"
title="Restart tutorial?"
@close="showRestartConfirm = false"
>
<div class="modal-message">
Start the tour again from the beginning? You'll see the tips again as you use the app.
</div>
<div class="modal-actions">
<button class="btn btn-secondary" @click="showRestartConfirm = false">Cancel</button>
<button class="btn btn-primary" @click="confirmRestartTutorial">Restart</button>
</div>
</ModalDialog>
<!-- Restart success -->
<ModalDialog
v-if="showRestartSuccess"
title="Tour reset"
@close="showRestartSuccess = false"
>
<div class="modal-message">Tour reset — here we go!</div>
<div class="modal-actions">
<button class="btn btn-primary" @click="showRestartSuccess = false">OK</button>
</div>
</ModalDialog>
</div>
</template>
@@ -113,6 +161,11 @@ import {
import { parseErrorResponse, isEmailValid } from '@/common/api'
import { ALREADY_MARKED } from '@/common/errorCodes'
import { logoutUser, suppressForceLogout } from '@/stores/auth'
import {
tutorialEnabled,
setTutorialEnabled,
resetAllProgress,
} from '@/tutorial/controller'
import '@/assets/styles.css'
const router = useRouter()
@@ -136,6 +189,25 @@ const deleteErrorMessage = ref('')
const pushError = ref('')
const pushPermissionDenied = ref(false)
// Tutorial controls
const showRestartConfirm = ref(false)
const showRestartSuccess = ref(false)
async function onToggleTutorial(e: Event) {
const target = e.target as HTMLInputElement
await setTutorialEnabled(target.checked)
}
function openRestartConfirm() {
showRestartConfirm.value = true
}
async function confirmRestartTutorial() {
showRestartConfirm.value = false
await resetAllProgress()
showRestartSuccess.value = true
}
const initialData = ref<{
image_id: string | null
first_name: string
@@ -500,4 +572,45 @@ function closeDeleteError() {
outline: none;
border-color: var(--btn-primary, #4a90e2);
}
.help-section {
margin-top: 1.4rem;
padding-top: 1.2rem;
border-top: 1px solid var(--form-input-border, #cbd5e1);
display: flex;
flex-direction: column;
gap: 0.6rem;
}
.help-heading {
margin: 0 0 0.2rem;
font-size: 1rem;
color: var(--form-heading, #667eea);
}
.help-row {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 1rem;
cursor: pointer;
}
.help-row-text {
display: flex;
flex-direction: column;
gap: 0.15rem;
}
.help-row-label {
font-weight: 600;
color: var(--text-primary, #222);
}
.help-row-desc {
font-size: 0.88rem;
color: var(--text-secondary, #888);
}
.help-toggle {
width: 22px;
height: 22px;
margin-top: 2px;
accent-color: var(--btn-primary, #667eea);
cursor: pointer;
}
</style>
+15 -1
View File
@@ -36,7 +36,7 @@
</template>
<script setup lang="ts">
import { ref, onMounted, onUnmounted } from 'vue'
import { ref, onMounted, onUnmounted, watch } from 'vue'
import { useRouter } from 'vue-router'
import ItemList from '../shared/ItemList.vue'
import MessageBlock from '@/components/shared/MessageBlock.vue'
@@ -47,6 +47,11 @@ import type { Reward } from '@/common/models'
import { REWARD_FIELDS } from '@/common/models'
import { eventBus } from '@/common/eventBus'
import {
maybeShow as tutorialMaybeShow,
markStepSeen as tutorialMark,
tutorialReady,
} from '@/tutorial/controller'
const $router = useRouter()
@@ -62,6 +67,15 @@ function handleRewardModified(event: any) {
}
}
watch([rewardCountRef, tutorialReady], ([count, ready]) => {
if (!ready) return
if (count === 0) {
tutorialMaybeShow('create-reward', () => document.querySelector('.fab') as HTMLElement | null)
} else if (typeof count === 'number' && count > 0) {
void tutorialMark('has-created-reward')
}
})
onMounted(() => {
eventBus.on('reward_modified', handleRewardModified)
})
@@ -122,6 +122,7 @@ import EntityEditForm from '@/components/shared/EntityEditForm.vue'
import ImagePicker from '@/components/utils/ImagePicker.vue'
import { getCachedImageUrl } from '@/common/imageCache'
import type { RoutineItem } from '@/common/models'
import { maybeShow as tutorialMaybeShow } from '@/tutorial/controller'
import '@/assets/styles.css'
const props = defineProps<{ id?: string }>()
@@ -161,6 +162,13 @@ const draggingIdx = ref<number | null>(null)
const dragOverIdx = ref<number | null>(null)
onMounted(async () => {
if (!isEdit.value) {
tutorialMaybeShow('create-routine')
tutorialMaybeShow(
'create-routine-add-task',
() => document.querySelector('.add-task-trigger') as HTMLElement | null,
)
}
if (isEdit.value && props.id) {
loading.value = true
try {
@@ -1,9 +1,14 @@
<script setup lang="ts">
import { ref, onMounted, onBeforeUnmount, onUnmounted } from 'vue'
import { ref, onMounted, onBeforeUnmount, onUnmounted, watch } from 'vue'
import { useRouter } from 'vue-router'
import { getCachedImageUrl, revokeAllImageUrls } from '../../common/imageCache'
import { isParentAuthenticated } from '../../stores/auth'
import { eventBus } from '@/common/eventBus'
import {
maybeShow as tutorialMaybeShow,
markStepSeen as tutorialMark,
tutorialReady,
} from '@/tutorial/controller'
import type {
Child,
ChildModifiedEventPayload,
@@ -150,6 +155,22 @@ const createChild = () => {
router.push({ name: 'CreateChild' })
}
function maybeTriggerCreateChildTutorial() {
if (!isParentAuthenticated.value) return
if (loading.value) return
if (!tutorialReady.value) return
if (children.value.length === 0) {
tutorialMaybeShow('create-child', () => document.querySelector('.fab') as HTMLElement | null)
} else {
void tutorialMark('has-created-child')
}
}
watch([tutorialReady, loading, children, isParentAuthenticated], maybeTriggerCreateChildTutorial, {
immediate: false,
deep: true,
})
onMounted(async () => {
eventBus.on('child_modified', handleChildModified)
eventBus.on('child_task_triggered', handleChildTaskTriggered)
@@ -158,6 +179,7 @@ onMounted(async () => {
const listPromise = fetchChildren()
listPromise.then((list) => {
children.value = list
maybeTriggerCreateChildTutorial()
})
// listen for outside clicks to auto-close any open kebab menu
document.addEventListener('click', onDocClick, true)
@@ -214,6 +236,10 @@ const selectChild = (childId: string | number) => {
const openMenu = (childId: string | number, evt?: Event) => {
evt?.stopPropagation()
activeMenuFor.value = childId
tutorialMaybeShow(
'child-kebab',
() => document.querySelector('.kebab-menu') as HTMLElement | null,
)
}
const closeMenu = () => {
activeMenuFor.value = null
@@ -21,6 +21,10 @@ import {
isPushOptedOut,
ensurePushSubscriptionSynced,
} from '@/services/pushSubscription'
import {
hydrateFromProfile as hydrateTutorial,
maybeShow as tutorialMaybeShow,
} from '@/tutorial/controller'
const router = useRouter()
const show = ref(false)
@@ -57,6 +61,11 @@ async function fetchUserProfile() {
userImageId.value = data.image_id || null
userFirstName.value = data.first_name || ''
userEmail.value = data.email || ''
hydrateTutorial({
tutorial_enabled: data.tutorial_enabled,
tutorial_progress: data.tutorial_progress,
})
void maybeShowSetupPinTutorial()
// Update avatar initial
avatarInitial.value = userFirstName.value ? userFirstName.value.charAt(0).toUpperCase() : '?'
@@ -83,6 +92,19 @@ async function fetchUserProfile() {
}
}
async function maybeShowSetupPinTutorial() {
try {
const res = await fetch('/api/user/has-pin', { credentials: 'include' })
if (!res.ok) return
const data = await res.json()
if (!data.has_pin) {
tutorialMaybeShow('setup-parent-pin', () => avatarButtonRef.value)
}
} catch {
// Silent: tutorial just won't fire.
}
}
async function loadAvatarImages(imageId: string) {
try {
const blob = await getCachedImageBlob(imageId)
@@ -161,10 +161,11 @@
</template>
<script setup lang="ts">
import { ref, computed } from 'vue'
import { ref, computed, onMounted } from 'vue'
import ModalDialog from './ModalDialog.vue'
import TimePickerPopover from './TimePickerPopover.vue'
import DateInputField from './DateInputField.vue'
import { maybeShow as tutorialMaybeShow } from '@/tutorial/controller'
import {
setChoreSchedule,
deleteChoreSchedule,
@@ -258,6 +259,13 @@ const intervalTime = ref<TimeValue>({
const saving = ref(false)
const errorMsg = ref<string | null>(null)
onMounted(() => {
tutorialMaybeShow(
'create-chore-schedule',
() => document.querySelector('.day-chips .chip') as HTMLElement | null,
)
})
// ── original snapshot (for dirty detection) ──────────────────────────────────
const origMode = props.schedule?.mode ?? 'days'
+15 -1
View File
@@ -36,7 +36,7 @@
</template>
<script setup lang="ts">
import { ref, onMounted, onUnmounted } from 'vue'
import { ref, onMounted, onUnmounted, watch } from 'vue'
import { useRouter } from 'vue-router'
import ItemList from '../shared/ItemList.vue'
import MessageBlock from '@/components/shared/MessageBlock.vue'
@@ -45,6 +45,11 @@ import DeleteModal from '../shared/DeleteModal.vue'
import type { Task } from '@/common/models'
import { TASK_FIELDS } from '@/common/models'
import { eventBus } from '@/common/eventBus'
import {
maybeShow as tutorialMaybeShow,
markStepSeen as tutorialMark,
tutorialReady,
} from '@/tutorial/controller'
const $router = useRouter()
const showConfirm = ref(false)
@@ -56,6 +61,15 @@ function handleModified() {
listRef.value?.refresh()
}
watch([countRef, tutorialReady], ([count, ready]) => {
if (!ready) return
if (count === 0) {
tutorialMaybeShow('create-chore', () => document.querySelector('.fab') as HTMLElement | null)
} else if (typeof count === 'number' && count > 0) {
void tutorialMark('has-created-chore')
}
})
onMounted(() => {
eventBus.on('task_modified', handleModified)
})
@@ -18,6 +18,7 @@
import { ref, onMounted, computed, nextTick } from 'vue'
import { useRouter } from 'vue-router'
import EntityEditForm from '../shared/EntityEditForm.vue'
import { maybeShow as tutorialMaybeShow } from '@/tutorial/controller'
import '@/assets/styles.css'
const props = defineProps<{ id?: string }>()
@@ -36,6 +37,7 @@ const loading = ref(false)
const error = ref<string | null>(null)
onMounted(async () => {
if (!isEdit.value) tutorialMaybeShow('create-kindness')
if (isEdit.value && props.id) {
loading.value = true
try {
@@ -18,6 +18,7 @@
import { ref, onMounted, computed, nextTick } from 'vue'
import { useRouter } from 'vue-router'
import EntityEditForm from '../shared/EntityEditForm.vue'
import { maybeShow as tutorialMaybeShow } from '@/tutorial/controller'
import '@/assets/styles.css'
const props = defineProps<{ id?: string }>()
@@ -36,6 +37,7 @@ const loading = ref(false)
const error = ref<string | null>(null)
onMounted(async () => {
if (!isEdit.value) tutorialMaybeShow('create-penalty')
if (isEdit.value && props.id) {
loading.value = true
try {
@@ -1,6 +1,7 @@
<script setup lang="ts">
import { ref, onMounted, onBeforeUnmount, nextTick, computed } from 'vue'
import { getCachedImageUrl } from '@/common/imageCache'
import { maybeShow as tutorialMaybeShow } from '@/tutorial/controller'
import '@/assets/styles.css'
const props = defineProps<{
@@ -159,6 +160,10 @@ onMounted(async () => {
} finally {
loadingImages.value = false
}
tutorialMaybeShow(
'create-chore-image',
() => document.querySelector('.icon-btn') as HTMLElement | null,
)
})
async function resizeImageFile(