feat: add default_has_deadline to ChoreSchedule and update related components for deadline management
Some checks failed
Chore App Build, Test, and Push Docker Images / build-and-push (push) Has been cancelled

This commit is contained in:
2026-02-27 10:42:43 -05:00
parent f5a752d873
commit 1777700cc8
8 changed files with 150 additions and 59 deletions

View File

@@ -60,6 +60,7 @@ def set_chore_schedule(child_id, task_id):
return jsonify({'error': 'day_configs must be a list', 'code': ErrorCodes.INVALID_VALUE}), 400 return jsonify({'error': 'day_configs must be a list', 'code': ErrorCodes.INVALID_VALUE}), 400
default_hour = data.get('default_hour', 8) default_hour = data.get('default_hour', 8)
default_minute = data.get('default_minute', 0) default_minute = data.get('default_minute', 0)
default_has_deadline = data.get('default_has_deadline', True)
schedule = ChoreSchedule( schedule = ChoreSchedule(
child_id=child_id, child_id=child_id,
task_id=task_id, task_id=task_id,
@@ -67,6 +68,7 @@ def set_chore_schedule(child_id, task_id):
day_configs=day_configs, day_configs=day_configs,
default_hour=default_hour, default_hour=default_hour,
default_minute=default_minute, default_minute=default_minute,
default_has_deadline=default_has_deadline,
) )
else: else:
interval_days = data.get('interval_days', 2) interval_days = data.get('interval_days', 2)

View File

@@ -35,6 +35,7 @@ class ChoreSchedule(BaseModel):
day_configs: list = field(default_factory=list) # list of DayConfig dicts day_configs: list = field(default_factory=list) # list of DayConfig dicts
default_hour: int = 8 # master deadline hour for 'days' mode default_hour: int = 8 # master deadline hour for 'days' mode
default_minute: int = 0 # master deadline minute for 'days' mode default_minute: int = 0 # master deadline minute for 'days' mode
default_has_deadline: bool = True # False = 'Anytime', no expiry for 'days' mode
# mode='interval' fields # mode='interval' fields
interval_days: int = 2 # 17 interval_days: int = 2 # 17
@@ -52,6 +53,7 @@ class ChoreSchedule(BaseModel):
day_configs=d.get('day_configs', []), day_configs=d.get('day_configs', []),
default_hour=d.get('default_hour', 8), default_hour=d.get('default_hour', 8),
default_minute=d.get('default_minute', 0), default_minute=d.get('default_minute', 0),
default_has_deadline=d.get('default_has_deadline', True),
interval_days=d.get('interval_days', 2), interval_days=d.get('interval_days', 2),
anchor_date=d.get('anchor_date', ''), anchor_date=d.get('anchor_date', ''),
interval_has_deadline=d.get('interval_has_deadline', True), interval_has_deadline=d.get('interval_has_deadline', True),
@@ -71,6 +73,7 @@ class ChoreSchedule(BaseModel):
'day_configs': self.day_configs, 'day_configs': self.day_configs,
'default_hour': self.default_hour, 'default_hour': self.default_hour,
'default_minute': self.default_minute, 'default_minute': self.default_minute,
'default_has_deadline': self.default_has_deadline,
'interval_days': self.interval_days, 'interval_days': self.interval_days,
'anchor_date': self.anchor_date, 'anchor_date': self.anchor_date,
'interval_has_deadline': self.interval_has_deadline, 'interval_has_deadline': self.interval_has_deadline,

View File

