feat: Implement routines feature with CRUD operations and child assignment
All checks were successful
Chore App Build, Test, and Push Docker Images / build-and-push (push) Successful in 3m0s

- Add backend routines management with add, get, update, delete, and list functionalities.
- Create models for Routine, RoutineItem, RoutineSchedule, and RoutineExtension.
- Develop event types for routine confirmation and modification.
- Implement frontend components for routine assignment, confirmation dialog, and routine management views.
- Add unit tests for routine API and integration tests for routine CRUD flow.
- Create end-to-end test plan for routines feature covering parent and child interactions.
This commit is contained in:
2026-05-05 09:08:19 -04:00
parent 082097b4f9
commit eb775ba7d8
41 changed files with 2847 additions and 29 deletions

View File

@@ -0,0 +1,120 @@
# Routines Feature E2E Plan
## Application Overview
The routines feature adds a new parent-defined checklist entity that sits between chores and rewards in child mode. A routine is confirmed as a whole (not per-item), then approved or rejected by the parent. Routine visibility and state are schedule-aware, include deadline extension support, and support per-child point overrides.
This plan is intentionally implementation-driven so we can incrementally add tests as each phase ships.
---
## Coverage Areas
### 1. Parent Routine Library Management
File target: e2e/mode_parent/routines/routine-library.spec.ts
1. Parent can create a routine with name, points, image.
2. Parent can add multiple routine items and persist item order.
3. Parent can edit routine metadata and item metadata.
4. Parent can delete a routine item.
5. Parent can delete a routine and it disappears from routine library list.
### 2. Parent Child Assignment and Management
File target: e2e/mode_parent/routines/routine-assignment.spec.ts
1. Parent can assign routine to a child from assignable list.
2. Parent can remove routine assignment from child.
3. Parent can set/replace full routine assignment list for a child.
4. Parent can set routine point override and child-facing value reflects override.
5. Parent can set routine schedule and update it later.
6. Parent can extend routine deadline for today.
### 3. Child Routine Rendering and Navigation
File target: e2e/mode_child/routines/routine-visibility.spec.ts
1. Routines list renders between chores and rewards.
2. Clicking a routine opens routine detail route (/child/:id/routine/:routineId).
3. Detail view shows routine image, title, points, and non-interactive item list.
4. Back navigation returns to child main view.
### 4. Child Routine Confirmation Flow
File target: e2e/mode_child/routines/routine-confirmation.spec.ts
1. Child can mark routine Done; routine becomes pending.
2. Child can cancel a pending routine confirmation.
3. Child cannot submit duplicate pending confirmations.
4. Approved-today routine shows completed state on child UI.
5. Rejected routine returns to available state.
### 5. Parent Pending Confirmation Flow
File target: e2e/mode_parent/routines/routine-approval.spec.ts
1. Routine confirmation appears in parent notification/pending list.
2. Parent can approve routine; child points increase by default routine points.
3. Parent can reject routine; child points do not change.
4. Parent can reset approved/rejected routine to clear completion state.
5. If routine override exists, approval uses override points instead of base points.
### 6. Routine Scheduling and Deadline Behavior
File target: e2e/mode_child/routines/routine-schedule.spec.ts
1. Day-based schedules only show routines on scheduled days.
2. Interval schedules show/hide routines on expected interval dates.
3. Routine with deadline in the past displays TOO LATE and blocks Done.
4. Extending deadline removes TOO LATE state for that day.
5. Changing schedule resets stale pending status.
### 7. Cascade and Data Integrity
File target: e2e/mode_parent/routines/routine-cascade.spec.ts
1. Deleting routine removes it from all assigned children.
2. Deleting routine removes routine items.
3. Deleting routine removes routine schedules and extensions.
4. Deleting routine removes routine point overrides.
5. Deleting child removes child routine schedules/extensions/overrides.
### 8. SSE Reactivity
File target: e2e/multi-session/routines/routine-sse.spec.ts
1. Parent routine add/edit/delete updates child assignment views without refresh.
2. Child routine pending/approved/rejected/reset updates parent notification UI without refresh.
3. Schedule/extension changes update child routine card state without refresh.
4. Override changes update points display without refresh.
---
## Test Data and Execution Notes
1. Use API seeding in beforeAll and cleanup in afterAll per spec file.
2. Use stable role/label-based locators only.
3. Avoid /auth/login navigation in tests; rely on global storageState.
4. For time-sensitive schedule tests, set deterministic times (or use clock controls where practical).
---
## Unit Test Expansion Checklist
### Backend unit tests to add during implementation
1. Routine CRUD API validation and ownership checks.
2. Routine item CRUD and ordering behavior.
3. Child routine assignment list and assignable-list filtering.
4. Routine confirmation approve/reject/reset state transitions.
5. Routine schedule and extension endpoints (including duplicate extension conflict).
6. Routine deletion cascades (items/schedules/extensions/overrides/child assignments).
### Frontend unit tests to add during implementation
1. API helper coverage for routine endpoints.
2. ScheduleModal entityType routing behavior for task vs routine.
3. Child routine list sorting/filtering utility behavior.
4. Routine detail confirmation UI state transitions.
5. Parent pending confirmation card rendering for entity_type='routine'.

View File

