This commit is contained in:
2025-12-02 17:02:20 -05:00
parent f82ba25160
commit 6423d1c1a2
49 changed files with 2320 additions and 349 deletions

View File

@@ -4,6 +4,16 @@ from db.db import child_db, task_db, reward_db
from api.reward_status import RewardStatus
from api.child_tasks import ChildTask
from api.child_rewards import ChildReward
from events.sse import send_to_user, send_event_to_user
from events.types.child_add import ChildAdd
from events.types.child_delete import ChildDelete
from events.types.child_update import ChildUpdate
from events.types.event import Event
from events.types.event_types import EventType
from events.types.reward_set import RewardSet
from events.types.reward_update import RewardUpdate
from events.types.task_set import TaskSet
from events.types.task_update import TaskUpdate
from models.child import Child
from models.task import Task
@@ -31,8 +41,9 @@ def add_child():
if not image:
image = 'boy01'
child = Child(name, age, image_id=image)
child = Child(name=name, age=age, image_id=image)
child_db.insert(child.to_dict())
send_event_to_user("user123", Event(EventType.CHILD_ADD.value, ChildAdd(child.id, "set")))
return jsonify({'message': f'Child {name} added.'}), 201
@child_api.route('/child/<id>/edit', methods=['PUT'])
@@ -56,6 +67,7 @@ def edit_child(id):
if image is not None:
child['image_id'] = image
child_db.update(child, ChildQuery.id == id)
send_event_to_user("user123", Event(EventType.CHILD_UPDATE.value, ChildUpdate(id, "set")))
return jsonify({'message': f'Child {id} updated.'}), 200
@child_api.route('/child/list', methods=['GET'])
@@ -68,6 +80,8 @@ def list_children():
def delete_child(id):
ChildQuery = Query()
if child_db.remove(ChildQuery.id == id):
send_event_to_user("user123",
Event(EventType.CHILD_DELETE.value, ChildDelete(id, "deleted")))
return jsonify({'message': f'Child {id} deleted.'}), 200
return jsonify({'error': 'Child not found'}), 404
@@ -89,6 +103,37 @@ def assign_task_to_child(id):
child_db.update({'tasks': child['tasks']}, ChildQuery.id == id)
return jsonify({'message': f'Task {task_id} assigned to {child['name']}.'}), 200
# python
@child_api.route('/child/<id>/set-tasks', methods=['PUT'])
def set_child_tasks(id):
data = request.get_json() or {}
task_ids = data.get('task_ids')
if not isinstance(task_ids, list):
return jsonify({'error': 'task_ids must be a list'}), 400
# Deduplicate and drop falsy values
new_task_ids = [tid for tid in dict.fromkeys(task_ids) if tid]
ChildQuery = Query()
result = child_db.search(ChildQuery.id == id)
if not result:
return jsonify({'error': 'Child not found'}), 404
# Optional: validate task IDs exist in the task DB
TaskQuery = Query()
valid_task_ids = []
for tid in new_task_ids:
if task_db.get(TaskQuery.id == tid):
valid_task_ids.append(tid)
# Replace tasks with validated IDs
child_db.update({'tasks': valid_task_ids}, ChildQuery.id == id)
send_event_to_user("user123", Event(EventType.TASK_SET.value, TaskSet(id, "set")))
return jsonify({
'message': f'Tasks set for child {id}.',
'task_ids': valid_task_ids,
'count': len(valid_task_ids)
}), 200
@child_api.route('/child/<id>/remove-task', methods=['POST'])
@@ -159,6 +204,48 @@ def list_assignable_tasks(id):
return jsonify({'tasks': assignable_tasks, 'count': len(assignable_tasks)}), 200
@child_api.route('/child/<id>/list-all-tasks', methods=['GET'])
def list_all_tasks(id):
ChildQuery = Query()
result = child_db.search(ChildQuery.id == id)
if not result:
return jsonify({'error': 'Child not found'}), 404
child = result[0]
assigned_ids = set(child.get('tasks', []))
# Get all tasks from database
all_tasks = task_db.all()
assigned_tasks = []
assignable_tasks = []
for task in all_tasks:
if not task or not task.get('id'):
continue
ct = ChildTask(
task.get('name'),
task.get('is_good'),
task.get('points'),
task.get('image_id'),
task.get('id')
)
if task.get('id') in assigned_ids:
assigned_tasks.append(ct.to_dict())
else:
assignable_tasks.append(ct.to_dict())
return jsonify({
'assigned_tasks': assigned_tasks,
'assignable_tasks': assignable_tasks,
'assigned_count': len(assigned_tasks),
'assignable_count': len(assignable_tasks)
}), 200
@child_api.route('/child/<id>/trigger-task', methods=['POST'])
def trigger_child_task(id):
data = request.get_json()
@@ -188,6 +275,7 @@ def trigger_child_task(id):
child.points = max(child.points, 0)
# update the child in the database
child_db.update({'points': child.points}, ChildQuery.id == id)
send_event_to_user("user123", Event(EventType.TASK_UPDATE.value, TaskUpdate(task.id, child.id,"complete", child.points)))
return jsonify({'message': f'{task.name} points assigned to {child.name}.', 'points': child.points, 'id': child.id}), 200
@@ -209,6 +297,78 @@ def assign_reward_to_child(id):
child_db.update({'rewards': child['rewards']}, ChildQuery.id == id)
return jsonify({'message': f'Reward {reward_id} assigned to {child["name"]}.'}), 200
@child_api.route('/child/<id>/list-all-rewards', methods=['GET'])
def list_all_rewards(id):
ChildQuery = Query()
result = child_db.search(ChildQuery.id == id)
if not result:
return jsonify({'error': 'Child not found'}), 404
child = result[0]
assigned_ids = set(child.get('rewards', []))
# Get all rewards from database
all_rewards = reward_db.all()
assigned_rewards = []
assignable_rewards = []
for reward in all_rewards:
if not reward or not reward.get('id'):
continue
cr = ChildReward(
reward.get('name'),
reward.get('cost'),
reward.get('image_id'),
reward.get('id')
)
if reward.get('id') in assigned_ids:
assigned_rewards.append(cr.to_dict())
else:
assignable_rewards.append(cr.to_dict())
return jsonify({
'assigned_rewards': assigned_rewards,
'assignable_rewards': assignable_rewards,
'assigned_count': len(assigned_rewards),
'assignable_count': len(assignable_rewards)
}), 200
@child_api.route('/child/<id>/set-rewards', methods=['PUT'])
def set_child_rewards(id):
data = request.get_json() or {}
reward_ids = data.get('reward_ids')
if not isinstance(reward_ids, list):
return jsonify({'error': 'reward_ids must be a list'}), 400
# Deduplicate and drop falsy values
new_reward_ids = [rid for rid in dict.fromkeys(reward_ids) if rid]
ChildQuery = Query()
result = child_db.search(ChildQuery.id == id)
if not result:
return jsonify({'error': 'Child not found'}), 404
# Optional: validate reward IDs exist in the reward DB
RewardQuery = Query()
valid_reward_ids = []
for rid in new_reward_ids:
if reward_db.get(RewardQuery.id == rid):
valid_reward_ids.append(rid)
# Replace rewards with validated IDs
child_db.update({'rewards': valid_reward_ids}, ChildQuery.id == id)
send_event_to_user("user123", Event(EventType.REWARD_SET.value, RewardSet(id, "set")))
return jsonify({
'message': f'Rewards set for child {id}.',
'reward_ids': valid_reward_ids,
'count': len(valid_reward_ids)
}), 200
@child_api.route('/child/<id>/remove-reward', methods=['POST'])
def remove_reward_from_child(id):
data = request.get_json()
@@ -298,6 +458,7 @@ def trigger_child_reward(id):
child.points -= reward.cost
# update the child in the database
child_db.update({'points': child.points}, ChildQuery.id == id)
send_event_to_user("user123", Event(EventType.REWARD_UPDATE.value, RewardUpdate(reward.id, child.id, "redeemed", child.points)))
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'])
@@ -331,11 +492,11 @@ def reward_status(id):
RewardQuery = Query()
statuses = []
for reward_id in reward_ids:
reward = reward_db.get(RewardQuery.id == reward_id)
reward: Reward = Reward.from_dict(reward_db.get(RewardQuery.id == reward_id))
if not reward:
continue
points_needed = max(0, reward.get('cost', 0) - points)
status = RewardStatus(reward.get('id'), reward.get('name'), points_needed, reward.get('image_id'))
points_needed = max(0, reward.cost - points)
status = RewardStatus(reward.id, reward.name, points_needed, reward.cost, reward.image_id)
statuses.append(status.to_dict())
statuses.sort(key=lambda s: s['points_needed'])

View File

@@ -61,8 +61,8 @@ def upload():
format_extension_map = {'JPEG': '.jpg', 'PNG': '.png'}
extension = format_extension_map.get(original_format, '.png')
_id = str(uuid.uuid4())
filename = _id + extension
image_record = Image(extension=extension, permanent=perm, type=image_type)
filename = image_record.id + extension
filepath = os.path.abspath(os.path.join(UPLOAD_FOLDER, filename))
try:
@@ -76,10 +76,9 @@ def upload():
except Exception:
return jsonify({'error': 'Failed to save processed image'}), 500
image_record = Image(image_type, extension, permanent=perm, id=_id)
image_db.insert(image_record.to_dict())
return jsonify({'message': 'Image uploaded successfully', 'filename': filename, 'id': _id}), 200
return jsonify({'message': 'Image uploaded successfully', 'filename': filename, 'id': image_record.id}), 200
@image_api.route('/image/request/<id>', methods=['GET'])
def request_image(id):

View File

@@ -1,5 +1,12 @@
from flask import Blueprint, request, jsonify
from tinydb import Query
from events.sse import send_event_to_user
from events.types.event import Event
from events.types.event_types import EventType
from events.types.reward_created import RewardCreated
from events.types.reward_deleted import RewardDeleted
from events.types.reward_edited import RewardEdited
from models.reward import Reward
from db.db import reward_db, child_db
@@ -15,8 +22,11 @@ def add_reward():
image = data.get('image_id', '')
if not name or description is None or cost is None:
return jsonify({'error': 'Name, description, and cost are required'}), 400
reward = Reward(name, description, cost, image_id=image)
reward = Reward(name=name, description=description, cost=cost, image_id=image)
reward_db.insert(reward.to_dict())
send_event_to_user("user123", Event(EventType.REWARD_CREATED.value,
RewardCreated(reward.id, "created")))
return jsonify({'message': f'Reward {name} added.'}), 201
@@ -46,6 +56,9 @@ def delete_reward(id):
if id in rewards:
rewards.remove(id)
child_db.update({'rewards': rewards}, ChildQuery.id == child.get('id'))
send_event_to_user("user123", Event(EventType.REWARD_DELETED.value,
RewardDeleted(id, "created")))
return jsonify({'message': f'Reward {id} deleted.'}), 200
return jsonify({'error': 'Reward not found'}), 404
@@ -87,4 +100,7 @@ def edit_reward(id):
reward_db.update(updates, RewardQuery.id == id)
updated = reward_db.get(RewardQuery.id == id)
send_event_to_user("user123", Event(EventType.REWARD_EDITED.value,
RewardEdited(id, "created")))
return jsonify(updated), 200

View File

@@ -1,8 +1,9 @@
class RewardStatus:
def __init__(self, id, name, points_needed, image_id):
def __init__(self, id, name, points_needed, cost, image_id):
self.id = id
self.name = name
self.points_needed = points_needed
self.cost = cost
self.image_id = image_id
def to_dict(self):
@@ -10,5 +11,6 @@ class RewardStatus:
'id': self.id,
'name': self.name,
'points_needed': self.points_needed,
'cost': self.cost,
'image_id': self.image_id
}