@@ -450,18 +450,18 @@ describe('ScheduleModal save — interval mode', () => {
// Interval form — next occurrence label // Interval form — next occurrence label
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
describe('ScheduleModal interval form — next occurrence', () => { describe('ScheduleModal interval form — next occurrence', () => {
it('shows next occurrences label in interval mode', async () => { it('shows next occurrence label in interval mode', async () => {
const w = mountModal() const w = mountModal()
await w.findAll('.mode-btn')[1].trigger('click') await w.findAll('.mode-btn')[1].trigger('click')
await nextTick() await nextTick()
expect(w.find('.next-occurrence-label').exists()).toBe(true) expect(w.find('.next-occurrence-label').exists()).toBe(true)
}) })
it('next occurrences label contains "Next occurrences:"', async () => { it('next occurrence label contains "Next occurrence:"', async () => {
const w = mountModal() const w = mountModal()
await w.findAll('.mode-btn')[1].trigger('click') await w.findAll('.mode-btn')[1].trigger('click')
await nextTick() await nextTick()
expect(w.find('.next-occurrence-label').text()).toContain('Next occurrences:') expect(w.find('.next-occurrence-label').text()).toContain('Next occurrence:')
}) })
}) })

View File

@@ -23,6 +23,7 @@ export interface ChoreSchedule {
day_configs: DayConfig[] day_configs: DayConfig[]
default_hour?: number // master deadline hour; present when mode='days' default_hour?: number // master deadline hour; present when mode='days'
default_minute?: number // master deadline minute; present when mode='days' default_minute?: number // master deadline minute; present when mode='days'
default_has_deadline?: boolean // false = 'Anytime', no expiry for 'days' mode
// mode='interval' // mode='interval'
interval_days: number // 17 interval_days: number // 17
anchor_date: string // ISO date string e.g. "2026-02-25"; "" means use today anchor_date: string // ISO date string e.g. "2026-02-25"; "" means use today

View File

@@ -66,6 +66,8 @@ export function getDueTimeToday(
localDate: Date, localDate: Date,
): { hour: number; minute: number } | null { ): { hour: number; minute: number } | null {
if (schedule.mode === 'days') { if (schedule.mode === 'days') {
// default_has_deadline === false means 'Anytime' — no expiry for scheduled days
if (schedule.default_has_deadline === false) return null
const todayWeekday = getLocalWeekday(localDate) const todayWeekday = getLocalWeekday(localDate)
const dayConfig = schedule.day_configs.find((dc: DayConfig) => dc.day === todayWeekday) const dayConfig = schedule.day_configs.find((dc: DayConfig) => dc.day === todayWeekday)
if (!dayConfig) return null if (!dayConfig) return null

View File

@@ -1,8 +1,22 @@
<template> <template>
<input class="date-input-field" type="date" :value="modelValue" :min="min" @change="onChanged" /> <div class="date-input-wrapper">
<button type="button" class="date-display-btn" @click="openPicker">
{{ displayLabel }}
</button>
<input
ref="pickerRef"
class="date-input-hidden"
type="date"
:value="modelValue"
:min="min"
@change="onChanged"
/>
</div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref, computed } from 'vue'
const props = defineProps<{ const props = defineProps<{
modelValue: string modelValue: string
min?: string min?: string
@@ -12,13 +26,43 @@ const emit = defineEmits<{
(e: 'update:modelValue', value: string): void (e: 'update:modelValue', value: string): void
}>() }>()
const pickerRef = ref<HTMLInputElement | null>(null)
const displayLabel = computed(() => {
if (!props.modelValue) return 'Select date'
const [y, m, d] = props.modelValue.split('-').map(Number)
const date = new Date(y, m - 1, d)
if (isNaN(date.getTime())) return props.modelValue
return date.toLocaleDateString('en-US', {
weekday: 'short',
month: 'short',
day: 'numeric',
year: 'numeric',
})
})
function openPicker() {
if (pickerRef.value) {
if (typeof pickerRef.value.showPicker === 'function') {
pickerRef.value.showPicker()
} else {
pickerRef.value.click()
}
}
}
function onChanged(event: Event) { function onChanged(event: Event) {
emit('update:modelValue', (event.target as HTMLInputElement).value) emit('update:modelValue', (event.target as HTMLInputElement).value)
} }
</script> </script>
<style scoped> <style scoped>
.date-input-field { .date-input-wrapper {
position: relative;
display: inline-flex;
}
.date-display-btn {
padding: 0.4rem 0.6rem; padding: 0.4rem 0.6rem;
border: 1.5px solid var(--kebab-menu-border, #bcc1c9); border: 1.5px solid var(--kebab-menu-border, #bcc1c9);
border-radius: 6px; border-radius: 6px;
@@ -30,10 +74,21 @@ function onChanged(event: Event) {
cursor: pointer; cursor: pointer;
outline: none; outline: none;
transition: border-color 0.15s; transition: border-color 0.15s;
white-space: nowrap;
} }
.date-input-field:hover, .date-display-btn:hover,
.date-input-field:focus { .date-display-btn:focus {
border-color: var(--btn-primary, #667eea); border-color: var(--btn-primary, #667eea);
} }
.date-input-hidden {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
opacity: 0;
pointer-events: none;
cursor: pointer;
}
</style> </style>

View File

@@ -28,13 +28,22 @@
<!-- Default deadline --> <!-- Default deadline -->
<div v-if="selectedDays.size > 0" class="default-deadline-row"> <div v-if="selectedDays.size > 0" class="default-deadline-row">
<span class="field-label">Deadline</span> <span class="field-label">Deadline</span>
<TimePickerPopover :modelValue="defaultTime" @update:modelValue="onDefaultTimeChanged" /> <TimePickerPopover
v-if="hasDefaultDeadline"
:modelValue="defaultTime"
@update:modelValue="onDefaultTimeChanged"
/>
<span v-else class="anytime-label">Anytime</span>
<button type="button" class="link-btn" @click="hasDefaultDeadline = !hasDefaultDeadline">
{{ hasDefaultDeadline ? 'Clear (Anytime)' : 'Set deadline' }}
</button>
</div> </div>
<!-- Selected day exception list --> <!-- Selected day exception list -->
<div v-if="selectedDays.size > 0" class="exception-list"> <div v-if="selectedDays.size > 0" class="exception-list">
<div v-for="idx in sortedSelectedDays" :key="idx" class="exception-row"> <div v-for="idx in sortedSelectedDays" :key="idx" class="exception-row">
<span class="exception-day-name">{{ DAY_LABELS[idx] }}</span> <span class="exception-day-name">{{ DAY_LABELS[idx] }}</span>
<div class="exception-right">
<template v-if="exceptions.has(idx)"> <template v-if="exceptions.has(idx)">
<TimePickerPopover <TimePickerPopover
:modelValue="exceptions.get(idx)!" :modelValue="exceptions.get(idx)!"
@@ -45,7 +54,7 @@
</button> </button>
</template> </template>
<template v-else> <template v-else>
<span class="default-label">{{ formatDefaultTime }}</span> <span class="default-label">{{ hasDefaultDeadline ? formatDefaultTime : 'Anytime' }}</span>
<button type="button" class="link-btn" @click="exceptions.set(idx, { ...defaultTime })"> <button type="button" class="link-btn" @click="exceptions.set(idx, { ...defaultTime })">
Set different time Set different time
</button> </button>
@@ -53,6 +62,7 @@
</div> </div>
</div> </div>
</div> </div>
</div>
<!-- Interval form --> <!-- Interval form -->
<div v-else class="interval-form"> <div v-else class="interval-form">
@@ -91,6 +101,11 @@
</div> </div>
</div> </div>
<!-- Next occurrence preview -->
<div v-if="nextOccurrence" class="next-occurrence-row">
<span class="next-occurrence-label">Next occurrence: {{ nextOccurrence }}</span>
</div>
<!-- Deadline row --> <!-- Deadline row -->
<div class="interval-time-row"> <div class="interval-time-row">
<label class="field-label">Deadline</label> <label class="field-label">Deadline</label>
@@ -104,12 +119,6 @@
{{ hasDeadline ? 'Clear (Anytime)' : 'Set deadline' }} {{ hasDeadline ? 'Clear (Anytime)' : 'Set deadline' }}
</button> </button>
</div> </div>
<!-- Next occurrences preview -->
<div v-if="nextOccurrences.length" class="next-occurrence-row">
<span class="next-occurrence-label">Next occurrences:</span>
<span v-for="(d, i) in nextOccurrences" :key="i" class="next-occurrence-date">{{ d }}</span>
</div>
</div> </div>
<p v-if="errorMsg" class="error-msg">{{ errorMsg }}</p> <p v-if="errorMsg" class="error-msg">{{ errorMsg }}</p>
@@ -185,6 +194,7 @@ const { days: _days, base: _base, exMap: _exMap } = initDaysState(props.schedule
const selectedDays = ref<Set<number>>(_days) const selectedDays = ref<Set<number>>(_days)
const defaultTime = ref<TimeValue>(_base) const defaultTime = ref<TimeValue>(_base)
const exceptions = ref<Map<number, TimeValue>>(_exMap) const exceptions = ref<Map<number, TimeValue>>(_exMap)
const hasDefaultDeadline = ref<boolean>(props.schedule?.default_has_deadline ?? true)
// ── helpers (date) ─────────────────────────────────────────────────────────── // ── helpers (date) ───────────────────────────────────────────────────────────
@@ -210,6 +220,7 @@ const errorMsg = ref<string | null>(null)
const origMode = props.schedule?.mode ?? 'days' const origMode = props.schedule?.mode ?? 'days'
const origDays = new Set(_days) const origDays = new Set(_days)
const origDefaultTime: TimeValue = { ..._base } const origDefaultTime: TimeValue = { ..._base }
const origHasDefaultDeadline = props.schedule?.default_has_deadline ?? true
const origExceptions = new Map<number, TimeValue>( const origExceptions = new Map<number, TimeValue>(
Array.from(_exMap.entries()).map(([k, v]) => [k, { ...v }]), Array.from(_exMap.entries()).map(([k, v]) => [k, { ...v }]),
) )
@@ -227,32 +238,26 @@ const sortedSelectedDays = computed(() => Array.from(selectedDays.value).sort((a
const todayISO = getTodayISO() const todayISO = getTodayISO()
const nextOccurrences = computed((): string[] => { const nextOccurrence = computed((): string | null => {
if (!anchorDate.value) return [] if (!anchorDate.value) return null
const [y, m, d] = anchorDate.value.split('-').map(Number) const [y, m, d] = anchorDate.value.split('-').map(Number)
const anchor = new Date(y, m - 1, d, 0, 0, 0, 0) const anchor = new Date(y, m - 1, d, 0, 0, 0, 0)
if (isNaN(anchor.getTime())) return [] if (isNaN(anchor.getTime())) return null
const today = new Date() const today = new Date()
today.setHours(0, 0, 0, 0) today.setHours(0, 0, 0, 0)
const fmt = (dt: Date) => const fmt = (dt: Date) =>
dt.toLocaleDateString('en-US', { weekday: 'long', month: 'short', day: 'numeric' }) dt.toLocaleDateString('en-US', { weekday: 'short', month: 'short', day: 'numeric', year: 'numeric' })
// Find the first hit date >= today // Find the first hit date strictly after today
let first: Date | null = null for (let i = 1; i <= 365; i++) {
const candidate = new Date(anchor)
for (let i = 0; i <= 365; i++) {
const cur = new Date(anchor.getTime() + i * 86_400_000) const cur = new Date(anchor.getTime() + i * 86_400_000)
const diffDays = Math.round((cur.getTime() - anchor.getTime()) / 86_400_000) const diffDays = Math.round((cur.getTime() - anchor.getTime()) / 86_400_000)
if (diffDays % intervalDays.value === 0 && cur >= today) { if (diffDays % intervalDays.value === 0 && cur >= today) {
first = cur return fmt(cur)
break
} }
} }
if (!first) return [] return null
void candidate // suppress unused warning
const second = new Date(first.getTime() + intervalDays.value * 86_400_000)
return [fmt(first), fmt(second)]
}) })
const formatDefaultTime = computed(() => { const formatDefaultTime = computed(() => {
@@ -318,6 +323,8 @@ const isDirty = computed(() => {
defaultTime.value.minute !== origDefaultTime.minute defaultTime.value.minute !== origDefaultTime.minute
) )
return true return true
// Default deadline toggled
if (hasDefaultDeadline.value !== origHasDefaultDeadline) return true
// Exceptions changed // Exceptions changed
if (exceptions.value.size !== origExceptions.size) return true if (exceptions.value.size !== origExceptions.size) return true
for (const [day, t] of exceptions.value) { for (const [day, t] of exceptions.value) {
@@ -364,6 +371,7 @@ async function save() {
day_configs, day_configs,
default_hour: defaultTime.value.hour, default_hour: defaultTime.value.hour,
default_minute: defaultTime.value.minute, default_minute: defaultTime.value.minute,
default_has_deadline: hasDefaultDeadline.value,
}) })
} else { } else {
res = await setChoreSchedule(props.childId, props.task.id, { res = await setChoreSchedule(props.childId, props.task.id, {
@@ -468,8 +476,14 @@ async function save() {
.exception-row { .exception-row {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 0.65rem; gap: 0.4rem;
flex-wrap: wrap; }
.exception-right {
display: flex;
align-items: center;
gap: 0.5rem;
margin-left: auto;
} }
.exception-day-name { .exception-day-name {
@@ -486,9 +500,13 @@ async function save() {
} }
.link-btn { .link-btn {
display: inline-flex;
align-items: center;
background: none; background: none;
border: none; border: none;
padding: 0; padding: 0.6rem 0.5rem;
margin: -0.6rem -0.5rem;
min-height: 44px;
cursor: pointer; cursor: pointer;
font-size: 0.82rem; font-size: 0.82rem;
font-weight: 600; font-weight: 600;
@@ -496,6 +514,7 @@ async function save() {
text-decoration: underline; text-decoration: underline;
font-family: inherit; font-family: inherit;
transition: opacity 0.12s; transition: opacity 0.12s;
white-space: nowrap;
} }
.link-btn:hover { .link-btn:hover {
@@ -550,8 +569,8 @@ async function save() {
} }
.stepper-btn { .stepper-btn {
width: 2rem; min-width: 2.75rem;
height: 2rem; min-height: 2.75rem;
background: transparent; background: transparent;
border: none; border: none;
color: var(--primary, #667eea); color: var(--primary, #667eea);
@@ -560,6 +579,9 @@ async function save() {
cursor: pointer; cursor: pointer;
transition: background 0.12s; transition: background 0.12s;
font-family: inherit; font-family: inherit;
display: flex;
align-items: center;
justify-content: center;
} }
.stepper-btn:hover:not(:disabled) { .stepper-btn:hover:not(:disabled) {
@@ -588,21 +610,13 @@ async function save() {
.next-occurrence-row { .next-occurrence-row {
display: flex; display: flex;
flex-direction: column; align-items: center;
gap: 0.15rem;
} }
.next-occurrence-label { .next-occurrence-label {
font-size: 0.8rem;
color: var(--form-label, #888);
font-weight: 500;
}
.next-occurrence-date {
font-size: 0.85rem; font-size: 0.85rem;
color: var(--form-label, #888); color: var(--form-label, #888);
font-style: italic; font-style: italic;
padding-left: 0.5rem;
} }
/* Error */ /* Error */

View File

@@ -9,7 +9,7 @@ describe('DateInputField', () => {
expect(input.exists()).toBe(true) expect(input.exists()).toBe(true)
}) })
it('reflects modelValue as the input value', () => { it('reflects modelValue as the hidden input value', () => {
const w = mount(DateInputField, { props: { modelValue: '2026-04-15' } }) const w = mount(DateInputField, { props: { modelValue: '2026-04-15' } })
const input = w.find<HTMLInputElement>('input[type="date"]') const input = w.find<HTMLInputElement>('input[type="date"]')
expect((input.element as HTMLInputElement).value).toBe('2026-04-15') expect((input.element as HTMLInputElement).value).toBe('2026-04-15')
@@ -39,7 +39,21 @@ describe('DateInputField', () => {
it('renders without min prop when not provided', () => { it('renders without min prop when not provided', () => {
const w = mount(DateInputField, { props: { modelValue: '2026-03-01' } }) const w = mount(DateInputField, { props: { modelValue: '2026-03-01' } })
const input = w.find<HTMLInputElement>('input[type="date"]') const input = w.find<HTMLInputElement>('input[type="date"]')
// min attribute should be absent or empty
expect((input.element as HTMLInputElement).min).toBe('') expect((input.element as HTMLInputElement).min).toBe('')
}) })
it('shows "Select date" placeholder when modelValue is empty', () => {
const w = mount(DateInputField, { props: { modelValue: '' } })
expect(w.find('.date-display-btn').text()).toBe('Select date')
})
it('shows formatted date with weekday when modelValue is set', () => {
const w = mount(DateInputField, { props: { modelValue: '2026-03-05' } })
// Thu, Mar 5, 2026
const text = w.find('.date-display-btn').text()
expect(text).toContain('Thu')
expect(text).toContain('Mar')
expect(text).toContain('5')
expect(text).toContain('2026')
})
}) })