Add TimeSelector and ScheduleModal components with tests
All checks were successful
Chore App Build, Test, and Push Docker Images / build-and-push (push) Successful in 2m45s
All checks were successful
Chore App Build, Test, and Push Docker Images / build-and-push (push) Successful in 2m45s
- Implemented TimeSelector component for selecting time with AM/PM toggle and minute/hour increment/decrement functionality. - Created ScheduleModal component for scheduling chores with options for specific days or intervals. - Added utility functions for scheduling logic in scheduleUtils.ts. - Developed comprehensive tests for TimeSelector and scheduleUtils functions to ensure correct behavior.
This commit is contained in:
283
frontend/vue-app/src/__tests__/ScheduleModal.spec.ts
Normal file
283
frontend/vue-app/src/__tests__/ScheduleModal.spec.ts
Normal file
@@ -0,0 +1,283 @@
|
||||
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'
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Mocks
|
||||
// ---------------------------------------------------------------------------
|
||||
const mockSetChoreSchedule = vi.fn()
|
||||
|
||||
vi.mock('@/common/api', () => ({
|
||||
setChoreSchedule: (...args: unknown[]) => mockSetChoreSchedule(...args),
|
||||
parseErrorResponse: vi.fn().mockResolvedValue({ msg: 'error', code: 'ERR' }),
|
||||
}))
|
||||
|
||||
// Stubs that render their default slot so form content is accessible
|
||||
const ModalDialogStub = {
|
||||
template: '<div><slot /></div>',
|
||||
props: ['imageUrl', 'title', 'subtitle'],
|
||||
}
|
||||
const TimeSelectorStub = {
|
||||
template: '<div class="time-selector-stub" />',
|
||||
props: ['modelValue'],
|
||||
emits: ['update:modelValue'],
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
const TASK: ChildTask = { id: 'task-1', name: 'Clean Room', is_good: true, points: 5, image_id: '' }
|
||||
const CHILD_ID = 'child-1'
|
||||
|
||||
function mountModal(schedule: ChoreSchedule | null = null) {
|
||||
return mount(ScheduleModal, {
|
||||
props: { task: TASK, childId: CHILD_ID, schedule },
|
||||
global: {
|
||||
stubs: { ModalDialog: ModalDialogStub, TimeSelector: TimeSelectorStub },
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
mockSetChoreSchedule.mockReset()
|
||||
mockSetChoreSchedule.mockResolvedValue({ ok: true })
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Mode toggle
|
||||
// ---------------------------------------------------------------------------
|
||||
describe('ScheduleModal mode toggle', () => {
|
||||
it('defaults to Specific Days mode', () => {
|
||||
const w = mountModal()
|
||||
expect(w.find('.days-form').exists()).toBe(true)
|
||||
expect(w.find('.interval-form').exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('switches to Every X Days on button click', async () => {
|
||||
const w = mountModal()
|
||||
const modeBtns = w.findAll('.mode-btn')
|
||||
await modeBtns[1].trigger('click') // "Every X Days"
|
||||
expect(w.find('.interval-form').exists()).toBe(true)
|
||||
expect(w.find('.days-form').exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('switches back to Specific Days', async () => {
|
||||
const w = mountModal()
|
||||
const modeBtns = w.findAll('.mode-btn')
|
||||
await modeBtns[1].trigger('click') // switch to interval
|
||||
await modeBtns[0].trigger('click') // switch back
|
||||
expect(w.find('.days-form').exists()).toBe(true)
|
||||
expect(w.find('.interval-form').exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('marks "Specific Days" button active by default', () => {
|
||||
const w = mountModal()
|
||||
const modeBtns = w.findAll('.mode-btn')
|
||||
expect(modeBtns[0].classes()).toContain('active')
|
||||
expect(modeBtns[1].classes()).not.toContain('active')
|
||||
})
|
||||
|
||||
it('marks "Every X Days" button active after switching', async () => {
|
||||
const w = mountModal()
|
||||
const modeBtns = w.findAll('.mode-btn')
|
||||
await modeBtns[1].trigger('click')
|
||||
expect(modeBtns[1].classes()).toContain('active')
|
||||
expect(modeBtns[0].classes()).not.toContain('active')
|
||||
})
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Specific Days form — check/uncheck days
|
||||
// ---------------------------------------------------------------------------
|
||||
describe('ScheduleModal Specific Days form', () => {
|
||||
it('renders 7 day rows', () => {
|
||||
const w = mountModal()
|
||||
expect(w.findAll('.day-row').length).toBe(7)
|
||||
})
|
||||
|
||||
it('no days are checked by default (no existing schedule)', () => {
|
||||
const w = mountModal()
|
||||
const checkboxes = w.findAll<HTMLInputElement>('input[type="checkbox"]')
|
||||
expect(checkboxes.every((cb) => !(cb.element as HTMLInputElement).checked)).toBe(true)
|
||||
})
|
||||
|
||||
it('checking a day reveals a TimeSelector for that day', async () => {
|
||||
const w = mountModal()
|
||||
expect(w.findAll('.time-selector-stub').length).toBe(0)
|
||||
const checkboxes = w.findAll('input[type="checkbox"]')
|
||||
await checkboxes[1].trigger('change') // Monday (idx 1)
|
||||
await nextTick()
|
||||
expect(w.findAll('.time-selector-stub').length).toBe(1)
|
||||
})
|
||||
|
||||
it('unchecking a day removes its TimeSelector', async () => {
|
||||
const w = mountModal()
|
||||
const checkboxes = w.findAll('input[type="checkbox"]')
|
||||
await checkboxes[0].trigger('change') // check Sunday
|
||||
await nextTick()
|
||||
expect(w.findAll('.time-selector-stub').length).toBe(1)
|
||||
await checkboxes[0].trigger('change') // uncheck Sunday
|
||||
await nextTick()
|
||||
expect(w.findAll('.time-selector-stub').length).toBe(0)
|
||||
})
|
||||
|
||||
it('Save is disabled when no days checked (days mode)', () => {
|
||||
const w = mountModal()
|
||||
const saveBtn = w.find('.btn-primary')
|
||||
expect((saveBtn.element as HTMLButtonElement).disabled).toBe(true)
|
||||
})
|
||||
|
||||
it('Save is enabled after checking at least one day', async () => {
|
||||
const w = mountModal()
|
||||
const checkboxes = w.findAll('input[type="checkbox"]')
|
||||
await checkboxes[2].trigger('change') // Tuesday
|
||||
await nextTick()
|
||||
const saveBtn = w.find('.btn-primary')
|
||||
expect((saveBtn.element as HTMLButtonElement).disabled).toBe(false)
|
||||
})
|
||||
|
||||
it('pre-populates checked days from an existing schedule', () => {
|
||||
const existing: ChoreSchedule = {
|
||||
child_id: CHILD_ID,
|
||||
task_id: TASK.id,
|
||||
mode: 'days',
|
||||
day_configs: [
|
||||
{ day: 1, hour: 8, minute: 0 },
|
||||
{ day: 4, hour: 9, minute: 30 },
|
||||
],
|
||||
interval_days: 2,
|
||||
anchor_weekday: 0,
|
||||
interval_hour: 0,
|
||||
interval_minute: 0,
|
||||
}
|
||||
const w = mountModal(existing)
|
||||
// Two TimeSelectorStubs should already be visible
|
||||
expect(w.findAll('.time-selector-stub').length).toBe(2)
|
||||
})
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Save emits correct ChoreSchedule shape — days mode
|
||||
// ---------------------------------------------------------------------------
|
||||
describe('ScheduleModal save — days mode', () => {
|
||||
it('calls setChoreSchedule with correct days payload', async () => {
|
||||
const w = mountModal()
|
||||
// Check Monday (idx 1)
|
||||
const checkboxes = w.findAll('input[type="checkbox"]')
|
||||
await checkboxes[1].trigger('change')
|
||||
await nextTick()
|
||||
|
||||
await w.find('.btn-primary').trigger('click')
|
||||
await nextTick()
|
||||
|
||||
expect(mockSetChoreSchedule).toHaveBeenCalledWith(
|
||||
CHILD_ID,
|
||||
TASK.id,
|
||||
expect.objectContaining({
|
||||
mode: 'days',
|
||||
day_configs: [{ day: 1, hour: 8, minute: 0 }],
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
it('emits "saved" after successful save', async () => {
|
||||
const w = mountModal()
|
||||
const checkboxes = w.findAll('input[type="checkbox"]')
|
||||
await checkboxes[0].trigger('change') // check Sunday
|
||||
await nextTick()
|
||||
|
||||
await w.find('.btn-primary').trigger('click')
|
||||
await nextTick()
|
||||
await nextTick() // await the async save()
|
||||
|
||||
expect(w.emitted('saved')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('does not emit "saved" on API error', async () => {
|
||||
mockSetChoreSchedule.mockResolvedValue({ ok: false })
|
||||
const w = mountModal()
|
||||
const checkboxes = w.findAll('input[type="checkbox"]')
|
||||
await checkboxes[0].trigger('change')
|
||||
await nextTick()
|
||||
|
||||
await w.find('.btn-primary').trigger('click')
|
||||
await nextTick()
|
||||
await nextTick()
|
||||
|
||||
expect(w.emitted('saved')).toBeFalsy()
|
||||
})
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Save emits correct ChoreSchedule shape — interval mode
|
||||
// ---------------------------------------------------------------------------
|
||||
describe('ScheduleModal save — interval mode', () => {
|
||||
it('calls setChoreSchedule with correct interval payload', async () => {
|
||||
const w = mountModal()
|
||||
// Switch to interval mode
|
||||
await w.findAll('.mode-btn')[1].trigger('click')
|
||||
|
||||
// Set interval_days input to 3
|
||||
const intervalInput = w.find<HTMLInputElement>('.interval-input')
|
||||
await intervalInput.setValue(3)
|
||||
|
||||
// Set anchor_weekday to 2 (Tuesday)
|
||||
const anchorSelect = w.find<HTMLSelectElement>('.anchor-select')
|
||||
await anchorSelect.setValue(2)
|
||||
|
||||
await w.find('.btn-primary').trigger('click')
|
||||
await nextTick()
|
||||
|
||||
expect(mockSetChoreSchedule).toHaveBeenCalledWith(
|
||||
CHILD_ID,
|
||||
TASK.id,
|
||||
expect.objectContaining({
|
||||
mode: 'interval',
|
||||
interval_days: 3,
|
||||
anchor_weekday: 2,
|
||||
interval_hour: expect.any(Number),
|
||||
interval_minute: expect.any(Number),
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
it('Save enabled by default in interval mode (interval_days defaults to 2)', async () => {
|
||||
const w = mountModal()
|
||||
await w.findAll('.mode-btn')[1].trigger('click')
|
||||
const saveBtn = w.find('.btn-primary')
|
||||
expect((saveBtn.element as HTMLButtonElement).disabled).toBe(false)
|
||||
})
|
||||
|
||||
it('pre-populates interval fields from existing interval schedule', () => {
|
||||
const existing: ChoreSchedule = {
|
||||
child_id: CHILD_ID,
|
||||
task_id: TASK.id,
|
||||
mode: 'interval',
|
||||
day_configs: [],
|
||||
interval_days: 4,
|
||||
anchor_weekday: 3,
|
||||
interval_hour: 14,
|
||||
interval_minute: 30,
|
||||
}
|
||||
const w = mountModal(existing)
|
||||
// Should default to interval mode
|
||||
expect(w.find('.interval-form').exists()).toBe(true)
|
||||
const input = w.find<HTMLInputElement>('.interval-input')
|
||||
expect(Number((input.element as HTMLInputElement).value)).toBe(4)
|
||||
const select = w.find<HTMLSelectElement>('.anchor-select')
|
||||
expect(Number((select.element as HTMLSelectElement).value)).toBe(3)
|
||||
})
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Cancel
|
||||
// ---------------------------------------------------------------------------
|
||||
describe('ScheduleModal cancel', () => {
|
||||
it('emits "cancelled" when Cancel is clicked', async () => {
|
||||
const w = mountModal()
|
||||
await w.find('.btn-secondary').trigger('click')
|
||||
expect(w.emitted('cancelled')).toBeTruthy()
|
||||
})
|
||||
})
|
||||
154
frontend/vue-app/src/__tests__/TimeSelector.spec.ts
Normal file
154
frontend/vue-app/src/__tests__/TimeSelector.spec.ts
Normal file
@@ -0,0 +1,154 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { mount } from '@vue/test-utils'
|
||||
import TimeSelector from '../components/shared/TimeSelector.vue'
|
||||
|
||||
function mk(hour: number, minute: number) {
|
||||
return mount(TimeSelector, { props: { modelValue: { hour, minute } } })
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Display
|
||||
// ---------------------------------------------------------------------------
|
||||
describe('TimeSelector display', () => {
|
||||
it('shows 12 for midnight (hour=0)', () => {
|
||||
const w = mk(0, 0)
|
||||
expect(w.findAll('.time-value')[0].text()).toBe('12')
|
||||
expect(w.find('.ampm-btn').text()).toBe('AM')
|
||||
})
|
||||
|
||||
it('shows 12 for noon (hour=12)', () => {
|
||||
const w = mk(12, 0)
|
||||
expect(w.findAll('.time-value')[0].text()).toBe('12')
|
||||
expect(w.find('.ampm-btn').text()).toBe('PM')
|
||||
})
|
||||
|
||||
it('shows 3:30 PM for hour=15 minute=30', () => {
|
||||
const w = mk(15, 30)
|
||||
const cols = w.findAll('.time-value')
|
||||
expect(cols[0].text()).toBe('3')
|
||||
expect(cols[1].text()).toBe('30')
|
||||
expect(w.find('.ampm-btn').text()).toBe('PM')
|
||||
})
|
||||
|
||||
it('pads single-digit minutes', () => {
|
||||
const w = mk(8, 0)
|
||||
expect(w.findAll('.time-value')[1].text()).toBe('00')
|
||||
})
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Increment / Decrement hour
|
||||
// ---------------------------------------------------------------------------
|
||||
describe('TimeSelector hour increment/decrement', () => {
|
||||
it('emits hour+1 when increment hour clicked', async () => {
|
||||
const w = mk(8, 15)
|
||||
await w.findAll('.arrow-btn')[0].trigger('click')
|
||||
const emitted = w.emitted('update:modelValue') as any[]
|
||||
expect(emitted[0][0]).toEqual({ hour: 9, minute: 15 })
|
||||
})
|
||||
|
||||
it('emits hour-1 when decrement hour clicked', async () => {
|
||||
const w = mk(8, 15)
|
||||
await w.findAll('.arrow-btn')[1].trigger('click')
|
||||
const emitted = w.emitted('update:modelValue') as any[]
|
||||
expect(emitted[0][0]).toEqual({ hour: 7, minute: 15 })
|
||||
})
|
||||
|
||||
it('wraps hour 23 → 0 on increment', async () => {
|
||||
const w = mk(23, 0)
|
||||
await w.findAll('.arrow-btn')[0].trigger('click')
|
||||
expect(w.emitted('update:modelValue')![0][0]).toEqual({ hour: 0, minute: 0 })
|
||||
})
|
||||
|
||||
it('wraps hour 0 → 23 on decrement', async () => {
|
||||
const w = mk(0, 0)
|
||||
await w.findAll('.arrow-btn')[1].trigger('click')
|
||||
expect(w.emitted('update:modelValue')![0][0]).toEqual({ hour: 23, minute: 0 })
|
||||
})
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Increment / Decrement minute (15-min steps)
|
||||
// ---------------------------------------------------------------------------
|
||||
describe('TimeSelector minute increment/decrement', () => {
|
||||
it('emits minute+15 on increment', async () => {
|
||||
const w = mk(8, 0)
|
||||
await w.findAll('.arrow-btn')[2].trigger('click')
|
||||
expect(w.emitted('update:modelValue')![0][0]).toEqual({ hour: 8, minute: 15 })
|
||||
})
|
||||
|
||||
it('emits minute-15 on decrement', async () => {
|
||||
const w = mk(8, 30)
|
||||
await w.findAll('.arrow-btn')[3].trigger('click')
|
||||
expect(w.emitted('update:modelValue')![0][0]).toEqual({ hour: 8, minute: 15 })
|
||||
})
|
||||
|
||||
it('minute 45 → 0 and carry hour on increment', async () => {
|
||||
const w = mk(8, 45)
|
||||
await w.findAll('.arrow-btn')[2].trigger('click')
|
||||
expect(w.emitted('update:modelValue')![0][0]).toEqual({ hour: 9, minute: 0 })
|
||||
})
|
||||
|
||||
it('minute 0 → 45 and borrow hour on decrement', async () => {
|
||||
const w = mk(8, 0)
|
||||
await w.findAll('.arrow-btn')[3].trigger('click')
|
||||
expect(w.emitted('update:modelValue')![0][0]).toEqual({ hour: 7, minute: 45 })
|
||||
})
|
||||
|
||||
// The key boundary case from the spec: 11:45 AM → 12:00 PM
|
||||
it('11:45 AM (hour=11, minute=45) → increment minute → 12:00 PM (hour=12)', async () => {
|
||||
const w = mk(11, 45)
|
||||
await w.findAll('.arrow-btn')[2].trigger('click')
|
||||
expect(w.emitted('update:modelValue')![0][0]).toEqual({ hour: 12, minute: 0 })
|
||||
})
|
||||
|
||||
// Inverse: 12:00 PM (hour=12) → decrement minute → 11:45 AM (hour=11, minute=45)
|
||||
it('12:00 PM (hour=12, minute=0) → decrement minute → 11:45 (hour=11)', async () => {
|
||||
const w = mk(12, 0)
|
||||
await w.findAll('.arrow-btn')[3].trigger('click')
|
||||
expect(w.emitted('update:modelValue')![0][0]).toEqual({ hour: 11, minute: 45 })
|
||||
})
|
||||
|
||||
// Midnight boundary: 0:00 → decrement → 23:45
|
||||
it('0:00 AM (hour=0, minute=0) → decrement minute → 23:45', async () => {
|
||||
const w = mk(0, 0)
|
||||
await w.findAll('.arrow-btn')[3].trigger('click')
|
||||
expect(w.emitted('update:modelValue')![0][0]).toEqual({ hour: 23, minute: 45 })
|
||||
})
|
||||
|
||||
// 23:45 → increment → 0:00
|
||||
it('23:45 → increment minute → 0:00', async () => {
|
||||
const w = mk(23, 45)
|
||||
await w.findAll('.arrow-btn')[2].trigger('click')
|
||||
expect(w.emitted('update:modelValue')![0][0]).toEqual({ hour: 0, minute: 0 })
|
||||
})
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// AM/PM toggle
|
||||
// ---------------------------------------------------------------------------
|
||||
describe('TimeSelector AM/PM toggle', () => {
|
||||
it('toggles AM → PM (hour < 12 adds 12)', async () => {
|
||||
const w = mk(8, 0)
|
||||
await w.find('.ampm-btn').trigger('click')
|
||||
expect(w.emitted('update:modelValue')![0][0]).toEqual({ hour: 20, minute: 0 })
|
||||
})
|
||||
|
||||
it('toggles PM → AM (hour >= 12 subtracts 12)', async () => {
|
||||
const w = mk(14, 30)
|
||||
await w.find('.ampm-btn').trigger('click')
|
||||
expect(w.emitted('update:modelValue')![0][0]).toEqual({ hour: 2, minute: 30 })
|
||||
})
|
||||
|
||||
it('toggles midnight (0) → 12 (noon)', async () => {
|
||||
const w = mk(0, 0)
|
||||
await w.find('.ampm-btn').trigger('click')
|
||||
expect(w.emitted('update:modelValue')![0][0]).toEqual({ hour: 12, minute: 0 })
|
||||
})
|
||||
|
||||
it('toggles noon (12) → 0 (midnight)', async () => {
|
||||
const w = mk(12, 0)
|
||||
await w.find('.ampm-btn').trigger('click')
|
||||
expect(w.emitted('update:modelValue')![0][0]).toEqual({ hour: 0, minute: 0 })
|
||||
})
|
||||
})
|
||||
254
frontend/vue-app/src/__tests__/scheduleUtils.spec.ts
Normal file
254
frontend/vue-app/src/__tests__/scheduleUtils.spec.ts
Normal file
@@ -0,0 +1,254 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import {
|
||||
intervalHitsToday,
|
||||
isScheduledToday,
|
||||
getDueTimeToday,
|
||||
isPastTime,
|
||||
isExtendedToday,
|
||||
formatDueTimeLabel,
|
||||
msUntilExpiry,
|
||||
toLocalISODate,
|
||||
} from '../common/scheduleUtils'
|
||||
import type { ChoreSchedule } from '../common/models'
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// toLocalISODate
|
||||
// ---------------------------------------------------------------------------
|
||||
describe('toLocalISODate', () => {
|
||||
it('formats a date as YYYY-MM-DD', () => {
|
||||
const d = new Date(2025, 0, 5) // Jan 5, 2025 local
|
||||
expect(toLocalISODate(d)).toBe('2025-01-05')
|
||||
})
|
||||
|
||||
it('pads month and day', () => {
|
||||
const d = new Date(2025, 2, 9) // Mar 9
|
||||
expect(toLocalISODate(d)).toBe('2025-03-09')
|
||||
})
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// intervalHitsToday
|
||||
// ---------------------------------------------------------------------------
|
||||
describe('intervalHitsToday', () => {
|
||||
it('anchor day hits on itself', () => {
|
||||
// Wednesday = weekday 3
|
||||
const wednesday = new Date(2025, 0, 8) // Jan 8, 2025 is a Wednesday
|
||||
expect(intervalHitsToday(3, 2, wednesday)).toBe(true)
|
||||
})
|
||||
|
||||
it('anchor=Wednesday, interval=2, Thursday does NOT hit', () => {
|
||||
const thursday = new Date(2025, 0, 9)
|
||||
expect(intervalHitsToday(3, 2, thursday)).toBe(false)
|
||||
})
|
||||
|
||||
it('anchor=Wednesday, interval=2, Friday hits (2 days after anchor)', () => {
|
||||
const friday = new Date(2025, 0, 10)
|
||||
expect(intervalHitsToday(3, 2, friday)).toBe(true)
|
||||
})
|
||||
|
||||
it('interval=1 hits every day', () => {
|
||||
// interval of 1 means every day; diffDays % 1 === 0 always
|
||||
// Note: interval_days=1 is disallowed in UI (min 2) but logic should still work
|
||||
const monday = new Date(2025, 0, 6)
|
||||
expect(intervalHitsToday(1, 1, monday)).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// isScheduledToday
|
||||
// ---------------------------------------------------------------------------
|
||||
describe('isScheduledToday', () => {
|
||||
const daysSchedule: ChoreSchedule = {
|
||||
child_id: 'c1',
|
||||
task_id: 't1',
|
||||
mode: 'days',
|
||||
day_configs: [
|
||||
{ day: 1, hour: 8, minute: 0 }, // Monday
|
||||
{ day: 3, hour: 9, minute: 30 }, // Wednesday
|
||||
],
|
||||
interval_days: 2,
|
||||
anchor_weekday: 0,
|
||||
interval_hour: 0,
|
||||
interval_minute: 0,
|
||||
}
|
||||
|
||||
it('returns true on a scheduled weekday', () => {
|
||||
const monday = new Date(2025, 0, 6) // Jan 6, 2025 = Monday
|
||||
expect(isScheduledToday(daysSchedule, monday)).toBe(true)
|
||||
})
|
||||
|
||||
it('returns false on an unscheduled weekday', () => {
|
||||
const tuesday = new Date(2025, 0, 7) // Tuesday
|
||||
expect(isScheduledToday(daysSchedule, tuesday)).toBe(false)
|
||||
})
|
||||
|
||||
it('interval mode delegates to intervalHitsToday', () => {
|
||||
const intervalSchedule: ChoreSchedule = {
|
||||
child_id: 'c1',
|
||||
task_id: 't1',
|
||||
mode: 'interval',
|
||||
day_configs: [],
|
||||
interval_days: 2,
|
||||
anchor_weekday: 3, // Wednesday
|
||||
interval_hour: 8,
|
||||
interval_minute: 0,
|
||||
}
|
||||
const wednesday = new Date(2025, 0, 8)
|
||||
expect(isScheduledToday(intervalSchedule, wednesday)).toBe(true)
|
||||
const thursday = new Date(2025, 0, 9)
|
||||
expect(isScheduledToday(intervalSchedule, thursday)).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// getDueTimeToday
|
||||
// ---------------------------------------------------------------------------
|
||||
describe('getDueTimeToday', () => {
|
||||
const daysSchedule: ChoreSchedule = {
|
||||
child_id: 'c1',
|
||||
task_id: 't1',
|
||||
mode: 'days',
|
||||
day_configs: [{ day: 1, hour: 8, minute: 30 }],
|
||||
interval_days: 2,
|
||||
anchor_weekday: 0,
|
||||
interval_hour: 0,
|
||||
interval_minute: 0,
|
||||
}
|
||||
|
||||
it('returns correct time for a scheduled day', () => {
|
||||
const monday = new Date(2025, 0, 6)
|
||||
expect(getDueTimeToday(daysSchedule, monday)).toEqual({ hour: 8, minute: 30 })
|
||||
})
|
||||
|
||||
it('returns null for an unscheduled day', () => {
|
||||
const tuesday = new Date(2025, 0, 7)
|
||||
expect(getDueTimeToday(daysSchedule, tuesday)).toBeNull()
|
||||
})
|
||||
|
||||
it('interval mode returns interval time on hit days', () => {
|
||||
const intervalSchedule: ChoreSchedule = {
|
||||
child_id: 'c1',
|
||||
task_id: 't1',
|
||||
mode: 'interval',
|
||||
day_configs: [],
|
||||
interval_days: 2,
|
||||
anchor_weekday: 3,
|
||||
interval_hour: 14,
|
||||
interval_minute: 45,
|
||||
}
|
||||
const wednesday = new Date(2025, 0, 8)
|
||||
expect(getDueTimeToday(intervalSchedule, wednesday)).toEqual({ hour: 14, minute: 45 })
|
||||
})
|
||||
|
||||
it('interval mode returns null on non-hit days', () => {
|
||||
const intervalSchedule: ChoreSchedule = {
|
||||
child_id: 'c1',
|
||||
task_id: 't1',
|
||||
mode: 'interval',
|
||||
day_configs: [],
|
||||
interval_days: 2,
|
||||
anchor_weekday: 3,
|
||||
interval_hour: 14,
|
||||
interval_minute: 45,
|
||||
}
|
||||
const thursday = new Date(2025, 0, 9)
|
||||
expect(getDueTimeToday(intervalSchedule, thursday)).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// isPastTime
|
||||
// ---------------------------------------------------------------------------
|
||||
describe('isPastTime', () => {
|
||||
it('returns true when current time equals due time', () => {
|
||||
const now = new Date(2025, 0, 6, 8, 30, 0)
|
||||
expect(isPastTime(8, 30, now)).toBe(true)
|
||||
})
|
||||
|
||||
it('returns true when current time is past due time', () => {
|
||||
const now = new Date(2025, 0, 6, 9, 0, 0)
|
||||
expect(isPastTime(8, 30, now)).toBe(true)
|
||||
})
|
||||
|
||||
it('returns false when current time is before due time', () => {
|
||||
const now = new Date(2025, 0, 6, 8, 0, 0)
|
||||
expect(isPastTime(8, 30, now)).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// isExtendedToday
|
||||
// ---------------------------------------------------------------------------
|
||||
describe('isExtendedToday', () => {
|
||||
it('returns true when extension date matches today', () => {
|
||||
const today = new Date(2025, 0, 15)
|
||||
expect(isExtendedToday('2025-01-15', today)).toBe(true)
|
||||
})
|
||||
|
||||
it('returns false when extension date does not match today', () => {
|
||||
const today = new Date(2025, 0, 15)
|
||||
expect(isExtendedToday('2025-01-14', today)).toBe(false)
|
||||
})
|
||||
|
||||
it('returns false for null extension date', () => {
|
||||
const today = new Date(2025, 0, 15)
|
||||
expect(isExtendedToday(null, today)).toBe(false)
|
||||
})
|
||||
|
||||
it('returns false for undefined extension date', () => {
|
||||
const today = new Date(2025, 0, 15)
|
||||
expect(isExtendedToday(undefined, today)).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// formatDueTimeLabel
|
||||
// ---------------------------------------------------------------------------
|
||||
describe('formatDueTimeLabel', () => {
|
||||
it('formats midnight as 12:00 AM', () => {
|
||||
expect(formatDueTimeLabel(0, 0)).toBe('12:00 AM')
|
||||
})
|
||||
|
||||
it('formats noon as 12:00 PM', () => {
|
||||
expect(formatDueTimeLabel(12, 0)).toBe('12:00 PM')
|
||||
})
|
||||
|
||||
it('formats 8:30 AM', () => {
|
||||
expect(formatDueTimeLabel(8, 30)).toBe('8:30 AM')
|
||||
})
|
||||
|
||||
it('formats 14:45 as 2:45 PM', () => {
|
||||
expect(formatDueTimeLabel(14, 45)).toBe('2:45 PM')
|
||||
})
|
||||
|
||||
it('pads single-digit minutes', () => {
|
||||
expect(formatDueTimeLabel(9, 5)).toBe('9:05 AM')
|
||||
})
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// msUntilExpiry
|
||||
// ---------------------------------------------------------------------------
|
||||
describe('msUntilExpiry', () => {
|
||||
it('returns positive ms when due time is in the future', () => {
|
||||
const now = new Date(2025, 0, 6, 8, 0, 0, 0)
|
||||
const ms = msUntilExpiry(8, 30, now)
|
||||
expect(ms).toBe(30 * 60 * 1000) // 30 minutes
|
||||
})
|
||||
|
||||
it('returns 0 when due time is in the past', () => {
|
||||
const now = new Date(2025, 0, 6, 9, 0, 0, 0)
|
||||
expect(msUntilExpiry(8, 30, now)).toBe(0)
|
||||
})
|
||||
|
||||
it('returns 0 when due time equals current time', () => {
|
||||
const now = new Date(2025, 0, 6, 8, 30, 0, 0)
|
||||
expect(msUntilExpiry(8, 30, now)).toBe(0)
|
||||
})
|
||||
|
||||
it('correctly handles seconds and milliseconds offset', () => {
|
||||
// 8:29:30.500 → due at 8:30:00.000 = 29500ms remaining
|
||||
const now = new Date(2025, 0, 6, 8, 29, 30, 500)
|
||||
expect(msUntilExpiry(8, 30, now)).toBe(29500)
|
||||
})
|
||||
})
|
||||
@@ -111,3 +111,52 @@ export async function deleteChildOverride(childId: string, entityId: string): Pr
|
||||
method: 'DELETE',
|
||||
})
|
||||
}
|
||||
|
||||
// ── Chore Schedule API ──────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Get the schedule for a specific child + task pair.
|
||||
*/
|
||||
export async function getChoreSchedule(childId: string, taskId: string): Promise<Response> {
|
||||
return fetch(`/api/child/${childId}/task/${taskId}/schedule`)
|
||||
}
|
||||
|
||||
/**
|
||||
* Create or replace the schedule for a specific child + task pair.
|
||||
*/
|
||||
export async function setChoreSchedule(
|
||||
childId: string,
|
||||
taskId: string,
|
||||
schedule: object,
|
||||
): Promise<Response> {
|
||||
return fetch(`/api/child/${childId}/task/${taskId}/schedule`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(schedule),
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete the schedule for a specific child + task pair.
|
||||
*/
|
||||
export async function deleteChoreSchedule(childId: string, taskId: string): Promise<Response> {
|
||||
return fetch(`/api/child/${childId}/task/${taskId}/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').
|
||||
*/
|
||||
export async function extendChoreTime(
|
||||
childId: string,
|
||||
taskId: string,
|
||||
localDate: string,
|
||||
): Promise<Response> {
|
||||
return fetch(`/api/child/${childId}/task/${taskId}/extend`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ date: localDate }),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -8,6 +8,40 @@ export interface Task {
|
||||
}
|
||||
export const TASK_FIELDS = ['id', 'name', 'points', 'is_good', 'image_id'] as const
|
||||
|
||||
export interface DayConfig {
|
||||
day: number // 0=Sun, 1=Mon, ..., 6=Sat
|
||||
hour: number // 0–23 (24h)
|
||||
minute: number // 0, 15, 30, or 45
|
||||
}
|
||||
|
||||
export interface ChoreSchedule {
|
||||
id: string
|
||||
child_id: string
|
||||
task_id: string
|
||||
mode: 'days' | 'interval'
|
||||
// mode='days'
|
||||
day_configs: DayConfig[]
|
||||
// mode='interval'
|
||||
interval_days: number // 2–7
|
||||
anchor_weekday: number // 0=Sun–6=Sat
|
||||
interval_hour: number
|
||||
interval_minute: number
|
||||
created_at: number
|
||||
updated_at: number
|
||||
}
|
||||
|
||||
export interface ChildTask {
|
||||
id: string
|
||||
name: string
|
||||
is_good: boolean
|
||||
points: number
|
||||
image_id: string | null
|
||||
image_url?: string | null
|
||||
custom_value?: number | null
|
||||
schedule?: ChoreSchedule | null
|
||||
extension_date?: string | null // ISO date of today's extension, if any
|
||||
}
|
||||
|
||||
export interface User {
|
||||
id: string
|
||||
first_name: string
|
||||
@@ -97,6 +131,8 @@ export interface Event {
|
||||
| TrackingEventCreatedPayload
|
||||
| ChildOverrideSetPayload
|
||||
| ChildOverrideDeletedPayload
|
||||
| ChoreScheduleModifiedPayload
|
||||
| ChoreTimeExtendedPayload
|
||||
}
|
||||
|
||||
export interface ChildModifiedEventPayload {
|
||||
@@ -213,3 +249,14 @@ export const CHILD_OVERRIDE_FIELDS = [
|
||||
'created_at',
|
||||
'updated_at',
|
||||
] as const
|
||||
|
||||
export interface ChoreScheduleModifiedPayload {
|
||||
child_id: string
|
||||
task_id: string
|
||||
operation: 'SET' | 'DELETED'
|
||||
}
|
||||
|
||||
export interface ChoreTimeExtendedPayload {
|
||||
child_id: string
|
||||
task_id: string
|
||||
}
|
||||
|
||||
123
frontend/vue-app/src/common/scheduleUtils.ts
Normal file
123
frontend/vue-app/src/common/scheduleUtils.ts
Normal file
@@ -0,0 +1,123 @@
|
||||
import type { ChoreSchedule, DayConfig } from './models'
|
||||
|
||||
/**
|
||||
* Returns the JS day-of-week index for a given Date.
|
||||
* 0 = Sunday, 6 = Saturday (matches Python / our DayConfig.day convention).
|
||||
*/
|
||||
function getLocalWeekday(d: Date): number {
|
||||
return d.getDay()
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true if the interval schedule hits on the given localDate.
|
||||
*
|
||||
* Anchor: the most recent occurrence of `anchorWeekday` on or before today (this week).
|
||||
* Pattern: every `intervalDays` days starting from that anchor.
|
||||
*/
|
||||
export function intervalHitsToday(
|
||||
anchorWeekday: number,
|
||||
intervalDays: number,
|
||||
localDate: Date,
|
||||
): boolean {
|
||||
const todayWeekday = getLocalWeekday(localDate)
|
||||
|
||||
// Find the most recent anchorWeekday on or before today within the current week.
|
||||
// We calculate the anchor as: (today - daysSinceAnchor)
|
||||
const daysSinceAnchor = (todayWeekday - anchorWeekday + 7) % 7
|
||||
const anchor = new Date(localDate)
|
||||
anchor.setDate(anchor.getDate() - daysSinceAnchor)
|
||||
anchor.setHours(0, 0, 0, 0)
|
||||
|
||||
const today = new Date(localDate)
|
||||
today.setHours(0, 0, 0, 0)
|
||||
|
||||
const diffDays = Math.round((today.getTime() - anchor.getTime()) / 86400000)
|
||||
return diffDays % intervalDays === 0
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true if the schedule applies today (localDate).
|
||||
* - 'days' mode: today's weekday is in day_configs
|
||||
* - 'interval' mode: intervalHitsToday
|
||||
*/
|
||||
export function isScheduledToday(schedule: ChoreSchedule, localDate: Date): boolean {
|
||||
if (schedule.mode === 'days') {
|
||||
const todayWeekday = getLocalWeekday(localDate)
|
||||
return schedule.day_configs.some((dc: DayConfig) => dc.day === todayWeekday)
|
||||
} else {
|
||||
return intervalHitsToday(schedule.anchor_weekday, schedule.interval_days, localDate)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the due time {hour, minute} for today, or null if no due time is configured
|
||||
* for today (e.g. the day is not scheduled).
|
||||
*/
|
||||
export function getDueTimeToday(
|
||||
schedule: ChoreSchedule,
|
||||
localDate: Date,
|
||||
): { hour: number; minute: number } | null {
|
||||
if (schedule.mode === 'days') {
|
||||
const todayWeekday = getLocalWeekday(localDate)
|
||||
const dayConfig = schedule.day_configs.find((dc: DayConfig) => dc.day === todayWeekday)
|
||||
if (!dayConfig) return null
|
||||
return { hour: dayConfig.hour, minute: dayConfig.minute }
|
||||
} else {
|
||||
if (!intervalHitsToday(schedule.anchor_weekday, schedule.interval_days, localDate)) return null
|
||||
return { hour: schedule.interval_hour, minute: schedule.interval_minute }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true if the given due time has already passed given the current local time.
|
||||
*/
|
||||
export function isPastTime(dueHour: number, dueMinute: number, localNow: Date): boolean {
|
||||
const nowMinutes = localNow.getHours() * 60 + localNow.getMinutes()
|
||||
const dueMinutes = dueHour * 60 + dueMinute
|
||||
return nowMinutes >= dueMinutes
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true if extensionDate matches today's local ISO date string.
|
||||
*/
|
||||
export function isExtendedToday(
|
||||
extensionDate: string | null | undefined,
|
||||
localDate: Date,
|
||||
): boolean {
|
||||
if (!extensionDate) return false
|
||||
return extensionDate === toLocalISODate(localDate)
|
||||
}
|
||||
|
||||
/**
|
||||
* Formats a 24h hour/minute pair into a 12h "X:XX AM/PM" label.
|
||||
*/
|
||||
export function formatDueTimeLabel(hour: number, minute: number): string {
|
||||
const period = hour < 12 ? 'AM' : 'PM'
|
||||
const h12 = hour % 12 === 0 ? 12 : hour % 12
|
||||
const mm = String(minute).padStart(2, '0')
|
||||
return `${h12}:${mm} ${period}`
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the number of milliseconds from localNow until the due time fires today.
|
||||
* Returns 0 if it has already passed.
|
||||
*/
|
||||
export function msUntilExpiry(dueHour: number, dueMinute: number, localNow: Date): number {
|
||||
const nowMs =
|
||||
localNow.getHours() * 3600000 +
|
||||
localNow.getMinutes() * 60000 +
|
||||
localNow.getSeconds() * 1000 +
|
||||
localNow.getMilliseconds()
|
||||
const dueMs = dueHour * 3600000 + dueMinute * 60000
|
||||
return Math.max(0, dueMs - nowMs)
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the local date as an ISO date string (YYYY-MM-DD) without timezone conversion.
|
||||
*/
|
||||
export function toLocalISODate(d: Date): string {
|
||||
const year = d.getFullYear()
|
||||
const month = String(d.getMonth() + 1).padStart(2, '0')
|
||||
const day = String(d.getDate()).padStart(2, '0')
|
||||
return `${year}-${month}-${day}`
|
||||
}
|
||||
@@ -14,6 +14,7 @@ import type {
|
||||
Event,
|
||||
Task,
|
||||
RewardStatus,
|
||||
ChildTask,
|
||||
ChildTaskTriggeredEventPayload,
|
||||
ChildRewardTriggeredEventPayload,
|
||||
ChildRewardRequestEventPayload,
|
||||
@@ -22,7 +23,18 @@ import type {
|
||||
TaskModifiedEventPayload,
|
||||
RewardModifiedEventPayload,
|
||||
ChildModifiedEventPayload,
|
||||
ChoreScheduleModifiedPayload,
|
||||
ChoreTimeExtendedPayload,
|
||||
} from '@/common/models'
|
||||
import {
|
||||
isScheduledToday,
|
||||
isPastTime,
|
||||
getDueTimeToday,
|
||||
formatDueTimeLabel,
|
||||
msUntilExpiry,
|
||||
isExtendedToday,
|
||||
toLocalISODate,
|
||||
} from '@/common/scheduleUtils'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
@@ -290,12 +302,90 @@ function removeInactivityListeners() {
|
||||
if (inactivityTimer) clearTimeout(inactivityTimer)
|
||||
}
|
||||
|
||||
const childChoreListRef = ref()
|
||||
const readyItemId = ref<string | null>(null)
|
||||
const expiryTimers = ref<number[]>([])
|
||||
const lastFetchDate = ref<string>(toLocalISODate(new Date()))
|
||||
|
||||
function handleItemReady(itemId: string) {
|
||||
readyItemId.value = itemId
|
||||
}
|
||||
|
||||
function handleChoreScheduleModified(event: Event) {
|
||||
const payload = event.payload as ChoreScheduleModifiedPayload
|
||||
if (child.value && payload.child_id === child.value.id) {
|
||||
childChoreListRef.value?.refresh()
|
||||
setTimeout(() => resetExpiryTimers(), 300)
|
||||
}
|
||||
}
|
||||
|
||||
function handleChoreTimeExtended(event: Event) {
|
||||
const payload = event.payload as ChoreTimeExtendedPayload
|
||||
if (child.value && payload.child_id === child.value.id) {
|
||||
childChoreListRef.value?.refresh()
|
||||
setTimeout(() => resetExpiryTimers(), 300)
|
||||
}
|
||||
}
|
||||
|
||||
function isChoreScheduledToday(item: ChildTask): boolean {
|
||||
if (!item.schedule) return true
|
||||
const today = new Date()
|
||||
return isScheduledToday(item.schedule, today)
|
||||
}
|
||||
|
||||
function isChoreExpired(item: ChildTask): boolean {
|
||||
if (!item.schedule) return false
|
||||
const today = new Date()
|
||||
const due = getDueTimeToday(item.schedule, today)
|
||||
if (!due) return false
|
||||
if (item.extension_date && isExtendedToday(item.extension_date, today)) return false
|
||||
return isPastTime(due.hour, due.minute, today)
|
||||
}
|
||||
|
||||
function choreDueLabel(item: ChildTask): string | null {
|
||||
if (!item.schedule) return null
|
||||
const today = new Date()
|
||||
const due = getDueTimeToday(item.schedule, today)
|
||||
if (!due) return null
|
||||
if (item.extension_date && isExtendedToday(item.extension_date, today)) return null
|
||||
return formatDueTimeLabel(due.hour, due.minute)
|
||||
}
|
||||
|
||||
function clearExpiryTimers() {
|
||||
expiryTimers.value.forEach((t) => clearTimeout(t))
|
||||
expiryTimers.value = []
|
||||
}
|
||||
|
||||
function resetExpiryTimers() {
|
||||
clearExpiryTimers()
|
||||
const items: ChildTask[] = childChoreListRef.value?.items ?? []
|
||||
const now = new Date()
|
||||
for (const item of items) {
|
||||
if (!item.schedule) continue
|
||||
const due = getDueTimeToday(item.schedule, now)
|
||||
if (!due) continue
|
||||
if (item.extension_date && isExtendedToday(item.extension_date, now)) continue
|
||||
const ms = msUntilExpiry(due.hour, due.minute, now)
|
||||
if (ms > 0) {
|
||||
const tid = window.setTimeout(() => {
|
||||
childChoreListRef.value?.refresh()
|
||||
}, ms)
|
||||
expiryTimers.value.push(tid)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function onVisibilityChange() {
|
||||
if (document.visibilityState === 'visible') {
|
||||
const today = toLocalISODate(new Date())
|
||||
if (today !== lastFetchDate.value) {
|
||||
lastFetchDate.value = today
|
||||
childChoreListRef.value?.refresh()
|
||||
setTimeout(() => resetExpiryTimers(), 300)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const hasPendingRewards = computed(() =>
|
||||
childRewardListRef.value?.items.some((r: RewardStatus) => r.redeeming),
|
||||
)
|
||||
@@ -310,6 +400,9 @@ onMounted(async () => {
|
||||
eventBus.on('reward_modified', handleRewardModified)
|
||||
eventBus.on('child_modified', handleChildModified)
|
||||
eventBus.on('child_reward_request', handleRewardRequest)
|
||||
eventBus.on('chore_schedule_modified', handleChoreScheduleModified)
|
||||
eventBus.on('chore_time_extended', handleChoreTimeExtended)
|
||||
document.addEventListener('visibilitychange', onVisibilityChange)
|
||||
if (route.params.id) {
|
||||
const idParam = Array.isArray(route.params.id) ? route.params.id[0] : route.params.id
|
||||
if (idParam !== undefined) {
|
||||
@@ -321,6 +414,7 @@ onMounted(async () => {
|
||||
rewards.value = data.rewards || []
|
||||
}
|
||||
loading.value = false
|
||||
setTimeout(() => resetExpiryTimers(), 300)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -340,6 +434,10 @@ onUnmounted(() => {
|
||||
eventBus.off('reward_modified', handleRewardModified)
|
||||
eventBus.off('child_modified', handleChildModified)
|
||||
eventBus.off('child_reward_request', handleRewardRequest)
|
||||
eventBus.off('chore_schedule_modified', handleChoreScheduleModified)
|
||||
eventBus.off('chore_time_extended', handleChoreTimeExtended)
|
||||
document.removeEventListener('visibilitychange', onVisibilityChange)
|
||||
clearExpiryTimers()
|
||||
removeInactivityListeners()
|
||||
})
|
||||
</script>
|
||||
@@ -362,14 +460,17 @@ onUnmounted(() => {
|
||||
:readyItemId="readyItemId"
|
||||
@item-ready="handleItemReady"
|
||||
@trigger-item="triggerTask"
|
||||
:getItemClass="(item) => ({ bad: !item.is_good, good: item.is_good })"
|
||||
:filter-fn="
|
||||
(item) => {
|
||||
return item.is_good
|
||||
}
|
||||
:getItemClass="
|
||||
(item: ChildTask) => ({
|
||||
bad: !item.is_good,
|
||||
good: item.is_good,
|
||||
'chore-inactive': isChoreExpired(item),
|
||||
})
|
||||
"
|
||||
:filter-fn="(item: ChildTask) => item.is_good && isChoreScheduledToday(item)"
|
||||
>
|
||||
<template #item="{ item }">
|
||||
<template #item="{ item }: { item: ChildTask }">
|
||||
<span v-if="isChoreExpired(item)" class="pending">TOO LATE</span>
|
||||
<div class="item-name">{{ item.name }}</div>
|
||||
<img v-if="item.image_url" :src="item.image_url" alt="Task Image" class="item-image" />
|
||||
<div
|
||||
@@ -383,6 +484,7 @@ onUnmounted(() => {
|
||||
}}
|
||||
Points
|
||||
</div>
|
||||
<div v-if="choreDueLabel(item)" class="due-label">{{ choreDueLabel(item) }}</div>
|
||||
</template>
|
||||
</ScrollingList>
|
||||
<ScrollingList
|
||||
@@ -565,6 +667,18 @@ onUnmounted(() => {
|
||||
pointer-events: none;
|
||||
filter: grayscale(0.7);
|
||||
}
|
||||
:deep(.chore-inactive) {
|
||||
opacity: 0.45;
|
||||
filter: grayscale(0.6);
|
||||
}
|
||||
|
||||
.due-label {
|
||||
font-size: 0.72rem;
|
||||
font-weight: 600;
|
||||
color: var(--due-label-color, #aaa);
|
||||
margin-top: 0.15rem;
|
||||
letter-spacing: 0.3px;
|
||||
}
|
||||
|
||||
.modal-message {
|
||||
margin-bottom: 1.2rem;
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, onUnmounted, watch, nextTick } from 'vue'
|
||||
import ModalDialog from '../shared/ModalDialog.vue'
|
||||
import ScheduleModal from '../shared/ScheduleModal.vue'
|
||||
import PendingRewardDialog from './PendingRewardDialog.vue'
|
||||
import TaskConfirmDialog from './TaskConfirmDialog.vue'
|
||||
import RewardConfirmDialog from './RewardConfirmDialog.vue'
|
||||
@@ -8,7 +9,7 @@ import { useRoute, useRouter } from 'vue-router'
|
||||
import ChildDetailCard from './ChildDetailCard.vue'
|
||||
import ScrollingList from '../shared/ScrollingList.vue'
|
||||
import StatusMessage from '../shared/StatusMessage.vue'
|
||||
import { setChildOverride, parseErrorResponse } from '@/common/api'
|
||||
import { setChildOverride, parseErrorResponse, extendChoreTime } from '@/common/api'
|
||||
import { eventBus } from '@/common/eventBus'
|
||||
import '@/assets/styles.css'
|
||||
import type {
|
||||
@@ -17,6 +18,7 @@ import type {
|
||||
Event,
|
||||
Reward,
|
||||
RewardStatus,
|
||||
ChildTask,
|
||||
ChildTaskTriggeredEventPayload,
|
||||
ChildRewardTriggeredEventPayload,
|
||||
ChildRewardRequestEventPayload,
|
||||
@@ -27,7 +29,18 @@ import type {
|
||||
RewardModifiedEventPayload,
|
||||
ChildOverrideSetPayload,
|
||||
ChildOverrideDeletedPayload,
|
||||
ChoreScheduleModifiedPayload,
|
||||
ChoreTimeExtendedPayload,
|
||||
} from '@/common/models'
|
||||
import {
|
||||
isScheduledToday,
|
||||
isPastTime,
|
||||
getDueTimeToday,
|
||||
formatDueTimeLabel,
|
||||
msUntilExpiry,
|
||||
isExtendedToday,
|
||||
toLocalISODate,
|
||||
} from '@/common/scheduleUtils'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
@@ -56,6 +69,20 @@ const pendingEditOverrideTarget = ref<{ entity: Task | Reward; type: 'task' | 'r
|
||||
null,
|
||||
)
|
||||
|
||||
// Kebab menu
|
||||
const activeMenuFor = ref<string | null>(null)
|
||||
const shouldIgnoreNextCardClick = ref(false)
|
||||
|
||||
// Schedule modal
|
||||
const showScheduleModal = ref(false)
|
||||
const scheduleTarget = ref<ChildTask | null>(null)
|
||||
|
||||
// Expiry timers
|
||||
const expiryTimers = ref<number[]>([])
|
||||
|
||||
// Last fetch date (for overnight detection)
|
||||
const lastFetchDate = ref<string>(toLocalISODate(new Date()))
|
||||
|
||||
function handleItemReady(itemId: string) {
|
||||
readyItemId.value = itemId
|
||||
}
|
||||
@@ -216,6 +243,155 @@ function handleOverrideDeleted(event: Event) {
|
||||
}
|
||||
}
|
||||
|
||||
function handleChoreScheduleModified(event: Event) {
|
||||
const payload = event.payload as ChoreScheduleModifiedPayload
|
||||
if (child.value && payload.child_id === child.value.id) {
|
||||
childChoreListRef.value?.refresh()
|
||||
resetExpiryTimers()
|
||||
}
|
||||
}
|
||||
|
||||
function handleChoreTimeExtended(event: Event) {
|
||||
const payload = event.payload as ChoreTimeExtendedPayload
|
||||
if (child.value && payload.child_id === child.value.id) {
|
||||
childChoreListRef.value?.refresh()
|
||||
resetExpiryTimers()
|
||||
}
|
||||
}
|
||||
|
||||
// ── Kebab menu ───────────────────────────────────────────────────────────────
|
||||
|
||||
const onDocClick = (e: MouseEvent) => {
|
||||
if (activeMenuFor.value !== null) {
|
||||
const path = (e.composedPath?.() ?? (e as any).path ?? []) as EventTarget[]
|
||||
const inside = path.some((node) => {
|
||||
if (!(node instanceof HTMLElement)) return false
|
||||
return (
|
||||
node.classList.contains('chore-kebab-wrap') ||
|
||||
node.classList.contains('kebab-btn') ||
|
||||
node.classList.contains('kebab-menu')
|
||||
)
|
||||
})
|
||||
if (!inside) {
|
||||
activeMenuFor.value = null
|
||||
if (path.some((n) => n instanceof HTMLElement && n.classList.contains('item-card'))) {
|
||||
shouldIgnoreNextCardClick.value = true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function openChoreMenu(taskId: string, e: MouseEvent) {
|
||||
e.stopPropagation()
|
||||
activeMenuFor.value = taskId
|
||||
}
|
||||
|
||||
function closeChoreMenu() {
|
||||
activeMenuFor.value = null
|
||||
}
|
||||
|
||||
// ── Schedule modal ────────────────────────────────────────────────────────────
|
||||
|
||||
function openScheduleModal(item: ChildTask, e: MouseEvent) {
|
||||
e.stopPropagation()
|
||||
closeChoreMenu()
|
||||
scheduleTarget.value = item
|
||||
showScheduleModal.value = true
|
||||
}
|
||||
|
||||
function onScheduleSaved() {
|
||||
showScheduleModal.value = false
|
||||
scheduleTarget.value = null
|
||||
childChoreListRef.value?.refresh()
|
||||
resetExpiryTimers()
|
||||
}
|
||||
|
||||
// ── Extend Time ───────────────────────────────────────────────────────────────
|
||||
|
||||
async function doExtendTime(item: ChildTask, e: MouseEvent) {
|
||||
e.stopPropagation()
|
||||
closeChoreMenu()
|
||||
if (!child.value) return
|
||||
const today = toLocalISODate(new Date())
|
||||
const res = await extendChoreTime(child.value.id, item.id, today)
|
||||
if (!res.ok) {
|
||||
const { msg } = await parseErrorResponse(res)
|
||||
alert(`Error: ${msg}`)
|
||||
}
|
||||
// SSE chore_time_extended event will trigger a refresh
|
||||
}
|
||||
|
||||
// ── Schedule state helpers (for item-slot use) ────────────────────────────────
|
||||
|
||||
function isChoreScheduledToday(item: ChildTask): boolean {
|
||||
if (!item.schedule) return true // no schedule = always active
|
||||
return isScheduledToday(item.schedule, new Date())
|
||||
}
|
||||
|
||||
function isChoreExpired(item: ChildTask): boolean {
|
||||
if (!item.schedule) return false
|
||||
const now = new Date()
|
||||
if (!isScheduledToday(item.schedule, now)) return false
|
||||
const due = getDueTimeToday(item.schedule, now)
|
||||
if (!due) return false
|
||||
if (isExtendedToday(item.extension_date, now)) return false
|
||||
return isPastTime(due.hour, due.minute, now)
|
||||
}
|
||||
|
||||
function choreDueLabel(item: ChildTask): string | null {
|
||||
if (!item.schedule) return null
|
||||
const now = new Date()
|
||||
if (!isScheduledToday(item.schedule, now)) return null
|
||||
const due = getDueTimeToday(item.schedule, now)
|
||||
if (!due) return null
|
||||
if (isExtendedToday(item.extension_date, now)) return null
|
||||
if (isPastTime(due.hour, due.minute, now)) return null
|
||||
return `Due by ${formatDueTimeLabel(due.hour, due.minute)}`
|
||||
}
|
||||
|
||||
function isChoreInactive(item: ChildTask): boolean {
|
||||
return !isChoreScheduledToday(item) || isChoreExpired(item)
|
||||
}
|
||||
|
||||
// ── Expiry timers ─────────────────────────────────────────────────────────────
|
||||
|
||||
function clearExpiryTimers() {
|
||||
expiryTimers.value.forEach(clearTimeout)
|
||||
expiryTimers.value = []
|
||||
}
|
||||
|
||||
function resetExpiryTimers() {
|
||||
clearExpiryTimers()
|
||||
const items: ChildTask[] = childChoreListRef.value?.items ?? []
|
||||
const now = new Date()
|
||||
for (const item of items) {
|
||||
if (!item.schedule || !item.is_good) continue
|
||||
if (!isScheduledToday(item.schedule, now)) continue
|
||||
const due = getDueTimeToday(item.schedule, now)
|
||||
if (!due) continue
|
||||
if (isExtendedToday(item.extension_date, now)) continue
|
||||
if (isPastTime(due.hour, due.minute, now)) continue
|
||||
const ms = msUntilExpiry(due.hour, due.minute, now)
|
||||
const handle = setTimeout(() => {
|
||||
// trigger a reactive update by refreshing the list
|
||||
childChoreListRef.value?.refresh()
|
||||
}, ms) as unknown as number
|
||||
expiryTimers.value.push(handle)
|
||||
}
|
||||
}
|
||||
|
||||
// ── Midnight detection (tab left open overnight) ──────────────────────────────
|
||||
|
||||
function onVisibilityChange() {
|
||||
if (document.visibilityState !== 'visible') return
|
||||
const today = toLocalISODate(new Date())
|
||||
if (today !== lastFetchDate.value) {
|
||||
lastFetchDate.value = today
|
||||
childChoreListRef.value?.refresh()
|
||||
resetExpiryTimers()
|
||||
}
|
||||
}
|
||||
|
||||
function handleEditItem(item: Task | Reward, type: 'task' | 'reward') {
|
||||
// If editing a pending reward, warn first
|
||||
if (type === 'reward' && (item as any).redeeming) {
|
||||
@@ -230,6 +406,11 @@ function handleEditItem(item: Task | Reward, type: 'task' | 'reward') {
|
||||
showOverrideModal.value = true
|
||||
}
|
||||
|
||||
function editChorePoints(item: Task) {
|
||||
handleEditItem(item, 'task')
|
||||
closeChoreMenu()
|
||||
}
|
||||
|
||||
async function confirmPendingRewardAndEdit() {
|
||||
if (!pendingEditOverrideTarget.value) return
|
||||
const item = pendingEditOverrideTarget.value.entity as any
|
||||
@@ -304,6 +485,11 @@ onMounted(async () => {
|
||||
eventBus.on('child_reward_request', handleRewardRequest)
|
||||
eventBus.on('child_override_set', handleOverrideSet)
|
||||
eventBus.on('child_override_deleted', handleOverrideDeleted)
|
||||
eventBus.on('chore_schedule_modified', handleChoreScheduleModified)
|
||||
eventBus.on('chore_time_extended', handleChoreTimeExtended)
|
||||
|
||||
document.addEventListener('click', onDocClick, true)
|
||||
document.addEventListener('visibilitychange', onVisibilityChange)
|
||||
|
||||
if (route.params.id) {
|
||||
const idParam = Array.isArray(route.params.id) ? route.params.id[0] : route.params.id
|
||||
@@ -335,6 +521,12 @@ onUnmounted(() => {
|
||||
eventBus.off('reward_modified', handleRewardModified)
|
||||
eventBus.off('child_override_set', handleOverrideSet)
|
||||
eventBus.off('child_override_deleted', handleOverrideDeleted)
|
||||
eventBus.off('chore_schedule_modified', handleChoreScheduleModified)
|
||||
eventBus.off('chore_time_extended', handleChoreTimeExtended)
|
||||
|
||||
document.removeEventListener('click', onDocClick, true)
|
||||
document.removeEventListener('visibilitychange', onVisibilityChange)
|
||||
clearExpiryTimers()
|
||||
})
|
||||
|
||||
function getPendingRewardIds(): string[] {
|
||||
@@ -470,27 +662,66 @@ function goToAssignRewards() {
|
||||
:ids="tasks"
|
||||
itemKey="tasks"
|
||||
imageField="image_id"
|
||||
:enableEdit="true"
|
||||
:enableEdit="false"
|
||||
:childId="child?.id"
|
||||
:readyItemId="readyItemId"
|
||||
:isParentAuthenticated="true"
|
||||
@trigger-item="triggerTask"
|
||||
@edit-item="(item) => handleEditItem(item, 'task')"
|
||||
@item-ready="handleItemReady"
|
||||
:getItemClass="(item) => ({ bad: !item.is_good, good: item.is_good })"
|
||||
:filter-fn="
|
||||
(item) => {
|
||||
return item.is_good
|
||||
}
|
||||
:getItemClass="
|
||||
(item) => ({
|
||||
bad: !item.is_good,
|
||||
good: item.is_good,
|
||||
'chore-inactive': isChoreInactive(item),
|
||||
})
|
||||
"
|
||||
:filter-fn="(item) => item.is_good"
|
||||
>
|
||||
<template #item="{ item }">
|
||||
<template #item="{ item }: { item: ChildTask }">
|
||||
<!-- Kebab menu -->
|
||||
<div class="chore-kebab-wrap" @click.stop>
|
||||
<button
|
||||
class="kebab-btn"
|
||||
@mousedown.stop.prevent
|
||||
@click="openChoreMenu(item.id, $event)"
|
||||
:aria-expanded="activeMenuFor === item.id ? 'true' : 'false'"
|
||||
aria-label="Options"
|
||||
>
|
||||
⋮
|
||||
</button>
|
||||
<div
|
||||
v-if="activeMenuFor === item.id"
|
||||
class="kebab-menu"
|
||||
@mousedown.stop.prevent
|
||||
@click.stop
|
||||
>
|
||||
<button class="menu-item" @mousedown.stop.prevent @click="editChorePoints(item)">
|
||||
Edit Points
|
||||
</button>
|
||||
<button
|
||||
class="menu-item"
|
||||
@mousedown.stop.prevent
|
||||
@click="openScheduleModal(item, $event)"
|
||||
>
|
||||
Schedule
|
||||
</button>
|
||||
<button
|
||||
v-if="isChoreExpired(item)"
|
||||
class="menu-item"
|
||||
@mousedown.stop.prevent
|
||||
@click="doExtendTime(item, $event)"
|
||||
>
|
||||
Extend Time
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- TOO LATE badge -->
|
||||
<span v-if="isChoreExpired(item)" class="chore-stamp">TOO LATE</span>
|
||||
|
||||
<div class="item-name">{{ item.name }}</div>
|
||||
<img v-if="item.image_url" :src="item.image_url" alt="Task Image" class="item-image" />
|
||||
<div
|
||||
class="item-points"
|
||||
:class="{ 'good-points': item.is_good, 'bad-points': !item.is_good }"
|
||||
>
|
||||
<div class="item-points good-points">
|
||||
{{
|
||||
item.custom_value !== undefined && item.custom_value !== null
|
||||
? item.custom_value
|
||||
@@ -498,6 +729,7 @@ function goToAssignRewards() {
|
||||
}}
|
||||
Points
|
||||
</div>
|
||||
<div v-if="choreDueLabel(item)" class="due-label">{{ choreDueLabel(item) }}</div>
|
||||
</template>
|
||||
</ScrollingList>
|
||||
<ScrollingList
|
||||
@@ -595,6 +827,16 @@ function goToAssignRewards() {
|
||||
"
|
||||
/>
|
||||
|
||||
<!-- Schedule Modal -->
|
||||
<ScheduleModal
|
||||
v-if="showScheduleModal && scheduleTarget && child"
|
||||
:task="scheduleTarget"
|
||||
:childId="child.id"
|
||||
:schedule="scheduleTarget.schedule ?? null"
|
||||
@saved="onScheduleSaved"
|
||||
@cancelled="showScheduleModal = false"
|
||||
/>
|
||||
|
||||
<!-- Override Edit Modal -->
|
||||
<ModalDialog
|
||||
v-if="showOverrideModal && overrideEditTarget && child"
|
||||
@@ -730,6 +972,101 @@ function goToAssignRewards() {
|
||||
border-color: var(--list-item-border-reward);
|
||||
background: var(--list-item-bg-reward);
|
||||
}
|
||||
:deep(.chore-inactive) {
|
||||
opacity: 0.45;
|
||||
filter: grayscale(60%);
|
||||
}
|
||||
|
||||
/* Chore kebab menu (inside item-card which is position:relative in ScrollingList) */
|
||||
.chore-kebab-wrap {
|
||||
position: absolute;
|
||||
top: 4px;
|
||||
right: 4px;
|
||||
z-index: 20;
|
||||
}
|
||||
|
||||
.kebab-btn {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: transparent;
|
||||
border: 0;
|
||||
padding: 0;
|
||||
cursor: pointer;
|
||||
color: var(--kebab-icon-color, #4a4a6a);
|
||||
border-radius: 6px;
|
||||
font-size: 1.4rem;
|
||||
}
|
||||
|
||||
.kebab-btn:focus {
|
||||
outline: none;
|
||||
box-shadow: 0 0 0 3px rgba(102, 126, 234, 0.18);
|
||||
}
|
||||
|
||||
.kebab-menu {
|
||||
position: absolute;
|
||||
top: 36px;
|
||||
right: 0;
|
||||
min-width: 140px;
|
||||
background: var(--kebab-menu-bg, #f7fafc);
|
||||
border: 1.5px solid var(--kebab-menu-border, #bcc1c9);
|
||||
box-shadow: var(--kebab-menu-shadow);
|
||||
backdrop-filter: blur(var(--kebab-menu-blur));
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
z-index: 30;
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.menu-item {
|
||||
padding: 0.85rem 0.9rem;
|
||||
background: transparent;
|
||||
border: 0;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
font-weight: 600;
|
||||
color: var(--menu-item-color, #333);
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.menu-item:hover {
|
||||
background: var(--menu-item-hover-bg, rgba(102, 126, 234, 0.08));
|
||||
}
|
||||
|
||||
/* TOO LATE stamp on expired chores */
|
||||
.chore-stamp {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
width: 80%;
|
||||
background: rgba(34, 34, 43, 0.85);
|
||||
color: var(--btn-danger, #ef4444);
|
||||
font-weight: 700;
|
||||
font-size: 1.05rem;
|
||||
text-align: center;
|
||||
border-radius: 6px;
|
||||
padding: 0.4rem 0;
|
||||
letter-spacing: 2px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 2;
|
||||
opacity: 0.95;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* Due time sub-text */
|
||||
.due-label {
|
||||
font-size: 0.72rem;
|
||||
font-weight: 600;
|
||||
color: var(--item-points-color, #ffd166);
|
||||
margin-top: 0.2rem;
|
||||
letter-spacing: 0.3px;
|
||||
}
|
||||
|
||||
/* Override modal styles */
|
||||
.override-content {
|
||||
|
||||
340
frontend/vue-app/src/components/shared/ScheduleModal.vue
Normal file
340
frontend/vue-app/src/components/shared/ScheduleModal.vue
Normal file
@@ -0,0 +1,340 @@
|
||||
<template>
|
||||
<ModalDialog
|
||||
:image-url="task.image_url"
|
||||
:title="'Schedule Chore'"
|
||||
:subtitle="task.name"
|
||||
@backdrop-click="$emit('cancelled')"
|
||||
>
|
||||
<!-- Mode toggle -->
|
||||
<div class="mode-toggle">
|
||||
<button :class="['mode-btn', { active: mode === 'days' }]" @click="mode = 'days'">
|
||||
Specific Days
|
||||
</button>
|
||||
<button :class="['mode-btn', { active: mode === 'interval' }]" @click="mode = 'interval'">
|
||||
Every X Days
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Specific Days form -->
|
||||
<div v-if="mode === 'days'" class="days-form">
|
||||
<div v-for="(label, idx) in DAY_LABELS" :key="idx" class="day-row">
|
||||
<label class="day-check">
|
||||
<input type="checkbox" :checked="isDayChecked(idx)" @change="toggleDay(idx)" />
|
||||
<span class="day-label">{{ label }}</span>
|
||||
</label>
|
||||
<TimeSelector
|
||||
v-if="isDayChecked(idx)"
|
||||
:modelValue="getDayTime(idx)"
|
||||
@update:modelValue="setDayTime(idx, $event)"
|
||||
class="day-time-selector"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Interval form -->
|
||||
<div v-else class="interval-form">
|
||||
<div class="interval-row">
|
||||
<label class="field-label">Every</label>
|
||||
<input v-model.number="intervalDays" type="number" min="2" max="7" class="interval-input" />
|
||||
<span class="field-label">days, starting on</span>
|
||||
<select v-model.number="anchorWeekday" class="anchor-select">
|
||||
<option v-for="(label, idx) in DAY_LABELS" :key="idx" :value="idx">{{ label }}</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="interval-time-row">
|
||||
<label class="field-label">Due by</label>
|
||||
<TimeSelector :modelValue="intervalTime" @update:modelValue="intervalTime = $event" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p v-if="errorMsg" class="error-msg">{{ errorMsg }}</p>
|
||||
|
||||
<!-- Actions -->
|
||||
<div class="modal-actions">
|
||||
<button class="btn-secondary" @click="$emit('cancelled')" :disabled="saving">Cancel</button>
|
||||
<button class="btn-primary" @click="save" :disabled="saving || !isValid">
|
||||
{{ saving ? 'Saving…' : 'Save' }}
|
||||
</button>
|
||||
</div>
|
||||
</ModalDialog>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed } from 'vue'
|
||||
import ModalDialog from './ModalDialog.vue'
|
||||
import TimeSelector from './TimeSelector.vue'
|
||||
import { setChoreSchedule, parseErrorResponse } from '@/common/api'
|
||||
import type { ChildTask, ChoreSchedule, DayConfig } from '@/common/models'
|
||||
|
||||
interface TimeValue {
|
||||
hour: number
|
||||
minute: number
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
task: ChildTask
|
||||
childId: string
|
||||
schedule: ChoreSchedule | null
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'saved'): void
|
||||
(e: 'cancelled'): void
|
||||
}>()
|
||||
|
||||
const DAY_LABELS = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday']
|
||||
|
||||
// ── local state ──────────────────────────────────────────────────────────────
|
||||
|
||||
const mode = ref<'days' | 'interval'>(props.schedule?.mode ?? 'days')
|
||||
|
||||
// days mode
|
||||
const dayTimes = ref<Map<number, TimeValue>>(buildDayTimes(props.schedule))
|
||||
|
||||
// interval mode
|
||||
const intervalDays = ref<number>(props.schedule?.interval_days ?? 2)
|
||||
const anchorWeekday = ref<number>(props.schedule?.anchor_weekday ?? 0)
|
||||
const intervalTime = ref<TimeValue>({
|
||||
hour: props.schedule?.interval_hour ?? 8,
|
||||
minute: props.schedule?.interval_minute ?? 0,
|
||||
})
|
||||
|
||||
const saving = ref(false)
|
||||
const errorMsg = ref<string | null>(null)
|
||||
|
||||
// ── helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
function buildDayTimes(schedule: ChoreSchedule | null): Map<number, TimeValue> {
|
||||
const map = new Map<number, TimeValue>()
|
||||
if (schedule?.mode === 'days') {
|
||||
for (const dc of schedule.day_configs) {
|
||||
map.set(dc.day, { hour: dc.hour, minute: dc.minute })
|
||||
}
|
||||
}
|
||||
return map
|
||||
}
|
||||
|
||||
function isDayChecked(dayIdx: number): boolean {
|
||||
return dayTimes.value.has(dayIdx)
|
||||
}
|
||||
|
||||
function getDayTime(dayIdx: number): TimeValue {
|
||||
return dayTimes.value.get(dayIdx) ?? { hour: 8, minute: 0 }
|
||||
}
|
||||
|
||||
function toggleDay(dayIdx: number) {
|
||||
if (dayTimes.value.has(dayIdx)) {
|
||||
dayTimes.value.delete(dayIdx)
|
||||
} else {
|
||||
dayTimes.value.set(dayIdx, { hour: 8, minute: 0 })
|
||||
}
|
||||
}
|
||||
|
||||
function setDayTime(dayIdx: number, val: TimeValue) {
|
||||
dayTimes.value.set(dayIdx, val)
|
||||
}
|
||||
|
||||
// ── validation ───────────────────────────────────────────────────────────────
|
||||
|
||||
const isValid = computed(() => {
|
||||
if (mode.value === 'days') {
|
||||
return dayTimes.value.size > 0
|
||||
} else {
|
||||
return intervalDays.value >= 2 && intervalDays.value <= 7
|
||||
}
|
||||
})
|
||||
|
||||
// ── save ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
async function save() {
|
||||
if (!isValid.value || saving.value) return
|
||||
errorMsg.value = null
|
||||
saving.value = true
|
||||
|
||||
let payload: object
|
||||
if (mode.value === 'days') {
|
||||
const day_configs: DayConfig[] = Array.from(dayTimes.value.entries()).map(([day, t]) => ({
|
||||
day,
|
||||
hour: t.hour,
|
||||
minute: t.minute,
|
||||
}))
|
||||
payload = { mode: 'days', day_configs }
|
||||
} else {
|
||||
payload = {
|
||||
mode: 'interval',
|
||||
interval_days: intervalDays.value,
|
||||
anchor_weekday: anchorWeekday.value,
|
||||
interval_hour: intervalTime.value.hour,
|
||||
interval_minute: intervalTime.value.minute,
|
||||
}
|
||||
}
|
||||
|
||||
const res = await setChoreSchedule(props.childId, props.task.id, payload)
|
||||
saving.value = false
|
||||
|
||||
if (res.ok) {
|
||||
emit('saved')
|
||||
} else {
|
||||
const { msg } = await parseErrorResponse(res)
|
||||
errorMsg.value = msg
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.mode-toggle {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 1.2rem;
|
||||
}
|
||||
|
||||
.mode-btn {
|
||||
flex: 1;
|
||||
padding: 0.55rem 0.4rem;
|
||||
border: 1.5px solid var(--primary, #667eea);
|
||||
border-radius: 6px;
|
||||
background: transparent;
|
||||
color: var(--primary, #667eea);
|
||||
font-weight: 600;
|
||||
font-size: 0.9rem;
|
||||
cursor: pointer;
|
||||
transition:
|
||||
background 0.15s,
|
||||
color 0.15s;
|
||||
}
|
||||
|
||||
.mode-btn.active {
|
||||
background: var(--btn-primary, #667eea);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
/* Days form */
|
||||
.days-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.65rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.day-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.day-check {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
cursor: pointer;
|
||||
min-width: 110px;
|
||||
}
|
||||
|
||||
.day-check input[type='checkbox'] {
|
||||
width: 1.1rem;
|
||||
height: 1.1rem;
|
||||
cursor: pointer;
|
||||
accent-color: var(--btn-primary, #667eea);
|
||||
}
|
||||
|
||||
.day-label {
|
||||
font-size: 0.95rem;
|
||||
font-weight: 600;
|
||||
color: var(--secondary, #7257b3);
|
||||
}
|
||||
|
||||
.day-time-selector {
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
/* Interval form */
|
||||
.interval-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.interval-row,
|
||||
.interval-time-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.6rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.field-label {
|
||||
font-size: 0.9rem;
|
||||
font-weight: 600;
|
||||
color: var(--secondary, #7257b3);
|
||||
}
|
||||
|
||||
.interval-input {
|
||||
width: 3.5rem;
|
||||
padding: 0.4rem 0.4rem;
|
||||
border: 1.5px solid var(--kebab-menu-border, #bcc1c9);
|
||||
border-radius: 6px;
|
||||
font-size: 1rem;
|
||||
text-align: center;
|
||||
color: var(--secondary, #7257b3);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.anchor-select {
|
||||
padding: 0.4rem 0.5rem;
|
||||
border: 1.5px solid var(--kebab-menu-border, #bcc1c9);
|
||||
border-radius: 6px;
|
||||
font-size: 0.95rem;
|
||||
color: var(--secondary, #7257b3);
|
||||
font-weight: 600;
|
||||
background: var(--modal-bg, #fff);
|
||||
}
|
||||
|
||||
/* Error */
|
||||
.error-msg {
|
||||
color: var(--error, #e53e3e);
|
||||
font-size: 0.9rem;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
/* Actions */
|
||||
.modal-actions {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
justify-content: center;
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: var(--btn-primary, #667eea);
|
||||
color: #fff;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
padding: 0.55rem 1.4rem;
|
||||
font-size: 1rem;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.btn-primary:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
background: transparent;
|
||||
color: var(--secondary, #7257b3);
|
||||
border: 1.5px solid var(--secondary, #7257b3);
|
||||
border-radius: 8px;
|
||||
padding: 0.55rem 1.4rem;
|
||||
font-size: 1rem;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.btn-secondary:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
</style>
|
||||
174
frontend/vue-app/src/components/shared/TimeSelector.vue
Normal file
174
frontend/vue-app/src/components/shared/TimeSelector.vue
Normal file
@@ -0,0 +1,174 @@
|
||||
<template>
|
||||
<div class="time-selector">
|
||||
<!-- Hour column -->
|
||||
<div class="time-col">
|
||||
<button class="arrow-btn" @click="incrementHour" aria-label="Increase hour">▲</button>
|
||||
<div class="time-value">{{ displayHour }}</div>
|
||||
<button class="arrow-btn" @click="decrementHour" aria-label="Decrease hour">▼</button>
|
||||
</div>
|
||||
|
||||
<div class="time-separator">:</div>
|
||||
|
||||
<!-- Minute column -->
|
||||
<div class="time-col">
|
||||
<button class="arrow-btn" @click="incrementMinute" aria-label="Increase minute">▲</button>
|
||||
<div class="time-value">{{ displayMinute }}</div>
|
||||
<button class="arrow-btn" @click="decrementMinute" aria-label="Decrease minute">▼</button>
|
||||
</div>
|
||||
|
||||
<!-- AM/PM toggle -->
|
||||
<button class="ampm-btn" @click="toggleAmPm">{{ period }}</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
|
||||
interface TimeValue {
|
||||
hour: number // 0–23 (24h)
|
||||
minute: number // 0, 15, 30, or 45
|
||||
}
|
||||
|
||||
const props = defineProps<{ modelValue: TimeValue }>()
|
||||
const emit = defineEmits<{ (e: 'update:modelValue', val: TimeValue): void }>()
|
||||
|
||||
const MINUTES = [0, 15, 30, 45]
|
||||
|
||||
// ── display helpers ──────────────────────────────────────────────────────────
|
||||
|
||||
const period = computed(() => (props.modelValue.hour < 12 ? 'AM' : 'PM'))
|
||||
|
||||
const displayHour = computed(() => {
|
||||
const h = props.modelValue.hour % 12
|
||||
return String(h === 0 ? 12 : h)
|
||||
})
|
||||
|
||||
const displayMinute = computed(() => String(props.modelValue.minute).padStart(2, '0'))
|
||||
|
||||
// ── mutations ────────────────────────────────────────────────────────────────
|
||||
|
||||
function incrementHour() {
|
||||
const next = (props.modelValue.hour + 1) % 24
|
||||
emit('update:modelValue', { hour: next, minute: props.modelValue.minute })
|
||||
}
|
||||
|
||||
function decrementHour() {
|
||||
const next = (props.modelValue.hour - 1 + 24) % 24
|
||||
emit('update:modelValue', { hour: next, minute: props.modelValue.minute })
|
||||
}
|
||||
|
||||
function incrementMinute() {
|
||||
const idx = MINUTES.indexOf(props.modelValue.minute)
|
||||
if (idx === MINUTES.length - 1) {
|
||||
// Wrap minute to 0 and carry into next hour
|
||||
const nextHour = (props.modelValue.hour + 1) % 24
|
||||
emit('update:modelValue', { hour: nextHour, minute: 0 })
|
||||
} else {
|
||||
emit('update:modelValue', { hour: props.modelValue.hour, minute: MINUTES[idx + 1] })
|
||||
}
|
||||
}
|
||||
|
||||
function decrementMinute() {
|
||||
const idx = MINUTES.indexOf(props.modelValue.minute)
|
||||
if (idx === 0) {
|
||||
// Wrap minute to 45 and borrow from previous hour
|
||||
const prevHour = (props.modelValue.hour - 1 + 24) % 24
|
||||
emit('update:modelValue', { hour: prevHour, minute: 45 })
|
||||
} else {
|
||||
emit('update:modelValue', { hour: props.modelValue.hour, minute: MINUTES[idx - 1] })
|
||||
}
|
||||
}
|
||||
|
||||
function toggleAmPm() {
|
||||
const next = props.modelValue.hour < 12 ? props.modelValue.hour + 12 : props.modelValue.hour - 12
|
||||
emit('update:modelValue', { hour: next, minute: props.modelValue.minute })
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.time-selector {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.time-col {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 0.15rem;
|
||||
}
|
||||
|
||||
.arrow-btn {
|
||||
width: 2.5rem;
|
||||
height: 2.5rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: transparent;
|
||||
border: 1px solid var(--kebab-menu-border, #bcc1c9);
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
font-size: 0.85rem;
|
||||
color: var(--primary, #667eea);
|
||||
transition: background 0.15s;
|
||||
}
|
||||
|
||||
.arrow-btn:hover {
|
||||
background: var(--menu-item-hover-bg, rgba(102, 126, 234, 0.08));
|
||||
}
|
||||
|
||||
.time-value {
|
||||
width: 2.5rem;
|
||||
height: 2.5rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 1.4rem;
|
||||
font-weight: 700;
|
||||
color: var(--secondary, #7257b3);
|
||||
border: 1.5px solid var(--kebab-menu-border, #bcc1c9);
|
||||
border-radius: 6px;
|
||||
background: var(--modal-bg, #fff);
|
||||
}
|
||||
|
||||
.time-separator {
|
||||
font-size: 1.6rem;
|
||||
font-weight: 700;
|
||||
color: var(--secondary, #7257b3);
|
||||
padding-bottom: 0.1rem;
|
||||
align-self: center;
|
||||
}
|
||||
|
||||
.ampm-btn {
|
||||
width: 3rem;
|
||||
height: 2.5rem;
|
||||
font-size: 0.95rem;
|
||||
font-weight: 700;
|
||||
border: 1.5px solid var(--btn-primary, #667eea);
|
||||
border-radius: 6px;
|
||||
background: var(--btn-primary, #667eea);
|
||||
color: #fff;
|
||||
cursor: pointer;
|
||||
margin-left: 0.35rem;
|
||||
transition: opacity 0.15s;
|
||||
}
|
||||
|
||||
.ampm-btn:hover {
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.arrow-btn,
|
||||
.time-value {
|
||||
width: 2.8rem;
|
||||
height: 2.8rem;
|
||||
}
|
||||
|
||||
.ampm-btn {
|
||||
width: 3.2rem;
|
||||
height: 2.8rem;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user