Refactor Time Selector and Scheduler UI; Implement TimePickerPopover Component
Some checks failed
Chore App Build, Test, and Push Docker Images / build-and-push (push) Failing after 1m5s

- Updated TimeSelector.vue styles for smaller dimensions and font sizes.
- Added new API proxy for '/events' in vite.config.ts.
- Created bug specifications for various UI issues and fixes in bugs-1.0.5-001.md and bugs-1.0.5-002.md.
- Introduced TimePickerPopover.vue for a new time selection interface in the chore scheduler.
- Refactored ScheduleModal.vue to replace checkbox rows with a chip-based design for selecting specific days.
- Enhanced chore scheduling logic to ensure proper handling of time extensions and UI updates.
This commit is contained in:
2026-02-25 19:45:31 -05:00
parent a41a357f50
commit 91a52c1973
19 changed files with 1250 additions and 176 deletions

View File

@@ -348,7 +348,7 @@ function choreDueLabel(item: ChildTask): string | null {
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)
return `Due by ${formatDueTimeLabel(due.hour, due.minute)}`
}
function clearExpiryTimers() {
@@ -470,7 +470,7 @@ onUnmounted(() => {
:filter-fn="(item: ChildTask) => item.is_good && isChoreScheduledToday(item)"
>
<template #item="{ item }: { item: ChildTask }">
<span v-if="isChoreExpired(item)" class="pending">TOO LATE</span>
<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
@@ -668,14 +668,46 @@ onUnmounted(() => {
filter: grayscale(0.7);
}
:deep(.chore-inactive) {
opacity: 0.45;
filter: grayscale(0.6);
position: relative;
}
:deep(.chore-inactive::before) {
content: '';
position: absolute;
inset: 0;
background: rgba(160, 160, 160, 0.45);
filter: grayscale(80%);
z-index: 1;
pointer-events: none;
border-radius: inherit;
}
.chore-stamp {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
width: 80%;
background: rgba(34, 34, 34, 0.65);
color: var(--text-bad-color, #ef4444);
text-shadow: var(--item-points-shadow);
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-label {
font-size: 0.72rem;
font-size: 0.85rem;
font-weight: 600;
color: var(--due-label-color, #aaa);
color: var(--text-bad-color, #ef4444);
margin-top: 0.15rem;
letter-spacing: 0.3px;
}

View File

@@ -72,6 +72,9 @@ const pendingEditOverrideTarget = ref<{ entity: Task | Reward; type: 'task' | 'r
// Kebab menu
const activeMenuFor = ref<string | null>(null)
const shouldIgnoreNextCardClick = ref(false)
const selectedChoreId = ref<string | null>(null)
const menuPosition = ref({ top: 0, left: 0 })
const kebabBtnRefs = ref<Map<string, HTMLElement>>(new Map())
// Schedule modal
const showScheduleModal = ref(false)
@@ -85,6 +88,12 @@ const lastFetchDate = ref<string>(toLocalISODate(new Date()))
function handleItemReady(itemId: string) {
readyItemId.value = itemId
selectedChoreId.value = null
}
function handleChoreItemReady(itemId: string) {
readyItemId.value = itemId
selectedChoreId.value = itemId || null
}
function handleTaskTriggered(event: Event) {
@@ -247,7 +256,7 @@ function handleChoreScheduleModified(event: Event) {
const payload = event.payload as ChoreScheduleModifiedPayload
if (child.value && payload.child_id === child.value.id) {
childChoreListRef.value?.refresh()
resetExpiryTimers()
setTimeout(() => resetExpiryTimers(), 300)
}
}
@@ -255,7 +264,7 @@ function handleChoreTimeExtended(event: Event) {
const payload = event.payload as ChoreTimeExtendedPayload
if (child.value && payload.child_id === child.value.id) {
childChoreListRef.value?.refresh()
resetExpiryTimers()
setTimeout(() => resetExpiryTimers(), 300)
}
}
@@ -274,6 +283,7 @@ const onDocClick = (e: MouseEvent) => {
})
if (!inside) {
activeMenuFor.value = null
selectedChoreId.value = null
if (path.some((n) => n instanceof HTMLElement && n.classList.contains('item-card'))) {
shouldIgnoreNextCardClick.value = true
}
@@ -283,6 +293,11 @@ const onDocClick = (e: MouseEvent) => {
function openChoreMenu(taskId: string, e: MouseEvent) {
e.stopPropagation()
const btn = kebabBtnRefs.value.get(taskId)
if (btn) {
const rect = btn.getBoundingClientRect()
menuPosition.value = { top: rect.bottom, left: rect.right - 140 }
}
activeMenuFor.value = taskId
}
@@ -302,8 +317,7 @@ function openScheduleModal(item: ChildTask, e: MouseEvent) {
function onScheduleSaved() {
showScheduleModal.value = false
scheduleTarget.value = null
childChoreListRef.value?.refresh()
resetExpiryTimers()
setTimeout(() => resetExpiryTimers(), 300)
}
// ── Extend Time ───────────────────────────────────────────────────────────────
@@ -317,8 +331,9 @@ async function doExtendTime(item: ChildTask, e: MouseEvent) {
if (!res.ok) {
const { msg } = await parseErrorResponse(res)
alert(`Error: ${msg}`)
return
}
// SSE chore_time_extended event will trigger a refresh
setTimeout(() => resetExpiryTimers(), 300)
}
// ── Schedule state helpers (for item-slot use) ────────────────────────────────
@@ -667,7 +682,7 @@ function goToAssignRewards() {
:readyItemId="readyItemId"
:isParentAuthenticated="true"
@trigger-item="triggerTask"
@item-ready="handleItemReady"
@item-ready="handleChoreItemReady"
:getItemClass="
(item) => ({
bad: !item.is_good,
@@ -681,7 +696,14 @@ function goToAssignRewards() {
<!-- Kebab menu -->
<div class="chore-kebab-wrap" @click.stop>
<button
v-show="selectedChoreId === item.id"
class="kebab-btn"
:ref="
(el) => {
if (el) kebabBtnRefs.set(item.id, el as HTMLElement)
else kebabBtnRefs.delete(item.id)
}
"
@mousedown.stop.prevent
@click="openChoreMenu(item.id, $event)"
:aria-expanded="activeMenuFor === item.id ? 'true' : 'false'"
@@ -689,31 +711,34 @@ function goToAssignRewards() {
>
</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"
<Teleport to="body">
<div
v-if="activeMenuFor === item.id"
class="kebab-menu"
:style="{ top: menuPosition.top + 'px', left: menuPosition.left + 'px' }"
@mousedown.stop.prevent
@click="openScheduleModal(item, $event)"
@click.stop
>
Schedule
</button>
<button
v-if="isChoreExpired(item)"
class="menu-item"
@mousedown.stop.prevent
@click="doExtendTime(item, $event)"
>
Extend Time
</button>
</div>
<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>
</Teleport>
</div>
<!-- TOO LATE badge -->
@@ -973,8 +998,20 @@ function goToAssignRewards() {
background: var(--list-item-bg-reward);
}
:deep(.chore-inactive) {
opacity: 0.45;
filter: grayscale(60%);
position: relative;
}
:deep(.chore-inactive::before) {
content: '';
position: absolute;
inset: 0;
background: rgba(160, 160, 160, 0.45);
filter: grayscale(80%);
z-index: 1;
pointer-events: none;
border-radius: inherit;
}
:deep(.chore-inactive) .kebab-btn {
color: rgba(255, 255, 255, 0.85);
}
/* Chore kebab menu (inside item-card which is position:relative in ScrollingList) */
@@ -1006,9 +1043,7 @@ function goToAssignRewards() {
}
.kebab-menu {
position: absolute;
top: 36px;
right: 0;
position: fixed;
min-width: 140px;
background: var(--kebab-menu-bg, #f7fafc);
border: 1.5px solid var(--kebab-menu-border, #bcc1c9);
@@ -1017,7 +1052,7 @@ function goToAssignRewards() {
display: flex;
flex-direction: column;
overflow: hidden;
z-index: 30;
z-index: 9999;
border-radius: 6px;
}
@@ -1043,8 +1078,9 @@ function goToAssignRewards() {
left: 50%;
transform: translate(-50%, -50%);
width: 80%;
background: rgba(34, 34, 43, 0.85);
color: var(--btn-danger, #ef4444);
background: rgba(34, 34, 34, 0.65);
color: var(--text-bad-color, #ef4444);
text-shadow: var(--item-points-shadow);
font-weight: 700;
font-size: 1.05rem;
text-align: center;
@@ -1061,9 +1097,9 @@ function goToAssignRewards() {
/* Due time sub-text */
.due-label {
font-size: 0.72rem;
font-size: 0.85rem;
font-weight: 600;
color: var(--item-points-color, #ffd166);
color: var(--text-bad-color, #ef4444);
margin-top: 0.2rem;
letter-spacing: 0.3px;
}

View File

@@ -592,4 +592,50 @@ describe('ChildView', () => {
expect(wrapper.vm.readyItemId).toBe('')
})
})
describe('choreDueLabel', () => {
beforeEach(async () => {
wrapper = mount(ChildView)
await nextTick()
await new Promise((resolve) => setTimeout(resolve, 50))
})
it('returns null when item has no schedule', () => {
const result = wrapper.vm.choreDueLabel({
id: 'task-1',
name: 'Test',
points: 5,
is_good: true,
schedule: null,
})
expect(result).toBe(null)
})
it('returns "Due by ..." prefix format when chore has a future due time today', () => {
vi.useFakeTimers()
// Feb 24 2026 is a Tuesday (day index 2) at 10:00am
vi.setSystemTime(new Date('2026-02-24T10:00:00'))
try {
const item = {
id: 'task-1',
name: 'Test',
points: 5,
is_good: true,
schedule: {
mode: 'days' as const,
day_configs: [{ day: 2, hour: 14, minute: 30 }], // Tuesday 2:30pm — future
interval_days: null,
anchor_weekday: null,
interval_hour: null,
interval_minute: null,
},
extension_date: null,
}
const result = wrapper.vm.choreDueLabel(item)
expect(result).toMatch(/^Due by /)
} finally {
vi.useRealTimers()
}
})
})
})

View File

@@ -450,4 +450,35 @@ describe('ParentView', () => {
expect(wrapper.vm.showOverrideModal).toBe(false)
})
})
describe('Chore Card Selection (selectedChoreId)', () => {
beforeEach(async () => {
wrapper = mount(ParentView)
await nextTick()
await new Promise((resolve) => setTimeout(resolve, 50))
})
it('selectedChoreId is null initially', () => {
expect(wrapper.vm.selectedChoreId).toBe(null)
})
it('handleChoreItemReady sets selectedChoreId and readyItemId', () => {
wrapper.vm.handleChoreItemReady('task-1')
expect(wrapper.vm.selectedChoreId).toBe('task-1')
expect(wrapper.vm.readyItemId).toBe('task-1')
})
it('handleItemReady clears selectedChoreId when another list card is selected', () => {
wrapper.vm.handleChoreItemReady('task-1')
wrapper.vm.handleItemReady('reward-1')
expect(wrapper.vm.selectedChoreId).toBe(null)
expect(wrapper.vm.readyItemId).toBe('reward-1')
})
it('handleChoreItemReady with empty string clears selectedChoreId', () => {
wrapper.vm.handleChoreItemReady('task-1')
wrapper.vm.handleChoreItemReady('')
expect(wrapper.vm.selectedChoreId).toBe(null)
})
})
})