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
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:
@@ -281,3 +281,16 @@ describe('ScheduleModal cancel', () => {
|
||||
expect(w.emitted('cancelled')).toBeTruthy()
|
||||
})
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Backdrop click
|
||||
// ---------------------------------------------------------------------------
|
||||
describe('ScheduleModal backdrop', () => {
|
||||
it('does not emit cancelled when ModalDialog fires backdrop-click', async () => {
|
||||
const w = mountModal()
|
||||
const dialog = w.findComponent(ModalDialogStub)
|
||||
dialog.vm.$emit('backdrop-click')
|
||||
await nextTick()
|
||||
expect(w.emitted('cancelled')).toBeFalsy()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -160,6 +160,7 @@
|
||||
|
||||
--pending-block-bg: #222b;
|
||||
--pending-block-color: #62ff7a;
|
||||
--text-bad-color: #ef4444;
|
||||
|
||||
/* TaskEditView styles */
|
||||
--toggle-btn-bg: #f3f3f3;
|
||||
|
||||
@@ -2,6 +2,24 @@ import { onMounted, onBeforeUnmount, watch } from 'vue'
|
||||
import type { Ref } from 'vue'
|
||||
import { eventBus } from './eventBus'
|
||||
|
||||
// Singleton BroadcastChannel for cross-tab event relay.
|
||||
// When this tab receives an SSE event it rebroadcasts it so other tabs
|
||||
// (which may not have received it from the server) can react immediately.
|
||||
const tabChannel =
|
||||
typeof BroadcastChannel !== 'undefined' ? new BroadcastChannel('app_events') : null
|
||||
|
||||
function emitEvent(data: any) {
|
||||
eventBus.emit(data.type, data)
|
||||
eventBus.emit('sse', data)
|
||||
}
|
||||
|
||||
if (tabChannel) {
|
||||
// Receive events that were relayed by another tab and emit them locally.
|
||||
tabChannel.onmessage = (msg) => {
|
||||
emitEvent(msg.data)
|
||||
}
|
||||
}
|
||||
|
||||
export function useBackendEvents(userId: Ref<string>) {
|
||||
let eventSource: EventSource | null = null
|
||||
|
||||
@@ -13,9 +31,9 @@ export function useBackendEvents(userId: Ref<string>) {
|
||||
eventSource.onmessage = (event) => {
|
||||
const data = JSON.parse(event.data)
|
||||
|
||||
// Emit globally for any component that cares
|
||||
eventBus.emit(data.type, data)
|
||||
eventBus.emit('sse', data) // optional: catch-all channel
|
||||
// Emit locally and relay to all other same-origin tabs.
|
||||
emitEvent(data)
|
||||
tabChannel?.postMessage(data)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,6 +21,8 @@ export interface ChoreSchedule {
|
||||
mode: 'days' | 'interval'
|
||||
// mode='days'
|
||||
day_configs: DayConfig[]
|
||||
default_hour?: number // master deadline hour; present when mode='days'
|
||||
default_minute?: number // master deadline minute; present when mode='days'
|
||||
// mode='interval'
|
||||
interval_days: number // 2–7
|
||||
anchor_weekday: number // 0=Sun–6=Sat
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,10 +1,5 @@
|
||||
<template>
|
||||
<ModalDialog
|
||||
:image-url="task.image_url"
|
||||
:title="'Schedule Chore'"
|
||||
:subtitle="task.name"
|
||||
@backdrop-click="$emit('cancelled')"
|
||||
>
|
||||
<ModalDialog :image-url="task.image_url" :title="'Schedule Chore'" :subtitle="task.name">
|
||||
<!-- Mode toggle -->
|
||||
<div class="mode-toggle">
|
||||
<button :class="['mode-btn', { active: mode === 'days' }]" @click="mode = 'days'">
|
||||
@@ -17,17 +12,45 @@
|
||||
|
||||
<!-- 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"
|
||||
/>
|
||||
<!-- Day chips -->
|
||||
<div class="day-chips">
|
||||
<button
|
||||
v-for="(label, idx) in DAY_CHIP_LABELS"
|
||||
:key="idx"
|
||||
type="button"
|
||||
:class="['chip', { active: selectedDays.has(idx) }]"
|
||||
@click="toggleDay(idx)"
|
||||
>
|
||||
{{ label }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Default deadline -->
|
||||
<div v-if="selectedDays.size > 0" class="default-deadline-row">
|
||||
<span class="field-label">Deadline</span>
|
||||
<TimePickerPopover :modelValue="defaultTime" @update:modelValue="onDefaultTimeChanged" />
|
||||
</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>
|
||||
<template v-if="exceptions.has(idx)">
|
||||
<TimePickerPopover
|
||||
: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>
|
||||
</template>
|
||||
<template v-else>
|
||||
<span class="default-label">{{ formatDefaultTime }}</span>
|
||||
<button type="button" class="link-btn" @click="exceptions.set(idx, { ...defaultTime })">
|
||||
Set different time
|
||||
</button>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -51,8 +74,10 @@
|
||||
|
||||
<!-- 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">
|
||||
<button class="btn btn-secondary" @click="$emit('cancelled')" :disabled="saving">
|
||||
Cancel
|
||||
</button>
|
||||
<button class="btn-primary" @click="save" :disabled="saving || !isValid || !isDirty">
|
||||
{{ saving ? 'Saving…' : 'Save' }}
|
||||
</button>
|
||||
</div>
|
||||
@@ -63,7 +88,8 @@
|
||||
import { ref, computed } from 'vue'
|
||||
import ModalDialog from './ModalDialog.vue'
|
||||
import TimeSelector from './TimeSelector.vue'
|
||||
import { setChoreSchedule, parseErrorResponse } from '@/common/api'
|
||||
import TimePickerPopover from './TimePickerPopover.vue'
|
||||
import { setChoreSchedule, deleteChoreSchedule, parseErrorResponse } from '@/common/api'
|
||||
import type { ChildTask, ChoreSchedule, DayConfig } from '@/common/models'
|
||||
|
||||
interface TimeValue {
|
||||
@@ -83,13 +109,40 @@ const emit = defineEmits<{
|
||||
}>()
|
||||
|
||||
const DAY_LABELS = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday']
|
||||
const DAY_CHIP_LABELS = ['Su', 'Mo', 'Tu', 'We', 'Th', 'Fr', 'Sa']
|
||||
|
||||
// ── local state ──────────────────────────────────────────────────────────────
|
||||
|
||||
const mode = ref<'days' | 'interval'>(props.schedule?.mode ?? 'days')
|
||||
|
||||
// days mode
|
||||
const dayTimes = ref<Map<number, TimeValue>>(buildDayTimes(props.schedule))
|
||||
// days mode — three-layer state
|
||||
function initDaysState(schedule: ChoreSchedule | null): {
|
||||
days: Set<number>
|
||||
base: TimeValue
|
||||
exMap: Map<number, TimeValue>
|
||||
} {
|
||||
const days = new Set<number>()
|
||||
const exMap = new Map<number, TimeValue>()
|
||||
let base: TimeValue = { hour: 8, minute: 0 }
|
||||
|
||||
if (schedule?.mode === 'days') {
|
||||
// Load the persisted default time (falls back to 8:00 AM for old records)
|
||||
base = { hour: schedule.default_hour ?? 8, minute: schedule.default_minute ?? 0 }
|
||||
for (const dc of schedule.day_configs) {
|
||||
days.add(dc.day)
|
||||
if (dc.hour !== base.hour || dc.minute !== base.minute) {
|
||||
exMap.set(dc.day, { hour: dc.hour, minute: dc.minute })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { days, base, exMap }
|
||||
}
|
||||
|
||||
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)
|
||||
|
||||
// interval mode
|
||||
const intervalDays = ref<number>(props.schedule?.interval_days ?? 2)
|
||||
@@ -102,74 +155,130 @@ const intervalTime = ref<TimeValue>({
|
||||
const saving = ref(false)
|
||||
const errorMsg = ref<string | null>(null)
|
||||
|
||||
// ── original snapshot (for dirty detection) ──────────────────────────────────
|
||||
|
||||
const origMode = props.schedule?.mode ?? 'days'
|
||||
const origDays = new Set(_days)
|
||||
const origDefaultTime: TimeValue = { ..._base }
|
||||
const origExceptions = new Map<number, TimeValue>(
|
||||
Array.from(_exMap.entries()).map(([k, v]) => [k, { ...v }]),
|
||||
)
|
||||
const origIntervalDays = props.schedule?.interval_days ?? 2
|
||||
const origAnchorWeekday = props.schedule?.anchor_weekday ?? 0
|
||||
const origIntervalTime: TimeValue = {
|
||||
hour: props.schedule?.interval_hour ?? 8,
|
||||
minute: props.schedule?.interval_minute ?? 0,
|
||||
}
|
||||
|
||||
// ── computed ─────────────────────────────────────────────────────────────────
|
||||
|
||||
const sortedSelectedDays = computed(() => Array.from(selectedDays.value).sort((a, b) => a - b))
|
||||
|
||||
const formatDefaultTime = computed(() => {
|
||||
const h = defaultTime.value.hour % 12 || 12
|
||||
const m = String(defaultTime.value.minute).padStart(2, '0')
|
||||
const period = defaultTime.value.hour < 12 ? 'AM' : 'PM'
|
||||
return `${String(h).padStart(2, '0')}:${m} ${period}`
|
||||
})
|
||||
|
||||
// ── 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)
|
||||
if (selectedDays.value.has(dayIdx)) {
|
||||
selectedDays.value.delete(dayIdx)
|
||||
exceptions.value.delete(dayIdx)
|
||||
// trigger reactivity
|
||||
selectedDays.value = new Set(selectedDays.value)
|
||||
} else {
|
||||
dayTimes.value.set(dayIdx, { hour: 8, minute: 0 })
|
||||
const next = new Set(selectedDays.value)
|
||||
next.add(dayIdx)
|
||||
selectedDays.value = next
|
||||
}
|
||||
}
|
||||
|
||||
function setDayTime(dayIdx: number, val: TimeValue) {
|
||||
dayTimes.value.set(dayIdx, val)
|
||||
function onDefaultTimeChanged(val: TimeValue) {
|
||||
defaultTime.value = val
|
||||
}
|
||||
|
||||
// ── validation ───────────────────────────────────────────────────────────────
|
||||
// ── validation + dirty ───────────────────────────────────────────────────────
|
||||
|
||||
const isValid = computed(() => {
|
||||
if (mode.value === 'days') {
|
||||
return dayTimes.value.size > 0
|
||||
} else {
|
||||
// days mode: 0 selections is valid — means "unschedule" (always active)
|
||||
if (mode.value === 'interval') {
|
||||
return intervalDays.value >= 2 && intervalDays.value <= 7
|
||||
}
|
||||
return true
|
||||
})
|
||||
|
||||
const isDirty = computed(() => {
|
||||
if (mode.value !== origMode) return true
|
||||
|
||||
if (mode.value === 'days') {
|
||||
// Selected days set changed
|
||||
if (selectedDays.value.size !== origDays.size) return true
|
||||
for (const d of selectedDays.value) {
|
||||
if (!origDays.has(d)) return true
|
||||
}
|
||||
// Default time changed
|
||||
if (
|
||||
defaultTime.value.hour !== origDefaultTime.hour ||
|
||||
defaultTime.value.minute !== origDefaultTime.minute
|
||||
)
|
||||
return true
|
||||
// Exceptions changed
|
||||
if (exceptions.value.size !== origExceptions.size) return true
|
||||
for (const [day, t] of exceptions.value) {
|
||||
const orig = origExceptions.get(day)
|
||||
if (!orig || orig.hour !== t.hour || orig.minute !== t.minute) return true
|
||||
}
|
||||
return false
|
||||
} else {
|
||||
if (intervalDays.value !== origIntervalDays) return true
|
||||
if (anchorWeekday.value !== origAnchorWeekday) return true
|
||||
if (
|
||||
intervalTime.value.hour !== origIntervalTime.hour ||
|
||||
intervalTime.value.minute !== origIntervalTime.minute
|
||||
)
|
||||
return true
|
||||
return false
|
||||
}
|
||||
})
|
||||
|
||||
// ── save ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
async function save() {
|
||||
if (!isValid.value || saving.value) return
|
||||
if (!isValid.value || !isDirty.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 }
|
||||
let res: Response
|
||||
if (mode.value === 'days' && selectedDays.value.size === 0) {
|
||||
// 0 days = remove schedule entirely → chore becomes always active
|
||||
res = await deleteChoreSchedule(props.childId, props.task.id)
|
||||
} else if (mode.value === 'days') {
|
||||
const day_configs: DayConfig[] = Array.from(selectedDays.value).map((day) => {
|
||||
const override = exceptions.value.get(day)
|
||||
return {
|
||||
day,
|
||||
hour: override?.hour ?? defaultTime.value.hour,
|
||||
minute: override?.minute ?? defaultTime.value.minute,
|
||||
}
|
||||
})
|
||||
res = await setChoreSchedule(props.childId, props.task.id, {
|
||||
mode: 'days',
|
||||
day_configs,
|
||||
default_hour: defaultTime.value.hour,
|
||||
default_minute: defaultTime.value.minute,
|
||||
})
|
||||
} else {
|
||||
payload = {
|
||||
res = await setChoreSchedule(props.childId, props.task.id, {
|
||||
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) {
|
||||
@@ -212,40 +321,93 @@ async function save() {
|
||||
.days-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.65rem;
|
||||
gap: 1rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.day-row {
|
||||
.day-chips {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.chip {
|
||||
min-width: 2.4rem;
|
||||
padding: 0.4rem 0.5rem;
|
||||
border: 1.5px solid var(--primary, #667eea);
|
||||
border-radius: 999px;
|
||||
background: transparent;
|
||||
color: var(--primary, #667eea);
|
||||
font-size: 0.85rem;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
text-align: center;
|
||||
transition:
|
||||
background 0.15s,
|
||||
color 0.15s;
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
.chip:hover {
|
||||
background: color-mix(in srgb, var(--btn-primary, #667eea) 12%, transparent);
|
||||
}
|
||||
|
||||
.chip.active {
|
||||
background: var(--btn-primary, #667eea);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.default-deadline-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.exception-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.55rem;
|
||||
}
|
||||
|
||||
.exception-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.65rem;
|
||||
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;
|
||||
.exception-day-name {
|
||||
font-size: 0.9rem;
|
||||
font-weight: 700;
|
||||
color: var(--secondary, #7257b3);
|
||||
min-width: 75px;
|
||||
}
|
||||
|
||||
.day-time-selector {
|
||||
margin-left: auto;
|
||||
.default-label {
|
||||
font-size: 0.85rem;
|
||||
color: var(--form-label, #888);
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.link-btn {
|
||||
background: none;
|
||||
border: none;
|
||||
padding: 0;
|
||||
cursor: pointer;
|
||||
font-size: 0.82rem;
|
||||
font-weight: 600;
|
||||
color: var(--primary, #667eea);
|
||||
text-decoration: underline;
|
||||
font-family: inherit;
|
||||
transition: opacity 0.12s;
|
||||
}
|
||||
|
||||
.link-btn:hover {
|
||||
opacity: 0.75;
|
||||
}
|
||||
|
||||
.reset-btn {
|
||||
color: var(--error, #e53e3e);
|
||||
}
|
||||
|
||||
/* Interval form */
|
||||
@@ -256,7 +418,12 @@ async function save() {
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.interval-row,
|
||||
.interval-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.6rem;
|
||||
}
|
||||
|
||||
.interval-time-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -321,20 +488,4 @@ async function save() {
|
||||
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>
|
||||
|
||||
261
frontend/vue-app/src/components/shared/TimePickerPopover.vue
Normal file
261
frontend/vue-app/src/components/shared/TimePickerPopover.vue
Normal file
@@ -0,0 +1,261 @@
|
||||
<template>
|
||||
<div ref="rootEl" class="time-picker-popover">
|
||||
<button type="button" class="time-display" @click="toggleOpen" :class="{ open: isOpen }">
|
||||
{{ formattedTime }}
|
||||
</button>
|
||||
|
||||
<div v-if="isOpen" class="popover-panel">
|
||||
<div class="columns">
|
||||
<!-- Hours column -->
|
||||
<div class="col">
|
||||
<div class="col-header">Hour</div>
|
||||
<div class="col-scroll">
|
||||
<button
|
||||
v-for="h in HOURS"
|
||||
:key="h"
|
||||
type="button"
|
||||
class="col-item"
|
||||
:class="{ selected: displayHour === h }"
|
||||
@click="selectHour(h)"
|
||||
>
|
||||
{{ h }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-divider">:</div>
|
||||
|
||||
<!-- Minutes column -->
|
||||
<div class="col">
|
||||
<div class="col-header">Min</div>
|
||||
<div class="col-scroll">
|
||||
<button
|
||||
v-for="m in MINUTES"
|
||||
:key="m"
|
||||
type="button"
|
||||
class="col-item"
|
||||
:class="{ selected: props.modelValue.minute === m }"
|
||||
@click="selectMinute(m)"
|
||||
>
|
||||
{{ String(m).padStart(2, '0') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- AM/PM column -->
|
||||
<div class="col ampm-col">
|
||||
<div class="col-header">AM/PM</div>
|
||||
<div class="col-scroll">
|
||||
<button
|
||||
type="button"
|
||||
class="col-item"
|
||||
:class="{ selected: period === 'AM' }"
|
||||
@click="selectPeriod('AM')"
|
||||
>
|
||||
AM
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="col-item"
|
||||
:class="{ selected: period === 'PM' }"
|
||||
@click="selectPeriod('PM')"
|
||||
>
|
||||
PM
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onUnmounted } from 'vue'
|
||||
|
||||
interface TimeValue {
|
||||
hour: number // 0–23
|
||||
minute: number // 0, 15, 30, 45
|
||||
}
|
||||
|
||||
const props = defineProps<{ modelValue: TimeValue }>()
|
||||
const emit = defineEmits<{ (e: 'update:modelValue', val: TimeValue): void }>()
|
||||
|
||||
const HOURS = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12]
|
||||
const MINUTES = [0, 15, 30, 45]
|
||||
|
||||
const rootEl = ref<HTMLElement | null>(null)
|
||||
const isOpen = ref(false)
|
||||
|
||||
const period = computed(() => (props.modelValue.hour < 12 ? 'AM' : 'PM'))
|
||||
|
||||
const displayHour = computed(() => {
|
||||
const h = props.modelValue.hour % 12
|
||||
return h === 0 ? 12 : h
|
||||
})
|
||||
|
||||
const formattedTime = computed(() => {
|
||||
const h = displayHour.value
|
||||
const m = String(props.modelValue.minute).padStart(2, '0')
|
||||
return `${String(h).padStart(2, '0')}:${m} ${period.value}`
|
||||
})
|
||||
|
||||
function toggleOpen() {
|
||||
if (isOpen.value) {
|
||||
close()
|
||||
} else {
|
||||
isOpen.value = true
|
||||
document.addEventListener('mousedown', onDocumentMouseDown, { capture: true })
|
||||
}
|
||||
}
|
||||
|
||||
function close() {
|
||||
isOpen.value = false
|
||||
document.removeEventListener('mousedown', onDocumentMouseDown, { capture: true })
|
||||
}
|
||||
|
||||
function onDocumentMouseDown(e: MouseEvent) {
|
||||
if (rootEl.value && !rootEl.value.contains(e.target as Node)) {
|
||||
close()
|
||||
}
|
||||
}
|
||||
|
||||
function selectHour(h: number) {
|
||||
// h is 1–12 display; convert to 0–23 preserving period
|
||||
const isPm = period.value === 'PM'
|
||||
let hour24: number
|
||||
if (h === 12) {
|
||||
hour24 = isPm ? 12 : 0
|
||||
} else {
|
||||
hour24 = isPm ? h + 12 : h
|
||||
}
|
||||
emit('update:modelValue', { hour: hour24, minute: props.modelValue.minute })
|
||||
}
|
||||
|
||||
function selectMinute(m: number) {
|
||||
emit('update:modelValue', { hour: props.modelValue.hour, minute: m })
|
||||
}
|
||||
|
||||
function selectPeriod(p: 'AM' | 'PM') {
|
||||
const currentPeriod = period.value
|
||||
if (p === currentPeriod) return
|
||||
const newHour = p === 'PM' ? props.modelValue.hour + 12 : props.modelValue.hour - 12
|
||||
emit('update:modelValue', { hour: newHour, minute: props.modelValue.minute })
|
||||
}
|
||||
|
||||
onUnmounted(() => {
|
||||
document.removeEventListener('mousedown', onDocumentMouseDown, { capture: true })
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.time-picker-popover {
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.time-display {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 0.35rem 0.75rem;
|
||||
border: 1.5px solid var(--kebab-menu-border, #bcc1c9);
|
||||
border-radius: 8px;
|
||||
background: var(--modal-bg, #fff);
|
||||
color: var(--secondary, #7257b3);
|
||||
font-size: 0.95rem;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
transition:
|
||||
border-color 0.15s,
|
||||
box-shadow 0.15s;
|
||||
white-space: nowrap;
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
.time-display:hover,
|
||||
.time-display.open {
|
||||
border-color: var(--btn-primary, #667eea);
|
||||
box-shadow: 0 0 0 2px color-mix(in srgb, var(--btn-primary, #667eea) 20%, transparent);
|
||||
}
|
||||
|
||||
.popover-panel {
|
||||
position: absolute;
|
||||
top: calc(100% + 6px);
|
||||
left: 0;
|
||||
z-index: 200;
|
||||
background: var(--modal-bg, #fff);
|
||||
border: 1.5px solid var(--kebab-menu-border, #bcc1c9);
|
||||
border-radius: 10px;
|
||||
box-shadow: 0 4px 18px var(--modal-shadow, rgba(0, 0, 0, 0.15));
|
||||
padding: 0.5rem;
|
||||
}
|
||||
|
||||
.columns {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.col {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
min-width: 3rem;
|
||||
}
|
||||
|
||||
.ampm-col {
|
||||
min-width: 3.2rem;
|
||||
}
|
||||
|
||||
.col-header {
|
||||
font-size: 0.7rem;
|
||||
font-weight: 700;
|
||||
text-align: center;
|
||||
color: var(--primary, #667eea);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
margin-bottom: 0.35rem;
|
||||
padding: 0 0.15rem;
|
||||
}
|
||||
|
||||
.col-scroll {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.18rem;
|
||||
max-height: 12rem;
|
||||
overflow-y: auto;
|
||||
scrollbar-width: thin;
|
||||
}
|
||||
|
||||
.col-item {
|
||||
padding: 0.3rem 0.4rem;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
background: transparent;
|
||||
color: var(--secondary, #7257b3);
|
||||
font-size: 0.9rem;
|
||||
font-weight: 600;
|
||||
text-align: center;
|
||||
cursor: pointer;
|
||||
transition:
|
||||
background 0.12s,
|
||||
color 0.12s;
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
.col-item:hover {
|
||||
background: var(--menu-item-hover-bg, rgba(102, 126, 234, 0.1));
|
||||
}
|
||||
|
||||
.col-item.selected {
|
||||
background: var(--btn-primary, #667eea);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.col-divider {
|
||||
font-size: 1.1rem;
|
||||
font-weight: 700;
|
||||
color: var(--secondary, #7257b3);
|
||||
padding-top: 1.6rem;
|
||||
align-self: flex-start;
|
||||
}
|
||||
</style>
|
||||
@@ -101,8 +101,8 @@ function toggleAmPm() {
|
||||
}
|
||||
|
||||
.arrow-btn {
|
||||
width: 2.5rem;
|
||||
height: 2.5rem;
|
||||
width: 1.8rem;
|
||||
height: 1.8rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
@@ -110,7 +110,7 @@ function toggleAmPm() {
|
||||
border: 1px solid var(--kebab-menu-border, #bcc1c9);
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
font-size: 0.85rem;
|
||||
font-size: 0.75rem;
|
||||
color: var(--primary, #667eea);
|
||||
transition: background 0.15s;
|
||||
}
|
||||
@@ -120,12 +120,12 @@ function toggleAmPm() {
|
||||
}
|
||||
|
||||
.time-value {
|
||||
width: 2.5rem;
|
||||
height: 2.5rem;
|
||||
width: 1.8rem;
|
||||
height: 1.8rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 1.4rem;
|
||||
font-size: 1rem;
|
||||
font-weight: 700;
|
||||
color: var(--secondary, #7257b3);
|
||||
border: 1.5px solid var(--kebab-menu-border, #bcc1c9);
|
||||
@@ -134,7 +134,7 @@ function toggleAmPm() {
|
||||
}
|
||||
|
||||
.time-separator {
|
||||
font-size: 1.6rem;
|
||||
font-size: 1rem;
|
||||
font-weight: 700;
|
||||
color: var(--secondary, #7257b3);
|
||||
padding-bottom: 0.1rem;
|
||||
@@ -142,9 +142,9 @@ function toggleAmPm() {
|
||||
}
|
||||
|
||||
.ampm-btn {
|
||||
width: 3rem;
|
||||
height: 2.5rem;
|
||||
font-size: 0.95rem;
|
||||
width: 2.2rem;
|
||||
height: 1.8rem;
|
||||
font-size: 0.8rem;
|
||||
font-weight: 700;
|
||||
border: 1.5px solid var(--btn-primary, #667eea);
|
||||
border-radius: 6px;
|
||||
@@ -158,17 +158,4 @@ function toggleAmPm() {
|
||||
.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>
|
||||
|
||||
@@ -21,6 +21,11 @@ export default defineConfig(({ mode }) => {
|
||||
changeOrigin: true,
|
||||
rewrite: (path) => path.replace(/^\/api/, ''),
|
||||
},
|
||||
'/events': {
|
||||
target: 'http://192.168.1.102:5000',
|
||||
changeOrigin: true,
|
||||
secure: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
resolve: {
|
||||
|
||||
Reference in New Issue
Block a user