Add detailed Copilot instructions and enhance child API logging
All checks were successful
Gitea Actions Demo / build-and-push (push) Successful in 14s
All checks were successful
Gitea Actions Demo / build-and-push (push) Successful in 14s
- Introduced a comprehensive instructions document for the Reward project, outlining architecture, data flow, key patterns, and developer workflows. - Enhanced logging in the child API to track points and reward costs, improving error handling for insufficient points. - Updated Vue components to reflect changes in reward handling and improve user experience with pending rewards.
This commit is contained in:
61
.github/copilot-instructions.md
vendored
Normal file
61
.github/copilot-instructions.md
vendored
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
# Reward Project: AI Coding Agent Instructions
|
||||||
|
|
||||||
|
## 🏗️ Architecture & Data Flow
|
||||||
|
|
||||||
|
- **Stack**: Flask (Python, backend) + Vue 3 (TypeScript, frontend) + TinyDB (JSON, thread-safe, see `db/`).
|
||||||
|
- **API**: RESTful endpoints in `api/`, grouped by entity (child, reward, task, user, image, etc). Each API file maps to a business domain.
|
||||||
|
- **Models**: Maintain strict 1:1 mapping between Python `@dataclass`es (`models/`) and TypeScript interfaces (`web/vue-app/src/common/models.ts`).
|
||||||
|
- **Database**: Use TinyDB with `from_dict()`/`to_dict()` for serialization. All logic should operate on model instances, not raw dicts.
|
||||||
|
- **Events**: Real-time updates via Server-Sent Events (SSE). Every mutation (add/edit/delete/trigger) must call `send_event_for_current_user` (see `events/`).
|
||||||
|
- **Changes**: Do not use comments to replace code. All changes must be reflected in both backend and frontend files as needed.
|
||||||
|
|
||||||
|
## 🧩 Key Patterns & Conventions
|
||||||
|
|
||||||
|
- **Frontend Styling**: Use only `:root` CSS variables from `global.css` for all colors, spacing, and tokens. Example: `--btn-primary`, `--list-item-bg-good`.
|
||||||
|
- **Scoped Styles**: All `.vue` files must use `<style scoped>`. Reference global variables for theme consistency.
|
||||||
|
- **Rewards UI**: If `points >= cost`, apply `--item-card-ready-shadow` and `--item-card-ready-border`.
|
||||||
|
- **API Error Handling**: Backend returns JSON with `error` and `code` (see `api/error_codes.py`). Frontend extracts `{ msg, code }` using `parseErrorResponse(res)` from `api.ts`.
|
||||||
|
- **Validation**: Use `isEmailValid` and `isPasswordStrong` (min 8 chars, 1 letter, 1 number) from `api.ts` for all user input. Use `sanitize_email()` for directory names and unique IDs.
|
||||||
|
- **JWT Auth**: Tokens are stored in HttpOnly, Secure, SameSite=Strict cookies.
|
||||||
|
|
||||||
|
## 🚦 Frontend Logic & Event Bus
|
||||||
|
|
||||||
|
- **SSE Event Management**: Register listeners in `onMounted`, clean up in `onUnmounted`. Listen for events like `child_task_triggered`, `child_reward_request`, `task_modified`, etc. See `web/vue-app/src/common/backendEvents.ts` and `components/BackendEventsListener.vue`.
|
||||||
|
- **UI Guardrails**:
|
||||||
|
- Before triggering a task, check for pending rewards. If found, prompt for cancellation before proceeding.
|
||||||
|
- On `EDIT`, always refetch the full object from the API to ensure state integrity.
|
||||||
|
- **Layout Hierarchy**: Use `ParentLayout` for admin/management, `ChildLayout` for dashboard/focus views.
|
||||||
|
|
||||||
|
## ⚖️ Business Logic & Safeguards
|
||||||
|
|
||||||
|
- **Points**: Always enforce `child.points = max(child.points, 0)` after any mutation.
|
||||||
|
- **Token Expiry**: Verification tokens expire in 4 hours; password reset tokens in 10 minutes.
|
||||||
|
- **Image Assets**: Models use `image_id` for storage; frontend resolves to `image_url` for rendering.
|
||||||
|
|
||||||
|
## 🛠️ Developer Workflows
|
||||||
|
|
||||||
|
- **Backend**: Run Flask with `python -m flask run --host=0.0.0.0 --port=5000` from project root. Main entry: `main.py`.
|
||||||
|
- **Frontend**: From `web/vue-app/`, run `npm install` then `npm run dev`.
|
||||||
|
- **Tests**: Run backend tests with `pytest tests/`. Frontend tests: `npm run test` in `web/vue-app/`.
|
||||||
|
- **Debugging**: Use VS Code launch configs or run Flask/Vue dev servers directly. For SSE, use browser dev tools to inspect event streams.
|
||||||
|
|
||||||
|
## 📁 Key Files & Directories
|
||||||
|
|
||||||
|
- `api/` — Flask API endpoints (one file per entity)
|
||||||
|
- `models/` — Python dataclasses (business logic, serialization)
|
||||||
|
- `db/` — TinyDB setup and helpers
|
||||||
|
- `events/` — SSE event types, broadcaster, payloads
|
||||||
|
- `web/vue-app/` — Vue 3 frontend (see `src/common/`, `src/components/`, `src/layout/`)
|
||||||
|
- `web/vue-app/src/common/models.ts` — TypeScript interfaces (mirror Python models)
|
||||||
|
- `web/vue-app/src/common/api.ts` — API helpers, error parsing, validation
|
||||||
|
- `web/vue-app/src/common/backendEvents.ts` — SSE event types and handlers
|
||||||
|
|
||||||
|
## 🧠 Integration & Cross-Component Patterns
|
||||||
|
|
||||||
|
- **Every backend mutation must trigger an SSE event** for the current user.
|
||||||
|
- **Frontend state is event-driven**: always listen for and react to SSE events for real-time updates.
|
||||||
|
- **Model changes require updating both Python and TypeScript definitions** to maintain parity.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
For any unclear or missing conventions, review the referenced files or ask for clarification. Keep this document concise and actionable for AI agents.
|
||||||
@@ -21,8 +21,10 @@ from models.child import Child
|
|||||||
from models.pending_reward import PendingReward
|
from models.pending_reward import PendingReward
|
||||||
from models.reward import Reward
|
from models.reward import Reward
|
||||||
from models.task import Task
|
from models.task import Task
|
||||||
|
import logging
|
||||||
|
|
||||||
child_api = Blueprint('child_api', __name__)
|
child_api = Blueprint('child_api', __name__)
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@child_api.route('/child/<name>', methods=['GET'])
|
@child_api.route('/child/<name>', methods=['GET'])
|
||||||
@child_api.route('/child/<id>', methods=['GET'])
|
@child_api.route('/child/<id>', methods=['GET'])
|
||||||
@@ -495,24 +497,30 @@ def trigger_child_reward(id):
|
|||||||
return jsonify({'error': 'Reward not found in reward database'}), 404
|
return jsonify({'error': 'Reward not found in reward database'}), 404
|
||||||
reward: Reward = Reward.from_dict(reward_result[0])
|
reward: Reward = Reward.from_dict(reward_result[0])
|
||||||
|
|
||||||
|
# Check if child has enough points
|
||||||
|
logger.info(f'Child {child.name} has {child.points} points, reward {reward.name} costs {reward.cost} points')
|
||||||
|
if child.points < reward.cost:
|
||||||
|
points_needed = reward.cost - child.points
|
||||||
|
return jsonify({
|
||||||
|
'error': 'Insufficient points',
|
||||||
|
'points_needed': points_needed,
|
||||||
|
'current_points': child.points,
|
||||||
|
'reward_cost': reward.cost
|
||||||
|
}), 400
|
||||||
|
|
||||||
# Remove matching pending reward requests for this child and reward
|
# Remove matching pending reward requests for this child and reward
|
||||||
PendingQuery = Query()
|
PendingQuery = Query()
|
||||||
removed = pending_reward_db.remove(
|
removed = pending_reward_db.remove(
|
||||||
(PendingQuery.child_id == child.id) & (PendingQuery.reward_id == reward.id)
|
(PendingQuery.child_id == child.id) & (PendingQuery.reward_id == reward.id)
|
||||||
)
|
)
|
||||||
if removed:
|
if removed:
|
||||||
resp = send_event_for_current_user(Event(EventType.CHILD_REWARD_REQUEST.value, ChildRewardRequest(reward.id, child.id, ChildRewardRequest.REQUEST_GRANTED)))
|
send_event_for_current_user(Event(EventType.CHILD_REWARD_REQUEST.value, ChildRewardRequest(reward.id, child.id, ChildRewardRequest.REQUEST_GRANTED)))
|
||||||
if resp:
|
|
||||||
return resp
|
|
||||||
|
|
||||||
|
|
||||||
# update the child's points based on reward cost
|
# update the child's points based on reward cost
|
||||||
child.points -= reward.cost
|
child.points -= reward.cost
|
||||||
# update the child in the database
|
# update the child in the database
|
||||||
child_db.update({'points': child.points}, ChildQuery.id == id)
|
child_db.update({'points': child.points}, ChildQuery.id == id)
|
||||||
resp = send_event_for_current_user(Event(EventType.CHILD_REWARD_TRIGGERED.value, ChildRewardTriggered(reward.id, child.id, child.points)))
|
send_event_for_current_user(Event(EventType.CHILD_REWARD_TRIGGERED.value, ChildRewardTriggered(reward.id, child.id, child.points)))
|
||||||
if resp:
|
|
||||||
return resp
|
|
||||||
return jsonify({'message': f'{reward.name} assigned to {child.name}.', 'points': child.points, 'id': child.id}), 200
|
return jsonify({'message': f'{reward.name} assigned to {child.name}.', 'points': child.points, 'id': child.id}), 200
|
||||||
|
|
||||||
@child_api.route('/child/<id>/affordable-rewards', methods=['GET'])
|
@child_api.route('/child/<id>/affordable-rewards', methods=['GET'])
|
||||||
@@ -584,6 +592,7 @@ def request_reward(id):
|
|||||||
reward = Reward.from_dict(reward_result[0])
|
reward = Reward.from_dict(reward_result[0])
|
||||||
|
|
||||||
# Check if child has enough points
|
# Check if child has enough points
|
||||||
|
logger.info(f'Child {child.name} has {child.points} points, reward {reward.name} costs {reward.cost} points')
|
||||||
if child.points < reward.cost:
|
if child.points < reward.cost:
|
||||||
points_needed = reward.cost - child.points
|
points_needed = reward.cost - child.points
|
||||||
return jsonify({
|
return jsonify({
|
||||||
@@ -595,9 +604,8 @@ def request_reward(id):
|
|||||||
|
|
||||||
pending = PendingReward(child_id=child.id, reward_id=reward.id)
|
pending = PendingReward(child_id=child.id, reward_id=reward.id)
|
||||||
pending_reward_db.insert(pending.to_dict())
|
pending_reward_db.insert(pending.to_dict())
|
||||||
resp = send_event_for_current_user(Event(EventType.CHILD_REWARD_REQUEST.value, ChildRewardRequest(child.id, reward.id, ChildRewardRequest.REQUEST_CREATED)))
|
logger.info(f'Pending reward request created for child {child.name} for reward {reward.name}')
|
||||||
if resp:
|
send_event_for_current_user(Event(EventType.CHILD_REWARD_REQUEST.value, ChildRewardRequest(child.id, reward.id, ChildRewardRequest.REQUEST_CREATED)))
|
||||||
return resp
|
|
||||||
return jsonify({
|
return jsonify({
|
||||||
'message': f'Reward request for {reward.name} submitted for {child.name}.',
|
'message': f'Reward request for {reward.name} submitted for {child.name}.',
|
||||||
'reward_id': reward.id,
|
'reward_id': reward.id,
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ def get_queue(user_id: str, connection_id: str) -> queue.Queue:
|
|||||||
def send_to_user(user_id: str, data: Dict[str, Any]):
|
def send_to_user(user_id: str, data: Dict[str, Any]):
|
||||||
"""Send data to all connections for a specific user."""
|
"""Send data to all connections for a specific user."""
|
||||||
logger.info(f"Sending data to {user_id} user quesues are {user_queues.keys()}")
|
logger.info(f"Sending data to {user_id} user quesues are {user_queues.keys()}")
|
||||||
|
logger.info(f"Data: {data}")
|
||||||
if user_id in user_queues:
|
if user_id in user_queues:
|
||||||
logger.info(f"Queued {user_id}")
|
logger.info(f"Queued {user_id}")
|
||||||
# Format as SSE message once
|
# Format as SSE message once
|
||||||
@@ -37,8 +38,6 @@ def send_to_user(user_id: str, data: Dict[str, Any]):
|
|||||||
# Send to all connections for this user
|
# Send to all connections for this user
|
||||||
for connection_id, q in user_queues[user_id].items():
|
for connection_id, q in user_queues[user_id].items():
|
||||||
try:
|
try:
|
||||||
logger.info(f"Sending message to {connection_id}")
|
|
||||||
q.put(message)
|
|
||||||
q.put(message, block=False)
|
q.put(message, block=False)
|
||||||
except queue.Full:
|
except queue.Full:
|
||||||
# Skip if queue is full (connection might be dead)
|
# Skip if queue is full (connection might be dead)
|
||||||
@@ -61,7 +60,6 @@ def sse_response_for_user(user_id: str):
|
|||||||
try:
|
try:
|
||||||
while True:
|
while True:
|
||||||
# Get message from queue (blocks until available)
|
# Get message from queue (blocks until available)
|
||||||
logger.info(f"blocking on get for {user_id} user")
|
|
||||||
message = user_queue.get()
|
message = user_queue.get()
|
||||||
yield message
|
yield message
|
||||||
except GeneratorExit:
|
except GeneratorExit:
|
||||||
|
|||||||
@@ -1,8 +1,7 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, onMounted, onUnmounted } from 'vue'
|
import { ref, onMounted, onUnmounted, computed } from 'vue'
|
||||||
import { useRoute, useRouter } from 'vue-router'
|
import { useRoute, useRouter } from 'vue-router'
|
||||||
import ChildDetailCard from './ChildDetailCard.vue'
|
import ChildDetailCard from './ChildDetailCard.vue'
|
||||||
import ChildRewardList from '../reward/ChildRewardList.vue'
|
|
||||||
import ScrollingList from '../shared/ScrollingList.vue'
|
import ScrollingList from '../shared/ScrollingList.vue'
|
||||||
import { eventBus } from '@/common/eventBus'
|
import { eventBus } from '@/common/eventBus'
|
||||||
import '@/assets/view-shared.css'
|
import '@/assets/view-shared.css'
|
||||||
@@ -11,6 +10,7 @@ import type {
|
|||||||
Event,
|
Event,
|
||||||
Task,
|
Task,
|
||||||
Reward,
|
Reward,
|
||||||
|
RewardStatus,
|
||||||
ChildTaskTriggeredEventPayload,
|
ChildTaskTriggeredEventPayload,
|
||||||
ChildRewardTriggeredEventPayload,
|
ChildRewardTriggeredEventPayload,
|
||||||
ChildRewardRequestEventPayload,
|
ChildRewardRequestEventPayload,
|
||||||
@@ -33,8 +33,6 @@ const showRewardDialog = ref(false)
|
|||||||
const showCancelDialog = ref(false)
|
const showCancelDialog = ref(false)
|
||||||
const dialogReward = ref<Reward | null>(null)
|
const dialogReward = ref<Reward | null>(null)
|
||||||
const childRewardListRef = ref()
|
const childRewardListRef = ref()
|
||||||
const childChoreListRef = ref()
|
|
||||||
const childPenaltyListRef = ref()
|
|
||||||
|
|
||||||
function handleTaskTriggered(event: Event) {
|
function handleTaskTriggered(event: Event) {
|
||||||
const payload = event.payload as ChildTaskTriggeredEventPayload
|
const payload = event.payload as ChildTaskTriggeredEventPayload
|
||||||
@@ -47,7 +45,6 @@ function handleRewardTriggered(event: Event) {
|
|||||||
const payload = event.payload as ChildRewardTriggeredEventPayload
|
const payload = event.payload as ChildRewardTriggeredEventPayload
|
||||||
if (child.value && payload.child_id == child.value.id) {
|
if (child.value && payload.child_id == child.value.id) {
|
||||||
child.value.points = payload.points
|
child.value.points = payload.points
|
||||||
childRewardListRef.value?.refresh()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -62,7 +59,6 @@ function handleChildRewardSet(event: Event) {
|
|||||||
const payload = event.payload as ChildRewardsSetEventPayload
|
const payload = event.payload as ChildRewardsSetEventPayload
|
||||||
if (child.value && payload.child_id == child.value.id) {
|
if (child.value && payload.child_id == child.value.id) {
|
||||||
rewards.value = payload.reward_ids
|
rewards.value = payload.reward_ids
|
||||||
childRewardListRef.value?.refresh()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -171,26 +167,25 @@ const triggerTask = (task: Task) => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const triggerReward = (reward: Reward) => {}
|
const triggerReward = (reward: RewardStatus) => {
|
||||||
/*
|
|
||||||
|
|
||||||
const triggerReward = (reward: Reward, redeemable: boolean, pending: boolean) => {
|
|
||||||
if ('speechSynthesis' in window && reward.name) {
|
if ('speechSynthesis' in window && reward.name) {
|
||||||
const utterString =
|
const utterString =
|
||||||
reward.name + (redeemable ? '' : `, You still need ${reward.points_needed} points.`)
|
reward.name +
|
||||||
|
(reward.points_needed <= 0 ? '' : `, You still need ${reward.points_needed} points.`)
|
||||||
const utter = new window.SpeechSynthesisUtterance(utterString)
|
const utter = new window.SpeechSynthesisUtterance(utterString)
|
||||||
window.speechSynthesis.speak(utter)
|
window.speechSynthesis.speak(utter)
|
||||||
|
console.log('Reward data is:', reward)
|
||||||
|
if (reward.redeeming) {
|
||||||
|
dialogReward.value = reward
|
||||||
|
showCancelDialog.value = true
|
||||||
|
return // Do not allow redeeming if already pending
|
||||||
|
}
|
||||||
|
if (reward.points_needed <= 0) {
|
||||||
|
dialogReward.value = reward
|
||||||
|
showRewardDialog.value = true
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if (pending) {
|
}
|
||||||
dialogReward.value = reward
|
|
||||||
showCancelDialog.value = true
|
|
||||||
return // Do not allow redeeming if already pending
|
|
||||||
}
|
|
||||||
if (redeemable) {
|
|
||||||
dialogReward.value = reward
|
|
||||||
showRewardDialog.value = true
|
|
||||||
}
|
|
||||||
}*/
|
|
||||||
|
|
||||||
async function cancelPendingReward() {
|
async function cancelPendingReward() {
|
||||||
if (!child.value?.id || !dialogReward.value) return
|
if (!child.value?.id || !dialogReward.value) return
|
||||||
@@ -258,7 +253,7 @@ function resetInactivityTimer() {
|
|||||||
if (inactivityTimer) clearTimeout(inactivityTimer)
|
if (inactivityTimer) clearTimeout(inactivityTimer)
|
||||||
inactivityTimer = setTimeout(() => {
|
inactivityTimer = setTimeout(() => {
|
||||||
router.push({ name: 'ChildrenListView' })
|
router.push({ name: 'ChildrenListView' })
|
||||||
}, 6000000) // 60 seconds
|
}, 60000) // 60 seconds
|
||||||
}
|
}
|
||||||
|
|
||||||
function setupInactivityListeners() {
|
function setupInactivityListeners() {
|
||||||
@@ -272,6 +267,10 @@ function removeInactivityListeners() {
|
|||||||
if (inactivityTimer) clearTimeout(inactivityTimer)
|
if (inactivityTimer) clearTimeout(inactivityTimer)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const hasPendingRewards = computed(() =>
|
||||||
|
childRewardListRef.value?.items.some((r: RewardStatus) => r.redeeming),
|
||||||
|
)
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
try {
|
try {
|
||||||
eventBus.on('child_task_triggered', handleTaskTriggered)
|
eventBus.on('child_task_triggered', handleTaskTriggered)
|
||||||
@@ -379,19 +378,15 @@ onUnmounted(() => {
|
|||||||
<ScrollingList
|
<ScrollingList
|
||||||
title="Rewards"
|
title="Rewards"
|
||||||
ref="childRewardListRef"
|
ref="childRewardListRef"
|
||||||
:fetchBaseUrl="`/api/reward/list?ids=${rewards.join(',')}`"
|
:fetchBaseUrl="`/api/child/${child?.id}/reward-status`"
|
||||||
:ids="rewards"
|
itemKey="reward_status"
|
||||||
itemKey="rewards"
|
|
||||||
imageField="image_id"
|
imageField="image_id"
|
||||||
@trigger-item="triggerReward"
|
@trigger-item="triggerReward"
|
||||||
:getItemClass="(item) => ({ bad: !item.is_good, good: item.is_good })"
|
:getItemClass="
|
||||||
:filter-fn="
|
(item) => ({ reward: true, disabled: hasPendingRewards && !item.redeeming })
|
||||||
(item) => {
|
|
||||||
return !item.is_good
|
|
||||||
}
|
|
||||||
"
|
"
|
||||||
>
|
>
|
||||||
<template #item="{ item }">
|
<template #item="{ item }: { item: RewardStatus }">
|
||||||
<div class="item-name">{{ item.name }}</div>
|
<div class="item-name">{{ item.name }}</div>
|
||||||
<img
|
<img
|
||||||
v-if="item.image_url"
|
v-if="item.image_url"
|
||||||
@@ -399,21 +394,13 @@ onUnmounted(() => {
|
|||||||
alt="Reward Image"
|
alt="Reward Image"
|
||||||
class="item-image"
|
class="item-image"
|
||||||
/>
|
/>
|
||||||
<div
|
<div class="item-points">
|
||||||
class="item-points"
|
<span v-if="item.redeeming" class="pending">PENDING</span>
|
||||||
:class="{ 'good-points': item.is_good, 'bad-points': !item.is_good }"
|
<span v-if="item.points_needed <= 0" class="ready">REWARD READY</span>
|
||||||
>
|
<span v-else>{{ item.points_needed }} more points</span>
|
||||||
{{ item.is_good ? item.points : -item.points }} Points
|
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</ScrollingList>
|
</ScrollingList>
|
||||||
<ChildRewardList
|
|
||||||
ref="childRewardListRef"
|
|
||||||
:child-id="child ? child.id : null"
|
|
||||||
:child-points="child?.points ?? 0"
|
|
||||||
:is-parent-authenticated="false"
|
|
||||||
@trigger-reward="triggerReward"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -421,8 +408,8 @@ onUnmounted(() => {
|
|||||||
<div class="modal">
|
<div class="modal">
|
||||||
<div class="reward-info">
|
<div class="reward-info">
|
||||||
<img
|
<img
|
||||||
v-if="dialogReward.image_id"
|
v-if="dialogReward.image_url"
|
||||||
:src="dialogReward.image_id"
|
:src="dialogReward.image_url"
|
||||||
alt="Reward Image"
|
alt="Reward Image"
|
||||||
class="reward-image"
|
class="reward-image"
|
||||||
/>
|
/>
|
||||||
@@ -445,8 +432,8 @@ onUnmounted(() => {
|
|||||||
<div class="modal">
|
<div class="modal">
|
||||||
<div class="reward-info">
|
<div class="reward-info">
|
||||||
<img
|
<img
|
||||||
v-if="dialogReward.image_id"
|
v-if="dialogReward.image_url"
|
||||||
:src="dialogReward.image_id"
|
:src="dialogReward.image_url"
|
||||||
alt="Reward Image"
|
alt="Reward Image"
|
||||||
class="reward-image"
|
class="reward-image"
|
||||||
/>
|
/>
|
||||||
@@ -469,6 +456,13 @@ onUnmounted(() => {
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
|
.assign-buttons {
|
||||||
|
display: flex;
|
||||||
|
gap: 1rem;
|
||||||
|
justify-content: center;
|
||||||
|
margin: 2rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
.back-btn {
|
.back-btn {
|
||||||
background: var(--back-btn-bg);
|
background: var(--back-btn-bg);
|
||||||
border: 0;
|
border: 0;
|
||||||
@@ -480,19 +474,37 @@ onUnmounted(() => {
|
|||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
|
|
||||||
.assign-buttons {
|
|
||||||
display: flex;
|
|
||||||
gap: 1rem;
|
|
||||||
justify-content: center;
|
|
||||||
margin: 2rem 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.item-points {
|
.item-points {
|
||||||
color: var(--item-points-color, #ffd166);
|
color: var(--item-points-color, #ffd166);
|
||||||
font-size: 1rem;
|
font-size: 1rem;
|
||||||
font-weight: 900;
|
font-weight: 900;
|
||||||
text-shadow: var(--item-points-shadow);
|
text-shadow: var(--item-points-shadow);
|
||||||
}
|
}
|
||||||
|
.ready {
|
||||||
|
color: var(--item-points-ready-color, #38c172);
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
}
|
||||||
|
.pending {
|
||||||
|
position: absolute;
|
||||||
|
top: 50%;
|
||||||
|
left: 50%;
|
||||||
|
transform: translate(-50%, -50%);
|
||||||
|
width: 80%;
|
||||||
|
background: var(--pending-block-bg, #222b);
|
||||||
|
color: var(--pending-block-color, #62ff7a);
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
/* Mobile tweaks */
|
/* Mobile tweaks */
|
||||||
@media (max-width: 480px) {
|
@media (max-width: 480px) {
|
||||||
@@ -509,4 +521,13 @@ onUnmounted(() => {
|
|||||||
border-color: var(--list-item-border-bad);
|
border-color: var(--list-item-border-bad);
|
||||||
background: var(--list-item-bg-bad);
|
background: var(--list-item-bg-bad);
|
||||||
}
|
}
|
||||||
|
:deep(.reward) {
|
||||||
|
border-color: var(--list-item-border-reward);
|
||||||
|
background: var(--list-item-bg-reward);
|
||||||
|
}
|
||||||
|
:deep(.disabled) {
|
||||||
|
opacity: 0.5;
|
||||||
|
pointer-events: none;
|
||||||
|
filter: grayscale(0.7);
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -1,10 +1,8 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, onMounted, computed, onUnmounted } from 'vue'
|
import { ref, onMounted, computed, onUnmounted } from 'vue'
|
||||||
import { useRoute, useRouter } from 'vue-router'
|
import { useRoute, useRouter } from 'vue-router'
|
||||||
import { isParentAuthenticated } from '../../stores/auth'
|
|
||||||
import ChildDetailCard from './ChildDetailCard.vue'
|
import ChildDetailCard from './ChildDetailCard.vue'
|
||||||
import ChildTaskList from '../task/ChildTaskList.vue'
|
import ScrollingList from '../shared/ScrollingList.vue'
|
||||||
import ChildRewardList from '../reward/ChildRewardList.vue'
|
|
||||||
import { eventBus } from '@/common/eventBus'
|
import { eventBus } from '@/common/eventBus'
|
||||||
import '@/assets/view-shared.css'
|
import '@/assets/view-shared.css'
|
||||||
import type {
|
import type {
|
||||||
@@ -36,14 +34,13 @@ const selectedTask = ref<Task | null>(null)
|
|||||||
const showRewardConfirm = ref(false)
|
const showRewardConfirm = ref(false)
|
||||||
const selectedReward = ref<Reward | null>(null)
|
const selectedReward = ref<Reward | null>(null)
|
||||||
const childRewardListRef = ref()
|
const childRewardListRef = ref()
|
||||||
const childChoreListRef = ref()
|
|
||||||
const childHabitListRef = ref()
|
|
||||||
const showPendingRewardDialog = ref(false)
|
const showPendingRewardDialog = ref(false)
|
||||||
|
|
||||||
function handleTaskTriggered(event: Event) {
|
function handleTaskTriggered(event: Event) {
|
||||||
const payload = event.payload as ChildTaskTriggeredEventPayload
|
const payload = event.payload as ChildTaskTriggeredEventPayload
|
||||||
if (child.value && payload.child_id == child.value.id) {
|
if (child.value && payload.child_id == child.value.id) {
|
||||||
child.value.points = payload.points
|
child.value.points = payload.points
|
||||||
|
childRewardListRef.value?.refresh()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -66,7 +63,6 @@ function handleChildRewardSet(event: Event) {
|
|||||||
const payload = event.payload as ChildRewardsSetEventPayload
|
const payload = event.payload as ChildRewardsSetEventPayload
|
||||||
if (child.value && payload.child_id == child.value.id) {
|
if (child.value && payload.child_id == child.value.id) {
|
||||||
rewards.value = payload.reward_ids
|
rewards.value = payload.reward_ids
|
||||||
childRewardListRef.value?.refresh()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -105,6 +101,7 @@ function handleTaskModified(event: Event) {
|
|||||||
if (data) {
|
if (data) {
|
||||||
tasks.value = data.tasks || []
|
tasks.value = data.tasks || []
|
||||||
}
|
}
|
||||||
|
loading.value = false
|
||||||
})
|
})
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.warn('Failed to fetch child after EDIT operation:', err)
|
console.warn('Failed to fetch child after EDIT operation:', err)
|
||||||
@@ -117,6 +114,7 @@ function handleTaskModified(event: Event) {
|
|||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.warn('Failed to fetch child after task modification:', err)
|
console.warn('Failed to fetch child after task modification:', err)
|
||||||
|
loading.value = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -154,9 +152,11 @@ function handleChildModified(event: Event) {
|
|||||||
if (data) {
|
if (data) {
|
||||||
child.value = data
|
child.value = data
|
||||||
}
|
}
|
||||||
|
loading.value = false
|
||||||
})
|
})
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.warn('Failed to fetch child after EDIT operation:', err)
|
console.warn('Failed to fetch child after EDIT operation:', err)
|
||||||
|
loading.value = false
|
||||||
}
|
}
|
||||||
break
|
break
|
||||||
default:
|
default:
|
||||||
@@ -178,7 +178,6 @@ async function fetchChildData(id: string | number) {
|
|||||||
console.error(err)
|
console.error(err)
|
||||||
return null
|
return null
|
||||||
} finally {
|
} finally {
|
||||||
loading.value = false
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -203,6 +202,7 @@ onMounted(async () => {
|
|||||||
tasks.value = data.tasks || []
|
tasks.value = data.tasks || []
|
||||||
rewards.value = data.rewards || []
|
rewards.value = data.rewards || []
|
||||||
}
|
}
|
||||||
|
loading.value = false
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -222,10 +222,16 @@ onUnmounted(() => {
|
|||||||
eventBus.off('reward_modified', handleRewardModified)
|
eventBus.off('reward_modified', handleRewardModified)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
function getPendingRewardIds(): string[] {
|
||||||
|
const items = childRewardListRef.value?.items || []
|
||||||
|
return items.filter((item: RewardStatus) => item.redeeming).map((item: RewardStatus) => item.id)
|
||||||
|
}
|
||||||
|
|
||||||
const triggerTask = (task: Task) => {
|
const triggerTask = (task: Task) => {
|
||||||
selectedTask.value = task
|
selectedTask.value = task
|
||||||
const pendingRewardIds = childRewardListRef.value?.getPendingRewards()
|
const pendingRewardIds = getPendingRewardIds()
|
||||||
if (pendingRewardIds && pendingRewardIds.length > 0) {
|
console.log('Pending reward IDs:', pendingRewardIds)
|
||||||
|
if (pendingRewardIds.length > 0) {
|
||||||
showPendingRewardDialog.value = true
|
showPendingRewardDialog.value = true
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -253,8 +259,8 @@ async function cancelPendingReward() {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
const pendingRewardIds = childRewardListRef.value?.getPendingRewards()
|
const pendingRewardIds = getPendingRewardIds()
|
||||||
await Promise.all(pendingRewardIds?.map((id: string) => cancelRewardById(id)) || [])
|
await Promise.all(pendingRewardIds.map((id: string) => cancelRewardById(id)))
|
||||||
childRewardListRef.value?.refresh()
|
childRewardListRef.value?.refresh()
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Failed to cancel pending reward:', err)
|
console.error('Failed to cancel pending reward:', err)
|
||||||
@@ -286,8 +292,8 @@ const confirmTriggerTask = async () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const triggerReward = (reward: Reward, redeemable: boolean) => {
|
const triggerReward = (reward: RewardStatus) => {
|
||||||
if (!redeemable) return
|
if (reward.points_needed > 0) return
|
||||||
selectedReward.value = reward
|
selectedReward.value = reward
|
||||||
showRewardConfirm.value = true
|
showRewardConfirm.value = true
|
||||||
}
|
}
|
||||||
@@ -328,8 +334,6 @@ function goToAssignRewards() {
|
|||||||
router.push({ name: 'RewardAssignView', params: { id: child.value.id } })
|
router.push({ name: 'RewardAssignView', params: { id: child.value.id } })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const childId = computed(() => child.value?.id ?? null)
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -340,31 +344,82 @@ const childId = computed(() => child.value?.id ?? null)
|
|||||||
<div v-else class="layout">
|
<div v-else class="layout">
|
||||||
<div class="main">
|
<div class="main">
|
||||||
<ChildDetailCard :child="child" />
|
<ChildDetailCard :child="child" />
|
||||||
<ChildTaskList
|
<ScrollingList
|
||||||
title="Chores"
|
title="Chores"
|
||||||
ref="childChoreListRef"
|
ref="childChoreListRef"
|
||||||
:task-ids="tasks"
|
:fetchBaseUrl="`/api/task/list?ids=${tasks.join(',')}`"
|
||||||
:child-id="childId"
|
:ids="tasks"
|
||||||
:is-parent-authenticated="isParentAuthenticated"
|
itemKey="tasks"
|
||||||
:filter-type="1"
|
imageField="image_id"
|
||||||
@trigger-task="triggerTask"
|
@trigger-item="triggerTask"
|
||||||
/>
|
:getItemClass="(item) => ({ bad: !item.is_good, good: item.is_good })"
|
||||||
<ChildTaskList
|
:filter-fn="
|
||||||
title="Bad Habits"
|
(item) => {
|
||||||
ref="childHabitListRef"
|
return item.is_good
|
||||||
:task-ids="tasks"
|
}
|
||||||
:child-id="childId"
|
"
|
||||||
:is-parent-authenticated="isParentAuthenticated"
|
>
|
||||||
:filter-type="2"
|
<template #item="{ item }">
|
||||||
@trigger-task="triggerTask"
|
<div class="item-name">{{ item.name }}</div>
|
||||||
/>
|
<img v-if="item.image_url" :src="item.image_url" alt="Task Image" class="item-image" />
|
||||||
<ChildRewardList
|
<div
|
||||||
|
class="item-points"
|
||||||
|
:class="{ 'good-points': item.is_good, 'bad-points': !item.is_good }"
|
||||||
|
>
|
||||||
|
{{ item.is_good ? item.points : -item.points }} Points
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</ScrollingList>
|
||||||
|
<ScrollingList
|
||||||
|
title="Penalties"
|
||||||
|
ref="childPenaltyListRef"
|
||||||
|
:fetchBaseUrl="`/api/task/list?ids=${tasks.join(',')}`"
|
||||||
|
:ids="tasks"
|
||||||
|
itemKey="tasks"
|
||||||
|
imageField="image_id"
|
||||||
|
@trigger-item="triggerTask"
|
||||||
|
:getItemClass="(item) => ({ bad: !item.is_good, good: item.is_good })"
|
||||||
|
:filter-fn="
|
||||||
|
(item) => {
|
||||||
|
return !item.is_good
|
||||||
|
}
|
||||||
|
"
|
||||||
|
>
|
||||||
|
<template #item="{ item }">
|
||||||
|
<div class="item-name">{{ item.name }}</div>
|
||||||
|
<img v-if="item.image_url" :src="item.image_url" alt="Task Image" class="item-image" />
|
||||||
|
<div
|
||||||
|
class="item-points"
|
||||||
|
:class="{ 'good-points': item.is_good, 'bad-points': !item.is_good }"
|
||||||
|
>
|
||||||
|
{{ item.is_good ? item.points : -item.points }} Points
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</ScrollingList>
|
||||||
|
<ScrollingList
|
||||||
|
title="Rewards"
|
||||||
ref="childRewardListRef"
|
ref="childRewardListRef"
|
||||||
:child-id="childId"
|
:fetchBaseUrl="`/api/child/${child?.id}/reward-status`"
|
||||||
:child-points="child?.points ?? 0"
|
itemKey="reward_status"
|
||||||
:is-parent-authenticated="false"
|
imageField="image_id"
|
||||||
@trigger-reward="triggerReward"
|
@trigger-item="triggerReward"
|
||||||
/>
|
:getItemClass="(item) => ({ reward: true })"
|
||||||
|
>
|
||||||
|
<template #item="{ item }: { item: RewardStatus }">
|
||||||
|
<div class="item-name">{{ item.name }}</div>
|
||||||
|
<img
|
||||||
|
v-if="item.image_url"
|
||||||
|
:src="item.image_url"
|
||||||
|
alt="Reward Image"
|
||||||
|
class="item-image"
|
||||||
|
/>
|
||||||
|
<div class="item-points">
|
||||||
|
<span v-if="item.redeeming" class="pending">PENDING</span>
|
||||||
|
<span v-if="item.points_needed <= 0" class="ready">REWARD READY</span>
|
||||||
|
<span v-else>{{ item.points_needed }} more points</span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</ScrollingList>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="assign-buttons">
|
<div class="assign-buttons">
|
||||||
@@ -432,8 +487,8 @@ const childId = computed(() => child.value?.id ?? null)
|
|||||||
<div class="modal">
|
<div class="modal">
|
||||||
<div class="reward-info">
|
<div class="reward-info">
|
||||||
<img
|
<img
|
||||||
v-if="selectedReward.image_id"
|
v-if="selectedReward.image_url"
|
||||||
:src="selectedReward.image_id"
|
:src="selectedReward.image_url"
|
||||||
alt="Reward Image"
|
alt="Reward Image"
|
||||||
class="reward-image"
|
class="reward-image"
|
||||||
/>
|
/>
|
||||||
@@ -478,4 +533,68 @@ const childId = computed(() => child.value?.id ?? null)
|
|||||||
justify-content: center;
|
justify-content: center;
|
||||||
margin: 2rem 0;
|
margin: 2rem 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.back-btn {
|
||||||
|
background: var(--back-btn-bg);
|
||||||
|
border: 0;
|
||||||
|
padding: 0.6rem 1rem;
|
||||||
|
border-radius: 8px;
|
||||||
|
cursor: pointer;
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
color: var(--back-btn-color);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.item-points {
|
||||||
|
color: var(--item-points-color, #ffd166);
|
||||||
|
font-size: 1rem;
|
||||||
|
font-weight: 900;
|
||||||
|
text-shadow: var(--item-points-shadow);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ready {
|
||||||
|
color: var(--item-points-ready-color, #38c172);
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
}
|
||||||
|
.pending {
|
||||||
|
position: absolute;
|
||||||
|
top: 50%;
|
||||||
|
left: 50%;
|
||||||
|
transform: translate(-50%, -50%);
|
||||||
|
width: 80%;
|
||||||
|
background: var(--pending-block-bg, #222b);
|
||||||
|
color: var(--pending-block-color, #62ff7a);
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Mobile tweaks */
|
||||||
|
@media (max-width: 480px) {
|
||||||
|
.item-points {
|
||||||
|
font-size: 0.78rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.good) {
|
||||||
|
border-color: var(--list-item-border-good);
|
||||||
|
background: var(--list-item-bg-good);
|
||||||
|
}
|
||||||
|
:deep(.bad) {
|
||||||
|
border-color: var(--list-item-border-bad);
|
||||||
|
background: var(--list-item-bg-bad);
|
||||||
|
}
|
||||||
|
:deep(.reward) {
|
||||||
|
border-color: var(--list-item-border-reward);
|
||||||
|
background: var(--list-item-bg-reward);
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { getCachedImageUrl, revokeAllImageUrls } from '@/common/imageCache'
|
|||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
title: string
|
title: string
|
||||||
fetchBaseUrl: string
|
fetchBaseUrl: string
|
||||||
ids: readonly string[]
|
ids?: readonly string[]
|
||||||
itemKey: string
|
itemKey: string
|
||||||
imageFields?: readonly string[]
|
imageFields?: readonly string[]
|
||||||
isParentAuthenticated?: boolean
|
isParentAuthenticated?: boolean
|
||||||
@@ -78,6 +78,16 @@ const fetchItems = async () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function refresh() {
|
||||||
|
await fetchItems()
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
defineExpose({
|
||||||
|
refresh,
|
||||||
|
items,
|
||||||
|
})
|
||||||
|
|
||||||
const centerItem = async (itemId: string) => {
|
const centerItem = async (itemId: string) => {
|
||||||
await nextTick()
|
await nextTick()
|
||||||
const wrapper = scrollWrapper.value
|
const wrapper = scrollWrapper.value
|
||||||
@@ -231,6 +241,7 @@ onBeforeUnmount(() => {
|
|||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
|
user-select: none; /* Prevent image selection */
|
||||||
}
|
}
|
||||||
|
|
||||||
.item-card:hover {
|
.item-card:hover {
|
||||||
|
|||||||
Reference in New Issue
Block a user