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
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:
120
frontend/e2e/plans/routines-feature.plan.md
Normal file
120
frontend/e2e/plans/routines-feature.plan.md
Normal 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'.
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
60
frontend/src/common/__tests__/api.routine.spec.ts
Normal file
60
frontend/src/common/__tests__/api.routine.spec.ts
Normal 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 }),
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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 }),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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'
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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"
|
||||
|
||||
238
frontend/src/components/child/RoutineAssignView.vue
Normal file
238
frontend/src/components/child/RoutineAssignView.vue
Normal 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>
|
||||
126
frontend/src/components/child/RoutineConfirmDialog.vue
Normal file
126
frontend/src/components/child/RoutineConfirmDialog.vue
Normal 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>
|
||||
122
frontend/src/components/routine/RoutineEditView.vue
Normal file
122
frontend/src/components/routine/RoutineEditView.vue
Normal 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>
|
||||
119
frontend/src/components/routine/RoutineView.vue
Normal file
119
frontend/src/components/routine/RoutineView.vue
Normal 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>
|
||||
@@ -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,
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user