View File

@@ -1,5 +1,12 @@
from flask import Blueprint, request, jsonify
from tinydb import Query
from events.sse import send_event_to_user
from events.types.event import Event
from events.types.event_types import EventType
from events.types.task_created import TaskCreated
from events.types.task_deleted import TaskDeleted
from events.types.task_edited import TaskEdited
from models.task import Task
from db.db import task_db, child_db
@@ -15,8 +22,10 @@ def add_task():
image = data.get('image_id', '')
if not name or points is None or is_good is None:
return jsonify({'error': 'Name, points, and is_good are required'}), 400
task = Task(name, points, is_good, image_id=image)
task = Task(name=name, points=points, is_good=is_good, image_id=image)
task_db.insert(task.to_dict())
send_event_to_user("user123", Event(EventType.TASK_CREATED.value,
TaskCreated(task.id, "created")))
return jsonify({'message': f'Task {name} added.'}), 201
@task_api.route('/task/<id>', methods=['GET'])
@@ -44,6 +53,9 @@ def delete_task(id):
if id in tasks:
tasks.remove(id)
child_db.update({'tasks': tasks}, ChildQuery.id == child.get('id'))
send_event_to_user("user123", Event(EventType.TASK_DELETED.value,
TaskDeleted(id, "deleted")))
return jsonify({'message': f'Task {id} deleted.'}), 200
return jsonify({'error': 'Task not found'}), 404
@@ -85,4 +97,6 @@ def edit_task(id):
task_db.update(updates, TaskQuery.id == id)
updated = task_db.get(TaskQuery.id == id)
send_event_to_user("user123", Event(EventType.TASK_EDITED.value,
TaskEdited(id, "edited")))
return jsonify(updated), 200

10
events/broadcaster.py Normal file
View File

@@ -0,0 +1,10 @@
import time
from threading import Thread
class Broadcaster(Thread):
"""Background thread sending periodic notifications."""
def run(self):
while True:
#push event to all users
time.sleep(5) # Send every 5 seconds

74
events/sse.py Normal file
View File

@@ -0,0 +1,74 @@
import json
import queue
import uuid
from typing import Dict, Any
from flask import Response
from events.types.event import Event
# Maps user_id → dict of {connection_id: queue}
user_queues: Dict[str, Dict[str, queue.Queue]] = {}
def get_queue(user_id: str, connection_id: str) -> queue.Queue:
"""Get or create a queue for a specific user connection."""
if user_id not in user_queues:
user_queues[user_id] = {}
if connection_id not in user_queues[user_id]:
user_queues[user_id][connection_id] = queue.Queue()
return user_queues[user_id][connection_id]
def send_to_user(user_id: str, data: Dict[str, Any]):
"""Send data to all connections for a specific user."""
if user_id in user_queues:
# Format as SSE message once
message = f"data: {json.dumps(data)}\n\n".encode('utf-8')
# Send to all connections for this user
for connection_id, q in user_queues[user_id].items():
try:
q.put(message, block=False)
except queue.Full:
# Skip if queue is full (connection might be dead)
pass
def send_event_to_user(user_id: str, event: Event):
"""Send an Event to all connections for a specific user."""
send_to_user(user_id, event.to_dict())
def sse_response_for_user(user_id: str):
"""Create SSE response for a user connection."""
# Generate unique connection ID
connection_id = str(uuid.uuid4())
user_queue = get_queue(user_id, connection_id)
def generate():
try:
while True:
# Get message from queue (blocks until available)
message = user_queue.get()
yield message
except GeneratorExit:
# Clean up when client disconnects
if user_id in user_queues and connection_id in user_queues[user_id]:
del user_queues[user_id][connection_id]
# Remove user entry if no connections remain
if not user_queues[user_id]:
del user_queues[user_id]
return Response(
generate(),
mimetype='text/event-stream',
headers={
'Cache-Control': 'no-cache',
'X-Accel-Buffering': 'no',
'Connection': 'keep-alive'
}
)

18
events/types/child_add.py Normal file
View File

@@ -0,0 +1,18 @@
from events.types.payload import Payload
class ChildAdd(Payload):
def __init__(self, child_id: str, status: str):
super().__init__({
'child_id': child_id,
'status': status,
})
@property
def child_id(self) -> str:
return self.get("child_id")
@property
def status(self) -> str:
return self.get("status")

View File

@@ -0,0 +1,18 @@
from events.types.payload import Payload
class ChildDelete(Payload):
def __init__(self, child_id: str, status: str):
super().__init__({
'child_id': child_id,
'status': status,
})
@property
def child_id(self) -> str:
return self.get("child_id")
@property
def status(self) -> str:
return self.get("status")

View File

@@ -0,0 +1,18 @@
from events.types.payload import Payload
class ChildUpdate(Payload):
def __init__(self, child_id: str, status: str):
super().__init__({
'child_id': child_id,
'status': status,
})
@property
def child_id(self) -> str:
return self.get("child_id")
@property
def status(self) -> str:
return self.get("status")

18
events/types/event.py Normal file
View File

@@ -0,0 +1,18 @@
from dataclasses import dataclass, field
import time
from events.types.payload import Payload
@dataclass
class Event:
type: str
payload: Payload
timestamp: float = field(default_factory=time.time)
def to_dict(self):
return {
'type': self.type,
'payload': self.payload.data,
'timestamp': self.timestamp
}

View File

@@ -0,0 +1,19 @@
from enum import Enum
class EventType(Enum):
TASK_UPDATE = "task_update"
TASK_SET = "task_set"
TASK_TEST = "task_test"
REWARD_UPDATE = "reward_update"
REWARD_SET = "reward_set"
CHILD_UPDATE = "child_update"
CHILD_ADD = "child_add"
CHILD_DELETE = "child_delete"
TASK_CREATED = "task_created"
TASK_DELETED = "task_deleted"
TASK_EDITED = "task_edited"
REWARD_CREATED = "reward_created"
REWARD_DELETED = "reward_deleted"
REWARD_EDITED = "reward_edited"
# Add more event types here

6
events/types/payload.py Normal file
View File

@@ -0,0 +1,6 @@
class Payload:
def __init__(self, data: dict):
self.data = data
def get(self, key: str, default=None):
return self.data.get(key, default)

View File

@@ -0,0 +1,19 @@
from events.types.payload import Payload
class RewardCreated(Payload):
def __init__(self, reward_id: str, status: str):
super().__init__({
'reward_id': reward_id,
'status': status
})
@property
def reward_id(self) -> str:
return self.get("reward_id")
@property
def status(self) -> str:
return self.get("status")

View File

@@ -0,0 +1,19 @@
from events.types.payload import Payload
class RewardDeleted(Payload):
def __init__(self, reward_id: str, status: str):
super().__init__({
'reward_id': reward_id,
'status': status
})
@property
def reward_id(self) -> str:
return self.get("reward_id")
@property
def status(self) -> str:
return self.get("status")

View File

@@ -0,0 +1,19 @@
from events.types.payload import Payload
class RewardEdited(Payload):
def __init__(self, reward_id: str, status: str):
super().__init__({
'reward_id': reward_id,
'status': status
})
@property
def reward_id(self) -> str:
return self.get("reward_id")
@property
def status(self) -> str:
return self.get("status")

View File

@@ -0,0 +1,19 @@
from events.types.payload import Payload
class RewardSet(Payload):
def __init__(self, child_id: str, status: str):
super().__init__({
'child_id': child_id,
'status': status
})
@property
def child_id(self) -> str:
return self.get("child_id")
@property
def status(self) -> str:
return self.get("status")

View File

@@ -0,0 +1,29 @@
from events.types.payload import Payload
class RewardUpdate(Payload):
def __init__(self, reward_id: str, child_id: str, status: str, points: int):
super().__init__({
'reward_id': reward_id,
'child_id': child_id,
'status': status,
'points': points
})
@property
def reward_id(self) -> str:
return self.get("reward_id")
@property
def child_id(self) -> str:
return self.get("child_id")
@property
def status(self) -> str:
return self.get("status")
@property
def points(self) -> int:
return self.get("points", 0)

View File

@@ -0,0 +1,19 @@
from events.types.payload import Payload
class TaskCreated(Payload):
def __init__(self, task_id: str, status: str):
super().__init__({
'task_id': task_id,
'status': status
})
@property
def task_id(self) -> str:
return self.get("task_id")
@property
def status(self) -> str:
return self.get("status")

View File

@@ -0,0 +1,19 @@
from events.types.payload import Payload
class TaskDeleted(Payload):
def __init__(self, task_id: str, status: str):
super().__init__({
'task_id': task_id,
'status': status
})
@property
def task_id(self) -> str:
return self.get("task_id")
@property
def status(self) -> str:
return self.get("status")

View File

@@ -0,0 +1,19 @@
from events.types.payload import Payload
class TaskEdited(Payload):
def __init__(self, task_id: str, status: str):
super().__init__({
'task_id': task_id,
'status': status
})
@property
def task_id(self) -> str:
return self.get("task_id")
@property
def status(self) -> str:
return self.get("status")

19
events/types/task_set.py Normal file
View File

@@ -0,0 +1,19 @@
from events.types.payload import Payload
class TaskSet(Payload):
def __init__(self, child_id: str, status: str):
super().__init__({
'child_id': child_id,
'status': status
})
@property
def child_id(self) -> str:
return self.get("child_id")
@property
def status(self) -> str:
return self.get("status")

17
events/types/task_test.py Normal file
View File

@@ -0,0 +1,17 @@
from events.types.payload import Payload
class TaskTest(Payload):
def __init__(self, task_id: str, message: str):
super().__init__({
'task_id': task_id,
'message': message
})
@property
def task_id(self) -> str:
return self.get("task_id")
@property
def message(self) -> str:
return self.get("message")

View File

@@ -0,0 +1,29 @@
from events.types.payload import Payload
class TaskUpdate(Payload):
def __init__(self, task_id: str, child_id: str, status: str, points: int):
super().__init__({
'task_id': task_id,
'child_id': child_id,
'status': status,
'points': points
})
@property
def task_id(self) -> str:
return self.get("task_id")
@property
def child_id(self) -> str:
return self.get("child_id")
@property
def status(self) -> str:
return self.get("status")
@property
def points(self) -> int:
return self.get("points", 0)

38
main.py
View File

@@ -1,9 +1,12 @@
from flask import Flask
from flask import Flask, request
from flask_cors import CORS
from api.child_api import child_api
from api.image_api import image_api
from api.reward_api import reward_api
from api.task_api import task_api
from api.image_api import image_api
from events.broadcaster import Broadcaster
from events.sse import sse_response_for_user, send_to_user
app = Flask(__name__)
#CORS(app, resources={r"/api/*": {"origins": ["http://localhost:3000", "http://localhost:5173"]}})
@@ -13,7 +16,36 @@ app.register_blueprint(task_api)
app.register_blueprint(image_api)
CORS(app)
@app.route("/events")
def events():
# Authenticate user or read a token
user_id = request.args.get("user_id")
if not user_id:
return {"error": "Missing user_id"}, 400
return sse_response_for_user(user_id)
@app.route("/notify/<user_id>")
def notify_user(user_id):
# Example trigger
send_to_user(user_id, {
"type": "notification",
"message": f"Hello {user_id}, this is a private message!"
})
return {"status": "sent"}
def start_background_threads():
broadcaster = Broadcaster()
broadcaster.daemon = True
broadcaster.start()
# Initialize background workers on server start
start_background_threads()
if __name__ == '__main__':
app.run(debug=False, host='0.0.0.0', port=5000)
app.run(debug=False, host='0.0.0.0', port=5000, threaded=True)

