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
|
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)
|
||||||
|
|||||||
@@ -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 # 1–7
|
interval_days: int = 2 # 1–7
|
||||||
@@ -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,
|
||||||
|
|||||||
@@ -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:')
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -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 // 1–7
|
interval_days: number // 1–7
|
||||||
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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -28,28 +28,38 @@
|
|||||||
<!-- 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>
|
||||||
<template v-if="exceptions.has(idx)">
|
<div class="exception-right">
|
||||||
<TimePickerPopover
|
<template v-if="exceptions.has(idx)">
|
||||||
:modelValue="exceptions.get(idx)!"
|
<TimePickerPopover
|
||||||
@update:modelValue="(val) => exceptions.set(idx, val)"
|
:modelValue="exceptions.get(idx)!"
|
||||||
/>
|
@update:modelValue="(val) => exceptions.set(idx, val)"
|
||||||
<button type="button" class="link-btn reset-btn" @click="exceptions.delete(idx)">
|
/>
|
||||||
Reset to default
|
<button type="button" class="link-btn reset-btn" @click="exceptions.delete(idx)">
|
||||||
</button>
|
Reset to default
|
||||||
</template>
|
</button>
|
||||||
<template v-else>
|
</template>
|
||||||
<span class="default-label">{{ formatDefaultTime }}</span>
|
<template v-else>
|
||||||
<button type="button" class="link-btn" @click="exceptions.set(idx, { ...defaultTime })">
|
<span class="default-label">{{ hasDefaultDeadline ? formatDefaultTime : 'Anytime' }}</span>
|
||||||
Set different time
|
<button type="button" class="link-btn" @click="exceptions.set(idx, { ...defaultTime })">
|
||||||
</button>
|
Set different time
|
||||||
</template>
|
</button>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -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 */
|
||||||
|
|||||||
@@ -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')
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
Reference in New Issue
Block a user