Refactor code structure for improved readability and maintainability
All checks were successful
Chore App Build, Test, and Push Docker Images / build-and-push (push) Successful in 3m27s
58
.github/specs/feat-nav-selector-icons.md
vendored
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
# Feature: Change navigation selector icons
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
**Goal:** The current view navigation that is displayed in parent mode currently displays custom svg drawing for Children, Tasks, Rewards, and Notifications. I have new png image files that I'd like to replace them with.
|
||||||
|
|
||||||
|
**User Story:**
|
||||||
|
As a user, when I am in parent mode, I should be able to use the navigation selector as usual but see the updated images in the buttons.
|
||||||
|
|
||||||
|
**Rules:**
|
||||||
|
The size of the navigation view-selector should still remain the same for both desktop and mobile. The images should replace the svg drawings.
|
||||||
|
This will most likely only affect ParentView.vue / ParentLayout.vue
|
||||||
|
|
||||||
|
**Drawing to image replacements:**
|
||||||
|
aria-label: "Children" -> frontend/vue-app/public/nav/children.png
|
||||||
|
aria-label: "Tasks" -> frontend/vue-app/public/nav/chores.png
|
||||||
|
aria-label: "Rewards" -> frontend/vue-app/public/nav/rewards.png
|
||||||
|
aria-label: "Notifications" -> frontend/vue-app/public/nav/notifications.png
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Data Model Changes
|
||||||
|
|
||||||
|
### Backend Model
|
||||||
|
|
||||||
|
### Frontend Model
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Backend Implementation
|
||||||
|
|
||||||
|
## Backend Tests
|
||||||
|
|
||||||
|
- [ ]
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Frontend Implementation
|
||||||
|
|
||||||
|
## Frontend Tests
|
||||||
|
|
||||||
|
- [ ]
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Future Considerations
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Acceptance Criteria (Definition of Done)
|
||||||
|
|
||||||
|
### Backend
|
||||||
|
|
||||||
|
- [ ]
|
||||||
|
|
||||||
|
### Frontend
|
||||||
|
|
||||||
|
- [ ]
|
||||||
2
.gitignore
vendored
@@ -6,3 +6,5 @@ frontend/vue-app/playwright-report/
|
|||||||
frontend/vue-app/test-results/
|
frontend/vue-app/test-results/
|
||||||
backend/test-results/
|
backend/test-results/
|
||||||
.vscode/keybindings.json
|
.vscode/keybindings.json
|
||||||
|
.DS_Store
|
||||||
|
**/.DS_Store
|
||||||
|
|||||||
BIN
frontend/vue-app/public/nav/children.png
Normal file
|
After Width: | Height: | Size: 905 KiB |
|
Before Width: | Height: | Size: 63 KiB After Width: | Height: | Size: 63 KiB |
|
Before Width: | Height: | Size: 27 KiB After Width: | Height: | Size: 27 KiB |
|
Before Width: | Height: | Size: 34 KiB After Width: | Height: | Size: 34 KiB |
|
Before Width: | Height: | Size: 24 KiB After Width: | Height: | Size: 24 KiB |
@@ -301,7 +301,10 @@ function handleChoreConfirmation(event: Event) {
|
|||||||
function isChoreCompletedToday(item: ChildTask): boolean {
|
function isChoreCompletedToday(item: ChildTask): boolean {
|
||||||
if (item.pending_status !== 'approved' || !item.approved_at) return false
|
if (item.pending_status !== 'approved' || !item.approved_at) return false
|
||||||
const today = new Date().toISOString().slice(0, 10)
|
const today = new Date().toISOString().slice(0, 10)
|
||||||
return item.approved_at.slice(0, 10) === today
|
if (item.approved_at.slice(0, 10) !== today) return false
|
||||||
|
// If the task has a schedule and today is not a scheduled day, don't show as completed
|
||||||
|
if (item.schedule && !isScheduledToday(item.schedule, new Date())) return false
|
||||||
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
function isChorePending(item: ChildTask): boolean {
|
function isChorePending(item: ChildTask): boolean {
|
||||||
|
|||||||
@@ -1,7 +1,13 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { useRouter, useRoute } from 'vue-router'
|
import { useRouter, useRoute } from 'vue-router'
|
||||||
import { computed, ref, onMounted } from 'vue'
|
import { computed, ref, onMounted, onUnmounted } from 'vue'
|
||||||
import LoginButton from '../components/shared/LoginButton.vue'
|
import LoginButton from '../components/shared/LoginButton.vue'
|
||||||
|
import { eventBus } from '@/common/eventBus'
|
||||||
|
import type {
|
||||||
|
Event,
|
||||||
|
ChildRewardRequestEventPayload,
|
||||||
|
ChildChoreConfirmationPayload,
|
||||||
|
} from '@/common/models'
|
||||||
|
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
@@ -30,9 +36,42 @@ const showBack = computed(
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// Notification badge count
|
||||||
|
const notificationCount = ref(0)
|
||||||
|
|
||||||
|
async function fetchNotificationCount() {
|
||||||
|
try {
|
||||||
|
const resp = await fetch('/api/pending-confirmations')
|
||||||
|
if (resp.ok) {
|
||||||
|
const data = await resp.json()
|
||||||
|
notificationCount.value = Array.isArray(data.confirmations) ? data.confirmations.length : 0
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
notificationCount.value = 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleRewardRequestBadge(event: Event) {
|
||||||
|
const payload = event.payload as ChildRewardRequestEventPayload
|
||||||
|
if (['CREATED', 'CANCELLED', 'GRANTED'].includes(payload.operation)) {
|
||||||
|
fetchNotificationCount()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleChoreConfirmationBadge(event: Event) {
|
||||||
|
const payload = event.payload as ChildChoreConfirmationPayload
|
||||||
|
if (['CONFIRMED', 'APPROVED', 'REJECTED', 'CANCELLED'].includes(payload.operation)) {
|
||||||
|
fetchNotificationCount()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Version fetching
|
// Version fetching
|
||||||
const appVersion = ref('')
|
const appVersion = ref('')
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
|
await fetchNotificationCount()
|
||||||
|
eventBus.on('child_reward_request', handleRewardRequestBadge)
|
||||||
|
eventBus.on('child_chore_confirmation', handleChoreConfirmationBadge)
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const resp = await fetch('/api/version')
|
const resp = await fetch('/api/version')
|
||||||
if (resp.ok) {
|
if (resp.ok) {
|
||||||
@@ -43,6 +82,11 @@ onMounted(async () => {
|
|||||||
appVersion.value = ''
|
appVersion.value = ''
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
eventBus.off('child_reward_request', handleRewardRequestBadge)
|
||||||
|
eventBus.off('child_chore_confirmation', handleChoreConfirmationBadge)
|
||||||
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -69,22 +113,7 @@ onMounted(async () => {
|
|||||||
aria-label="Children"
|
aria-label="Children"
|
||||||
title="Children"
|
title="Children"
|
||||||
>
|
>
|
||||||
<!-- Children Icon -->
|
<img src="/nav/children.png" class="nav-icon" alt="Children" />
|
||||||
<svg
|
|
||||||
width="24"
|
|
||||||
height="24"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
stroke-width="1.7"
|
|
||||||
stroke-linecap="round"
|
|
||||||
stroke-linejoin="round"
|
|
||||||
>
|
|
||||||
<circle cx="8" cy="10" r="3" />
|
|
||||||
<circle cx="16" cy="10" r="3" />
|
|
||||||
<path d="M2 20c0-2.5 3-4.5 6-4.5s6 2 6 4.5" />
|
|
||||||
<path d="M10 20c0-2 2-3.5 6-3.5s6 1.5 6 3.5" />
|
|
||||||
</svg>
|
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
:class="{
|
:class="{
|
||||||
@@ -105,21 +134,7 @@ onMounted(async () => {
|
|||||||
aria-label="Tasks"
|
aria-label="Tasks"
|
||||||
title="Tasks"
|
title="Tasks"
|
||||||
>
|
>
|
||||||
<!-- Book Icon -->
|
<img src="/nav/chores.png" class="nav-icon" alt="Tasks" />
|
||||||
<svg
|
|
||||||
width="24"
|
|
||||||
height="24"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
stroke-width="1.7"
|
|
||||||
stroke-linecap="round"
|
|
||||||
stroke-linejoin="round"
|
|
||||||
>
|
|
||||||
<path d="M4 19.5A2.5 2.5 0 0 1 6.5 17H20" />
|
|
||||||
<path d="M20 22V6a2 2 0 0 0-2-2H6.5A2.5 2.5 0 0 0 4 6.5v13" />
|
|
||||||
<path d="M16 2v4" />
|
|
||||||
</svg>
|
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
:class="{
|
:class="{
|
||||||
@@ -129,23 +144,7 @@ onMounted(async () => {
|
|||||||
aria-label="Rewards"
|
aria-label="Rewards"
|
||||||
title="Rewards"
|
title="Rewards"
|
||||||
>
|
>
|
||||||
<!-- Trophy Icon -->
|
<img src="/nav/rewards.png" class="nav-icon" alt="Rewards" />
|
||||||
<svg
|
|
||||||
width="24"
|
|
||||||
height="24"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
stroke-width="1.7"
|
|
||||||
stroke-linecap="round"
|
|
||||||
stroke-linejoin="round"
|
|
||||||
>
|
|
||||||
<path d="M8 21h8" />
|
|
||||||
<path d="M12 17v4" />
|
|
||||||
<path d="M17 17a5 5 0 0 0 5-5V7h-4" />
|
|
||||||
<path d="M7 17a5 5 0 0 1-5-5V7h4" />
|
|
||||||
<rect x="7" y="2" width="10" height="15" rx="5" />
|
|
||||||
</svg>
|
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
:class="{ active: ['NotificationView'].includes(String(route.name)) }"
|
:class="{ active: ['NotificationView'].includes(String(route.name)) }"
|
||||||
@@ -153,22 +152,10 @@ onMounted(async () => {
|
|||||||
aria-label="Notifications"
|
aria-label="Notifications"
|
||||||
title="Notifications"
|
title="Notifications"
|
||||||
>
|
>
|
||||||
<!-- Notification/Bell Icon -->
|
<span class="nav-icon-wrap">
|
||||||
<svg
|
<img src="/nav/notifications.png" class="nav-icon" alt="Notifications" />
|
||||||
width="24"
|
<span v-if="notificationCount > 0" class="notification-badge"></span>
|
||||||
height="24"
|
</span>
|
||||||
viewBox="0 0 24 24"
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
stroke-width="1.7"
|
|
||||||
stroke-linecap="round"
|
|
||||||
stroke-linejoin="round"
|
|
||||||
>
|
|
||||||
<path d="M18 16v-5a6 6 0 1 0-12 0v5" />
|
|
||||||
<path d="M2 16h20" />
|
|
||||||
<path d="M8 20a4 4 0 0 0 8 0" />
|
|
||||||
<circle cx="19" cy="7" r="2" fill="#ef4444" stroke="none" />
|
|
||||||
</svg>
|
|
||||||
</button>
|
</button>
|
||||||
</nav>
|
</nav>
|
||||||
<div v-else class="spacer"></div>
|
<div v-else class="spacer"></div>
|
||||||
@@ -293,8 +280,37 @@ onMounted(async () => {
|
|||||||
color: var(--button-active-text);
|
color: var(--button-active-text);
|
||||||
}
|
}
|
||||||
|
|
||||||
.view-selector button.active svg {
|
.nav-icon {
|
||||||
stroke: var(--button-active-text);
|
width: 24px;
|
||||||
|
height: 24px;
|
||||||
|
object-fit: contain;
|
||||||
|
opacity: 0.55;
|
||||||
|
transition: opacity 0.18s;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-icon-wrap {
|
||||||
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notification-badge {
|
||||||
|
position: absolute;
|
||||||
|
top: -3px;
|
||||||
|
right: -3px;
|
||||||
|
width: 8px;
|
||||||
|
height: 8px;
|
||||||
|
background: var(--btn-danger, #ef4444);
|
||||||
|
border-radius: 50%;
|
||||||
|
border: 1.5px solid var(--button-bg, #fff);
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.view-selector button.active .nav-icon,
|
||||||
|
.view-selector button:hover .nav-icon {
|
||||||
|
opacity: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
.view-selector button:hover:not(.active) {
|
.view-selector button:hover:not(.active) {
|
||||||
|
|||||||