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
Some checks failed
Chore App Build, Test, and Push Docker Images / build-and-push (push) Has been cancelled
This commit is contained in:
@@ -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
|
||||
default_hour = data.get('default_hour', 8)
|
||||
default_minute = data.get('default_minute', 0)
|
||||
default_has_deadline = data.get('default_has_deadline', True)
|
||||
schedule = ChoreSchedule(
|
||||
child_id=child_id,
|
||||
task_id=task_id,
|
||||
@@ -67,6 +68,7 @@ def set_chore_schedule(child_id, task_id):
|
||||
day_configs=day_configs,
|
||||
default_hour=default_hour,
|
||||
default_minute=default_minute,
|
||||
default_has_deadline=default_has_deadline,
|
||||
)
|
||||
else:
|
||||
interval_days = data.get('interval_days', 2)
|
||||
|
||||
@@ -35,6 +35,7 @@ class ChoreSchedule(BaseModel):
|
||||
day_configs: list = field(default_factory=list) # list of DayConfig dicts
|
||||
default_hour: int = 8 # master deadline hour 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
|
||||
interval_days: int = 2 # 1–7
|
||||
@@ -52,6 +53,7 @@ class ChoreSchedule(BaseModel):
|
||||
day_configs=d.get('day_configs', []),
|
||||
default_hour=d.get('default_hour', 8),
|
||||
default_minute=d.get('default_minute', 0),
|
||||
default_has_deadline=d.get('default_has_deadline', True),
|
||||
interval_days=d.get('interval_days', 2),
|
||||
anchor_date=d.get('anchor_date', ''),
|
||||
interval_has_deadline=d.get('interval_has_deadline', True),
|
||||
@@ -71,6 +73,7 @@ class ChoreSchedule(BaseModel):
|
||||
'day_configs': self.day_configs,
|
||||
'default_hour': self.default_hour,
|
||||
'default_minute': self.default_minute,
|
||||
'default_has_deadline': self.default_has_deadline,
|
||||
'interval_days': self.interval_days,
|
||||
'anchor_date': self.anchor_date,
|
||||
'interval_has_deadline': self.interval_has_deadline,
|
||||
|
||||
@@ -450,18 +450,18 @@ describe('ScheduleModal save — interval mode', () => {
|
||||
// Interval form — next occurrence label
|
||||
// ---------------------------------------------------------------------------
|
||||
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()
|
||||
await w.findAll('.mode-btn')[1].trigger('click')
|
||||
await nextTick()
|
||||
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()
|
||||
await w.findAll('.mode-btn')[1].trigger('click')
|
||||
await nextTick()
|
||||
expect(w.find('.next-occurrence-label').text()).toContain('Next occurrences:')
|
||||
expect(w.find('.next-occurrence-label').text()).toContain('Next occurrence:')
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -23,6 +23,7 @@ export interface ChoreSchedule {
|
||||
day_configs: DayConfig[]
|
||||
default_hour?: number // master deadline hour; 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'
|
||||
interval_days: number // 1–7
|
||||
anchor_date: string // ISO date string e.g. "2026-02-25"; "" means use today
|
||||
|
||||
@@ -66,6 +66,8 @@ export function getDueTimeToday(
|
||||
localDate: Date,
|
||||
): { hour: number; minute: number } | null {
|
||||
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 dayConfig = schedule.day_configs.find((dc: DayConfig) => dc.day === todayWeekday)
|
||||
if (!dayConfig) return null
|
||||
|
||||
@@ -1,8 +1,22 @@
|
||||
<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>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed } from 'vue'
|
||||
|
||||
const props = defineProps<{
|
||||
modelValue: string
|
||||
min?: string
|
||||
@@ -12,13 +26,43 @@ const emit = defineEmits<{
|
||||
(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) {
|
||||
emit('update:modelValue', (event.target as HTMLInputElement).value)
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.date-input-field {
|
||||
.date-input-wrapper {
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
}
|
||||
|
||||
.date-display-btn {
|
||||
padding: 0.4rem 0.6rem;
|
||||
border: 1.5px solid var(--kebab-menu-border, #bcc1c9);
|
||||
border-radius: 6px;
|
||||
@@ -30,10 +74,21 @@ function onChanged(event: Event) {
|
||||
cursor: pointer;
|
||||
outline: none;
|
||||
transition: border-color 0.15s;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.date-input-field:hover,
|
||||
.date-input-field:focus {
|
||||
.date-display-btn:hover,
|
||||
.date-display-btn:focus {
|
||||
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>
|
||||
|
||||
@@ -28,13 +28,22 @@
|
||||
<!-- Default deadline -->
|
||||
<div v-if="selectedDays.size > 0" class="default-deadline-row">
|
||||
<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>
|
||||
|
||||
<!-- Selected day exception list -->
|
||||
<div v-if="selectedDays.size > 0" class="exception-list">
|
||||
<div v-for="idx in sortedSelectedDays" :key="idx" class="exception-row">
|
||||
<span class="exception-day-name">{{ DAY_LABELS[idx] }}</span>
|
||||
<div class="exception-right">
|
||||
<template v-if="exceptions.has(idx)">
|
||||
<TimePickerPopover
|
||||
:modelValue="exceptions.get(idx)!"
|
||||
@@ -45,7 +54,7 @@
|
||||
</button>
|
||||
</template>
|
||||
<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 })">
|
||||
Set different time
|
||||
</button>
|
||||
@@ -53,6 +62,7 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Interval form -->
|
||||
<div v-else class="interval-form">
|
||||
@@ -91,6 +101,11 @@
|
||||
</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 -->
|
||||
<div class="interval-time-row">
|
||||
<label class="field-label">Deadline</label>
|
||||
@@ -104,12 +119,6 @@
|
||||
{{ hasDeadline ? 'Clear (Anytime)' : 'Set deadline' }}
|
||||
</button>
|
||||
</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>
|
||||
|
||||
<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 defaultTime = ref<TimeValue>(_base)
|
||||
const exceptions = ref<Map<number, TimeValue>>(_exMap)
|
||||
const hasDefaultDeadline = ref<boolean>(props.schedule?.default_has_deadline ?? true)
|
||||
|
||||
// ── helpers (date) ───────────────────────────────────────────────────────────
|
||||
|
||||
@@ -210,6 +220,7 @@ const errorMsg = ref<string | null>(null)
|
||||
const origMode = props.schedule?.mode ?? 'days'
|
||||
const origDays = new Set(_days)
|
||||
const origDefaultTime: TimeValue = { ..._base }
|
||||
const origHasDefaultDeadline = props.schedule?.default_has_deadline ?? true
|
||||
const origExceptions = new Map<number, TimeValue>(
|
||||
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 nextOccurrences = computed((): string[] => {
|
||||
if (!anchorDate.value) return []
|
||||
const nextOccurrence = computed((): string | null => {
|
||||
if (!anchorDate.value) return null
|
||||
const [y, m, d] = anchorDate.value.split('-').map(Number)
|
||||
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()
|
||||
today.setHours(0, 0, 0, 0)
|
||||
|
||||
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
|
||||
let first: Date | null = null
|
||||
const candidate = new Date(anchor)
|
||||
for (let i = 0; i <= 365; i++) {
|
||||
// Find the first hit date strictly after today
|
||||
for (let i = 1; i <= 365; i++) {
|
||||
const cur = new Date(anchor.getTime() + i * 86_400_000)
|
||||
const diffDays = Math.round((cur.getTime() - anchor.getTime()) / 86_400_000)
|
||||
if (diffDays % intervalDays.value === 0 && cur >= today) {
|
||||
first = cur
|
||||
break
|
||||
return fmt(cur)
|
||||
}
|
||||
}
|
||||
if (!first) return []
|
||||
void candidate // suppress unused warning
|
||||
const second = new Date(first.getTime() + intervalDays.value * 86_400_000)
|
||||
return [fmt(first), fmt(second)]
|
||||
return null
|
||||
})
|
||||
|
||||
const formatDefaultTime = computed(() => {
|
||||
@@ -318,6 +323,8 @@ const isDirty = computed(() => {
|
||||
defaultTime.value.minute !== origDefaultTime.minute
|
||||
)
|
||||
return true
|
||||
// Default deadline toggled
|
||||
if (hasDefaultDeadline.value !== origHasDefaultDeadline) return true
|
||||
// Exceptions changed
|
||||
if (exceptions.value.size !== origExceptions.size) return true
|
||||
for (const [day, t] of exceptions.value) {
|
||||
@@ -364,6 +371,7 @@ async function save() {
|
||||
day_configs,
|
||||
default_hour: defaultTime.value.hour,
|
||||
default_minute: defaultTime.value.minute,
|
||||
default_has_deadline: hasDefaultDeadline.value,
|
||||
})
|
||||
} else {
|
||||
res = await setChoreSchedule(props.childId, props.task.id, {
|
||||
@@ -468,8 +476,14 @@ async function save() {
|
||||
.exception-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.65rem;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.4rem;
|
||||
}
|
||||
|
||||
.exception-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.exception-day-name {
|
||||
@@ -486,9 +500,13 @@ async function save() {
|
||||
}
|
||||
|
||||
.link-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
background: none;
|
||||
border: none;
|
||||
padding: 0;
|
||||
padding: 0.6rem 0.5rem;
|
||||
margin: -0.6rem -0.5rem;
|
||||
min-height: 44px;
|
||||
cursor: pointer;
|
||||
font-size: 0.82rem;
|
||||
font-weight: 600;
|
||||
@@ -496,6 +514,7 @@ async function save() {
|
||||
text-decoration: underline;
|
||||
font-family: inherit;
|
||||
transition: opacity 0.12s;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.link-btn:hover {
|
||||
@@ -550,8 +569,8 @@ async function save() {
|
||||
}
|
||||
|
||||
.stepper-btn {
|
||||
width: 2rem;
|
||||
height: 2rem;
|
||||
min-width: 2.75rem;
|
||||
min-height: 2.75rem;
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: var(--primary, #667eea);
|
||||
@@ -560,6 +579,9 @@ async function save() {
|
||||
cursor: pointer;
|
||||
transition: background 0.12s;
|
||||
font-family: inherit;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.stepper-btn:hover:not(:disabled) {
|
||||
@@ -588,21 +610,13 @@ async function save() {
|
||||
|
||||
.next-occurrence-row {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.15rem;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.next-occurrence-label {
|
||||
font-size: 0.8rem;
|
||||
color: var(--form-label, #888);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.next-occurrence-date {
|
||||
font-size: 0.85rem;
|
||||
color: var(--form-label, #888);
|
||||
font-style: italic;
|
||||
padding-left: 0.5rem;
|
||||
}
|
||||
|
||||
/* Error */
|
||||
|
||||
@@ -9,7 +9,7 @@ describe('DateInputField', () => {
|
||||
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 input = w.find<HTMLInputElement>('input[type="date"]')
|
||||
expect((input.element as HTMLInputElement).value).toBe('2026-04-15')
|
||||
@@ -39,7 +39,21 @@ describe('DateInputField', () => {
|
||||
it('renders without min prop when not provided', () => {
|
||||
const w = mount(DateInputField, { props: { modelValue: '2026-03-01' } })
|
||||
const input = w.find<HTMLInputElement>('input[type="date"]')
|
||||
// min attribute should be absent or empty
|
||||
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')
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user