@@ -2,17 +2,21 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'
import { mount } from '@vue/test-utils'
import { nextTick } from 'vue'
import ScheduleModal from '../components/shared/ScheduleModal.vue'
import type { ChildTask, ChoreSchedule } from '../common/models'
import type { ChildTask, ChoreSchedule, RoutineSchedule } from '../common/models'
// ---------------------------------------------------------------------------
// Mocks
// ---------------------------------------------------------------------------
const mockSetChoreSchedule = vi.fn()
const mockDeleteChoreSchedule = vi.fn()
const mockSetRoutineSchedule = vi.fn()
const mockDeleteRoutineSchedule = vi.fn()
vi.mock('@/common/api', () => ({
setChoreSchedule: (...args: unknown[]) => mockSetChoreSchedule(...args),
deleteChoreSchedule: (...args: unknown[]) => mockDeleteChoreSchedule(...args),
setRoutineSchedule: (...args: unknown[]) => mockSetRoutineSchedule(...args),
deleteRoutineSchedule: (...args: unknown[]) => mockDeleteRoutineSchedule(...args),
parseErrorResponse: vi.fn().mockResolvedValue({ msg: 'error', code: 'ERR' }),
}))
@@ -39,9 +43,9 @@ const DateInputFieldStub = {
const TASK: ChildTask = { id: 'task-1', name: 'Clean Room', type: 'chore', points: 5, image_id: '' }
const CHILD_ID = 'child-1'
function mountModal(schedule: ChoreSchedule | null = null) {
function mountModal(schedule: ChoreSchedule | RoutineSchedule | null = null) {
return mount(ScheduleModal, {
props: { task: TASK, childId: CHILD_ID, schedule },
props: { entity: TASK, entityType: 'task', childId: CHILD_ID, schedule },
global: {
stubs: {
ModalDialog: ModalDialogStub,
@@ -55,8 +59,12 @@ function mountModal(schedule: ChoreSchedule | null = null) {
beforeEach(() => {
mockSetChoreSchedule.mockReset()
mockDeleteChoreSchedule.mockReset()
mockSetRoutineSchedule.mockReset()
mockDeleteRoutineSchedule.mockReset()
mockSetChoreSchedule.mockResolvedValue({ ok: true })
mockDeleteChoreSchedule.mockResolvedValue({ ok: true })
mockSetRoutineSchedule.mockResolvedValue({ ok: true })
mockDeleteRoutineSchedule.mockResolvedValue({ ok: true })
})
// ---------------------------------------------------------------------------
// Mode toggle
@@ -488,3 +496,39 @@ describe('ScheduleModal backdrop', () => {
expect(w.emitted('cancelled')).toBeFalsy()
})
})
// ---------------------------------------------------------------------------
// Entity type routing
// ---------------------------------------------------------------------------
describe('ScheduleModal entityType routing', () => {
it('uses routine schedule API when entityType is routine', async () => {
const routineEntity = { ...TASK, id: 'routine-1', items: [] }
const w = mount(ScheduleModal, {
props: {
entity: routineEntity,
entityType: 'routine',
childId: CHILD_ID,
schedule: null,
},
global: {
stubs: {
ModalDialog: ModalDialogStub,
TimePickerPopover: TimePickerPopoverStub,
DateInputField: DateInputFieldStub,
},
},
})
await w.findAll('.chip')[1].trigger('click')
await nextTick()
await w.find('.btn-primary').trigger('click')
await nextTick()
expect(mockSetRoutineSchedule).toHaveBeenCalledWith(
CHILD_ID,
'routine-1',
expect.objectContaining({ mode: 'days' }),
)
expect(mockSetChoreSchedule).not.toHaveBeenCalled()
})
})

View File

@@ -0,0 +1,60 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import {
confirmRoutine,
cancelRoutineConfirmation,
setChildRoutineOverride,
setChildOverride,
} from '../api'
describe('routine api helpers', () => {
const originalFetch = globalThis.fetch
beforeEach(() => {
globalThis.fetch = vi.fn().mockResolvedValue({ ok: true, status: 200 } as Response)
})
afterEach(() => {
globalThis.fetch = originalFetch
})
it('confirmRoutine posts to confirm endpoint', async () => {
await confirmRoutine('child-1', 'routine-1')
expect(globalThis.fetch).toHaveBeenCalledWith('/api/child/child-1/confirm-routine', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ routine_id: 'routine-1' }),
})
})
it('cancelRoutineConfirmation posts to cancel endpoint', async () => {
await cancelRoutineConfirmation('child-2', 'routine-2')
expect(globalThis.fetch).toHaveBeenCalledWith('/api/child/child-2/cancel-routine-confirmation', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ routine_id: 'routine-2' }),
})
})
it('setChildRoutineOverride delegates to override endpoint with routine entity type', async () => {
await setChildRoutineOverride('child-3', 'routine-3', 11)
expect(globalThis.fetch).toHaveBeenCalledWith('/api/child/child-3/override', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ entity_id: 'routine-3', entity_type: 'routine', custom_value: 11 }),
})
})
it('setChildOverride supports routine entity type directly', async () => {
await setChildOverride('child-4', 'routine-4', 'routine', 12)
expect(globalThis.fetch).toHaveBeenCalledWith('/api/child/child-4/override', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ entity_id: 'routine-4', entity_type: 'routine', custom_value: 12 }),
})
})
})

View File

