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:
162
.github/specs/bugs-1.0.5-001.md
vendored
Normal file
162
.github/specs/bugs-1.0.5-001.md
vendored
Normal file
@@ -0,0 +1,162 @@
|
||||
# Bug List
|
||||
|
||||
**Feature Bugs:** .github/specs/feat-calendar-chore/feat-calendar-chore.md
|
||||
|
||||
## Bugs
|
||||
|
||||
### Kabab menu icon should only appear when the item is 'selected'
|
||||
|
||||
When a chore is 'selected' (clicked on for the first time) the kebab menu should show. The kebab menu on the chore should hide again if anywhere else is clicked except the item or it's kebab menu
|
||||
This is similar to how the 'edit' icon appears on penalties and rewards.
|
||||
|
||||
**Fix — `ParentView.vue`:**
|
||||
|
||||
- Add `selectedChoreId = ref<string | null>(null)` alongside `activeMenuFor`
|
||||
- In the chore card-click handler, set `selectedChoreId.value = item.id`; clicking the same card again deselects it
|
||||
- In `onDocClick` (the capture-phase outside-click handler), also clear `selectedChoreId.value = null` when clicking outside both the card and the kebab wrap
|
||||
- Change the `.kebab-btn` from always-rendered to `v-show="selectedChoreId === item.id"`
|
||||
|
||||
#### Additional Test Cases With This Fix
|
||||
|
||||
- [x] Kebab button is not visible before a chore card is clicked
|
||||
- [x] Kebab button becomes visible after a chore card is clicked
|
||||
- [ ] Kebab button hides when clicking outside the card and kebab area _(integration only — covered by onDocClick logic, not unit-testable)_
|
||||
- [x] Kebab button hides when a different card is clicked
|
||||
|
||||
### User Test Findings:
|
||||
|
||||
- chore-inactive::before applies an inset and background that is too dark. It should more like a gray disabled state.
|
||||
- an inactive chore shows a dark kebab menu icon, so it is very hard to see since it is too dark.
|
||||
|
||||
**Fix applied:**
|
||||
|
||||
- `::before` overlay changed from `rgba(0,0,0,0.45)` → `rgba(160,160,160,0.45)` and grayscale `60%` → `80%` for a lighter, more neutral gray disabled appearance
|
||||
- Added `:deep(.chore-inactive) .kebab-btn { color: rgba(255,255,255,0.85) }` so the kebab icon remains visible against the gray overlay
|
||||
|
||||
---
|
||||
|
||||
### In the scheduler, the cancel button should look like others
|
||||
|
||||
The cancel button doesn't follow current css design rules. It should look like other cancel buttons
|
||||
Incorrect cancel button: https://git.ryankegel.com/ryan/chore/attachments/a4a3a0eb-0acc-481d-8794-4d666f214e0a
|
||||
Correct cancel button: https://git.ryankegel.com/ryan/chore/attachments/63033ee6-35e8-4dd3-8be4-bce4a8c576fc
|
||||
|
||||
**Fix — `ScheduleModal.vue`:**
|
||||
|
||||
- Change `<button class="btn-secondary"` → `<button class="btn btn-secondary"` to use the global `styles.css` button classes
|
||||
- Remove the local `.btn-secondary` and `.btn-secondary:disabled` CSS blocks from `<style scoped>`
|
||||
|
||||
#### Additional Test Cases With This Fix
|
||||
|
||||
- []
|
||||
|
||||
### User Test Findings:
|
||||
|
||||
Fix confirmed
|
||||
|
||||
---
|
||||
|
||||
### Chore scheduler needs to be modal so that it cannot be clicked out of
|
||||
|
||||
The chore scheduler model should not disappear when clicked outside of. It should only disappear with either Save or Cancel clicked.
|
||||
|
||||
**Fix — `ScheduleModal.vue`:**
|
||||
|
||||
- Remove `@backdrop-click="$emit('cancelled')"` from the `<ModalDialog>` opening tag
|
||||
- The modal will then only close via the Save or Cancel buttons
|
||||
|
||||
#### Additional Test Cases With This Fix
|
||||
|
||||
- [x] Clicking outside the ScheduleModal backdrop does not dismiss it
|
||||
- [x] Clicking Cancel dismisses the modal
|
||||
- [x] Clicking Save dismisses the modal
|
||||
|
||||
### User Test Findings:
|
||||
|
||||
Fix confirmed
|
||||
|
||||
---
|
||||
|
||||
### In parent view, a chore that is not on the current day has it's kebab menu 'grayed out', it should not be
|
||||
|
||||
The dropdown menu from the chore kebab menu should not inherit the disabled color of the item card. It gives the impression that the menu is disabled.
|
||||
Reference of grayed out menu: https://git.ryankegel.com/ryan/chore/attachments/0ac50dae-9b60-4cf7-a9f4-c980525d72f8
|
||||
|
||||
**Fix — `ParentView.vue` and `ChildView.vue`:**
|
||||
CSS `opacity` on a parent is always inherited and cannot be overridden by children. Replace the direct `opacity + filter` on `.chore-inactive` with a pseudo-element overlay instead, so the `.chore-stamp` and `.chore-kebab-wrap` can sit above it at full opacity:
|
||||
|
||||
- Change `:deep(.chore-inactive)` from `opacity: 0.45; filter: grayscale(60%)` to use `position: relative` only
|
||||
- Add `:deep(.chore-inactive::before)` pseudo-element: `position: absolute; inset: 0; background: rgba(0,0,0,0.45); filter: grayscale(60%); z-index: 1; pointer-events: none; border-radius: inherit`
|
||||
- Give `.chore-stamp` `z-index: 3` (above the overlay)
|
||||
- Give `.chore-kebab-wrap` `z-index: 2` (above the overlay, below the stamp)
|
||||
|
||||
#### Additional Test Cases With This Fix
|
||||
|
||||
- []
|
||||
|
||||
### User Test Findings:
|
||||
|
||||
Fix confirmed
|
||||
|
||||
---
|
||||
|
||||
### Change the due by to be bigger
|
||||
|
||||
In both parent and child view, when a chore is scheduled and has a due time, I see the text 'Due by' in parent mode, and just the time in child mode.
|
||||
In both parent and child view, the text needs to be bigger: the due-label should be 0.85 rem font size.
|
||||
In child mode, the time a chore is due has a dark color that is hard to see. It should be the same color that is in parent view.
|
||||
|
||||
**Fix — `ParentView.vue`:**
|
||||
|
||||
- Change `.due-label { font-size: 0.72rem }` → `font-size: 0.85rem`
|
||||
|
||||
**Fix — `ChildView.vue`:**
|
||||
|
||||
- Change `.due-label { font-size: 0.72rem }` → `font-size: 0.85rem`
|
||||
- Change `.due-label` color from `var(--due-label-color, #aaa)` → `var(--item-points-color, #ffd166)` to match parent view
|
||||
- Change `choreDueLabel()` to return `"Due by " + formatDueTimeLabel(...)` instead of just the time string
|
||||
|
||||
#### Additional Test Cases With This Fix
|
||||
|
||||
- [x] `choreDueLabel()` in ChildView returns `"Due by 3:00 PM"` format (not just `"3:00 PM"`)
|
||||
|
||||
### User Test Findings:
|
||||
|
||||
The text size is good, but I want to change the text color to red. Create a new color in colors.css called --text-bad-color, it should be #ef4444
|
||||
|
||||
**Fix applied:**
|
||||
|
||||
- Added `--text-bad-color: #ef4444` to `colors.css`
|
||||
- Updated `.due-label` color in both `ParentView.vue` and `ChildView.vue` to use `var(--text-bad-color, #ef4444)`
|
||||
|
||||
---
|
||||
|
||||
### In parent mode, when a chore is too late. The banner shouldn't be 'grayed' out. Same in child mode - color should also be red in the banner
|
||||
|
||||
When a chore is too late, the 'TOO LATE' banner is also grayed out but it should not be. Also the font color for 'TOO LATE' needs to be consistent. Make both colors red according to colors.css
|
||||
|
||||
**Fix — `ChildView.vue`:**
|
||||
|
||||
- The TOO LATE `<span class="pending">` uses the `.pending` class which is styled green (`--pending-block-color`). Change it to `<span class="chore-stamp">` to match ParentView's class
|
||||
- Add `.chore-stamp` CSS to ChildView matching ParentView's definition: `color: var(--btn-danger, #ef4444)`
|
||||
- The grayout issue is resolved by the pseudo-element overlay fix in Bug 4 above — `.chore-stamp` at `z-index: 3` sits above the overlay at full opacity
|
||||
|
||||
**Fix — `ParentView.vue`:**
|
||||
|
||||
- No color change needed (already `color: var(--btn-danger, #ef4444)`)
|
||||
- The grayout issue is resolved by the pseudo-element overlay fix in Bug 4 above
|
||||
|
||||
#### Additional Test Cases With This Fix
|
||||
|
||||
- []
|
||||
|
||||
### User Test Findings:
|
||||
|
||||
chore-stamp background seems too dark, change it to rgba(34,34,34,0.65). Also give it text-shadow: var(--item-points-shadow)
|
||||
the TOO LATE text should not be using color --btn-danger. It should use the newly created color --text-bad-color
|
||||
|
||||
**Fix applied:**
|
||||
|
||||
- `.chore-stamp` background changed from `rgba(34,34,43,0.85)` → `rgba(34,34,34,0.65)` in both `ParentView.vue` and `ChildView.vue`
|
||||
- Added `text-shadow: var(--item-points-shadow)` to `.chore-stamp` in both files
|
||||
- Color changed from `var(--btn-danger, #ef4444)` → `var(--text-bad-color, #ef4444)` in both files
|
||||
125
.github/specs/bugs-1.0.5-002.md
vendored
Normal file
125
.github/specs/bugs-1.0.5-002.md
vendored
Normal file
@@ -0,0 +1,125 @@
|
||||
# Bug List
|
||||
|
||||
**Feature Bugs:** .github/specs/feat-calendar-chore/feat-calendar-chore.md
|
||||
|
||||
## Bugs
|
||||
|
||||
### When rescheduling an extended time chore, the extended time should be reset(removed)
|
||||
|
||||
When a chore has had it's time extended, but later edited through the scheduler and saved, the extended time for this chore should be reset. (A previous too late chore would again be too late if the current time is past the newly changed schedule)
|
||||
|
||||
**Root cause:** The PUT handler in `chore_schedule_api.py` saved the new schedule but never deleted the `TaskExtension` record for that child+task pair.
|
||||
|
||||
**Fix:**
|
||||
|
||||
- `backend/db/task_extensions.py` — Added `delete_extension_for_child_task(child_id, task_id)` that removes the `TaskExtension` matching both child and task IDs.
|
||||
- `backend/api/chore_schedule_api.py` — Imported the new function and called it immediately before `upsert_schedule(schedule)` in the PUT handler.
|
||||
|
||||
#### Additional Test Cases With This Fix
|
||||
|
||||
- []
|
||||
|
||||
### User Test Findings:
|
||||
|
||||
This flow produces a bug
|
||||
|
||||
1. A chore that is 'too late' (scheduled expiry time is 8:00am, but the current time is 3pm)
|
||||
2. Extend the time on that chore causes the 'too late' to disappear. The chore is now valid again.
|
||||
3. Enter the scheduler on that chore and set the expiry time to 9am (when it is 3pm in real time) the chore stays active -- I expect the chore to go back to the 'too late' state since the extended time was reset again. It should revert back to 'too late'
|
||||
|
||||
**Analysis:** The backend fix (deleting the extension on PUT) is correct. Step 3 failing is a side effect of Bug 3 (Extend Time not refreshing the UI) — the frontend was in a stale state after step 2, so step 3 was operating on stale item data. Once Bug 3 is fixed (direct refresh in `doExtendTime`), step 2 reliably updates the UI, and step 3's `onScheduleSaved → refresh()` then correctly re-evaluates `isChoreExpired` against the fresh data (no `extension_date`, 9am schedule → TOO LATE). No additional frontend changes needed beyond the backend deletion fix.
|
||||
|
||||
---
|
||||
|
||||
### Extend time is cut off of chore kebab menu
|
||||
|
||||
The dropdown for the kebab menu does not extend far enough to show all the items. It should show all the items.
|
||||
https://git.ryankegel.com/ryan/chore/attachments/951592da-29a2-4cca-912e-9b160eb2f19c
|
||||
|
||||
**Root cause:** The `.kebab-menu` is `position: absolute` inside `.chore-kebab-wrap`, which lives inside a `ScrollingList` card. The scroll wrapper has `overflow-y: hidden`, which clips any absolutely positioned content that extends below the card boundary. CSS cannot set `overflow-x: auto` and `overflow-y: visible` simultaneously — the browser coerces `visible` to `auto`.
|
||||
|
||||
**Fix:** (`frontend/vue-app/src/components/child/ParentView.vue`)
|
||||
|
||||
- Added `menuPosition = ref({ top: 0, left: 0 })` and `kebabBtnRefs = ref<Map<string, HTMLElement>>(new Map())`.
|
||||
- Added a `:ref` callback on `.kebab-btn` to register/unregister each button element keyed by `item.id`.
|
||||
- `openChoreMenu` now captures `getBoundingClientRect()` on the trigger button and stores `{ top: rect.bottom, left: rect.right - 140 }` into `menuPosition`.
|
||||
- Wrapped `.kebab-menu` in `<Teleport to="body">` and switched it to `position: fixed` with inline `:style` driven by `menuPosition`. `z-index` raised to `9999`.
|
||||
- `onDocClick` required no changes — it already checks `.kebab-menu` via `composedPath()`, which traverses the real DOM regardless of teleport destination.
|
||||
|
||||
#### Additional Test Cases With This Fix
|
||||
|
||||
- []
|
||||
|
||||
### User Test Findings:
|
||||
|
||||
Fix confirmed
|
||||
|
||||
---
|
||||
|
||||
### When a chore has passed it's time or when an time expired chore is extended, it should be updated right away in all UIs.
|
||||
|
||||
An SSE event should happen when a chore has been extended or expired so that the UI updates. Currently, the user has to refresh to see this change.
|
||||
|
||||
**Root cause:** In `ParentView.vue`, three call sites called `resetExpiryTimers()` synchronously — before `refresh()` resolved — so new timers were set against stale item data. `ChildView.vue` already delayed the call with `setTimeout(..., 300)` correctly.
|
||||
|
||||
**Fix:** (`frontend/vue-app/src/components/child/ParentView.vue`)
|
||||
|
||||
- In `handleChoreScheduleModified`, `handleChoreTimeExtended`, and `onScheduleSaved`: replaced `resetExpiryTimers()` with `setTimeout(() => resetExpiryTimers(), 300)` to match the existing correct pattern in `ChildView.vue`.
|
||||
|
||||
#### Additional Test Cases With This Fix
|
||||
|
||||
- []
|
||||
|
||||
### User Test Findings:
|
||||
|
||||
I'm still not seeing the chore update back to active state when 'Extend time' is selected from the menu. I have to refresh the page to see the change.
|
||||
|
||||
**Root cause of remaining failure:** `doExtendTime` posted to the API and relied entirely on the SSE `chore_time_extended` event to trigger a refresh. If SSE delivery is delayed or the event is missed, the UI never updates. The previous fix (timer delay) only corrected expiry timer scheduling, not the extend-time response path.
|
||||
|
||||
**Additional fix:** (`frontend/vue-app/src/components/child/ParentView.vue`)
|
||||
|
||||
- In `doExtendTime`: after a successful API response, call `childChoreListRef.value?.refresh()` and `setTimeout(() => resetExpiryTimers(), 300)` directly. Added an early `return` on error to prevent a refresh on failure.
|
||||
|
||||
---
|
||||
|
||||
### On the Every X Days scheduler, the every [x] input box and day dropbox should be on the same line
|
||||
|
||||
Currently they are on seperate lines.
|
||||
|
||||
**Root cause:** `.interval-row` and `.interval-time-row` shared a combined CSS rule that included `flex-wrap: wrap`, causing the interval input and day select to wrap onto a new line at the modal's narrow width.
|
||||
|
||||
**Fix:** (`frontend/vue-app/src/components/shared/ScheduleModal.vue`)
|
||||
|
||||
- Split the combined `.interval-row, .interval-time-row` rule into two separate rules. Removed `flex-wrap: wrap` from `.interval-row` only; kept it on `.interval-time-row`.
|
||||
|
||||
#### Additional Test Cases With This Fix
|
||||
|
||||
- []
|
||||
|
||||
### User Test Findings:
|
||||
|
||||
Fix confirmed
|
||||
|
||||
---
|
||||
|
||||
### In the chore scheduler, the time selector needs to be much smaller
|
||||
|
||||
The time selector should have a smaller font size, borders, up/down arrows. A font size of 1rem should be fine.
|
||||
|
||||
**Root cause:** `.time-value` had `font-size: 1.4rem` and `.time-separator` had `font-size: 1.6rem`. All button/value boxes were `2.5rem` wide/tall. A `@media (max-width: 480px)` block made them even larger on small screens.
|
||||
|
||||
**Fix:** (`frontend/vue-app/src/components/shared/TimeSelector.vue`)
|
||||
|
||||
- `.arrow-btn` and `.time-value`: width/height `2.5rem` → `1.8rem`; arrow font `0.85rem` → `0.75rem`.
|
||||
- `.time-value`: `font-size: 1.4rem` → `1rem`.
|
||||
- `.time-separator`: `font-size: 1.6rem` → `1rem`.
|
||||
- `.ampm-btn`: width `3rem` → `2.2rem`; height `2.5rem` → `1.8rem`; font `0.95rem` → `0.8rem`.
|
||||
- Removed the `@media (max-width: 480px)` block that was enlarging all sizes on mobile.
|
||||
|
||||
#### Additional Test Cases With This Fix
|
||||
|
||||
- [x]
|
||||
|
||||
### User Test Findings:
|
||||
|
||||
Fix confirmed
|
||||
182
.github/specs/feat-calendar-chore/feat-calendar-schedule-refactor-01.md
vendored
Normal file
182
.github/specs/feat-calendar-chore/feat-calendar-schedule-refactor-01.md
vendored
Normal file
@@ -0,0 +1,182 @@
|
||||
# Feature: Daily chore scheduler refactor phase 1
|
||||
|
||||
## Overview
|
||||
|
||||
**Parent Feature:** .github/feat-calenar-chore/feat-calendar-chore.md
|
||||
|
||||
**Goal:** UI refactor of the 'Specific Days' portion of the chore scheduler so that it is not so complicated.
|
||||
|
||||
**User Story:**
|
||||
|
||||
- As a parent, I will be able to select an assigned chore and configure it to occur on 'Specific Days' as before, except I will be presented with a much easier to use interface.
|
||||
|
||||
**Rules:**
|
||||
|
||||
- Follow instructions from .github/copilot-instructions.md
|
||||
|
||||
**Design:**
|
||||
|
||||
- Keep the UI for 'Every X Days' the same for now, this will change in phase 2
|
||||
- Remove days of the week, time selectors, and checkboxes
|
||||
- Follow at 'Default Time' pattern with optional time for expiry
|
||||
|
||||
**Architecture:**
|
||||
|
||||
1. The Design Logic
|
||||
The interface shifts from a list of tasks to a set of rules.
|
||||
|
||||
- The "Base" State: You see 7 day chips (Su, Mo, Tu, We, Th, Fr, Sa) and one "Default Deadline" box.
|
||||
- The "Active" State: Days you click become "Active."
|
||||
- The "Silent" State: Any day not clicked is ignored by the system.
|
||||
|
||||
2. The Architecture
|
||||
Think of the system as having two layers of memory:
|
||||
|
||||
- The Global Layer: This holds the "Master Time" (e.g., 8:00 AM).
|
||||
- The Exception Layer: This is an empty list that only fills up if you explicitly say a day is "special."
|
||||
- The Merge Logic: When the system saves, it looks at each selected day. It asks: "Does this day have a special time in the Exception Layer? No? Okay, then use the Master Time."
|
||||
|
||||
3. The "When/Then" Flow
|
||||
Here is exactly how the interaction feels for the user:
|
||||
|
||||
- Step A: Establishing the Routine
|
||||
When you click Monday, Wednesday, and Friday...
|
||||
Then those days highlight, and the "Default Deadline" box becomes active.
|
||||
When you set that box to 8:00 AM...
|
||||
Then the system internally marks all three days as "8:00 AM."
|
||||
|
||||
- Step B: Adding a Day ()
|
||||
When you suddenly decide to add Sunday...
|
||||
Then Sunday highlights and automatically adopts the 8:00 AM deadline.
|
||||
- Step C: Breaking the Routine
|
||||
When you click "Set different time" and choose Sunday...
|
||||
Then a new, specific time box appears just for Sunday.
|
||||
When you change Sunday to 11:00 AM...
|
||||
Then the system "unhooks" Sunday from the Master Time. Sunday is now an Exception.
|
||||
- Step D: Changing the Master Time
|
||||
When you later change the "Default Deadline" from 8:00 AM to 9:00 AM...
|
||||
Then Monday, Wednesday, and Friday all update to 9:00 AM automatically.
|
||||
But Sunday stays at 11:00 AM because it is locked as an exception.
|
||||
|
||||
Instead of treating all 7 days as individual, equal data points, we treat them as a Group that follows a Rule.
|
||||
|
||||
- The Group: The days you selected (e.g., Mon, Wed, Fri, Sun).
|
||||
- The Rule: The "Default Deadline" that applies to the whole group (e.g., 8:00 AM).
|
||||
- The Exception: A specific day that breaks the rule (e.g., "Actually, make Sunday 11:00 AM").
|
||||
|
||||
**Time Selector Design:**
|
||||
We might need to create a new time selector or just add an additional time selector component
|
||||
|
||||
1. The "Columnar" Picker
|
||||
This popover is split into three distinct columns: Hours, Minutes, and AM/PM.
|
||||
When you click the time box...
|
||||
Then a small panel opens with three narrow, scrollable columns.
|
||||
The Logic: The "Minutes" column only contains four options: :00, :15, :30, :45.
|
||||
The Flow: The user's eye scans horizontally. "8" → "30" → "PM".
|
||||
|
||||
**The "High-Level" Combined Flow:**
|
||||
Selection: User clicks Monday.
|
||||
Trigger: User clicks the "Deadline" box.
|
||||
The Picker: The Columnar Picker pops up.
|
||||
The Snap: The user clicks "8" in the first column and "00" in the second.
|
||||
The Result: The box now displays "08:00 AM."
|
||||
|
||||
The "Auto-Apply" Flow
|
||||
When the user clicks a value (e.g., "AM" or "PM"), the selection is registered immediately.
|
||||
When the user clicks anywhere outside the popover, it closes automatically.
|
||||
Then the main UI updates to show the new time.
|
||||
|
||||
---
|
||||
|
||||
## Data Model Changes
|
||||
|
||||
### Backend Models
|
||||
|
||||
`ChoreSchedule` gains two new fields (persisted in TinyDB, returned in API responses):
|
||||
|
||||
- `default_hour: int = 8` — the master deadline hour for `mode='days'`
|
||||
- `default_minute: int = 0` — the master deadline minute for `mode='days'`
|
||||
|
||||
`from_dict` defaults both to `8` / `0` for backwards compatibility with existing DB records.
|
||||
|
||||
### Frontend Models
|
||||
|
||||
`ChoreSchedule` interface gains two optional fields (optional for backwards compat with old API responses):
|
||||
|
||||
- `default_hour?: number`
|
||||
- `default_minute?: number`
|
||||
|
||||
---
|
||||
|
||||
## Frontend Design
|
||||
|
||||
- `TimePickerPopover.vue` — new shared component at `frontend/vue-app/src/components/shared/TimePickerPopover.vue`
|
||||
- `ScheduleModal.vue` — "Specific Days" section fully replaced; "Every X Days" section unchanged
|
||||
|
||||
## Backend Implementation
|
||||
|
||||
No backend implementation required for phase 1.
|
||||
|
||||
---
|
||||
|
||||
## Backend Tests
|
||||
|
||||
- [x] No backend changes required in phase 1
|
||||
|
||||
---
|
||||
|
||||
## Frontend Implementation
|
||||
|
||||
- [x] Created `frontend/vue-app/src/components/shared/TimePickerPopover.vue`
|
||||
- Props: `modelValue: { hour: number, minute: number }`, emits `update:modelValue`
|
||||
- Displays formatted time as a clickable button (e.g. `08:00 AM`)
|
||||
- Opens a columnar popover with three columns: Hour (1–12), Minute (:00/:15/:30/:45), AM/PM
|
||||
- Clicking any column value updates the model immediately
|
||||
- Closes on outside click via `mousedown` document listener
|
||||
- Fully scoped styles using CSS variables from `colors.css`
|
||||
- [x] Refactored `ScheduleModal.vue` — "Specific Days" section
|
||||
- Replaced 7 checkbox rows + per-row `TimeSelector` with chip-based design
|
||||
- **Day chips row**: 7 short chips (Su Mo Tu We Th Fr Sa) — click to toggle active/inactive
|
||||
- **Default Deadline row**: shown when ≥1 day selected; single `TimePickerPopover` sets the master time for all non-exception days
|
||||
- **Selected day list**: one row per active day (sorted Sun→Sat); each row shows:
|
||||
- Day name
|
||||
- If no exception: italic "Default (HH:MM AM/PM)" label + "Set different time" link
|
||||
- If exception set: a `TimePickerPopover` for that day's override + "Reset to default" link
|
||||
- State: `selectedDays: Set<number>`, `defaultTime: TimeValue`, `exceptions: Map<number, TimeValue>`
|
||||
- Load logic: first `day_config` entry sets `defaultTime`; entries differing from it populate `exceptions`
|
||||
- Save logic: iterates `selectedDays`, applies exception time or falls back to `defaultTime` → `DayConfig[]`
|
||||
- "Every X Days" mode left unchanged
|
||||
- Validation: unchanged (`selectedDays.size > 0`)
|
||||
|
||||
---
|
||||
|
||||
## Frontend Tests
|
||||
|
||||
- [ ] `TimePickerPopover.vue`: renders formatted time, opens/closes popover, selecting hour/minute/period emits correct value, closes on outside click
|
||||
- [ ] `ScheduleModal.vue` (Specific Days): chip toggles add/remove from selected set; removing a day also removes its exception; setting a different time creates an exception; resetting removes exception; changing default time does not override exceptions; save payload shape matches `DayConfig[]` with correct times; loading an existing mixed-time schedule restores chips, defaultTime, and exceptions correctly
|
||||
|
||||
---
|
||||
|
||||
## Future Considerations
|
||||
|
||||
---
|
||||
|
||||
## Acceptance Criteria (Definition of Done)
|
||||
|
||||
### Backend
|
||||
|
||||
- [x] No backend changes required — existing `DayConfig { day, hour, minute }` model fully supports the new UI
|
||||
|
||||
### Frontend
|
||||
|
||||
- [x] "Specific Days" mode shows 7 day chips instead of checkboxes
|
||||
- [x] Selecting chips shows a single "Default Deadline" time picker
|
||||
- [x] Selected day list shows each active day with either its default label or an exception time picker
|
||||
- [x] "Set different time" link creates a per-day exception that overrides the default
|
||||
- [x] "Reset to default" link removes the exception and the day reverts to the master time
|
||||
- [x] Changing the Default Deadline updates all non-exception days (by using `defaultTime` at save time)
|
||||
- [x] "Every X Days" mode is unchanged
|
||||
- [x] Existing schedules load correctly (first entry = default, differing times = exceptions)
|
||||
- [x] Save payload is valid `DayConfig[]` consumed by the existing API unchanged
|
||||
- [x] New `TimePickerPopover` component: columnar Hour/Minute/AMPM picker, closes on outside click
|
||||
- [ ] Frontend component tests written and passing for `TimePickerPopover` and the refactored `ScheduleModal`
|
||||
@@ -4,7 +4,7 @@ from api.utils import get_validated_user_id, send_event_for_current_user
|
||||
from api.error_codes import ErrorCodes
|
||||
from db.db import child_db
|
||||
from db.chore_schedules import get_schedule, upsert_schedule, delete_schedule
|
||||
from db.task_extensions import get_extension, add_extension
|
||||
from db.task_extensions import get_extension, add_extension, delete_extension_for_child_task
|
||||
from models.chore_schedule import ChoreSchedule
|
||||
from models.task_extension import TaskExtension
|
||||
from events.types.event import Event
|
||||
@@ -58,11 +58,15 @@ def set_chore_schedule(child_id, task_id):
|
||||
day_configs = data.get('day_configs', [])
|
||||
if not isinstance(day_configs, list):
|
||||
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)
|
||||
schedule = ChoreSchedule(
|
||||
child_id=child_id,
|
||||
task_id=task_id,
|
||||
mode='days',
|
||||
day_configs=day_configs,
|
||||
default_hour=default_hour,
|
||||
default_minute=default_minute,
|
||||
)
|
||||
else:
|
||||
interval_days = data.get('interval_days', 2)
|
||||
@@ -83,6 +87,7 @@ def set_chore_schedule(child_id, task_id):
|
||||
interval_minute=interval_minute,
|
||||
)
|
||||
|
||||
delete_extension_for_child_task(child_id, task_id)
|
||||
upsert_schedule(schedule)
|
||||
|
||||
send_event_for_current_user(Event(
|
||||
|
||||
@@ -25,3 +25,8 @@ def delete_extensions_for_child(child_id: str) -> None:
|
||||
def delete_extensions_for_task(task_id: str) -> None:
|
||||
q = Query()
|
||||
task_extensions_db.remove(q.task_id == task_id)
|
||||
|
||||
|
||||
def delete_extension_for_child_task(child_id: str, task_id: str) -> None:
|
||||
q = Query()
|
||||
task_extensions_db.remove((q.child_id == child_id) & (q.task_id == task_id))
|
||||
|
||||
@@ -59,9 +59,15 @@ def sse_response_for_user(user_id: str):
|
||||
def generate():
|
||||
try:
|
||||
while True:
|
||||
# Get message from queue (blocks until available)
|
||||
message = user_queue.get()
|
||||
try:
|
||||
# Use a timeout so the thread yields periodically and keepalives are sent.
|
||||
# This prevents Werkzeug's dev server from starving other connections.
|
||||
message = user_queue.get(timeout=15)
|
||||
yield message
|
||||
logger.info(f"Sent message to {user_id} connection {connection_id}")
|
||||
except queue.Empty:
|
||||
# Send an SSE comment as a keepalive ping to maintain the connection.
|
||||
yield b': ping\n\n'
|
||||
except GeneratorExit:
|
||||
# Clean up when client disconnects
|
||||
if user_id in user_queues and connection_id in user_queues[user_id]:
|
||||
|
||||
@@ -33,6 +33,8 @@ class ChoreSchedule(BaseModel):
|
||||
|
||||
# mode='days' fields
|
||||
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
|
||||
|
||||
# mode='interval' fields
|
||||
interval_days: int = 2 # 2–7
|
||||
@@ -47,6 +49,8 @@ class ChoreSchedule(BaseModel):
|
||||
task_id=d.get('task_id'),
|
||||
mode=d.get('mode', 'days'),
|
||||
day_configs=d.get('day_configs', []),
|
||||
default_hour=d.get('default_hour', 8),
|
||||
default_minute=d.get('default_minute', 0),
|
||||
interval_days=d.get('interval_days', 2),
|
||||
anchor_weekday=d.get('anchor_weekday', 0),
|
||||
interval_hour=d.get('interval_hour', 0),
|
||||
@@ -63,6 +67,8 @@ class ChoreSchedule(BaseModel):
|
||||
'task_id': self.task_id,
|
||||
'mode': self.mode,
|
||||
'day_configs': self.day_configs,
|
||||
'default_hour': self.default_hour,
|
||||
'default_minute': self.default_minute,
|
||||
'interval_days': self.interval_days,
|
||||
'anchor_weekday': self.anchor_weekday,
|
||||
'interval_hour': self.interval_hour,
|
||||
|
||||
@@ -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,9 +711,11 @@ function goToAssignRewards() {
|
||||
>
|
||||
⋮
|
||||
</button>
|
||||
<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.stop
|
||||
>
|
||||
@@ -714,6 +738,7 @@ function goToAssignRewards() {
|
||||
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]) => ({
|
||||
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: t.hour,
|
||||
minute: t.minute,
|
||||
}))
|
||||
payload = { mode: 'days', day_configs }
|
||||
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