View File

@@ -1,18 +1,13 @@
# python
from dataclasses import dataclass, field
import uuid
import time
@dataclass
@dataclass(kw_only=True)
class BaseModel:
id: str = field(init=False)
created_at: float = field(init=False)
updated_at: float = field(init=False)
def __post_init__(self):
self.id = str(uuid.uuid4())
now = time.time()
self.created_at = now
self.updated_at = now
id: str = field(default_factory=lambda: str(uuid.uuid4()))
created_at: float = field(default_factory=time.time)
updated_at: float = field(default_factory=time.time)
def touch(self):
self.updated_at = time.time()
@@ -23,10 +18,3 @@ class BaseModel:
'created_at': self.created_at,
'updated_at': self.updated_at
}
@classmethod
def _apply_base_fields(cls, obj, d: dict):
obj.id = d.get('id', obj.id)
obj.created_at = d.get('created_at', obj.created_at)
obj.updated_at = d.get('updated_at', obj.updated_at)
return obj

View File

@@ -12,15 +12,18 @@ class Child(BaseModel):
@classmethod
def from_dict(cls, d: dict):
obj = cls(
return cls(
name=d.get('name'),
age=d.get('age'),
tasks=d.get('tasks', []),
rewards=d.get('rewards', []),
points=d.get('points', 0),
image_id=d.get('image_id')
image_id=d.get('image_id'),
id=d.get('id'),
created_at=d.get('created_at'),
updated_at=d.get('updated_at')
)
return cls._apply_base_fields(obj, d)
def to_dict(self):
base = super().to_dict()

View File

@@ -1,3 +1,4 @@
# python
from dataclasses import dataclass
from models.base import BaseModel
@@ -9,12 +10,15 @@ class Image(BaseModel):
@classmethod
def from_dict(cls, d: dict):
obj = cls(
# Supports overriding base fields (id, created_at, updated_at) if present
return cls(
type=d.get('type'),
extension=d.get('extension'),
permanent=d.get('permanent', False)
permanent=d.get('permanent', False),
id=d.get('id'),
created_at=d.get('created_at'),
updated_at=d.get('updated_at')
)
return cls._apply_base_fields(obj, d)
def to_dict(self):
base = super().to_dict()

View File

@@ -1,3 +1,4 @@
# python
from dataclasses import dataclass
from models.base import BaseModel
@@ -10,13 +11,16 @@ class Reward(BaseModel):
@classmethod
def from_dict(cls, d: dict):
obj = cls(
# Base fields are keyword-only; can be overridden if provided
return cls(
name=d.get('name'),
description=d.get('description'),
cost=d.get('cost', 0),
image_id=d.get('image_id')
image_id=d.get('image_id'),
id=d.get('id'),
created_at=d.get('created_at'),
updated_at=d.get('updated_at')
)
return cls._apply_base_fields(obj, d)
def to_dict(self):
base = super().to_dict()

View File

@@ -10,13 +10,15 @@ class Task(BaseModel):
@classmethod
def from_dict(cls, d: dict):
obj = cls(
return cls(
name=d.get('name'),
points=d.get('points', 0),
is_good=d.get('is_good', True),
image_id=d.get('image_id')
image_id=d.get('image_id'),
id=d.get('id'),
created_at=d.get('created_at'),
updated_at=d.get('updated_at')
)
return cls._apply_base_fields(obj, d)
def to_dict(self):
base = super().to_dict()

View File

@@ -125,9 +125,9 @@ def test_list_child_tasks_returns_tasks(client):
resp = client.get('/child/child_list_1/list-tasks')
assert resp.status_code == 200
data = resp.get_json()
returned_ids = {t['id'] for t in data['child_tasks']}
returned_ids = {t['id'] for t in data['tasks']}
assert returned_ids == {'t_list_1', 't_list_2'}
for t in data['child_tasks']:
for t in data['tasks']:
assert 'name' in t and 'points' in t and 'is_good' in t
def test_list_assignable_tasks_returns_expected_ids(client):
@@ -143,8 +143,8 @@ def test_list_assignable_tasks_returns_expected_ids(client):
resp = client.get(f'/child/{child_id}/list-assignable-tasks')
assert resp.status_code == 200
data = resp.get_json()
assert len(data['assignable_tasks']) == 1
assert data['assignable_tasks'][0]['id'] == 'tB'
assert len(data['tasks']) == 1
assert data['tasks'][0]['id'] == 'tB'
assert data['count'] == 1
def test_list_assignable_tasks_when_none_assigned(client):
@@ -158,7 +158,7 @@ def test_list_assignable_tasks_when_none_assigned(client):
resp = client.get(f'/child/{child_id}/list-assignable-tasks')
assert resp.status_code == 200
data = resp.get_json()
returned_ids = {t['id'] for t in data['assignable_tasks']}
returned_ids = {t['id'] for t in data['tasks']}
assert returned_ids == set(ids)
assert data['count'] == len(ids)
@@ -170,10 +170,60 @@ def test_list_assignable_tasks_empty_task_db(client):
resp = client.get(f'/child/{child_id}/list-assignable-tasks')
assert resp.status_code == 200
data = resp.get_json()
assert data['assignable_tasks'] == []
assert data['tasks'] == []
assert data['count'] == 0
def test_list_assignable_tasks_child_not_found(client):
resp = client.get('/child/does-not-exist/list-assignable-tasks')
assert resp.status_code == 404
assert b'Child not found' in resp.data
def setup_child_with_tasks(child_name='TestChild', age=8, assigned=None):
child_db.truncate()
task_db.truncate()
assigned = assigned or []
# Seed tasks
task_db.insert({'id': 't1', 'name': 'Task 1', 'points': 1, 'is_good': True})
task_db.insert({'id': 't2', 'name': 'Task 2', 'points': 2, 'is_good': False})
task_db.insert({'id': 't3', 'name': 'Task 3', 'points': 3, 'is_good': True})
# Seed child
child = Child(name=child_name, age=age, image_id='boy01').to_dict()
child['tasks'] = assigned[:]
child_db.insert(child)
return child['id']
def test_list_all_tasks_partitions_assigned_and_assignable(client):
child_id = setup_child_with_tasks(assigned=['t1', 't3'])
resp = client.get(f'/child/{child_id}/list-all-tasks')
assert resp.status_code == 200
data = resp.get_json()
assigned_ids = {t['id'] for t in data['assigned_tasks']}
assignable_ids = {t['id'] for t in data['assignable_tasks']}
assert assigned_ids == {'t1', 't3'}
assert assignable_ids == {'t2'}
assert data['assigned_count'] == 2
assert data['assignable_count'] == 1
def test_set_child_tasks_replaces_existing(client):
child_id = setup_child_with_tasks(assigned=['t1', 't2'])
# Provide new set including a valid and an invalid id (invalid should be filtered)
payload = {'task_ids': ['t3', 'missing', 't3']}
resp = client.put(f'/child/{child_id}/set-tasks', json=payload)
assert resp.status_code == 200
data = resp.get_json()
assert data['task_ids'] == ['t3']
assert data['count'] == 1
ChildQuery = Query()
child = child_db.get(ChildQuery.id == child_id)
assert child['tasks'] == ['t3']
def test_set_child_tasks_requires_list(client):
child_id = setup_child_with_tasks(assigned=['t2'])
resp = client.put(f'/child/{child_id}/set-tasks', json={'task_ids': 'not-a-list'})
assert resp.status_code == 400
assert b'task_ids must be a list' in resp.data
def test_set_child_tasks_child_not_found(client):
resp = client.put('/child/does-not-exist/set-tasks', json={'task_ids': ['t1', 't2']})
assert resp.status_code == 404
assert b'Child not found' in resp.data

View File

@@ -1,4 +1,7 @@
<script setup lang="ts"></script>
<script setup lang="ts">
import { useBackendEvents } from './common/backendEvents'
useBackendEvents('user123')
</script>
<template>
<router-view />

View File

@@ -0,0 +1,24 @@
import { onMounted, onBeforeUnmount } from 'vue'
import { eventBus } from './eventBus'
export function useBackendEvents(userId: string) {
let eventSource: EventSource | null = null
onMounted(() => {
console.log('Connecting to backend events for user:', userId)
eventSource = new EventSource(`/events?user_id=${userId}`)
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
}
})
onBeforeUnmount(() => {
console.log('Disconnecting from backend events for user:', userId)
eventSource?.close()
})
}

View File

@@ -0,0 +1,29 @@
type Callback = (payload: any) => void
class EventBus {
private listeners: Map<string, Callback[]> = new Map()
on(event: string, callback: Callback) {
if (!this.listeners.has(event)) {
this.listeners.set(event, [])
}
this.listeners.get(event)!.push(callback)
}
off(event: string, callback: Callback) {
const list = this.listeners.get(event)
if (!list) return
const index = list.indexOf(callback)
if (index !== -1) list.splice(index, 1)
}
emit(event: string, payload?: any) {
const list = this.listeners.get(event)
if (!list) return
list.forEach((callback) => callback(payload))
}
}
export const eventBus = new EventBus()

View File

@@ -0,0 +1,120 @@
export interface Task {
id: string
name: string
points: number
is_good: boolean
image_id: string | null
image_url?: string | null // optional, for resolved URLs
}
export interface Child {
id: string | number
name: string
age: number
points?: number
image_id: string | null
image_url?: string | null // optional, for resolved URLs
}
export interface Reward {
id: string
name: string
cost: number
points_needed: number
image_id: string | null
image_url?: string | null // optional, for resolved URLs
}
export interface RewardStatus {
id: string
name: string
points_needed: number
cost: number
image_id: string | null
image_url?: string | null // optional, for resolved URLs
}
export interface Event {
type: string
payload:
| TaskUpdateEventPayload
| RewardUpdateEventPayload
| ChildUpdateEventPayload
| ChildDeleteEventPayload
| TaskCreatedEventPayload
| TaskDeletedEventPayload
| TaskEditedEventPayload
| RewardCreatedEventPayload
| RewardDeletedEventPayload
| RewardEditedEventPayload
| RewardSetEventPayload
| TaskSetEventPayload
| ChildAddEventPayload
}
export interface TaskUpdateEventPayload {
task_id: string
child_id: string
status: string
points: number
}
export interface TaskSetEventPayload {
child_id: string
status: string
}
export interface RewardUpdateEventPayload {
reward_id: string
child_id: string
status: string
points: number
}
export interface RewardSetEventPayload {
child_id: string
status: string
}
export interface ChildAddEventPayload {
child_id: string
status: string
}
export interface ChildUpdateEventPayload {
child_id: string
status: string
}
export interface ChildDeleteEventPayload {
child_id: string
status: string
}
export interface TaskCreatedEventPayload {
task_id: string
status: string
}
export interface TaskDeletedEventPayload {
task_id: string
status: string
}
export interface TaskEditedEventPayload {
task_id: string
status: string
}
export interface RewardCreatedEventPayload {
reward_id: string
}
export interface RewardDeletedEventPayload {
reward_id: string
status: string
}
export interface RewardEditedEventPayload {
reward_id: string
status: string
}

View File

@@ -1,17 +1,10 @@
<script setup lang="ts">
import { ref, onMounted, onBeforeUnmount, computed } from 'vue'
import { ref, onMounted, onBeforeUnmount } from 'vue'
import { useRouter } from 'vue-router'
import { getCachedImageUrl, revokeAllImageUrls } from '../common/imageCache'
import { isParentAuthenticated } from '../stores/auth'
import ChildForm from './child/ChildForm.vue'
interface Child {
id: string | number
name: string
age: number
points?: number
image_id: string | null
}
import { eventBus } from '@/common/eventBus'
import type { Child, Event } from '@/common/models'
const router = useRouter()
const children = ref<Child[]>([])
@@ -27,19 +20,13 @@ const confirmDeleteVisible = ref(false)
const deletingChildId = ref<string | number | null>(null)
const deleting = ref(false)
const showEditDialog = ref(false)
const editingChild = ref<Child | null>(null)
const openEditDialog = (child: Child, evt?: Event) => {
const openChildEditor = (child: Child, evt?: Event) => {
evt?.stopPropagation()
editingChild.value = { ...child } // shallow copy for editing
showEditDialog.value = true
closeMenu()
router.push({ name: 'ChildEditView', params: { id: child.id } })
}
const closeEditDialog = () => {
showEditDialog.value = false
editingChild.value = null
function handleServerChange(event: Event) {
fetchChildren()
}
// points update state
@@ -85,12 +72,28 @@ const fetchChildren = async () => {
}
}
const createChild = () => {
router.push({ name: 'CreateChild' })
}
onMounted(async () => {
eventBus.on('child_update', handleServerChange)
eventBus.on('task_update', handleServerChange)
eventBus.on('reward_update', handleServerChange)
eventBus.on('child_delete', handleServerChange)
await fetchChildren()
// listen for outside clicks to auto-close any open kebab menu
document.addEventListener('click', onDocClick, true)
})
onBeforeUnmount(() => {
eventBus.off('child_update', handleServerChange)
eventBus.off('task_update', handleServerChange)
eventBus.off('reward_update', handleServerChange)
eventBus.off('child_delete', handleServerChange)
})
const shouldIgnoreNextCardClick = ref(false)
const onDocClick = (e: MouseEvent) => {
@@ -194,11 +197,6 @@ const deletePoints = async (childId: string | number, evt?: Event) => {
}
}
const gridColumns = computed(() => {
const n = Math.min(children.value.length, 3)
return `repeat(${n || 1}, minmax(var(--card-width, 289px), 1fr))`
})
onBeforeUnmount(() => {
document.removeEventListener('click', onDocClick, true)
revokeAllImageUrls()
@@ -238,7 +236,7 @@ onBeforeUnmount(() => {
<button
class="menu-item"
@mousedown.stop.prevent
@click="openEditDialog(child, $event)"
@click="openChildEditor(child, $event)"
>
Edit Child
</button>
@@ -269,18 +267,6 @@ onBeforeUnmount(() => {
</div>
</div>
<ChildForm
v-if="showEditDialog"
:child="editingChild"
@close="closeEditDialog"
@updated="
async () => {
closeEditDialog()
await fetchChildren()
}
"
/>
<!-- confirmation modal -->
<div
v-if="confirmDeleteVisible"
@@ -308,6 +294,14 @@ onBeforeUnmount(() => {
</div>
</div>
</div>
<!-- Add Child button (FAB) -->
<button v-if="isParentAuthenticated" class="fab" @click="createChild" aria-label="Add Child">
<svg width="28" height="28" viewBox="0 0 28 28" fill="none">
<circle cx="14" cy="14" r="14" fill="#667eea" />
<path d="M14 8v12M8 14h12" stroke="#fff" stroke-width="2" stroke-linecap="round" />
</svg>
</button>
</div>
</template>
@@ -525,4 +519,32 @@ h1 {
font-weight: 600;
text-align: center;
}
/* Floating Action Button (FAB) */
.fab {
position: fixed;
bottom: 2rem;
right: 2rem;
background: #667eea;
color: #fff;
border: none;
border-radius: 50%;
width: 56px;
height: 56px;
display: flex;
align-items: center;
justify-content: center;
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.2);
cursor: pointer;
font-size: 24px;
z-index: 1300;
}
.fab:hover {
background: #5a67d8;
}
.fab:active {
background: #4c51bf;
}
</style>

View File

@@ -41,83 +41,98 @@ onBeforeUnmount(() => {
</script>
<template>
<div v-if="child" class="detail-card">
<h1>{{ child.name }}</h1>
<div v-if="child" class="detail-card-horizontal">
<img v-if="imageUrl" :src="imageUrl" alt="Child Image" class="child-image" />
<div class="info">
<div class="info-item">
<span class="label">Age:</span>
<span class="value">{{ child.age }}</span>
</div>
<div class="info-item">
<span class="label">Points:</span>
<span class="value">{{ child.points ?? '—' }}</span>
</div>
<div class="main-info">
<div class="child-name">{{ child.name }}</div>
<div class="child-age">Age: {{ child.age }}</div>
</div>
<div class="points">
<span class="label">Points</span>
<span class="value">{{ child.points ?? '—' }}</span>
</div>
</div>
</template>
<style scoped>
.detail-card {
.detail-card-horizontal {
display: flex;
align-items: center;
background: white;
border-radius: 16px;
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.18);
padding: 1.2rem 1rem;
border-radius: 12px;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.13);
padding: 0.7rem 1rem;
max-width: 420px;
width: 100%;
min-height: 64px;
box-sizing: border-box;
}
.detail-card h1 {
font-size: 1.3rem;
color: #333;
margin-bottom: 1rem;
text-align: center;
gap: 1rem;
}
.child-image {
width: 64px;
height: 64px;
width: 48px;
height: 48px;
object-fit: cover;
border-radius: 50%;
display: block;
margin: 0 auto 0.7rem auto;
flex-shrink: 0;
}
.info {
.main-info {
display: flex;
flex-direction: column;
gap: 0.6rem;
justify-content: center;
flex: 1 1 auto;
min-width: 0;
}
.info-item {
.child-name {
font-size: 1.08rem;
font-weight: 600;
color: #333;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.child-age {
font-size: 0.97rem;
color: #666;
margin-top: 2px;
}
.points {
display: flex;
justify-content: space-between;
align-items: center;
padding: 0.5rem 0.7rem;
background: #f5f5f5;
border-radius: 7px;
font-size: 0.98rem;
flex-direction: column;
align-items: flex-end;
min-width: 54px;
margin-left: 0.7rem;
}
.points .label {
font-size: 0.85rem;
color: #888;
margin-bottom: 1px;
}
.points .value {
font-size: 1.1rem;
font-weight: 700;
color: #667eea;
}
/* Even more compact on small screens */
@media (max-width: 480px) {
.detail-card {
padding: 0.7rem 0.4rem;
.detail-card-horizontal {
padding: 0.5rem 0.4rem;
max-width: 98vw;
}
.detail-card h1 {
font-size: 1.05rem;
margin-bottom: 0.7rem;
gap: 0.6rem;
}
.child-image {
width: 48px;
height: 48px;
margin-bottom: 0.5rem;
width: 38px;
height: 38px;
}
.info-item {
padding: 0.38rem 0.5rem;
font-size: 0.93rem;
.points {
min-width: 38px;
margin-left: 0.3rem;
}
}
</style>

View File

@@ -0,0 +1,247 @@
<template>
<div class="child-edit-view">
<h2>{{ isEdit ? 'Edit Child' : 'Create Child' }}</h2>
<div v-if="loading" class="loading-message">Loading child...</div>
<form v-else @submit.prevent="submit" class="form">
<div class="form-group">
<label for="child-name">Name</label>
<input id="child-name" ref="nameInput" v-model="name" required maxlength="64" />
</div>
<div class="form-group">
<label for="child-age">Age</label>
<input id="child-age" v-model.number="age" type="number" min="0" max="120" required />
</div>
<div class="form-group image-picker-group">
<label for="child-image">Image</label>
<ImagePicker
id="child-image"
v-model="selectedImageId"
:image-type="1"
@add-image="onAddImage"
/>
</div>
<div v-if="error" class="error">{{ error }}</div>
<div class="actions">
<button type="button" class="btn cancel" @click="onCancel" :disabled="loading">
Cancel
</button>
<button type="submit" class="btn save" :disabled="loading">
{{ isEdit ? 'Save' : 'Create' }}
</button>
</div>
</form>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted, computed, nextTick } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import ImagePicker from '../ImagePicker.vue'
const route = useRoute()
const router = useRouter()
// Accept id as a prop for edit mode
const props = defineProps<{ id?: string }>()
const isEdit = computed(() => !!props.id)
const name = ref('')
const age = ref<number | null>(null)
const selectedImageId = ref<string | null>(null)
const localImageFile = ref<File | null>(null)
const nameInput = ref<HTMLInputElement | null>(null)
const loading = ref(false)
const error = ref<string | null>(null)
onMounted(async () => {
if (isEdit.value && props.id) {
loading.value = true
try {
const resp = await fetch(`/api/child/${props.id}`)
if (!resp.ok) throw new Error('Failed to load child')
const data = await resp.json()
name.value = data.name ?? ''
age.value = Number(data.age) ?? null
selectedImageId.value = data.image_id ?? null
} catch (e) {
error.value = 'Could not load child.'
} finally {
loading.value = false
await nextTick()
nameInput.value?.focus()
}
} else {
await nextTick()
nameInput.value?.focus()
}
})
function onAddImage({ id, file }: { id: string; file: File }) {
if (id === 'local-upload') {
localImageFile.value = file
}
}
const submit = async () => {
let imageId = selectedImageId.value
error.value = null
if (!name.value.trim()) {
error.value = 'Child name is required.'
return
}
if (age.value === null || age.value < 0) {
error.value = 'Age must be a non-negative number.'
return
}
loading.value = true
// If the selected image is a local upload, upload it first
if (imageId === 'local-upload' && localImageFile.value) {
const formData = new FormData()
formData.append('file', localImageFile.value)
formData.append('type', '1')
formData.append('permanent', 'false')
try {
const resp = await fetch('/api/image/upload', {
method: 'POST',
body: formData,
})
if (!resp.ok) throw new Error('Image upload failed')
const data = await resp.json()
imageId = data.id
} catch (err) {
alert('Failed to upload image.')
loading.value = false
return
}
}
// Now update or create the child
try {
let resp
if (isEdit.value && props.id) {
resp = await fetch(`/api/child/${props.id}/edit`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
name: name.value,
age: age.value,
image_id: imageId,
}),
})
} else {
resp = await fetch('/api/child/add', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
name: name.value,
age: age.value,
image_id: imageId,
}),
})
}
if (!resp.ok) throw new Error('Failed to save child')
await router.push({ name: 'ChildrenListView' })
} catch (err) {
alert('Failed to save child.')
}
loading.value = false
}
function onCancel() {
router.back()
}
</script>
<style scoped>
.child-edit-view {
max-width: 400px;
margin: 2rem auto;
background: #fff;
border-radius: 12px;
box-shadow: 0 4px 24px #667eea22;
padding: 2rem 2.2rem 1.5rem 2.2rem;
}
h2 {
margin-bottom: 1.2rem;
font-size: 1.15rem;
color: #667eea;
font-weight: 700;
text-align: center;
}
.form {
display: flex;
flex-direction: column;
gap: 1.1rem;
}
.form-group {
width: 100%;
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 0.35rem;
box-sizing: border-box;
}
label {
font-weight: 600;
color: #333;
font-size: 1rem;
}
input[type='text'],
input[type='number'] {
width: 100%;
min-width: 0;
box-sizing: border-box;
padding: 0.5rem 0.7rem;
border-radius: 8px;
border: 1px solid #e6e6e6;
font-size: 1rem;
background: #fafbff;
color: #222;
transition: border 0.2s;
}
input:focus {
outline: none;
border: 1.5px solid #667eea;
}
.actions {
display: flex;
gap: 0.7rem;
justify-content: center;
margin-top: 0.5rem;
}
.btn {
padding: 0.5rem 1.1rem;
border-radius: 8px;
border: 0;
cursor: pointer;
font-weight: 700;
font-size: 1rem;
transition: background 0.18s;
}
.btn.cancel {
background: #f3f3f3;
color: #666;
}
.btn.save {
background: #667eea;
color: white;
}
.btn.save:hover {
background: #5a67d8;
}
.form-group.image-picker-group {
display: block;
text-align: left;
}
.error {
color: #e53e3e;
margin-top: 0.7rem;
text-align: center;
}
.loading-message {
text-align: center;
color: #666;
margin-bottom: 1.2rem;
}
</style>

View File

@@ -1,37 +1,161 @@
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { useRoute } from 'vue-router'
import { ref, onMounted, onUnmounted } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import ChildDetailCard from './ChildDetailCard.vue'
import ChildTaskList from '../task/ChildTaskList.vue'
import ChildRewardList from '../reward/ChildRewardList.vue'
interface Child {
id: string | number
name: string
age: number
points?: number
}
import { eventBus } from '@/common/eventBus'
import type {
Child,
Event,
Task,
Reward,
TaskUpdateEventPayload,
RewardUpdateEventPayload,
ChildUpdateEventPayload,
ChildDeleteEventPayload,
TaskCreatedEventPayload,
TaskDeletedEventPayload,
TaskEditedEventPayload,
RewardCreatedEventPayload,
RewardDeletedEventPayload,
RewardEditedEventPayload,
} from '@/common/models'
const route = useRoute()
const router = useRouter()
const child = ref<Child | null>(null)
const tasks = ref<string[]>([])
const rewards = ref<string[]>([])
const loading = ref(true)
const error = ref<string | null>(null)
onMounted(async () => {
function handlePointsUpdate(event: Event) {
const payload = event.payload as TaskUpdateEventPayload | RewardUpdateEventPayload
if (child.value && payload.child_id == child.value.id) {
child.value.points = payload.points
}
}
function handleServerChange(event: Event) {
const payload = event.payload as
| TaskUpdateEventPayload
| RewardUpdateEventPayload
| ChildUpdateEventPayload
if (child.value && payload.child_id == child.value.id) {
fetchChildData(child.value.id)
}
}
function handleChildDeletion(event: Event) {
const payload = event.payload as ChildDeleteEventPayload
if (child.value && payload.child_id == child.value.id) {
// Navigate away back to children list
router.push({ name: 'ChildrenListView' })
}
}
function handleTaskChanged(event: Event) {
const payload = event.payload as
| TaskCreatedEventPayload
| TaskDeletedEventPayload
| TaskEditedEventPayload
if (child.value) {
const task_id = payload.task_id
if (tasks.value.includes(task_id)) {
fetchChildData(child.value.id)
}
}
}
function handleRewardChanged(event: Event) {
const payload = event.payload as
| RewardCreatedEventPayload
| RewardDeletedEventPayload
| RewardEditedEventPayload
if (child.value) {
const reward_id = payload.reward_id
if (rewards.value.includes(reward_id)) {
fetchChildData(child.value.id)
}
}
}
const handleTriggerTask = (task: Task) => {
if ('speechSynthesis' in window && task.name) {
const utter = new window.SpeechSynthesisUtterance(task.name)
window.speechSynthesis.speak(utter)
}
}
const handleTriggerReward = (reward: Reward, redeemable: boolean) => {
if ('speechSynthesis' in window && reward.name) {
console.log('Handle trigger reward:', reward, redeemable)
const utterString =
reward.name + (redeemable ? '' : `, You still need ${reward.points_needed} points.`)
const utter = new window.SpeechSynthesisUtterance(utterString)
window.speechSynthesis.speak(utter)
}
}
async function fetchChildData(id: string | number) {
loading.value = true
try {
const resp = await fetch(`/api/child/${route.params.id}`)
const resp = await fetch(`/api/child/${id}`)
if (!resp.ok) throw new Error(`HTTP ${resp.status}`)
const data = await resp.json()
child.value = data.children ? data.children : data
tasks.value = data.tasks || []
rewards.value = data.rewards || []
error.value = null
} catch (err) {
error.value = err instanceof Error ? err.message : 'Failed to fetch child'
console.error(err)
} finally {
loading.value = false
}
}
onMounted(async () => {
try {
eventBus.on('task_update', handlePointsUpdate)
eventBus.on('reward_update', handlePointsUpdate)
eventBus.on('task_set', handleServerChange)
eventBus.on('reward_set', handleServerChange)
eventBus.on('child_update', handleServerChange)
eventBus.on('child_delete', handleChildDeletion)
eventBus.on('task_created', handleTaskChanged)
eventBus.on('task_deleted', handleTaskChanged)
eventBus.on('task_edited', handleTaskChanged)
eventBus.on('reward_created', handleRewardChanged)
eventBus.on('reward_deleted', handleRewardChanged)
eventBus.on('reward_edited', handleRewardChanged)
if (route.params.id) {
const idParam = Array.isArray(route.params.id) ? route.params.id[0] : route.params.id
if (idParam !== undefined) {
fetchChildData(idParam)
}
}
} catch (err) {
console.error('Error in onMounted:', err)
}
})
onUnmounted(() => {
eventBus.off('task_update', handlePointsUpdate)
eventBus.off('reward_update', handlePointsUpdate)
eventBus.off('task_set', handleServerChange)
eventBus.off('reward_set', handleServerChange)
eventBus.off('child_update', handleServerChange)
eventBus.off('child_delete', handleChildDeletion)
eventBus.off('task_created', handleTaskChanged)
eventBus.off('task_deleted', handleTaskChanged)
eventBus.off('task_edited', handleTaskChanged)
eventBus.off('reward_created', handleRewardChanged)
eventBus.off('reward_deleted', handleRewardChanged)
eventBus.off('reward_edited', handleRewardChanged)
})
</script>
@@ -44,26 +168,33 @@ onMounted(async () => {
<div class="main">
<ChildDetailCard :child="child" />
<ChildTaskList
title="Chores"
:task-ids="tasks"
:child-id="child ? child.id : null"
:is-parent-authenticated="false"
:filter-type="1"
@trigger-task="handleTriggerTask"
/>
<ChildTaskList
title="Bad Habits"
:task-ids="tasks"
:child-id="child ? child.id : null"
:is-parent-authenticated="false"
:filter-type="2"
@trigger-task="handleTriggerTask"
/>
<ChildRewardList
:child-id="child ? child.id : null"
:child-points="child?.points ?? 0"
:is-parent-authenticated="false"
@trigger-reward="handleTriggerReward"
@points-updated="
({ id, points }) => {
if (child && child.id === id) child.points = points
}
"
/>
<!-- removed placeholder -->
</div>
<!-- Remove this aside block:
<aside class="side">
<div class="placeholder">Additional components go here</div>
</aside>
-->
</div>
</div>
</template>
@@ -74,7 +205,7 @@ onMounted(async () => {
max-width: 1200px;
margin: 0 auto;
min-height: 100vh;
padding: 2rem;
padding: 0.5rem;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
box-sizing: border-box;
}

View File

@@ -1,45 +1,188 @@
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { useRoute } from 'vue-router'
import { ref, onMounted, computed, onUnmounted } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { isParentAuthenticated } from '../../stores/auth'
import ChildDetailCard from './ChildDetailCard.vue'
import ChildTaskList from '../task/ChildTaskList.vue'
import ChildRewardList from '../reward/ChildRewardList.vue'
import AssignTaskButton from '../AssignTaskButton.vue'
interface Child {
id: string | number
name: string
age: number
points?: number
}
import { eventBus } from '@/common/eventBus'
import type {
Task,
Child,
Event,
Reward,
TaskUpdateEventPayload,
RewardUpdateEventPayload,
ChildUpdateEventPayload,
ChildDeleteEventPayload,
} from '@/common/models'
const route = useRoute()
const router = useRouter()
const child = ref<Child | null>(null)
const tasks = ref<string[]>([])
const loading = ref(true)
const error = ref<string | null>(null)
const rewardListRef = ref()
const showConfirm = ref(false)
const selectedTask = ref<Task | null>(null)
const showRewardConfirm = ref(false)
const selectedReward = ref<Reward | null>(null)
onMounted(async () => {
function handlePointsUpdate(event: Event) {
const payload = event.payload as TaskUpdateEventPayload | RewardUpdateEventPayload
if (child.value && payload.child_id == child.value.id) {
child.value.points = payload.points
}
}
function handleServerChange(event: Event) {
const payload = event.payload as
| TaskUpdateEventPayload
| RewardUpdateEventPayload
| ChildUpdateEventPayload
if (child.value && payload.child_id == child.value.id) {
fetchChildData(child.value.id)
}
}
function handleChildDeletion(event: Event) {
const payload = event.payload as ChildDeleteEventPayload
if (child.value && payload.child_id == child.value.id) {
// Navigate away back to children list
router.push({ name: 'ChildrenListView' })
}
}
async function fetchChildData(id: string | number) {
loading.value = true
try {
const resp = await fetch(`/api/child/${route.params.id}`)
const resp = await fetch(`/api/child/${id}`)
if (!resp.ok) throw new Error(`HTTP ${resp.status}`)
const data = await resp.json()
child.value = data.children ? data.children : data
tasks.value = data.tasks || []
error.value = null
} catch (err) {
error.value = err instanceof Error ? err.message : 'Failed to fetch child'
console.error(err)
} finally {
loading.value = false
}
}
onMounted(async () => {
try {
eventBus.on('task_update', handlePointsUpdate)
eventBus.on('reward_update', handlePointsUpdate)
eventBus.on('task_set', handleServerChange)
eventBus.on('reward_set', handleServerChange)
eventBus.on('child_update', handleServerChange)
eventBus.on('child_delete', handleChildDeletion)
if (route.params.id) {
const idParam = Array.isArray(route.params.id) ? route.params.id[0] : route.params.id
if (idParam !== undefined) {
fetchChildData(idParam)
}
}
} catch (err) {
console.error('Error in onMounted:', err)
}
})
onUnmounted(() => {
eventBus.off('task_update', handlePointsUpdate)
eventBus.off('reward_update', handlePointsUpdate)
eventBus.off('task_set', handleServerChange)
eventBus.off('reward_set', handleServerChange)
eventBus.off('child_update', handleServerChange)
eventBus.off('child_delete', handleChildDeletion)
})
const refreshRewards = () => {
rewardListRef.value?.refresh()
}
const handleTriggerTask = (task: Task) => {
selectedTask.value = task
showConfirm.value = true
}
const confirmTriggerTask = async () => {
if (!child.value?.id || !selectedTask.value) return
try {
const resp = await fetch(`/api/child/${child.value.id}/trigger-task`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ task_id: selectedTask.value.id }),
})
if (!resp.ok) return
const data = await resp.json()
console.log('Trigger task response data:', child.value.id, data.id)
if (child.value && child.value.id === data.id) child.value.points = data.points
} catch (err) {
console.error('Failed to trigger task:', err)
} finally {
showConfirm.value = false
selectedTask.value = null
}
}
const handleTriggerReward = (reward: Reward, redeemable: boolean) => {
console.log('Handle trigger reward:', reward, redeemable)
if (!redeemable) return
selectedReward.value = reward
showRewardConfirm.value = true
}
const confirmTriggerReward = async () => {
if (!child.value?.id || !selectedReward.value) return
try {
const resp = await fetch(`/api/child/${child.value.id}/trigger-reward`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ reward_id: selectedReward.value.id }),
})
if (!resp.ok) return
const data = await resp.json()
if (child.value && child.value.id === data.id) child.value.points = data.points
} catch (err) {
console.error('Failed to trigger reward:', err)
} finally {
showRewardConfirm.value = false
selectedReward.value = null
}
}
function goToAssignTasks() {
if (child.value?.id) {
router.push({ name: 'TaskAssignView', params: { id: child.value.id, type: 'good' } })
}
}
function goToAssignBadHabits() {
if (child.value?.id) {
router.push({ name: 'TaskAssignView', params: { id: child.value.id, type: 'bad' } })
}
}
function goToAssignRewards() {
if (child.value?.id) {
router.push({ name: 'RewardAssignView', params: { id: child.value.id } })
}
}
const handleTaskPointsUpdated = ({ id, points }: { id: string | number; points: number }) => {
if (child.value && child.value.id === id) child.value.points = points
refreshRewards()
}
const handleRewardPointsUpdated = ({ id, points }: { id: string | number; points: number }) => {
if (child.value && child.value.id === id) child.value.points = points
}
const childId = computed(() => child.value?.id ?? null)
</script>
<template>
@@ -51,31 +194,118 @@ const refreshRewards = () => {
<div class="main">
<ChildDetailCard :child="child" />
<ChildTaskList
title="Chores"
:task-ids="tasks"
:child-id="child ? child.id : null"
:child-id="childId"
:is-parent-authenticated="isParentAuthenticated"
@points-updated="
({ id, points }) => {
if (child && child.id === id) child.points = points
refreshRewards()
}
"
:filter-type="1"
@points-updated="handleTaskPointsUpdated"
@trigger-task="handleTriggerTask"
/>
<ChildTaskList
title="Bad Habits"
:task-ids="tasks"
:child-id="childId"
:is-parent-authenticated="isParentAuthenticated"
:filter-type="2"
@points-updated="handleTaskPointsUpdated"
@trigger-task="handleTriggerTask"
/>
<ChildRewardList
ref="rewardListRef"
:child-id="child ? child.id : null"
:is-parent-authenticated="isParentAuthenticated"
@points-updated="
({ id, points }) => {
if (child && child.id === id) child.points = points
refreshRewards()
}
"
:child-id="childId"
:child-points="child?.points ?? 0"
:is-parent-authenticated="false"
@points-updated="handleRewardPointsUpdated"
@trigger-reward="handleTriggerReward"
/>
</div>
</div>
<!-- Place the AssignTaskButton here, outside .main but inside .container -->
<AssignTaskButton :child-id="child ? child.id : null" />
<div class="assign-buttons">
<button v-if="child" class="assign-task-btn" @click="goToAssignTasks">Assign Tasks</button>
<button v-if="child" class="assign-bad-btn" @click="goToAssignBadHabits">
Assign Bad Habits
</button>
<button v-if="child" class="assign-reward-btn" @click="goToAssignRewards">
Assign Rewards
</button>
</div>
<div v-if="showConfirm && selectedTask" class="modal-backdrop">
<div class="modal">
<div class="task-info">
<img
v-if="selectedTask.image_url"
:src="selectedTask.image_url"
alt="Task Image"
class="task-image"
/>
<div class="task-details">
<div class="task-name">{{ selectedTask.name }}</div>
<div class="task-points" :class="selectedTask.is_good ? 'good' : 'bad'">
{{ selectedTask.points }} pts
</div>
</div>
</div>
<div class="dialog-message" style="margin-bottom: 1.2rem">
{{ selectedTask.is_good ? 'Add' : 'Subtract' }} these points
{{ selectedTask.is_good ? 'to' : 'from' }}
<span class="child-name">{{ child?.name }}</span>
</div>
<div class="actions">
<button @click="confirmTriggerTask">Yes</button>
<button
@click="
() => {
showConfirm = false
selectedTask = null
}
"
>
Cancel
</button>
</div>
</div>
</div>
<div v-if="showRewardConfirm && selectedReward" class="modal-backdrop">
<div class="modal">
<div class="reward-info">
<img
v-if="selectedReward.image_id"
:src="selectedReward.image_id"
alt="Reward Image"
class="reward-image"
/>
<div class="reward-details">
<div class="reward-name">{{ selectedReward.name }}</div>
<div class="reward-points">
{{
selectedReward.points_needed === 0
? 'Reward Ready!'
: selectedReward.points_needed + ' pts needed'
}}
</div>
</div>
</div>
<div class="dialog-message" style="margin-bottom: 1.2rem">
Redeem this reward for <span class="child-name">{{ child?.name }}</span
>?
</div>
<div class="actions">
<button @click="confirmTriggerReward">Yes</button>
<button
@click="
() => {
showRewardConfirm = false
selectedReward = null
}
"
>
Cancel
</button>
</div>
</div>
</div>
</div>
</template>
@@ -89,16 +319,6 @@ const refreshRewards = () => {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
box-sizing: border-box;
}
.back-btn {
background: white;
border: 0;
padding: 0.6rem 1rem;
border-radius: 8px;
cursor: pointer;
margin-bottom: 1.5rem;
color: #667eea;
font-weight: 600;
}
.loading,
.error {
color: white;
@@ -118,9 +338,6 @@ const refreshRewards = () => {
display: flex;
justify-content: center;
align-items: flex-start;
/* Remove grid styles */
/* grid-template-columns: 1fr 320px; */
/* gap: 1.5rem; */
}
.main {
display: flex;
@@ -130,22 +347,84 @@ const refreshRewards = () => {
width: 100%;
max-width: 600px; /* or whatever width fits your content best */
}
.side {
display: flex;
flex-direction: column;
gap: 1rem;
}
.placeholder {
background: rgba(255, 255, 255, 0.08);
color: white;
padding: 1rem;
border-radius: 8px;
min-height: 120px;
/* Modal styles */
.modal-backdrop {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.45);
display: flex;
align-items: center;
justify-content: center;
z-index: 1200;
}
.modal {
background: #fff;
color: #222;
padding: 1.5rem 2rem;
border-radius: 12px;
min-width: 240px;
box-shadow: 0 12px 40px rgba(0, 0, 0, 0.2);
text-align: center;
}
.task-info {
display: flex;
align-items: center;
gap: 1rem;
margin-bottom: 1rem;
}
.task-image {
width: 72px;
height: 72px;
object-fit: cover;
border-radius: 8px;
background: #eee;
}
.task-details {
display: flex;
flex-direction: column;
align-items: flex-start;
}
.task-name {
font-weight: 600;
font-size: 1.1rem;
}
.task-points,
.task-points.good,
.task-points.bad {
font-weight: 600;
font-size: 1.1rem;
}
.task-points.good {
color: #38c172;
}
.task-points.bad {
color: #ef4444;
}
.actions {
margin-top: 1.2rem;
display: flex;
gap: 1rem;
justify-content: center;
}
.actions button {
padding: 0.5rem 1.2rem;
border-radius: 8px;
border: none;
font-weight: 600;
cursor: pointer;
}
.actions button:first-child {
background: #667eea;
color: #fff;
}
.actions button:last-child {
background: #f3f3f3;
color: #666;
}
.actions button:last-child:hover {
background: #e2e8f0;
}
/* Mobile adjustments */
@media (max-width: 900px) {
@@ -158,17 +437,83 @@ const refreshRewards = () => {
.container {
padding: 1rem;
}
.back-btn {
padding: 0.45rem 0.75rem;
font-size: 0.95rem;
margin-bottom: 1rem;
}
.main {
gap: 1rem;
}
.placeholder {
padding: 0.75rem;
min-height: 80px;
}
}
.dialog-message {
font-size: 1.08rem;
color: #444;
font-weight: 500;
}
.dialog-message .child-name {
color: #667eea;
font-weight: 700;
margin-left: 2px;
}
.reward-info {
display: flex;
align-items: center;
gap: 1rem;
margin-bottom: 1rem;
}
.reward-image {
width: 72px;
height: 72px;
object-fit: cover;
border-radius: 8px;
background: #eee;
}
.reward-details {
display: flex;
flex-direction: column;
align-items: flex-start;
}
.reward-name {
font-weight: 600;
font-size: 1.1rem;
}
.reward-points {
color: #667eea;
font-weight: 500;
font-size: 1rem;
}
.assign-buttons {
display: flex;
gap: 1rem;
justify-content: center;
margin: 2rem 0;
}
.assign-task-btn,
.assign-bad-btn,
.assign-reward-btn {
font-weight: 600;
border: none;
border-radius: 8px;
padding: 0.7rem 1.5rem;
font-size: 1.1rem;
cursor: pointer;
box-shadow: 0 2px 8px rgba(102, 126, 234, 0.08);
transition: background 0.18s;
color: #fff;
background: #667eea;
}
.assign-task-btn:hover,
.assign-bad-btn:hover,
.assign-reward-btn:hover {
background: #5a67d8;
}
.assign-bad-btn {
background: #ef4444;
}
.assign-bad-btn:hover {
background: #dc2626;
}
.assign-reward-btn {
background: #38c172;
}
.assign-reward-btn:hover {
background: #2f855a;
}
</style>

View File

@@ -0,0 +1,94 @@
<template>
<div class="reward-assign-view">
<h2>Assign Rewards</h2>
<div class="reward-list-scroll">
<RewardList ref="rewardListRef" :child-id="childId" :selectable="true" />
</div>
<div class="actions">
<button class="btn cancel" @click="onCancel">Cancel</button>
<button class="btn submit" @click="onSubmit">Submit</button>
</div>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import RewardList from '../reward/RewardList.vue'
const route = useRoute()
const router = useRouter()
const childId = route.params.id
const rewardListRef = ref()
async function onSubmit() {
const selectedIds = rewardListRef.value?.selectedRewards ?? []
try {
const resp = await fetch(`/api/child/${childId}/set-rewards`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ reward_ids: selectedIds }),
})
if (!resp.ok) throw new Error('Failed to update rewards')
router.back()
} catch (err) {
alert('Failed to update rewards.')
}
}
function onCancel() {
router.back()
}
</script>
<style scoped>
.reward-assign-view {
display: flex;
flex-direction: column;
align-items: center;
flex: 1 1 auto;
width: 100%;
height: 100%;
padding: 0;
min-height: 0;
}
h2 {
font-size: 1.15rem;
color: #ffffff;
font-weight: 700;
text-align: center;
margin: 0.2rem;
}
.reward-list-scroll {
flex: 1 1 auto;
min-height: 0;
overflow-y: auto;
margin-bottom: 2rem;
}
.actions {
display: flex;
gap: 1rem;
justify-content: flex-end;
margin-top: 0;
}
.btn {
padding: 0.5rem 1.2rem;
border-radius: 8px;
border: none;
font-weight: 600;
cursor: pointer;
font-size: 1rem;
}
.btn.cancel {
background: #f3f3f3;
color: #666;
}
.btn.submit {
background: #667eea;
color: #fff;
}
.btn.submit:hover {
background: #5a67d8;
}
</style>

View File

@@ -0,0 +1,105 @@
<template>
<div class="task-assign-view">
<h2>Assign Tasks</h2>
<div class="task-list-scroll">
<TaskList
ref="taskListRef"
:child-id="childId"
:selectable="true"
:type-filter="typeFilter"
/>
</div>
<div class="actions">
<button class="btn cancel" @click="onCancel">Cancel</button>
<button class="btn submit" @click="onSubmit">Submit</button>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import TaskList from '../task/TaskList.vue'
const route = useRoute()
const router = useRouter()
const childId = route.params.id
const taskListRef = ref()
const typeFilter = computed(() => {
if (route.params.type === 'good') return 'good'
if (route.params.type === 'bad') return 'bad'
return 'all'
})
async function onSubmit() {
const selectedIds = taskListRef.value?.selectedTasks ?? []
try {
const resp = await fetch(`/api/child/${childId}/set-tasks`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ task_ids: selectedIds }),
})
if (!resp.ok) throw new Error('Failed to update tasks')
router.back()
} catch (err) {
alert('Failed to update tasks.')
}
}
function onCancel() {
router.back()
}
</script>
<style scoped>
.task-assign-view {
display: flex;
flex-direction: column;
align-items: center;
flex: 1 1 auto;
width: 100%;
height: 100%;
padding: 0;
min-height: 0;
}
h2 {
font-size: 1.15rem;
color: #ffffff;
font-weight: 700;
text-align: center;
margin: 0.2rem;
}
.task-list-scroll {
flex: 1 1 auto;
min-height: 0;
overflow-y: auto;
margin-bottom: 2rem;
}
.actions {
display: flex;
gap: 1rem;
justify-content: flex-end;
margin-top: 0;
}
.btn {
padding: 0.5rem 1.2rem;
border-radius: 8px;
border: none;
font-weight: 600;
cursor: pointer;
font-size: 1rem;
}
.btn.cancel {
background: #f3f3f3;
color: #666;
}
.btn.submit {
background: #667eea;
color: #fff;
}
.btn.submit:hover {
background: #5a67d8;
}
</style>

View File

@@ -2,23 +2,18 @@
import { ref, onMounted, onBeforeUnmount, watch, nextTick } from 'vue'
import { defineProps, defineEmits, defineExpose } from 'vue'
import { getCachedImageUrl, revokeAllImageUrls } from '../../common/imageCache'
interface Reward {
id: string
name: string
points_needed: number
image_id: string | null
}
import type { RewardStatus } from '@/common/models'
const imageCacheName = 'images-v1'
const props = defineProps<{
childId: string | number | null
childPoints: number
isParentAuthenticated: boolean
}>()
const emit = defineEmits(['points-updated'])
const emit = defineEmits(['points-updated', 'trigger-reward'])
const rewards = ref<Reward[]>([])
const rewards = ref<RewardStatus[]>([])
const loading = ref(true)
const error = ref<string | null>(null)
@@ -53,7 +48,7 @@ const fetchRewards = async (id: string | number | null) => {
}
}
const fetchImage = async (reward: Reward) => {
const fetchImage = async (reward: RewardStatus) => {
if (!reward.image_id) {
console.log(`No image ID for reward: ${reward.id}`)
return
@@ -86,8 +81,6 @@ const centerReward = async (rewardId: string) => {
}
const handleRewardClick = async (rewardId: string) => {
if (!props.isParentAuthenticated) return // Only allow if logged in
await nextTick()
const wrapper = scrollWrapper.value
const card = rewardRefs.value[rewardId]
@@ -111,24 +104,10 @@ const handleRewardClick = async (rewardId: string) => {
readyRewardId.value = null
}
const triggerReward = async (rewardId: string) => {
if (!props.childId) return
const triggerReward = (rewardId: string) => {
const reward = rewards.value.find((rew) => rew.id === rewardId)
if (!reward || reward.points_needed > 0) return // Don't trigger if not allowed
if (!props.isParentAuthenticated) return // Only allow if logged in
try {
const resp = await fetch(`/api/child/${props.childId}/trigger-reward`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ reward_id: rewardId }),
})
if (!resp.ok) return
const data = await resp.json()
// Emit the new points so the parent can update the child points
emit('points-updated', { id: props.childId, points: data.points })
} catch (err) {
console.error('Failed to trigger reward:', err)
}
if (!reward) return // Don't trigger if not allowed
emit('trigger-reward', reward, reward.points_needed <= 0)
}
onMounted(() => fetchRewards(props.childId))
@@ -136,6 +115,18 @@ watch(
() => props.childId,
(v) => fetchRewards(v),
)
watch(
() => props.childPoints,
() => {
// Option 1: If reward eligibility depends on points, recompute eligibility here
// Option 2: If you need to refetch from the backend, call fetchRewards(props.childId)
// For most cases, just recompute eligibility locally
// Example:
rewards.value.forEach((reward) => {
reward.points_needed = Math.max(0, reward.cost - props.childPoints)
})
},
)
// revoke created object URLs when component unmounts to avoid memory leaks
onBeforeUnmount(() => {
@@ -162,7 +153,6 @@ defineExpose({ refresh: () => fetchRewards(props.childId) })
class="reward-card"
:class="{
ready: readyRewardId === r.id,
disabled: r.points_needed > 0,
}"
:ref="(el) => (rewardRefs[r.id] = el)"
@click="() => handleRewardClick(r.id)"
@@ -191,7 +181,7 @@ defineExpose({ refresh: () => fetchRewards(props.childId) })
}
.reward-list-container h3 {
margin: 0 0 0.75rem 0;
margin: 0;
font-size: 1.05rem;
font-weight: 600;
}

View File

@@ -1,10 +1,12 @@
<script setup lang="ts">
import { ref, watch, onMounted, computed } from 'vue'
import { getCachedImageUrl } from '../../common/imageCache'
const props = defineProps<{
childId?: string | number
assignable?: boolean
assignFilter?: 'assignable' | 'assigned' | 'none'
deletable?: boolean
selectable?: boolean
}>()
const emit = defineEmits(['edit-reward', 'delete-reward'])
@@ -21,49 +23,93 @@ const rewards = ref<
const loading = ref(true)
const error = ref<string | null>(null)
const selectedRewards = ref<string[]>([])
const fetchRewards = async () => {
loading.value = true
error.value = null
let url = ''
if (props.childId) {
if (props.assignable) {
url = `/api/child/${props.childId}/list-assignable-rewards`
} else {
url = `/api/child/${props.childId}/list-rewards`
url = `/api/child/${props.childId}/list-all-rewards`
try {
const resp = await fetch(url)
if (!resp.ok) throw new Error(`HTTP ${resp.status}`)
const data = await resp.json()
const assigned = (data.assigned_rewards || []).map((reward: any) => ({
...reward,
assigned: true,
}))
const assignable = (data.assignable_rewards || []).map((reward: any) => ({
...reward,
assigned: false,
}))
let rewardList: any[] = []
if (props.assignFilter === 'assignable') {
rewardList = assignable
} else if (props.assignFilter === 'assigned') {
rewardList = assigned
} else if (props.assignFilter === 'none') {
rewardList = []
} else {
rewardList = [...assigned, ...assignable]
}
// Fetch images for each reward if image_id is present
await Promise.all(
rewardList.map(async (reward: any) => {
if (reward.image_id) {
try {
reward.image_url = await getCachedImageUrl(reward.image_id)
} catch (e) {
reward.image_url = null
}
}
}),
)
rewards.value = rewardList
// If selectable, pre-select assigned rewards
if (props.selectable) {
selectedRewards.value = assigned.map((reward: any) => String(reward.id))
}
} catch (err) {
error.value = err instanceof Error ? err.message : 'Failed to fetch rewards'
rewards.value = []
if (props.selectable) selectedRewards.value = []
} finally {
loading.value = false
}
} else {
url = '/api/reward/list'
}
try {
const resp = await fetch(url)
if (!resp.ok) throw new Error(`HTTP ${resp.status}`)
const data = await resp.json()
const rewardList = data.rewards || []
// Fetch images for each reward if image_id is present
await Promise.all(
rewardList.map(async (reward: any) => {
if (reward.image_id) {
try {
reward.image_url = await getCachedImageUrl(reward.image_id)
} catch (e) {
reward.image_url = null
try {
const resp = await fetch(url)
if (!resp.ok) throw new Error(`HTTP ${resp.status}`)
const data = await resp.json()
const rewardList = data.rewards || []
await Promise.all(
rewardList.map(async (reward: any) => {
if (reward.image_id) {
try {
reward.image_url = await getCachedImageUrl(reward.image_id)
} catch (e) {
reward.image_url = null
}
}
}
}),
)
rewards.value = rewardList
} catch (err) {
error.value = err instanceof Error ? err.message : 'Failed to fetch rewards'
rewards.value = []
} finally {
loading.value = false
}),
)
rewards.value = rewardList
if (props.selectable) selectedRewards.value = []
} catch (err) {
error.value = err instanceof Error ? err.message : 'Failed to fetch rewards'
rewards.value = []
if (props.selectable) selectedRewards.value = []
} finally {
loading.value = false
}
}
}
onMounted(fetchRewards)
watch(() => [props.childId, props.assignable], fetchRewards)
watch(() => [props.childId, props.assignFilter], fetchRewards)
const handleEdit = (rewardId: string) => {
emit('edit-reward', rewardId)
@@ -73,11 +119,10 @@ const handleDelete = (rewardId: string) => {
emit('delete-reward', rewardId)
}
defineExpose({ refresh: fetchRewards })
defineExpose({ refresh: fetchRewards, selectedRewards })
const ITEM_HEIGHT = 52 // px, adjust to match your .reward-list-item + margin
const listHeight = computed(() => {
// Add a little for padding, separators, etc.
const n = rewards.value.length
return `${Math.min(n * ITEM_HEIGHT + 8, window.innerHeight - 80)}px`
})
@@ -94,6 +139,14 @@ const listHeight = computed(() => {
<img v-if="reward.image_url" :src="reward.image_url" alt="Reward" class="reward-image" />
<span class="reward-name">{{ reward.name }}</span>
<span class="reward-cost"> {{ reward.cost }} pts </span>
<input
v-if="props.selectable"
type="checkbox"
class="reward-checkbox"
v-model="selectedRewards"
:value="reward.id"
@click.stop
/>
<button
v-if="props.deletable"
class="delete-btn"
@@ -121,8 +174,9 @@ const listHeight = computed(() => {
<style scoped>
.reward-listbox {
flex: 1 1 auto;
width: 100%;
width: auto;
max-width: 480px;
/* Subtract any header/nav height if needed, e.g. 4.5rem */
max-height: calc(100vh - 4.5rem);
overflow-y: auto;
margin: 0.2rem 0 0 0;
@@ -209,4 +263,11 @@ const listHeight = computed(() => {
.delete-btn svg {
display: block;
}
.reward-checkbox {
margin-left: 1rem;
width: 1.2em;
height: 1.2em;
accent-color: #667eea;
cursor: pointer;
}
</style>

View File

@@ -1,24 +1,22 @@
<script setup lang="ts">
import { ref, onMounted, onBeforeUnmount, nextTick } from 'vue'
import { ref, onMounted, onBeforeUnmount, nextTick, computed } from 'vue'
import { defineProps, defineEmits } from 'vue'
import { getCachedImageUrl, revokeAllImageUrls } from '../../common/imageCache'
interface Task {
id: string
name: string
points: number
is_good: boolean
image_id: string | null // Ensure image can be null or hold an object URL
}
import type { Task } from '@/common/models'
const imageCacheName = 'images-v1'
const props = defineProps<{
title: string
taskIds: string[]
childId: string | number | null
isParentAuthenticated: boolean
filterType?: number | null
}>()
const emit = defineEmits<{
(e: 'points-updated', payload: { id: string; points: number }): void
(e: 'trigger-task', task: Task): void
}>()
const emit = defineEmits(['points-updated'])
const tasks = ref<Task[]>([])
const loading = ref(true)
@@ -27,7 +25,6 @@ const scrollWrapper = ref<HTMLDivElement | null>(null)
const taskRefs = ref<Record<string, HTMLElement | null>>({})
const lastCenteredTaskId = ref<string | null>(null)
const lastCenterTime = ref<number>(0)
const readyTaskId = ref<string | null>(null)
const fetchTasks = async () => {
@@ -60,7 +57,7 @@ const fetchImage = async (task: Task) => {
try {
const url = await getCachedImageUrl(task.image_id, imageCacheName)
task.image_id = url
task.image_url = url
} catch (err) {
console.error('Error fetching image for task', task.id, err)
}
@@ -84,20 +81,9 @@ const centerTask = async (taskId: string) => {
}
}
const triggerTask = async (taskId: string) => {
if (!props.isParentAuthenticated || !props.childId) return
try {
const resp = await fetch(`/api/child/${props.childId}/trigger-task`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ task_id: taskId }),
})
if (!resp.ok) return
const data = await resp.json()
emit('points-updated', { id: data.id, points: data.points })
} catch (err) {
console.error('Failed to trigger task:', err)
}
const triggerTask = (taskId: string) => {
const task = tasks.value.find((t) => t.id === taskId)
if (task) emit('trigger-task', task)
}
const handleTaskClick = async (taskId: string) => {
@@ -115,16 +101,24 @@ const handleTaskClick = async (taskId: string) => {
// Center the task, but don't trigger
await centerTask(taskId)
lastCenteredTaskId.value = taskId
lastCenterTime.value = Date.now()
readyTaskId.value = taskId // <-- Add this line
readyTaskId.value = taskId
return
}
// If already centered and visible, trigger the task
// If already centered and visible, emit to parent
triggerTask(taskId)
readyTaskId.value = null
}
const filteredTasks = computed(() => {
if (props.filterType == 1) {
return tasks.value.filter((t) => t.is_good)
} else if (props.filterType == 2) {
return tasks.value.filter((t) => !t.is_good)
}
return tasks.value
})
onMounted(fetchTasks)
// revoke all created object URLs when component unmounts
@@ -135,16 +129,15 @@ onBeforeUnmount(() => {
<template>
<div class="task-list-container">
<h3>Tasks</h3>
<h3>{{ title }}</h3>
<div v-if="loading" class="loading">Loading tasks...</div>
<div v-else-if="error" class="error">Error: {{ error }}</div>
<div v-else-if="tasks.length === 0" class="empty">No tasks</div>
<div v-else-if="filteredTasks.length === 0" class="empty">No {{ title }}</div>
<div v-else class="scroll-wrapper" ref="scrollWrapper">
<div class="task-scroll">
<div
v-for="task in tasks"
v-for="task in filteredTasks"
:key="task.id"
class="task-card"
:class="{ good: task.is_good, bad: !task.is_good, ready: readyTaskId === task.id }"
@@ -152,7 +145,7 @@ onBeforeUnmount(() => {
@click="() => handleTaskClick(task.id)"
>
<div class="task-name">{{ task.name }}</div>
<img v-if="task.image_id" :src="task.image_id" alt="Task Image" class="task-image" />
<img v-if="task.image_url" :src="task.image_url" alt="Task Image" class="task-image" />
<div
class="task-points"
:class="{ 'good-points': task.is_good, 'bad-points': !task.is_good }"
@@ -177,7 +170,7 @@ onBeforeUnmount(() => {
}
.task-list-container h3 {
margin: 0 0 1rem 0;
margin: 0;
font-size: 1.1rem;
font-weight: 600;
}

View File

@@ -1,10 +1,13 @@
<script setup lang="ts">
import { ref, watch, onMounted, computed } from 'vue'
import { getCachedImageUrl } from '../../common/imageCache'
const props = defineProps<{
childId?: string | number
assignable?: boolean
assignFilter?: 'assignable' | 'assigned' | 'none'
typeFilter?: 'good' | 'bad' | 'all'
deletable?: boolean
selectable?: boolean
}>()
const emit = defineEmits(['edit-task', 'delete-task'])
@@ -16,53 +19,95 @@ const tasks = ref<
is_good: boolean
image_id?: string | null
image_url?: string | null
assigned?: boolean
}[]
>([])
const loading = ref(true)
const error = ref<string | null>(null)
const selectedTasks = ref<string[]>([])
const fetchTasks = async () => {
loading.value = true
error.value = null
let url = ''
if (props.childId) {
if (props.assignable) {
url = `/api/child/${props.childId}/list-assignable-tasks`
} else {
url = `/api/child/${props.childId}/list-tasks`
url = `/api/child/${props.childId}/list-all-tasks`
try {
const resp = await fetch(url)
if (!resp.ok) throw new Error(`HTTP ${resp.status}`)
const data = await resp.json()
const assigned = (data.assigned_tasks || []).map((task: any) => ({ ...task, assigned: true }))
const assignable = (data.assignable_tasks || []).map((task: any) => ({
...task,
assigned: false,
}))
let taskList: any[] = []
if (props.assignFilter === 'assignable') {
taskList = assignable
} else if (props.assignFilter === 'assigned') {
taskList = assigned
} else if (props.assignFilter === 'none') {
taskList = []
} else {
taskList = [...assigned, ...assignable]
}
// Fetch images for each task if image_id is present
await Promise.all(
taskList.map(async (task: any) => {
if (task.image_id) {
try {
task.image_url = await getCachedImageUrl(task.image_id)
} catch (e) {
task.image_url = null
}
}
}),
)
tasks.value = taskList
// If selectable, pre-select assigned tasks
if (props.selectable) {
selectedTasks.value = assigned.map((task: any) => String(task.id))
}
} catch (err) {
error.value = err instanceof Error ? err.message : 'Failed to fetch tasks'
tasks.value = []
if (props.selectable) selectedTasks.value = []
} finally {
loading.value = false
}
} else {
url = '/api/task/list'
}
try {
const resp = await fetch(url)
if (!resp.ok) throw new Error(`HTTP ${resp.status}`)
const data = await resp.json()
const taskList = data.tasks || []
// Fetch images for each task if image_id is present
await Promise.all(
taskList.map(async (task: any) => {
if (task.image_id) {
try {
task.image_url = await getCachedImageUrl(task.image_id)
} catch (e) {
task.image_url = null
try {
const resp = await fetch(url)
if (!resp.ok) throw new Error(`HTTP ${resp.status}`)
const data = await resp.json()
const taskList = data.tasks || []
await Promise.all(
taskList.map(async (task: any) => {
if (task.image_id) {
try {
task.image_url = await getCachedImageUrl(task.image_id)
} catch (e) {
task.image_url = null
}
}
}
}),
)
tasks.value = taskList
} catch (err) {
error.value = err instanceof Error ? err.message : 'Failed to fetch tasks'
tasks.value = []
} finally {
loading.value = false
}),
)
tasks.value = taskList
if (props.selectable) selectedTasks.value = []
} catch (err) {
error.value = err instanceof Error ? err.message : 'Failed to fetch tasks'
tasks.value = []
if (props.selectable) selectedTasks.value = []
} finally {
loading.value = false
}
}
}
onMounted(fetchTasks)
watch(() => [props.childId, props.assignable], fetchTasks)
watch(() => [props.childId, props.assignFilter], fetchTasks)
const handleEdit = (taskId: string) => {
emit('edit-task', taskId)
@@ -72,7 +117,7 @@ const handleDelete = (taskId: string) => {
emit('delete-task', taskId)
}
defineExpose({ refresh: fetchTasks })
defineExpose({ refresh: fetchTasks, selectedTasks })
const ITEM_HEIGHT = 52 // px, adjust to match your .task-list-item + margin
const listHeight = computed(() => {
@@ -80,6 +125,15 @@ const listHeight = computed(() => {
const n = tasks.value.length
return `${Math.min(n * ITEM_HEIGHT + 8, window.innerHeight - 80)}px`
})
const filteredTasks = computed(() => {
if (props.typeFilter === 'good') {
return tasks.value.filter((t) => t.is_good)
} else if (props.typeFilter === 'bad') {
return tasks.value.filter((t) => !t.is_good)
}
return tasks.value
})
</script>
<template>
@@ -88,7 +142,7 @@ const listHeight = computed(() => {
<div v-else-if="error" class="error">{{ error }}</div>
<div v-else-if="tasks.length === 0" class="empty">No tasks found.</div>
<div v-else>
<div v-for="(task, idx) in tasks" :key="task.id">
<div v-for="(task, idx) in filteredTasks" :key="task.id">
<div
class="task-list-item"
:class="{ good: task.is_good, bad: !task.is_good }"
@@ -99,6 +153,15 @@ const listHeight = computed(() => {
<span class="task-points">
{{ task.is_good ? task.points : '-' + task.points }} pts
</span>
<!-- Add checkbox if selectable -->
<input
v-if="props.selectable"
type="checkbox"
class="task-checkbox"
v-model="selectedTasks"
:value="task.id"
@click.stop
/>
<button
v-if="props.deletable"
class="delete-btn"
@@ -126,7 +189,7 @@ const listHeight = computed(() => {
<style scoped>
.task-listbox {
flex: 1 1 auto;
width: 100%;
width: auto;
max-width: 480px;
/* Subtract any header/nav height if needed, e.g. 4.5rem */
max-height: calc(100vh - 4.5rem);
@@ -223,4 +286,11 @@ const listHeight = computed(() => {
.delete-btn svg {
display: block;
}
.task-checkbox {
margin-left: 1rem;
width: 1.2em;
height: 1.2em;
accent-color: #667eea;
cursor: pointer;
}
</style>

View File

@@ -27,7 +27,15 @@ const showBack = computed(
</div>
<nav class="view-selector">
<button
:class="{ active: ['ParentChildrenListView', 'ParentView'].includes(String(route.name)) }"
:class="{
active: [
'ParentChildrenListView',
'ParentView',
'ChildEditView',
'CreateChild',
'TaskAssignView',
].includes(String(route.name)),
}"
@click="router.push({ name: 'ParentChildrenListView' })"
aria-label="Children"
title="Children"

View File

@@ -8,6 +8,9 @@ import TaskView from '../components/task/TaskView.vue'
import RewardView from '../components/reward/RewardView.vue'
import TaskEditView from '@/components/task/TaskEditView.vue'
import RewardEditView from '@/components/reward/RewardEditView.vue'
import ChildEditView from '@/components/child/ChildEditView.vue'
import TaskAssignView from '@/components/child/TaskAssignView.vue'
import RewardAssignView from '@/components/child/RewardAssignView.vue'
const routes = [
{
@@ -43,6 +46,17 @@ const routes = [
component: ParentView,
props: true,
},
{
path: 'children/create',
name: 'CreateChild',
component: ChildEditView,
},
{
path: ':id/edit',
name: 'ChildEditView',
component: ChildEditView,
props: true,
},
{
path: 'tasks',
name: 'TaskView',
@@ -77,6 +91,18 @@ const routes = [
component: RewardEditView,
props: true,
},
{
path: ':id/assign-tasks/:type?',
name: 'TaskAssignView',
component: TaskAssignView,
props: true,
},
{
path: ':id/assign-rewards',
name: 'RewardAssignView',
component: RewardAssignView,
props: true,
},
],
},
{

View File

@@ -18,6 +18,11 @@ export default defineConfig({
changeOrigin: true,
rewrite: (p) => p.replace(/^\/api/, ''),
},
'/events': {
target: 'http://192.168.1.102:5000',
changeOrigin: true,
secure: false,
},
},
},
resolve: {