@@ -168,7 +168,7 @@ export async function getTrackingEventsForChild(params: {
export async function setChildOverride(
childId: string,
entityId: string,
entityType: 'task' | 'reward',
entityType: 'task' | 'reward' | 'routine',
customValue: number,
): Promise<Response> {
return fetch(`/api/child/${childId}/override`, {
@@ -182,6 +182,14 @@ export async function setChildOverride(
})
}
export async function setChildRoutineOverride(
childId: string,
routineId: string,
customValue: number,
): Promise<Response> {
return setChildOverride(childId, routineId, 'routine', customValue)
}
/**
* Get all overrides for a specific child.
*/
@@ -231,6 +239,37 @@ export async function deleteChoreSchedule(childId: string, taskId: string): Prom
})
}
/**
* Get the schedule for a specific child + routine pair.
*/
export async function getRoutineSchedule(childId: string, routineId: string): Promise<Response> {
return fetch(`/api/child/${childId}/routine/${routineId}/schedule`)
}
/**
* Create or replace the schedule for a specific child + routine pair.
*/
export async function setRoutineSchedule(
childId: string,
routineId: string,
schedule: object,
): Promise<Response> {
return fetch(`/api/child/${childId}/routine/${routineId}/schedule`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(schedule),
})
}
/**
* Delete the schedule for a specific child + routine pair.
*/
export async function deleteRoutineSchedule(childId: string, routineId: string): Promise<Response> {
return fetch(`/api/child/${childId}/routine/${routineId}/schedule`, {
method: 'DELETE',
})
}
/**
* Extend a timed-out chore for the remainder of today only.
* `localDate` is the client's local ISO date (e.g. '2026-02-22').
@@ -247,6 +286,22 @@ export async function extendChoreTime(
})
}
/**
* Extend a timed-out routine for the remainder of today only.
* `localDate` is the client's local ISO date (e.g. '2026-02-22').
*/
export async function extendRoutineTime(
childId: string,
routineId: string,
localDate: string,
): Promise<Response> {
return fetch(`/api/child/${childId}/routine/${routineId}/extend`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ date: localDate }),
})
}
// ── Chore Confirmation API ──────────────────────────────────────────────────
/**
@@ -310,3 +365,28 @@ export async function resetChore(childId: string, taskId: string): Promise<Respo
export async function fetchPendingConfirmations(): Promise<Response> {
return fetch('/api/pending-confirmations')
}
/**
* Child confirms they completed a routine.
*/
export async function confirmRoutine(childId: string, routineId: string): Promise<Response> {
return fetch(`/api/child/${childId}/confirm-routine`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ routine_id: routineId }),
})
}
/**
* Child cancels a pending routine confirmation.
*/
export async function cancelRoutineConfirmation(
childId: string,
routineId: string,
): Promise<Response> {
return fetch(`/api/child/${childId}/cancel-routine-confirmation`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ routine_id: routineId }),
})
}

View File

@@ -37,6 +37,56 @@ export interface ChoreSchedule {
updated_at: number
}
export interface Routine {
id: string
name: string
points: number
image_id: string | null
image_url?: string | null
}
export const ROUTINE_FIELDS = ['id', 'name', 'points', 'image_id'] as const
export interface RoutineItem {
id: string
routine_id: string
name: string
image_id: string | null
order: number
}
export interface RoutineSchedule {
id: string
child_id: string
routine_id: string
mode: 'days' | 'interval'
day_configs: DayConfig[]
default_hour?: number
default_minute?: number
default_has_deadline?: boolean
interval_days: number
anchor_date: string
interval_has_deadline: boolean
interval_hour: number
interval_minute: number
enabled?: boolean
created_at: number
updated_at: number
}
export interface ChildRoutine {
id: string
name: string
points: number
image_id: string | null
image_url?: string | null
custom_value?: number | null
schedule?: RoutineSchedule | null
extension_date?: string | null
pending_status?: 'pending' | 'approved' | null
approved_at?: string | null
items: RoutineItem[]
}
export interface ChildTask {
id: string
name: string
@@ -72,12 +122,13 @@ export interface Child {
name: string
age: number
tasks: string[]
routines?: string[]
rewards: string[]
points: number
image_id: string | null
image_url?: string | null // optional, for resolved URLs
}
export const CHILD_FIELDS = ['id', 'name', 'age', 'tasks', 'rewards', 'points', 'image_id'] as const
export const CHILD_FIELDS = ['id', 'name', 'age', 'tasks', 'routines', 'rewards', 'points', 'image_id'] as const
export interface Reward {
id: string
@@ -114,7 +165,7 @@ export interface PendingConfirmation {
child_image_id: string | null
child_image_url?: string | null
entity_id: string
entity_type: 'chore' | 'reward'
entity_type: 'chore' | 'reward' | 'routine'
entity_name: string
entity_image_id: string | null
entity_image_url?: string | null
@@ -151,6 +202,11 @@ export interface Event {
| ChoreScheduleModifiedPayload
| ChoreTimeExtendedPayload
| ChildChoreConfirmationPayload
| RoutineModifiedEventPayload
| ChildRoutinesSetEventPayload
| RoutineScheduleModifiedPayload
| RoutineTimeExtendedPayload
| ChildRoutineConfirmationPayload
}
export interface ChildModifiedEventPayload {
@@ -194,6 +250,16 @@ export interface RewardModifiedEventPayload {
operation: 'ADD' | 'DELETE' | 'EDIT'
}
export interface RoutineModifiedEventPayload {
routine_id: string
operation: 'ADD' | 'DELETE' | 'EDIT'
}
export interface ChildRoutinesSetEventPayload {
child_id: string
routine_ids: string[]
}
export interface TrackingEventCreatedPayload {
tracking_event_id: string
child_id: string
@@ -211,7 +277,7 @@ export interface ChildOverrideDeletedPayload {
entity_type: string
}
export type EntityType = 'task' | 'reward' | 'penalty' | 'chore' | 'kindness'
export type EntityType = 'task' | 'reward' | 'penalty' | 'chore' | 'kindness' | 'routine'
export type ActionType =
| 'activated'
| 'requested'
@@ -254,7 +320,7 @@ export const TRACKING_EVENT_FIELDS = [
'metadata',
] as const
export type OverrideEntityType = 'task' | 'reward' | 'chore' | 'kindness' | 'penalty'
export type OverrideEntityType = 'task' | 'reward' | 'chore' | 'kindness' | 'penalty' | 'routine'
export interface ChildOverride {
id: string
@@ -292,3 +358,20 @@ export interface ChildChoreConfirmationPayload {
task_id: string
operation: 'CONFIRMED' | 'APPROVED' | 'REJECTED' | 'CANCELLED' | 'RESET'
}
export interface RoutineScheduleModifiedPayload {
child_id: string
routine_id: string
operation: 'SET' | 'DELETED'
}
export interface RoutineTimeExtendedPayload {
child_id: string
routine_id: string
}
export interface ChildRoutineConfirmationPayload {
child_id: string
routine_id: string
operation: 'PENDING' | 'APPROVED' | 'REJECTED' | 'RESET'
}

View File

@@ -6,6 +6,7 @@ import ScrollingList from '../shared/ScrollingList.vue'
import StatusMessage from '../shared/StatusMessage.vue'
import RewardConfirmDialog from './RewardConfirmDialog.vue'
import ChoreConfirmDialog from './ChoreConfirmDialog.vue'
import RoutineConfirmDialog from './RoutineConfirmDialog.vue'
import ModalDialog from '../shared/ModalDialog.vue'
import { eventBus } from '@/common/eventBus'
//import '@/assets/view-shared.css'
@@ -16,17 +17,22 @@ import type {
Task,
RewardStatus,
ChildTask,
ChildRoutine,
ChildTaskTriggeredEventPayload,
ChildRewardTriggeredEventPayload,
ChildRewardRequestEventPayload,
ChildTasksSetEventPayload,
ChildRewardsSetEventPayload,
ChildRoutinesSetEventPayload,
TaskModifiedEventPayload,
RewardModifiedEventPayload,
RoutineModifiedEventPayload,
ChildModifiedEventPayload,
ChoreScheduleModifiedPayload,
ChoreTimeExtendedPayload,
RoutineScheduleModifiedPayload,
ChildChoreConfirmationPayload,
ChildRoutineConfirmationPayload,
ChildOverrideSetPayload,
ChildOverrideDeletedPayload,
} from '@/common/models'
@@ -46,6 +52,7 @@ const router = useRouter()
const child = ref<Child | null>(null)
const tasks = ref<string[]>([])
const routines = ref<string[]>([])
const rewards = ref<string[]>([])
const loading = ref(true)
const error = ref<string | null>(null)
@@ -56,6 +63,9 @@ const dialogReward = ref<RewardStatus | null>(null)
const showChoreConfirmDialog = ref(false)
const showChoreCancelDialog = ref(false)
const dialogChore = ref<ChildTask | null>(null)
const showRoutineConfirmDialog = ref(false)
const dialogRoutine = ref<ChildRoutine | null>(null)
const childRoutineListRef = ref()
function handleTaskTriggered(event: Event) {
const payload = event.payload as ChildTaskTriggeredEventPayload
@@ -87,6 +97,13 @@ function handleChildRewardSet(event: Event) {
}
}
function handleChildRoutineSet(event: Event) {
const payload = event.payload as ChildRoutinesSetEventPayload
if (child.value && payload.child_id == child.value.id) {
routines.value = payload.routine_ids
}
}
function handleRewardRequest(event: Event) {
const payload = event.payload as ChildRewardRequestEventPayload
const childId = payload.child_id
@@ -222,6 +239,14 @@ const triggerTask = async (task: ChildTask) => {
// Kindness / Penalty: speech-only in child mode; point changes are handled in parent mode.
}
const handleRoutineClick = (routine: ChildRoutine) => {
// Show routine confirmation dialog
dialogRoutine.value = routine
setTimeout(() => {
showRoutineConfirmDialog.value = true
}, 150)
}
function isChoreCompletedToday(item: ChildTask): boolean {
if (item.pending_status !== 'approved' || !item.approved_at) return false
const approvedDate = new Date(item.approved_at)
@@ -273,6 +298,30 @@ function closeChoreCancelDialog() {
dialogChore.value = null
}
async function doConfirmRoutine() {
if (!child.value?.id || !dialogRoutine.value) return
try {
const resp = await fetch(`/api/child/${child.value.id}/confirm-routine`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ routine_id: dialogRoutine.value.id }),
})
if (!resp.ok) {
console.error('Failed to confirm routine')
}
} catch (err) {
console.error('Failed to confirm routine:', err)
} finally {
showRoutineConfirmDialog.value = false
dialogRoutine.value = null
}
}
function closeRoutineConfirmDialog() {
showRoutineConfirmDialog.value = false
dialogRoutine.value = null
}
const triggerReward = (reward: RewardStatus) => {
// Cancel any pending speech to avoid conflicts
if ('speechSynthesis' in window) {
@@ -545,6 +594,7 @@ onMounted(async () => {
eventBus.on('child_reward_triggered', handleRewardTriggered)
eventBus.on('child_tasks_set', handleChildTaskSet)
eventBus.on('child_rewards_set', handleChildRewardSet)
eventBus.on('child_routines_set', handleChildRoutineSet)
eventBus.on('task_modified', handleTaskModified)
eventBus.on('reward_modified', handleRewardModified)
eventBus.on('child_modified', handleChildModified)
@@ -563,6 +613,7 @@ onMounted(async () => {
if (data) {
child.value = data
tasks.value = data.tasks || []
routines.value = data.routines || []
rewards.value = data.rewards || []
}
loading.value = false
@@ -582,6 +633,7 @@ onUnmounted(() => {
eventBus.off('child_reward_triggered', handleRewardTriggered)
eventBus.off('child_tasks_set', handleChildTaskSet)
eventBus.off('child_rewards_set', handleChildRewardSet)
eventBus.off('child_routines_set', handleChildRoutineSet)
eventBus.off('task_modified', handleTaskModified)
eventBus.off('reward_modified', handleRewardModified)
eventBus.off('child_modified', handleChildModified)
@@ -731,6 +783,49 @@ onUnmounted(() => {
</div>
</template>
</ScrollingList>
<ScrollingList
title="Routines"
ref="childRoutineListRef"
:fetchBaseUrl="`/api/child/${child?.id}/list-routines`"
:ids="routines"
itemKey="routines"
imageField="image_id"
:isParentAuthenticated="false"
:readyItemId="readyItemId"
@item-ready="handleItemReady"
@trigger-item="handleRoutineClick"
:getItemClass="
(item: ChildRoutine) => ({
good: true,
'routine-pending': item.pending_status === 'pending',
'routine-approved': item.pending_status === 'approved',
})
"
>
<template #item="{ item }: { item: ChildRoutine }">
<span v-if="item.pending_status === 'pending'" class="chore-stamp pending-stamp"
>PENDING</span
>
<span v-else-if="item.pending_status === 'approved'" class="chore-stamp completed-stamp"
>APPROVED</span
>
<div class="item-name">{{ item.name }}</div>
<img
v-if="item.image_url"
:src="item.image_url"
alt="Routine Image"
class="item-image"
/>
<div class="item-points good-points">
{{
item.custom_value !== undefined && item.custom_value !== null
? item.custom_value
: item.points
}}
Points
</div>
</template>
</ScrollingList>
</div>
</div>
</div>
@@ -785,6 +880,16 @@ onUnmounted(() => {
<button @click="closeChoreCancelDialog" class="btn btn-secondary">No</button>
</div>
</ModalDialog>
<!-- Routine confirm dialog -->
<RoutineConfirmDialog
:show="showRoutineConfirmDialog"
:routineName="dialogRoutine?.name ?? ''"
:imageUrl="dialogRoutine?.image_url"
:items="dialogRoutine?.items"
@confirm="doConfirmRoutine"
@cancel="closeRoutineConfirmDialog"
/>
</template>
<style scoped>

View File

@@ -937,6 +937,16 @@ function goToAssignRewards() {
})
}
}
function goToAssignRoutines() {
if (child.value?.id) {
router.push({
name: 'RoutineAssignView',
params: { id: child.value.id },
query: { name: child.value.name },
})
}
}
</script>
<template>
@@ -1162,6 +1172,9 @@ function goToAssignRewards() {
<button v-if="child" class="btn btn-danger" @click="goToAssignBadHabits">
Assign Penalties
</button>
<button v-if="child" class="btn btn-primary" @click="goToAssignRoutines">
Assign Routines
</button>
<button v-if="child" class="btn btn-green" @click="goToAssignRewards">Assign Rewards</button>
</div>
@@ -1196,7 +1209,8 @@ function goToAssignRewards() {
<!-- Schedule Modal -->
<ScheduleModal
v-if="showScheduleModal && scheduleTarget && child"
:task="scheduleTarget"
:entity="scheduleTarget"
entityType="task"
:childId="child.id"
:schedule="scheduleTarget.schedule ?? null"
@saved="onScheduleSaved"

View File

@@ -0,0 +1,238 @@
<template>
<div class="assign-view">
<h2>Assign Routines{{ childName ? ` for ${childName}` : '' }}</h2>
<div class="list-container">
<MessageBlock v-if="routines.length === 0" message="No routines">
<span> <button class="round-btn" @click="goToCreate">Create</button> a routine </span>
</MessageBlock>
<div v-else class="routine-selection">
<div
v-for="routine in routines"
:key="routine.id"
class="routine-item"
@click="toggleRoutine(routine.id)"
>
<input
type="checkbox"
:checked="selectedIds.includes(routine.id)"
@change="toggleRoutine(routine.id)"
:aria-label="`Select ${routine.name}`"
/>
<img v-if="routine.image_url" :src="routine.image_url" :alt="routine.name" />
<span class="name">{{ routine.name }}</span>
<span class="value">{{ routine.points }} pts</span>
</div>
</div>
</div>
<div class="actions" v-if="routines.length > 0">
<button class="btn btn-secondary" @click="onCancel">Cancel</button>
<button class="btn btn-primary" @click="onSubmit" :disabled="isLoading">
{{ isLoading ? 'Saving...' : 'Submit' }}
</button>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import MessageBlock from '../shared/MessageBlock.vue'
import '@/assets/styles.css'
import type { Routine } from '@/common/models'
const route = useRoute()
const router = useRouter()
const childId = route.params.id as string
const childName = typeof route.query.name === 'string' ? route.query.name : ''
const routines = ref<Routine[]>([])
const selectedIds = ref<string[]>([])
const isLoading = ref(false)
onMounted(async () => {
await fetchRoutines()
})
async function fetchRoutines() {
try {
// Fetch child to get currently assigned routines
const childResp = await fetch(`/api/child/${childId}`)
if (!childResp.ok) throw new Error('Failed to fetch child')
const childData = await childResp.json()
selectedIds.value = childData.routines || []
// Fetch all routines
const routinesResp = await fetch('/api/routine/list')
if (!routinesResp.ok) throw new Error('Failed to fetch routines')
const routinesData = await routinesResp.json()
routines.value = routinesData.routines || []
} catch (error) {
console.error('Failed to fetch routines:', error)
}
}
function toggleRoutine(routineId: string) {
const index = selectedIds.value.indexOf(routineId)
if (index > -1) {
selectedIds.value.splice(index, 1)
} else {
selectedIds.value.push(routineId)
}
}
function goToCreate() {
router.push({ name: 'CreateRoutine' })
}
async function onSubmit() {
isLoading.value = true
try {
const resp = await fetch(`/api/child/${childId}/set-routines`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ routine_ids: selectedIds.value }),
})
if (!resp.ok) throw new Error('Failed to update routines')
router.back()
} catch (error) {
console.error('Failed to update routines:', error)
alert('Failed to update routines.')
} finally {
isLoading.value = false
}
}
function onCancel() {
router.back()
}
</script>
<style scoped>
.assign-view {
display: flex;
flex-direction: column;
align-items: center;
flex: 1 1 auto;
width: 100%;
height: 100%;
padding: 1rem;
gap: 1rem;
}
h2 {
margin: 0;
color: var(--text-primary);
font-size: 1.5rem;
}
.list-container {
flex: 1 1 auto;
width: 100%;
max-width: 500px;
overflow-y: auto;
}
.routine-selection {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.routine-item {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.75rem 1rem;
border: 2px solid var(--list-item-border-good);
border-radius: 8px;
background: var(--list-item-bg-good);
cursor: pointer;
transition:
background 0.2s,
border-color 0.2s;
}
.routine-item:hover {
background: var(--list-item-bg-good-hover, var(--list-item-bg-good));
border-color: var(--list-item-border-good-hover, var(--list-item-border-good));
}
.routine-item input[type='checkbox'] {
cursor: pointer;
width: 1.25rem;
height: 1.25rem;
}
.routine-item img {
width: 2.5rem;
height: 2.5rem;
border-radius: 6px;
object-fit: cover;
}
.routine-item .name {
flex: 1;
font-weight: 600;
color: var(--text-primary);
}
.routine-item .value {
min-width: 60px;
text-align: right;
font-weight: 600;
color: var(--text-secondary);
}
.actions {
display: flex;
gap: 1rem;
padding: 1rem;
width: 100%;
max-width: 500px;
justify-content: flex-end;
}
.btn {
padding: 0.5rem 1.5rem;
border: none;
border-radius: 6px;
font-weight: 600;
cursor: pointer;
transition: all 0.2s;
}
.btn-primary {
background: var(--btn-primary);
color: #fff;
}
.btn-primary:hover:not(:disabled) {
opacity: 0.9;
}
.btn-primary:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.btn-secondary {
background: var(--btn-secondary);
color: var(--btn-secondary-text);
}
.btn-secondary:hover {
opacity: 0.8;
}
.round-btn {
background: none;
border: none;
color: var(--btn-primary);
font-weight: 600;
cursor: pointer;
text-decoration: underline;
}
.round-btn:hover {
opacity: 0.8;
}
</style>

View File

@@ -0,0 +1,126 @@
<template>
<ModalDialog v-if="show" @backdrop-click="$emit('cancel')">
<template #default>
<div class="confirm-dialog">
<img v-if="imageUrl" :src="imageUrl" alt="Routine" class="routine-image" />
<p class="message">Confirm routine</p>
<p class="routine-name">{{ routineName }}?</p>
<div v-if="items && items.length > 0" class="items-preview">
<p class="items-label">Items:</p>
<ul class="items-list">
<li v-for="item in items.slice(0, 3)" :key="item.id">{{ item.name }}</li>
<li v-if="items.length > 3" class="more-items">+ {{ items.length - 3 }} more</li>
</ul>
</div>
<div class="actions">
<button class="btn btn-primary" @click="$emit('confirm')">Confirm</button>
<button class="btn btn-secondary" @click="$emit('cancel')">Cancel</button>
</div>
</div>
</template>
</ModalDialog>
</template>
<script setup lang="ts">
import ModalDialog from '../shared/ModalDialog.vue'
import type { RoutineItem } from '@/common/models'
defineProps<{
show: boolean
routineName: string
imageUrl?: string | null
items?: RoutineItem[]
}>()
defineEmits<{
confirm: []
cancel: []
}>()
</script>
<style scoped>
.confirm-dialog {
text-align: center;
padding: 0.5rem;
}
.routine-image {
width: 72px;
height: 72px;
object-fit: cover;
border-radius: 8px;
background: var(--info-image-bg);
margin-bottom: 0.75rem;
}
.message {
font-size: 1.1rem;
color: var(--dialog-message);
margin-bottom: 0.25rem;
}
.routine-name {
font-size: 1.3rem;
font-weight: 700;
color: var(--dialog-child-name);
margin-bottom: 1rem;
}
.items-preview {
margin-bottom: 1rem;
text-align: left;
background: var(--form-bg);
padding: 0.75rem;
border-radius: 6px;
}
.items-label {
font-size: 0.9rem;
font-weight: 600;
color: var(--text-secondary);
margin-bottom: 0.5rem;
}
.items-list {
list-style: none;
padding-left: 1rem;
margin: 0;
font-size: 0.95rem;
color: var(--text-primary);
}
.items-list li {
padding: 0.25rem 0;
}
.items-list li.more-items {
color: var(--text-secondary);
font-style: italic;
}
.actions {
display: flex;
gap: 1.5rem;
justify-content: center;
margin-top: 1.5rem;
}
.actions button {
padding: 0.7rem 1.8rem;
border-radius: 10px;
border: 0;
cursor: pointer;
font-weight: 700;
font-size: 1.05rem;
transition: background 0.18s;
min-width: 100px;
}
.btn-primary {
background: var(--btn-primary);
color: #fff;
}
.btn-primary:hover {
opacity: 0.9;
}
.btn-secondary {
background: var(--btn-secondary);
color: var(--btn-secondary-text);
}
.btn-secondary:hover {
opacity: 0.8;
}
</style>

View File

@@ -0,0 +1,122 @@
<template>
<div class="view">
<EntityEditForm
entityLabel="Routine"
:fields="fields"
:initialData="initialData"
:isEdit="isEdit"
:loading="loading"
:error="error"
@submit="handleSubmit"
@cancel="handleCancel"
@add-image="handleAddImage"
/>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted, computed, nextTick } from 'vue'
import { useRouter } from 'vue-router'
import EntityEditForm from '../shared/EntityEditForm.vue'
import '@/assets/styles.css'
const props = defineProps<{ id?: string }>()
const router = useRouter()
const isEdit = computed(() => !!props.id)
const fields = [
{ name: 'name', label: 'Routine Name', type: 'text' as const, required: true, maxlength: 64 },
{ name: 'points', label: 'Points', type: 'number' as const, required: true, min: 1, max: 1000 },
{ name: 'image_id', label: 'Image', type: 'image' as const, imageType: 4 },
]
const initialData = ref({ name: '', points: 1, image_id: null })
const localImageFile = ref<File | null>(null)
const loading = ref(false)
const error = ref<string | null>(null)
onMounted(async () => {
if (isEdit.value && props.id) {
loading.value = true
try {
const resp = await fetch(`/api/routine/${props.id}`)
if (!resp.ok) throw new Error('Failed to load routine')
const data = await resp.json()
initialData.value = {
name: data.name ?? '',
points: Number(data.points) || 1,
image_id: data.image_id ?? null,
}
} catch {
error.value = 'Could not load routine.'
} finally {
loading.value = false
await nextTick()
}
}
})
function handleAddImage({ id, file }: { id: string; file: File }) {
if (id === 'local-upload') localImageFile.value = file
}
async function handleSubmit(form: { name: string; points: number; image_id: string | null }) {
let imageId = form.image_id
error.value = null
if (!form.name.trim()) {
error.value = 'Routine name is required.'
return
}
if (form.points < 1) {
error.value = 'Points must be at least 1.'
return
}
loading.value = true
if (imageId === 'local-upload' && localImageFile.value) {
const formData = new FormData()
formData.append('file', localImageFile.value)
formData.append('type', '4')
formData.append('permanent', 'false')
try {
const resp = await fetch('/api/image/upload', { method: 'POST', body: formData })
if (!resp.ok) throw new Error('Image upload failed')
const data = await resp.json()
imageId = data.id
} catch {
error.value = 'Failed to upload image.'
loading.value = false
return
}
}
try {
const url = isEdit.value && props.id ? `/api/routine/${props.id}/edit` : '/api/routine/add'
const resp = await fetch(url, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name: form.name, points: form.points, image_id: imageId }),
})
if (!resp.ok) throw new Error('Failed to save routine')
await router.push({ name: 'RoutineView' })
} catch {
error.value = 'Failed to save routine.'
}
loading.value = false
}
function handleCancel() {
router.back()
}
</script>
<style scoped>
.view {
max-width: 400px;
margin: 0 auto;
background: var(--form-bg);
border-radius: 12px;
box-shadow: 0 4px 24px var(--form-shadow);
padding: 2rem 2.2rem 1.5rem 2.2rem;
}
</style>

View File

@@ -0,0 +1,119 @@
<template>
<div class="routine-view">
<MessageBlock v-if="countRef === 0" message="No routines">
<span> <button class="round-btn" @click="create">Create</button> a routine </span>
</MessageBlock>
<ItemList
v-else
ref="listRef"
fetchUrl="/api/routine/list"
itemKey="routines"
:itemFields="ROUTINE_FIELDS"
imageField="image_id"
deletable
@clicked="(item: Routine) => $router.push({ name: 'EditRoutine', params: { id: item.id } })"
@delete="confirmDelete"
@loading-complete="(count) => (countRef = count)"
:getItemClass="() => ({ good: true })"
>
<template #item="{ item }">
<img v-if="item.image_url" :src="item.image_url" />
<span class="name">{{ item.name }}</span>
<span class="value">{{ item.points }} pts</span>
</template>
</ItemList>
<FloatingActionButton aria-label="Create Routine" @click="create" />
<DeleteModal
:show="showConfirm"
message="Are you sure you want to delete this routine?"
@confirm="deleteItem"
@cancel="showConfirm = false"
/>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted, onUnmounted } from 'vue'
import { useRouter } from 'vue-router'
import ItemList from '../shared/ItemList.vue'
import MessageBlock from '@/components/shared/MessageBlock.vue'
import FloatingActionButton from '../shared/FloatingActionButton.vue'
import DeleteModal from '../shared/DeleteModal.vue'
import type { Routine } from '@/common/models'
import { ROUTINE_FIELDS } from '@/common/models'
import { eventBus } from '@/common/eventBus'
const $router = useRouter()
const showConfirm = ref(false)
const itemToDelete = ref<string | null>(null)
const listRef = ref()
const countRef = ref<number>(-1)
function handleModified() {
listRef.value?.refresh()
}
onMounted(() => {
eventBus.on('routine_modified', handleModified)
})
onUnmounted(() => {
eventBus.off('routine_modified', handleModified)
})
function confirmDelete(id: string) {
itemToDelete.value = id
showConfirm.value = true
}
const deleteItem = async () => {
const id =
typeof itemToDelete.value === 'object' && itemToDelete.value !== null
? (itemToDelete.value as any).id
: itemToDelete.value
if (!id) return
try {
const resp = await fetch(`/api/routine/${id}`, { method: 'DELETE' })
if (!resp.ok) throw new Error(`HTTP ${resp.status}`)
} catch (err) {
console.error('Failed to delete routine:', err)
} finally {
showConfirm.value = false
itemToDelete.value = null
}
}
const create = () => {
$router.push({ name: 'CreateRoutine' })
}
</script>
<style scoped>
.routine-view {
display: flex;
flex-direction: column;
align-items: center;
flex: 1 1 auto;
width: 100%;
height: 100%;
padding: 0;
min-height: 0;
}
.name {
flex: 1;
text-align: left;
font-weight: 600;
}
.value {
min-width: 60px;
text-align: right;
font-weight: 600;
}
:deep(.good) {
border-color: var(--list-item-border-good);
background: var(--list-item-bg-good);
}
</style>

View File

@@ -1,5 +1,5 @@
<template>
<ModalDialog :image-url="task.image_url" :title="'Schedule Chore'" :subtitle="task.name">
<ModalDialog :image-url="entity.image_url" :title="scheduleTitle" :subtitle="entity.name">
<!-- Enable/disable toggle row -->
<div class="schedule-toggle-row">
<span class="toggle-label">{{ scheduleEnabled ? 'Enabled' : 'Paused' }}</span>
@@ -165,8 +165,20 @@ import { ref, computed } from 'vue'
import ModalDialog from './ModalDialog.vue'
import TimePickerPopover from './TimePickerPopover.vue'
import DateInputField from './DateInputField.vue'
import { setChoreSchedule, deleteChoreSchedule, parseErrorResponse } from '@/common/api'
import type { ChildTask, ChoreSchedule, DayConfig } from '@/common/models'
import {
setChoreSchedule,
deleteChoreSchedule,
setRoutineSchedule,
deleteRoutineSchedule,
parseErrorResponse,
} from '@/common/api'
import type {
ChildTask,
ChildRoutine,
ChoreSchedule,
RoutineSchedule,
DayConfig,
} from '@/common/models'
interface TimeValue {
hour: number
@@ -174,11 +186,16 @@ interface TimeValue {
}
const props = defineProps<{
task: ChildTask
entity: ChildTask | ChildRoutine
entityType: 'task' | 'routine'
childId: string
schedule: ChoreSchedule | null
schedule: ChoreSchedule | RoutineSchedule | null
}>()
const scheduleTitle = computed(() =>
props.entityType === 'task' ? 'Schedule Chore' : 'Schedule Routine',
)
const emit = defineEmits<{
(e: 'saved'): void
(e: 'cancelled'): void
@@ -386,10 +403,20 @@ async function save() {
errorMsg.value = null
saving.value = true
const entityId = props.entity.id
const saveEntitySchedule = (payload: object) =>
props.entityType === 'task'
? setChoreSchedule(props.childId, entityId, payload)
: setRoutineSchedule(props.childId, entityId, payload)
const deleteEntitySchedule = () =>
props.entityType === 'task'
? deleteChoreSchedule(props.childId, entityId)
: deleteRoutineSchedule(props.childId, entityId)
let res: Response
if (mode.value === 'days' && selectedDays.value.size === 0) {
// 0 days = remove schedule entirely → chore becomes always active
res = await deleteChoreSchedule(props.childId, props.task.id)
// 0 days = remove schedule entirely so the item becomes always active
res = await deleteEntitySchedule()
} else if (mode.value === 'days') {
const day_configs: DayConfig[] = Array.from(selectedDays.value).map((day) => {
const override = exceptions.value.get(day)
@@ -399,7 +426,7 @@ async function save() {
minute: override?.minute ?? defaultTime.value.minute,
}
})
res = await setChoreSchedule(props.childId, props.task.id, {
res = await saveEntitySchedule({
mode: 'days',
enabled: scheduleEnabled.value,
day_configs,
@@ -408,7 +435,7 @@ async function save() {
default_has_deadline: hasDefaultDeadline.value,
})
} else {
res = await setChoreSchedule(props.childId, props.task.id, {
res = await saveEntitySchedule({
mode: 'interval',
enabled: scheduleEnabled.value,
interval_days: intervalDays.value,

View File

@@ -19,6 +19,12 @@
>
Penalties
</button>
<button
:class="{ active: activeTab === 'routines' }"
@click="$router.push({ name: 'RoutineView' })"
>
Routines
</button>
</nav>
<div class="sub-content">
<router-view :key="$route.fullPath" />
@@ -34,6 +40,8 @@ const route = useRoute()
const activeTab = computed(() => {
const name = String(route.name)
if (name.startsWith('Routine') || name === 'CreateRoutine' || name === 'EditRoutine')
return 'routines'
if (name.startsWith('Kindness') || name === 'CreateKindness' || name === 'EditKindness')
return 'kindness'
if (name.startsWith('Penalty') || name === 'CreatePenalty' || name === 'EditPenalty')

View File

@@ -9,15 +9,18 @@ import TaskSubNav from '../components/task/TaskSubNav.vue'
import ChoreView from '../components/task/ChoreView.vue'
import KindnessView from '../components/task/KindnessView.vue'
import PenaltyView from '../components/task/PenaltyView.vue'
import RoutineView from '../components/routine/RoutineView.vue'
import ChoreEditView from '@/components/task/ChoreEditView.vue'
import KindnessEditView from '@/components/task/KindnessEditView.vue'
import PenaltyEditView from '@/components/task/PenaltyEditView.vue'
import RoutineEditView from '@/components/routine/RoutineEditView.vue'
import RewardView from '../components/reward/RewardView.vue'
import RewardEditView from '@/components/reward/RewardEditView.vue'
import ChildEditView from '@/components/child/ChildEditView.vue'
import ChoreAssignView from '@/components/child/ChoreAssignView.vue'
import KindnessAssignView from '@/components/child/KindnessAssignView.vue'
import PenaltyAssignView from '@/components/child/PenaltyAssignView.vue'
import RoutineAssignView from '@/components/child/RoutineAssignView.vue'
import RewardAssignView from '@/components/child/RewardAssignView.vue'
import NotificationView from '@/components/notification/NotificationView.vue'
import AuthLayout from '@/layout/AuthLayout.vue'
@@ -139,6 +142,11 @@ const routes = [
name: 'PenaltyView',
component: PenaltyView,
},
{
path: 'routines',
name: 'RoutineView',
component: RoutineView,
},
],
},
{
@@ -174,6 +182,17 @@ const routes = [
component: PenaltyEditView,
props: true,
},
{
path: 'tasks/routines/create',
name: 'CreateRoutine',
component: RoutineEditView,
},
{
path: 'tasks/routines/:id/edit',
name: 'EditRoutine',
component: RoutineEditView,
props: true,
},
{
path: 'rewards',
name: 'RewardView',
@@ -215,6 +234,12 @@ const routes = [
component: RewardAssignView,
props: true,
},
{
path: ':id/assign-routines',
name: 'RoutineAssignView',
component: RoutineAssignView,
props: true,
},
{
path: 'notifications',
name: 'NotificationView',