feat: Implement routines feature with CRUD operations and child assignment
All checks were successful
Chore App Build, Test, and Push Docker Images / build-and-push (push) Successful in 3m0s
All checks were successful
Chore App Build, Test, and Push Docker Images / build-and-push (push) Successful in 3m0s
- Add backend routines management with add, get, update, delete, and list functionalities. - Create models for Routine, RoutineItem, RoutineSchedule, and RoutineExtension. - Develop event types for routine confirmation and modification. - Implement frontend components for routine assignment, confirmation dialog, and routine management views. - Add unit tests for routine API and integration tests for routine CRUD flow. - Create end-to-end test plan for routines feature covering parent and child interactions.
This commit is contained in:
@@ -10,7 +10,7 @@ from api.pending_confirmation import PendingConfirmationResponse
|
|||||||
from api.reward_status import RewardStatus
|
from api.reward_status import RewardStatus
|
||||||
from api.utils import send_event_for_current_user, get_validated_user_id
|
from api.utils import send_event_for_current_user, get_validated_user_id
|
||||||
import api.child_action_helpers as chore_actions
|
import api.child_action_helpers as chore_actions
|
||||||
from db.db import child_db, task_db, reward_db, pending_reward_db, pending_confirmations_db, users_db
|
from db.db import child_db, task_db, reward_db, routine_db, pending_reward_db, pending_confirmations_db, users_db
|
||||||
from db.tracking import insert_tracking_event
|
from db.tracking import insert_tracking_event
|
||||||
from db.child_overrides import get_override, delete_override, delete_overrides_for_child
|
from db.child_overrides import get_override, delete_override, delete_overrides_for_child
|
||||||
from events.types.child_chore_confirmation import ChildChoreConfirmation
|
from events.types.child_chore_confirmation import ChildChoreConfirmation
|
||||||
@@ -35,6 +35,8 @@ from utils.digest_token import create_action_token
|
|||||||
from collections import defaultdict
|
from collections import defaultdict
|
||||||
from db.chore_schedules import get_schedule
|
from db.chore_schedules import get_schedule
|
||||||
from db.task_extensions import get_extension_for_child_task
|
from db.task_extensions import get_extension_for_child_task
|
||||||
|
from db.routine_schedules import delete_schedules_for_child as delete_routine_schedules_for_child
|
||||||
|
from db.routine_extensions import delete_extensions_for_child as delete_routine_extensions_for_child
|
||||||
import logging
|
import logging
|
||||||
|
|
||||||
child_api = Blueprint('child_api', __name__)
|
child_api = Blueprint('child_api', __name__)
|
||||||
@@ -163,6 +165,14 @@ def delete_child(id):
|
|||||||
deleted_count = delete_overrides_for_child(id)
|
deleted_count = delete_overrides_for_child(id)
|
||||||
if deleted_count > 0:
|
if deleted_count > 0:
|
||||||
logger.info(f"Cascade deleted {deleted_count} overrides for child {id}")
|
logger.info(f"Cascade deleted {deleted_count} overrides for child {id}")
|
||||||
|
|
||||||
|
# Cascade delete routine schedule/extension rows for this child.
|
||||||
|
delete_routine_schedules_for_child(id)
|
||||||
|
delete_routine_extensions_for_child(id)
|
||||||
|
|
||||||
|
# Remove pending routine confirmations for this child.
|
||||||
|
PendingQuery = Query()
|
||||||
|
pending_confirmations_db.remove((PendingQuery.child_id == id) & (PendingQuery.entity_type == 'routine'))
|
||||||
|
|
||||||
if child_db.remove((ChildQuery.id == id) & (ChildQuery.user_id == user_id)):
|
if child_db.remove((ChildQuery.id == id) & (ChildQuery.user_id == user_id)):
|
||||||
resp = send_event_for_current_user(Event(EventType.CHILD_MODIFIED.value, ChildModified(id, ChildModified.OPERATION_DELETE)))
|
resp = send_event_for_current_user(Event(EventType.CHILD_MODIFIED.value, ChildModified(id, ChildModified.OPERATION_DELETE)))
|
||||||
@@ -1104,6 +1114,7 @@ def list_pending_confirmations():
|
|||||||
|
|
||||||
RewardQuery = Query()
|
RewardQuery = Query()
|
||||||
TaskQuery = Query()
|
TaskQuery = Query()
|
||||||
|
RoutineQuery = Query()
|
||||||
ChildQuery = Query()
|
ChildQuery = Query()
|
||||||
|
|
||||||
for pr in pending_items:
|
for pr in pending_items:
|
||||||
@@ -1122,8 +1133,12 @@ def list_pending_confirmations():
|
|||||||
# Look up entity details based on type
|
# Look up entity details based on type
|
||||||
if pending.entity_type == 'reward':
|
if pending.entity_type == 'reward':
|
||||||
entity_result = reward_db.get((RewardQuery.id == pending.entity_id) & ((RewardQuery.user_id == user_id) | (RewardQuery.user_id == None)))
|
entity_result = reward_db.get((RewardQuery.id == pending.entity_id) & ((RewardQuery.user_id == user_id) | (RewardQuery.user_id == None)))
|
||||||
else:
|
elif pending.entity_type == 'chore':
|
||||||
entity_result = task_db.get((TaskQuery.id == pending.entity_id) & ((TaskQuery.user_id == user_id) | (TaskQuery.user_id == None)))
|
entity_result = task_db.get((TaskQuery.id == pending.entity_id) & ((TaskQuery.user_id == user_id) | (TaskQuery.user_id == None)))
|
||||||
|
elif pending.entity_type == 'routine':
|
||||||
|
entity_result = routine_db.get((RoutineQuery.id == pending.entity_id) & ((RoutineQuery.user_id == user_id) | (RoutineQuery.user_id == None)))
|
||||||
|
else:
|
||||||
|
entity_result = None
|
||||||
|
|
||||||
if not entity_result:
|
if not entity_result:
|
||||||
continue
|
continue
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ from flask import Blueprint, request, jsonify
|
|||||||
from tinydb import Query
|
from tinydb import Query
|
||||||
from api.utils import get_validated_user_id, send_event_for_current_user
|
from api.utils import get_validated_user_id, send_event_for_current_user
|
||||||
from api.error_codes import ErrorCodes
|
from api.error_codes import ErrorCodes
|
||||||
from db.db import child_db, task_db, reward_db
|
from db.db import child_db, task_db, reward_db, routine_db
|
||||||
from db.child_overrides import (
|
from db.child_overrides import (
|
||||||
insert_override,
|
insert_override,
|
||||||
get_override,
|
get_override,
|
||||||
@@ -52,8 +52,8 @@ def set_child_override(child_id):
|
|||||||
return jsonify({'error': 'custom_value is required', 'code': ErrorCodes.MISSING_FIELD, 'field': 'custom_value'}), 400
|
return jsonify({'error': 'custom_value is required', 'code': ErrorCodes.MISSING_FIELD, 'field': 'custom_value'}), 400
|
||||||
|
|
||||||
# Validate entity_type
|
# Validate entity_type
|
||||||
if entity_type not in ['task', 'reward']:
|
if entity_type not in ['task', 'reward', 'routine']:
|
||||||
return jsonify({'error': 'entity_type must be "task" or "reward"', 'code': ErrorCodes.INVALID_VALUE, 'field': 'entity_type'}), 400
|
return jsonify({'error': 'entity_type must be "task", "reward", or "routine"', 'code': ErrorCodes.INVALID_VALUE, 'field': 'entity_type'}), 400
|
||||||
|
|
||||||
# Validate custom_value range
|
# Validate custom_value range
|
||||||
if not isinstance(custom_value, int) or custom_value < 0 or custom_value > 10000:
|
if not isinstance(custom_value, int) or custom_value < 0 or custom_value > 10000:
|
||||||
@@ -74,7 +74,7 @@ def set_child_override(child_id):
|
|||||||
if entity_id not in assigned_tasks:
|
if entity_id not in assigned_tasks:
|
||||||
return jsonify({'error': 'Task not assigned to child', 'code': ErrorCodes.ENTITY_NOT_ASSIGNED}), 404
|
return jsonify({'error': 'Task not assigned to child', 'code': ErrorCodes.ENTITY_NOT_ASSIGNED}), 404
|
||||||
|
|
||||||
else: # reward
|
elif entity_type == 'reward':
|
||||||
EntityQuery = Query()
|
EntityQuery = Query()
|
||||||
entity_result = reward_db.search(
|
entity_result = reward_db.search(
|
||||||
(EntityQuery.id == entity_id) &
|
(EntityQuery.id == entity_id) &
|
||||||
@@ -87,6 +87,19 @@ def set_child_override(child_id):
|
|||||||
assigned_rewards = child_dict.get('rewards', [])
|
assigned_rewards = child_dict.get('rewards', [])
|
||||||
if entity_id not in assigned_rewards:
|
if entity_id not in assigned_rewards:
|
||||||
return jsonify({'error': 'Reward not assigned to child', 'code': ErrorCodes.ENTITY_NOT_ASSIGNED}), 404
|
return jsonify({'error': 'Reward not assigned to child', 'code': ErrorCodes.ENTITY_NOT_ASSIGNED}), 404
|
||||||
|
|
||||||
|
else: # routine
|
||||||
|
EntityQuery = Query()
|
||||||
|
entity_result = routine_db.search(
|
||||||
|
(EntityQuery.id == entity_id) &
|
||||||
|
((EntityQuery.user_id == user_id) | (EntityQuery.user_id == None))
|
||||||
|
)
|
||||||
|
if not entity_result:
|
||||||
|
return jsonify({'error': 'Routine not found', 'code': 'ROUTINE_NOT_FOUND'}), 404
|
||||||
|
|
||||||
|
assigned_routines = child_dict.get('routines', [])
|
||||||
|
if entity_id not in assigned_routines:
|
||||||
|
return jsonify({'error': 'Routine not assigned to child', 'code': ErrorCodes.ENTITY_NOT_ASSIGNED}), 404
|
||||||
|
|
||||||
# Create and insert override
|
# Create and insert override
|
||||||
try:
|
try:
|
||||||
|
|||||||
528
backend/api/child_routine_api.py
Normal file
528
backend/api/child_routine_api.py
Normal file
@@ -0,0 +1,528 @@
|
|||||||
|
from collections import defaultdict
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
|
from flask import Blueprint, request, jsonify
|
||||||
|
from tinydb import Query
|
||||||
|
|
||||||
|
from api.error_codes import ErrorCodes
|
||||||
|
from api.utils import get_validated_user_id, send_event_for_current_user
|
||||||
|
from db.child_overrides import delete_override, get_override
|
||||||
|
from db.db import child_db, pending_confirmations_db, routine_db, users_db
|
||||||
|
from db.routine_extensions import delete_extension_for_child_routine, get_extension_for_child_routine
|
||||||
|
from db.routine_items import get_items_for_routine
|
||||||
|
from db.routine_schedules import delete_schedule, get_schedule
|
||||||
|
from events.types.child_routine_confirmation import ChildRoutineConfirmation
|
||||||
|
from events.types.child_routines_set import ChildRoutinesSet
|
||||||
|
from events.types.event import Event
|
||||||
|
from events.types.event_types import EventType
|
||||||
|
from models.child import Child
|
||||||
|
from models.pending_confirmation import PendingConfirmation
|
||||||
|
from models.routine import Routine
|
||||||
|
from utils.digest_token import create_action_token
|
||||||
|
from utils.push_sender import send_push_to_user
|
||||||
|
|
||||||
|
child_routine_api = Blueprint('child_routine_api', __name__)
|
||||||
|
|
||||||
|
|
||||||
|
def _is_iso_timestamp_today_utc(timestamp: str | None, today_utc: str) -> bool:
|
||||||
|
return bool(timestamp) and timestamp[:10] == today_utc
|
||||||
|
|
||||||
|
|
||||||
|
def _is_epoch_timestamp_today_utc(epoch_ts, today_utc: str) -> bool:
|
||||||
|
if epoch_ts is None:
|
||||||
|
return False
|
||||||
|
try:
|
||||||
|
return datetime.fromtimestamp(float(epoch_ts), timezone.utc).strftime('%Y-%m-%d') == today_utc
|
||||||
|
except (TypeError, ValueError, OSError):
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
class ChildRoutine:
|
||||||
|
def __init__(self, name, points, image_id, _id):
|
||||||
|
self.id = _id
|
||||||
|
self.name = name
|
||||||
|
self.points = points
|
||||||
|
self.image_id = image_id
|
||||||
|
|
||||||
|
def to_dict(self):
|
||||||
|
return {
|
||||||
|
'id': self.id,
|
||||||
|
'name': self.name,
|
||||||
|
'points': self.points,
|
||||||
|
'image_id': self.image_id,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_child_for_user(child_id: str, user_id: str):
|
||||||
|
child_q = Query()
|
||||||
|
result = child_db.search((child_q.id == child_id) & (child_q.user_id == user_id))
|
||||||
|
return Child.from_dict(result[0]) if result else None
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_routine_for_user(routine_id: str, user_id: str):
|
||||||
|
routine_q = Query()
|
||||||
|
routine_result = routine_db.get(
|
||||||
|
(routine_q.id == routine_id) & ((routine_q.user_id == user_id) | (routine_q.user_id == None))
|
||||||
|
)
|
||||||
|
return Routine.from_dict(routine_result) if routine_result else None
|
||||||
|
|
||||||
|
|
||||||
|
@child_routine_api.route('/child/<id>/assign-routine', methods=['POST'])
|
||||||
|
def assign_routine_to_child(id):
|
||||||
|
user_id = get_validated_user_id()
|
||||||
|
if not user_id:
|
||||||
|
return jsonify({'error': 'Unauthorized', 'code': ErrorCodes.UNAUTHORIZED}), 401
|
||||||
|
|
||||||
|
data = request.get_json() or {}
|
||||||
|
routine_id = data.get('routine_id')
|
||||||
|
if not routine_id:
|
||||||
|
return jsonify({'error': 'routine_id is required', 'code': ErrorCodes.MISSING_FIELD}), 400
|
||||||
|
|
||||||
|
child = _validate_child_for_user(id, user_id)
|
||||||
|
if not child:
|
||||||
|
return jsonify({'error': 'Child not found', 'code': ErrorCodes.CHILD_NOT_FOUND}), 404
|
||||||
|
|
||||||
|
routine = _resolve_routine_for_user(routine_id, user_id)
|
||||||
|
if not routine:
|
||||||
|
return jsonify({'error': 'Routine not found'}), 404
|
||||||
|
|
||||||
|
routine_ids = list(child.routines)
|
||||||
|
if routine_id not in routine_ids:
|
||||||
|
routine_ids.append(routine_id)
|
||||||
|
child_db.update({'routines': routine_ids}, Query().id == id)
|
||||||
|
|
||||||
|
send_event_for_current_user(
|
||||||
|
Event(EventType.CHILD_ROUTINES_SET.value, ChildRoutinesSet(id, routine_ids))
|
||||||
|
)
|
||||||
|
return jsonify({'message': f'Routine {routine_id} assigned to {child.name}.'}), 200
|
||||||
|
|
||||||
|
|
||||||
|
@child_routine_api.route('/child/<id>/remove-routine', methods=['POST'])
|
||||||
|
def remove_routine_from_child(id):
|
||||||
|
user_id = get_validated_user_id()
|
||||||
|
if not user_id:
|
||||||
|
return jsonify({'error': 'Unauthorized', 'code': ErrorCodes.UNAUTHORIZED}), 401
|
||||||
|
|
||||||
|
data = request.get_json() or {}
|
||||||
|
routine_id = data.get('routine_id')
|
||||||
|
if not routine_id:
|
||||||
|
return jsonify({'error': 'routine_id is required', 'code': ErrorCodes.MISSING_FIELD}), 400
|
||||||
|
|
||||||
|
child = _validate_child_for_user(id, user_id)
|
||||||
|
if not child:
|
||||||
|
return jsonify({'error': 'Child not found', 'code': ErrorCodes.CHILD_NOT_FOUND}), 404
|
||||||
|
|
||||||
|
routine_ids = list(child.routines)
|
||||||
|
if routine_id not in routine_ids:
|
||||||
|
return jsonify({'error': 'Routine not assigned to child', 'code': ErrorCodes.ENTITY_NOT_ASSIGNED}), 400
|
||||||
|
|
||||||
|
routine_ids.remove(routine_id)
|
||||||
|
child_db.update({'routines': routine_ids}, Query().id == id)
|
||||||
|
|
||||||
|
override = get_override(id, routine_id)
|
||||||
|
if override and override.entity_type == 'routine':
|
||||||
|
delete_override(id, routine_id)
|
||||||
|
|
||||||
|
delete_schedule(id, routine_id)
|
||||||
|
delete_extension_for_child_routine(id, routine_id)
|
||||||
|
|
||||||
|
pending_q = Query()
|
||||||
|
pending_confirmations_db.remove(
|
||||||
|
(pending_q.child_id == id) & (pending_q.entity_id == routine_id) &
|
||||||
|
(pending_q.entity_type == 'routine') & (pending_q.user_id == user_id)
|
||||||
|
)
|
||||||
|
|
||||||
|
send_event_for_current_user(
|
||||||
|
Event(EventType.CHILD_ROUTINES_SET.value, ChildRoutinesSet(id, routine_ids))
|
||||||
|
)
|
||||||
|
send_event_for_current_user(
|
||||||
|
Event(
|
||||||
|
EventType.CHILD_ROUTINE_CONFIRMATION.value,
|
||||||
|
ChildRoutineConfirmation(id, routine_id, ChildRoutineConfirmation.OPERATION_RESET)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return jsonify({'message': f'Routine {routine_id} removed from {child.name}.'}), 200
|
||||||
|
|
||||||
|
|
||||||
|
@child_routine_api.route('/child/<id>/set-routines', methods=['PUT'])
|
||||||
|
def set_child_routines(id):
|
||||||
|
user_id = get_validated_user_id()
|
||||||
|
if not user_id:
|
||||||
|
return jsonify({'error': 'Unauthorized', 'code': ErrorCodes.UNAUTHORIZED}), 401
|
||||||
|
|
||||||
|
data = request.get_json() or {}
|
||||||
|
routine_ids = data.get('routine_ids')
|
||||||
|
if not isinstance(routine_ids, list):
|
||||||
|
return jsonify({'error': 'routine_ids must be a list'}), 400
|
||||||
|
|
||||||
|
child = _validate_child_for_user(id, user_id)
|
||||||
|
if not child:
|
||||||
|
return jsonify({'error': 'Child not found', 'code': ErrorCodes.CHILD_NOT_FOUND}), 404
|
||||||
|
|
||||||
|
routine_q = Query()
|
||||||
|
valid_ids = []
|
||||||
|
for rid in dict.fromkeys(routine_ids):
|
||||||
|
if routine_db.get((routine_q.id == rid) & ((routine_q.user_id == user_id) | (routine_q.user_id == None))):
|
||||||
|
valid_ids.append(rid)
|
||||||
|
|
||||||
|
old_ids = set(child.routines)
|
||||||
|
new_ids = set(valid_ids)
|
||||||
|
unassigned_ids = old_ids - new_ids
|
||||||
|
|
||||||
|
pending_q = Query()
|
||||||
|
for rid in unassigned_ids:
|
||||||
|
override = get_override(id, rid)
|
||||||
|
if override and override.entity_type == 'routine':
|
||||||
|
delete_override(id, rid)
|
||||||
|
|
||||||
|
delete_schedule(id, rid)
|
||||||
|
delete_extension_for_child_routine(id, rid)
|
||||||
|
|
||||||
|
pending_confirmations_db.remove(
|
||||||
|
(pending_q.child_id == id) & (pending_q.entity_id == rid) &
|
||||||
|
(pending_q.entity_type == 'routine') & (pending_q.user_id == user_id)
|
||||||
|
)
|
||||||
|
|
||||||
|
child_db.update({'routines': valid_ids}, Query().id == id)
|
||||||
|
send_event_for_current_user(Event(EventType.CHILD_ROUTINES_SET.value, ChildRoutinesSet(id, valid_ids)))
|
||||||
|
|
||||||
|
return jsonify({'message': f'Routines set for child {id}.', 'routine_ids': valid_ids, 'count': len(valid_ids)}), 200
|
||||||
|
|
||||||
|
|
||||||
|
@child_routine_api.route('/child/<id>/list-routines', methods=['GET'])
|
||||||
|
def list_child_routines(id):
|
||||||
|
user_id = get_validated_user_id()
|
||||||
|
if not user_id:
|
||||||
|
return jsonify({'error': 'Unauthorized', 'code': ErrorCodes.UNAUTHORIZED}), 401
|
||||||
|
|
||||||
|
child = _validate_child_for_user(id, user_id)
|
||||||
|
if not child:
|
||||||
|
return jsonify({'error': 'Child not found', 'code': ErrorCodes.CHILD_NOT_FOUND}), 404
|
||||||
|
|
||||||
|
routine_q = Query()
|
||||||
|
pending_q = Query()
|
||||||
|
today_utc = datetime.now(timezone.utc).strftime('%Y-%m-%d')
|
||||||
|
|
||||||
|
child_routines = []
|
||||||
|
for rid in child.routines:
|
||||||
|
routine_record = routine_db.get((routine_q.id == rid) & ((routine_q.user_id == user_id) | (routine_q.user_id == None)))
|
||||||
|
if not routine_record:
|
||||||
|
continue
|
||||||
|
|
||||||
|
routine = Routine.from_dict(routine_record)
|
||||||
|
override = get_override(id, rid)
|
||||||
|
custom_value = override.custom_value if override and override.entity_type == 'routine' else None
|
||||||
|
|
||||||
|
cr = ChildRoutine(routine.name, routine.points, routine.image_id, routine.id)
|
||||||
|
cr_dict = cr.to_dict()
|
||||||
|
if custom_value is not None:
|
||||||
|
cr_dict['custom_value'] = custom_value
|
||||||
|
|
||||||
|
schedule = get_schedule(id, rid)
|
||||||
|
cr_dict['schedule'] = schedule.to_dict() if schedule else None
|
||||||
|
|
||||||
|
extension = get_extension_for_child_routine(id, rid)
|
||||||
|
cr_dict['extension_date'] = extension.date if extension else None
|
||||||
|
|
||||||
|
items = get_items_for_routine(rid)
|
||||||
|
cr_dict['items'] = [item.to_dict() for item in items]
|
||||||
|
|
||||||
|
pending = pending_confirmations_db.get(
|
||||||
|
(pending_q.child_id == id) & (pending_q.entity_id == rid) &
|
||||||
|
(pending_q.entity_type == 'routine') & (pending_q.user_id == user_id)
|
||||||
|
)
|
||||||
|
if pending:
|
||||||
|
status = pending.get('status')
|
||||||
|
approved_at = pending.get('approved_at')
|
||||||
|
created_at = pending.get('created_at')
|
||||||
|
|
||||||
|
if status == 'approved' and _is_iso_timestamp_today_utc(approved_at, today_utc):
|
||||||
|
cr_dict['pending_status'] = 'approved'
|
||||||
|
cr_dict['approved_at'] = approved_at
|
||||||
|
elif status == 'pending' and _is_epoch_timestamp_today_utc(created_at, today_utc):
|
||||||
|
cr_dict['pending_status'] = 'pending'
|
||||||
|
cr_dict['approved_at'] = None
|
||||||
|
else:
|
||||||
|
pending_id = pending.get('id')
|
||||||
|
if pending_id:
|
||||||
|
pending_confirmations_db.remove(pending_q.id == pending_id)
|
||||||
|
cr_dict['pending_status'] = None
|
||||||
|
cr_dict['approved_at'] = None
|
||||||
|
else:
|
||||||
|
cr_dict['pending_status'] = None
|
||||||
|
cr_dict['approved_at'] = None
|
||||||
|
|
||||||
|
child_routines.append(cr_dict)
|
||||||
|
|
||||||
|
return jsonify({'routines': child_routines}), 200
|
||||||
|
|
||||||
|
|
||||||
|
@child_routine_api.route('/child/<id>/list-assignable-routines', methods=['GET'])
|
||||||
|
def list_assignable_routines(id):
|
||||||
|
user_id = get_validated_user_id()
|
||||||
|
if not user_id:
|
||||||
|
return jsonify({'error': 'Unauthorized', 'code': ErrorCodes.UNAUTHORIZED}), 401
|
||||||
|
|
||||||
|
child = _validate_child_for_user(id, user_id)
|
||||||
|
if not child:
|
||||||
|
return jsonify({'error': 'Child not found', 'code': ErrorCodes.CHILD_NOT_FOUND}), 404
|
||||||
|
|
||||||
|
assigned_ids = set(child.routines)
|
||||||
|
all_routines = [r for r in routine_db.all() if r and r.get('id') and r.get('id') not in assigned_ids]
|
||||||
|
|
||||||
|
name_to_routines = defaultdict(list)
|
||||||
|
for routine in all_routines:
|
||||||
|
name_to_routines[routine.get('name')].append(routine)
|
||||||
|
|
||||||
|
filtered_routines = []
|
||||||
|
for _, routines in name_to_routines.items():
|
||||||
|
user_routines = [r for r in routines if r.get('user_id') is not None]
|
||||||
|
if len(user_routines) == 0:
|
||||||
|
filtered_routines.append(routines[0])
|
||||||
|
elif len(user_routines) == 1:
|
||||||
|
filtered_routines.append(user_routines[0])
|
||||||
|
else:
|
||||||
|
filtered_routines.extend(user_routines)
|
||||||
|
|
||||||
|
assignable = [
|
||||||
|
ChildRoutine(r.get('name'), r.get('points'), r.get('image_id'), r.get('id')).to_dict()
|
||||||
|
for r in filtered_routines
|
||||||
|
]
|
||||||
|
return jsonify({'routines': assignable, 'count': len(assignable)}), 200
|
||||||
|
|
||||||
|
|
||||||
|
@child_routine_api.route('/child/<id>/confirm-routine', methods=['POST'])
|
||||||
|
def confirm_routine(id):
|
||||||
|
user_id = get_validated_user_id()
|
||||||
|
if not user_id:
|
||||||
|
return jsonify({'error': 'Unauthorized', 'code': ErrorCodes.UNAUTHORIZED}), 401
|
||||||
|
|
||||||
|
data = request.get_json() or {}
|
||||||
|
routine_id = data.get('routine_id')
|
||||||
|
if not routine_id:
|
||||||
|
return jsonify({'error': 'routine_id is required', 'code': ErrorCodes.MISSING_FIELD}), 400
|
||||||
|
|
||||||
|
child = _validate_child_for_user(id, user_id)
|
||||||
|
if not child:
|
||||||
|
return jsonify({'error': 'Child not found', 'code': ErrorCodes.CHILD_NOT_FOUND}), 404
|
||||||
|
|
||||||
|
if routine_id not in child.routines:
|
||||||
|
return jsonify({'error': 'Routine not assigned to child', 'code': ErrorCodes.ENTITY_NOT_ASSIGNED}), 400
|
||||||
|
|
||||||
|
routine = _resolve_routine_for_user(routine_id, user_id)
|
||||||
|
if not routine:
|
||||||
|
return jsonify({'error': 'Routine not found'}), 404
|
||||||
|
|
||||||
|
pending_q = Query()
|
||||||
|
existing = pending_confirmations_db.get(
|
||||||
|
(pending_q.child_id == id) & (pending_q.entity_id == routine_id) &
|
||||||
|
(pending_q.entity_type == 'routine') & (pending_q.user_id == user_id)
|
||||||
|
)
|
||||||
|
if existing:
|
||||||
|
today_utc = datetime.now(timezone.utc).strftime('%Y-%m-%d')
|
||||||
|
if existing.get('status') == 'pending':
|
||||||
|
if _is_epoch_timestamp_today_utc(existing.get('created_at'), today_utc):
|
||||||
|
return jsonify({'error': 'Routine already pending confirmation', 'code': 'ROUTINE_ALREADY_PENDING'}), 400
|
||||||
|
pending_id = existing.get('id')
|
||||||
|
if pending_id:
|
||||||
|
pending_confirmations_db.remove(pending_q.id == pending_id)
|
||||||
|
if existing.get('status') == 'approved':
|
||||||
|
approved_at = existing.get('approved_at', '')
|
||||||
|
if _is_iso_timestamp_today_utc(approved_at, today_utc):
|
||||||
|
return jsonify({'error': 'Routine already completed today', 'code': 'ROUTINE_ALREADY_COMPLETED'}), 400
|
||||||
|
pending_id = existing.get('id')
|
||||||
|
if pending_id:
|
||||||
|
pending_confirmations_db.remove(pending_q.id == pending_id)
|
||||||
|
|
||||||
|
confirmation = PendingConfirmation(
|
||||||
|
child_id=id,
|
||||||
|
entity_id=routine_id,
|
||||||
|
entity_type='routine',
|
||||||
|
user_id=user_id,
|
||||||
|
)
|
||||||
|
pending_confirmations_db.insert(confirmation.to_dict())
|
||||||
|
|
||||||
|
send_event_for_current_user(
|
||||||
|
Event(
|
||||||
|
EventType.CHILD_ROUTINE_CONFIRMATION.value,
|
||||||
|
ChildRoutineConfirmation(id, routine_id, ChildRoutineConfirmation.OPERATION_PENDING)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
push_user = users_db.get(Query().id == user_id)
|
||||||
|
if push_user and push_user.get('push_notifications_enabled', True):
|
||||||
|
try:
|
||||||
|
approve_token = create_action_token(user_id, id, routine_id, 'routine', 'approve')
|
||||||
|
deny_token = create_action_token(user_id, id, routine_id, 'routine', 'deny')
|
||||||
|
push_payload = {
|
||||||
|
'type': 'routine_confirmed',
|
||||||
|
'title': 'Routine Pending',
|
||||||
|
'body': f'{child.name} completed {routine.name}',
|
||||||
|
'user_id': user_id,
|
||||||
|
'child_id': id,
|
||||||
|
'child_name': child.name,
|
||||||
|
'entity_id': routine_id,
|
||||||
|
'entity_type': 'routine',
|
||||||
|
'entity_name': routine.name,
|
||||||
|
'approve_token': approve_token.id,
|
||||||
|
'deny_token': deny_token.id,
|
||||||
|
}
|
||||||
|
send_push_to_user(user_id, push_payload)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
return jsonify({'message': f'Routine {routine.name} confirmed by {child.name}.', 'confirmation_id': confirmation.id}), 200
|
||||||
|
|
||||||
|
|
||||||
|
@child_routine_api.route('/child/<id>/cancel-routine-confirmation', methods=['POST'])
|
||||||
|
def cancel_routine_confirmation(id):
|
||||||
|
user_id = get_validated_user_id()
|
||||||
|
if not user_id:
|
||||||
|
return jsonify({'error': 'Unauthorized', 'code': ErrorCodes.UNAUTHORIZED}), 401
|
||||||
|
|
||||||
|
data = request.get_json() or {}
|
||||||
|
routine_id = data.get('routine_id')
|
||||||
|
if not routine_id:
|
||||||
|
return jsonify({'error': 'routine_id is required', 'code': ErrorCodes.MISSING_FIELD}), 400
|
||||||
|
|
||||||
|
child = _validate_child_for_user(id, user_id)
|
||||||
|
if not child:
|
||||||
|
return jsonify({'error': 'Child not found', 'code': ErrorCodes.CHILD_NOT_FOUND}), 404
|
||||||
|
|
||||||
|
pending_q = Query()
|
||||||
|
existing = pending_confirmations_db.get(
|
||||||
|
(pending_q.child_id == id) & (pending_q.entity_id == routine_id) &
|
||||||
|
(pending_q.entity_type == 'routine') & (pending_q.status == 'pending') &
|
||||||
|
(pending_q.user_id == user_id)
|
||||||
|
)
|
||||||
|
if not existing:
|
||||||
|
return jsonify({'error': 'No pending confirmation found', 'code': 'PENDING_NOT_FOUND'}), 400
|
||||||
|
|
||||||
|
pending_confirmations_db.remove(
|
||||||
|
(pending_q.child_id == id) & (pending_q.entity_id == routine_id) &
|
||||||
|
(pending_q.entity_type == 'routine') & (pending_q.status == 'pending') &
|
||||||
|
(pending_q.user_id == user_id)
|
||||||
|
)
|
||||||
|
|
||||||
|
send_event_for_current_user(
|
||||||
|
Event(
|
||||||
|
EventType.CHILD_ROUTINE_CONFIRMATION.value,
|
||||||
|
ChildRoutineConfirmation(id, routine_id, ChildRoutineConfirmation.OPERATION_RESET)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return jsonify({'message': 'Routine confirmation cancelled.'}), 200
|
||||||
|
|
||||||
|
|
||||||
|
@child_routine_api.route('/child/<id>/approve-routine/<confirmation_id>', methods=['POST'])
|
||||||
|
def approve_routine(id, confirmation_id):
|
||||||
|
user_id = get_validated_user_id()
|
||||||
|
if not user_id:
|
||||||
|
return jsonify({'error': 'Unauthorized', 'code': ErrorCodes.UNAUTHORIZED}), 401
|
||||||
|
|
||||||
|
child = _validate_child_for_user(id, user_id)
|
||||||
|
if not child:
|
||||||
|
return jsonify({'error': 'Child not found', 'code': ErrorCodes.CHILD_NOT_FOUND}), 404
|
||||||
|
|
||||||
|
pending_q = Query()
|
||||||
|
confirmation = pending_confirmations_db.get(
|
||||||
|
(pending_q.id == confirmation_id) & (pending_q.child_id == id) &
|
||||||
|
(pending_q.entity_type == 'routine') & (pending_q.user_id == user_id)
|
||||||
|
)
|
||||||
|
if not confirmation:
|
||||||
|
return jsonify({'error': 'Pending confirmation not found', 'code': 'PENDING_NOT_FOUND'}), 404
|
||||||
|
|
||||||
|
if confirmation.get('status') != 'pending':
|
||||||
|
return jsonify({'error': 'Confirmation is already resolved', 'code': 'ALREADY_RESOLVED'}), 400
|
||||||
|
|
||||||
|
routine_id = confirmation.get('entity_id')
|
||||||
|
routine = _resolve_routine_for_user(routine_id, user_id)
|
||||||
|
if not routine:
|
||||||
|
return jsonify({'error': 'Routine not found'}), 404
|
||||||
|
|
||||||
|
override = get_override(id, routine_id)
|
||||||
|
points_value = override.custom_value if override and override.entity_type == 'routine' else routine.points
|
||||||
|
|
||||||
|
new_points = max(0, child.points + points_value)
|
||||||
|
child_db.update({'points': new_points}, Query().id == id)
|
||||||
|
|
||||||
|
approved_at = datetime.now(timezone.utc).isoformat()
|
||||||
|
pending_confirmations_db.update(
|
||||||
|
{'status': 'approved', 'approved_at': approved_at},
|
||||||
|
pending_q.id == confirmation_id
|
||||||
|
)
|
||||||
|
|
||||||
|
send_event_for_current_user(
|
||||||
|
Event(
|
||||||
|
EventType.CHILD_ROUTINE_CONFIRMATION.value,
|
||||||
|
ChildRoutineConfirmation(id, routine_id, ChildRoutineConfirmation.OPERATION_APPROVED)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
return jsonify({
|
||||||
|
'message': f'Routine {routine.name} approved for {child.name}.',
|
||||||
|
'points': new_points,
|
||||||
|
'id': child.id,
|
||||||
|
}), 200
|
||||||
|
|
||||||
|
|
||||||
|
@child_routine_api.route('/child/<id>/reject-routine/<confirmation_id>', methods=['POST'])
|
||||||
|
def reject_routine(id, confirmation_id):
|
||||||
|
user_id = get_validated_user_id()
|
||||||
|
if not user_id:
|
||||||
|
return jsonify({'error': 'Unauthorized', 'code': ErrorCodes.UNAUTHORIZED}), 401
|
||||||
|
|
||||||
|
child = _validate_child_for_user(id, user_id)
|
||||||
|
if not child:
|
||||||
|
return jsonify({'error': 'Child not found', 'code': ErrorCodes.CHILD_NOT_FOUND}), 404
|
||||||
|
|
||||||
|
pending_q = Query()
|
||||||
|
confirmation = pending_confirmations_db.get(
|
||||||
|
(pending_q.id == confirmation_id) & (pending_q.child_id == id) &
|
||||||
|
(pending_q.entity_type == 'routine') & (pending_q.user_id == user_id)
|
||||||
|
)
|
||||||
|
if not confirmation:
|
||||||
|
return jsonify({'error': 'Pending confirmation not found', 'code': 'PENDING_NOT_FOUND'}), 404
|
||||||
|
|
||||||
|
if confirmation.get('status') != 'pending':
|
||||||
|
return jsonify({'error': 'Confirmation is already resolved', 'code': 'ALREADY_RESOLVED'}), 400
|
||||||
|
|
||||||
|
pending_confirmations_db.update({'status': 'rejected', 'approved_at': None}, pending_q.id == confirmation_id)
|
||||||
|
|
||||||
|
send_event_for_current_user(
|
||||||
|
Event(
|
||||||
|
EventType.CHILD_ROUTINE_CONFIRMATION.value,
|
||||||
|
ChildRoutineConfirmation(id, confirmation.get('entity_id'), ChildRoutineConfirmation.OPERATION_REJECTED)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
return jsonify({'message': 'Routine confirmation rejected.'}), 200
|
||||||
|
|
||||||
|
|
||||||
|
@child_routine_api.route('/child/<id>/reset-routine/<confirmation_id>', methods=['POST'])
|
||||||
|
def reset_routine(id, confirmation_id):
|
||||||
|
user_id = get_validated_user_id()
|
||||||
|
if not user_id:
|
||||||
|
return jsonify({'error': 'Unauthorized', 'code': ErrorCodes.UNAUTHORIZED}), 401
|
||||||
|
|
||||||
|
child = _validate_child_for_user(id, user_id)
|
||||||
|
if not child:
|
||||||
|
return jsonify({'error': 'Child not found', 'code': ErrorCodes.CHILD_NOT_FOUND}), 404
|
||||||
|
|
||||||
|
pending_q = Query()
|
||||||
|
confirmation = pending_confirmations_db.get(
|
||||||
|
(pending_q.id == confirmation_id) & (pending_q.child_id == id) &
|
||||||
|
(pending_q.entity_type == 'routine') & (pending_q.user_id == user_id)
|
||||||
|
)
|
||||||
|
if not confirmation:
|
||||||
|
return jsonify({'error': 'Pending confirmation not found', 'code': 'PENDING_NOT_FOUND'}), 404
|
||||||
|
|
||||||
|
routine_id = confirmation.get('entity_id')
|
||||||
|
pending_confirmations_db.remove(pending_q.id == confirmation_id)
|
||||||
|
|
||||||
|
send_event_for_current_user(
|
||||||
|
Event(
|
||||||
|
EventType.CHILD_ROUTINE_CONFIRMATION.value,
|
||||||
|
ChildRoutineConfirmation(id, routine_id, ChildRoutineConfirmation.OPERATION_RESET)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return jsonify({'message': 'Routine reset to available.'}), 200
|
||||||
185
backend/api/routine_api.py
Normal file
185
backend/api/routine_api.py
Normal file
@@ -0,0 +1,185 @@
|
|||||||
|
from flask import Blueprint, request, jsonify
|
||||||
|
from tinydb import Query
|
||||||
|
|
||||||
|
from api.utils import send_event_for_current_user, get_validated_user_id
|
||||||
|
from db.db import routine_db, child_db, pending_confirmations_db
|
||||||
|
from db.child_overrides import delete_overrides_for_entity
|
||||||
|
from db.routine_items import delete_for_routine
|
||||||
|
from db.routine_schedules import delete_schedules_for_routine
|
||||||
|
from db.routine_extensions import delete_extensions_for_routine
|
||||||
|
from events.types.event import Event
|
||||||
|
from events.types.event_types import EventType
|
||||||
|
from events.types.routine_modified import RoutineModified
|
||||||
|
from events.types.child_routines_set import ChildRoutinesSet
|
||||||
|
from models.routine import Routine
|
||||||
|
|
||||||
|
routine_api = Blueprint('routine_api', __name__)
|
||||||
|
|
||||||
|
|
||||||
|
@routine_api.route('/routine/add', methods=['PUT'])
|
||||||
|
def add_routine():
|
||||||
|
user_id = get_validated_user_id()
|
||||||
|
if not user_id:
|
||||||
|
return jsonify({'error': 'Unauthorized', 'code': 'UNAUTHORIZED'}), 401
|
||||||
|
|
||||||
|
data = request.get_json() or {}
|
||||||
|
name = data.get('name')
|
||||||
|
points = data.get('points')
|
||||||
|
image = data.get('image_id', '')
|
||||||
|
|
||||||
|
if not name or points is None:
|
||||||
|
return jsonify({'error': 'Name and points are required'}), 400
|
||||||
|
|
||||||
|
routine = Routine(name=name, points=points, image_id=image, user_id=user_id)
|
||||||
|
routine_db.insert(routine.to_dict())
|
||||||
|
|
||||||
|
send_event_for_current_user(
|
||||||
|
Event(EventType.ROUTINE_MODIFIED.value, RoutineModified(routine.id, RoutineModified.OPERATION_ADD))
|
||||||
|
)
|
||||||
|
return jsonify({'message': f'Routine {name} added.', 'routine': routine.to_dict()}), 201
|
||||||
|
|
||||||
|
|
||||||
|
@routine_api.route('/routine/<id>', methods=['GET'])
|
||||||
|
def get_routine(id):
|
||||||
|
user_id = get_validated_user_id()
|
||||||
|
if not user_id:
|
||||||
|
return jsonify({'error': 'Unauthorized', 'code': 'UNAUTHORIZED'}), 401
|
||||||
|
|
||||||
|
q = Query()
|
||||||
|
result = routine_db.search((q.id == id) & ((q.user_id == user_id) | (q.user_id == None)))
|
||||||
|
if not result:
|
||||||
|
return jsonify({'error': 'Routine not found'}), 404
|
||||||
|
|
||||||
|
return jsonify(result[0]), 200
|
||||||
|
|
||||||
|
|
||||||
|
@routine_api.route('/routine/list', methods=['GET'])
|
||||||
|
def list_routines():
|
||||||
|
user_id = get_validated_user_id()
|
||||||
|
if not user_id:
|
||||||
|
return jsonify({'error': 'Unauthorized', 'code': 'UNAUTHORIZED'}), 401
|
||||||
|
|
||||||
|
ids_param = request.args.get('ids')
|
||||||
|
q = Query()
|
||||||
|
routines = routine_db.search((q.user_id == user_id) | (q.user_id == None))
|
||||||
|
|
||||||
|
if ids_param is not None:
|
||||||
|
if ids_param.strip() == '':
|
||||||
|
routines = []
|
||||||
|
else:
|
||||||
|
ids = set(ids_param.split(','))
|
||||||
|
routines = [routine for routine in routines if routine.get('id') in ids]
|
||||||
|
|
||||||
|
user_routines = {r['name'].strip().lower(): r for r in routines if r.get('user_id') == user_id}
|
||||||
|
filtered_routines = []
|
||||||
|
for routine in routines:
|
||||||
|
if routine.get('user_id') is None and routine['name'].strip().lower() in user_routines:
|
||||||
|
continue
|
||||||
|
filtered_routines.append(routine)
|
||||||
|
|
||||||
|
user_created = sorted(
|
||||||
|
[r for r in filtered_routines if r.get('user_id') == user_id],
|
||||||
|
key=lambda x: x['name'].lower(),
|
||||||
|
)
|
||||||
|
default_items = sorted(
|
||||||
|
[r for r in filtered_routines if r.get('user_id') is None],
|
||||||
|
key=lambda x: x['name'].lower(),
|
||||||
|
)
|
||||||
|
|
||||||
|
return jsonify({'routines': user_created + default_items}), 200
|
||||||
|
|
||||||
|
|
||||||
|
@routine_api.route('/routine/<id>/edit', methods=['PUT'])
|
||||||
|
def edit_routine(id):
|
||||||
|
user_id = get_validated_user_id()
|
||||||
|
if not user_id:
|
||||||
|
return jsonify({'error': 'Unauthorized', 'code': 'UNAUTHORIZED'}), 401
|
||||||
|
|
||||||
|
q = Query()
|
||||||
|
existing = routine_db.get((q.id == id) & ((q.user_id == user_id) | (q.user_id == None)))
|
||||||
|
if not existing:
|
||||||
|
return jsonify({'error': 'Routine not found'}), 404
|
||||||
|
|
||||||
|
routine = Routine.from_dict(existing)
|
||||||
|
data = request.get_json(force=True) or {}
|
||||||
|
is_dirty = False
|
||||||
|
|
||||||
|
if 'name' in data:
|
||||||
|
name = data.get('name', '').strip()
|
||||||
|
if not name:
|
||||||
|
return jsonify({'error': 'Name cannot be empty'}), 400
|
||||||
|
routine.name = name
|
||||||
|
is_dirty = True
|
||||||
|
|
||||||
|
if 'points' in data:
|
||||||
|
points = data.get('points')
|
||||||
|
if not isinstance(points, int) or points <= 0:
|
||||||
|
return jsonify({'error': 'Points must be a positive integer'}), 400
|
||||||
|
routine.points = points
|
||||||
|
is_dirty = True
|
||||||
|
|
||||||
|
if 'image_id' in data:
|
||||||
|
routine.image_id = data.get('image_id', '')
|
||||||
|
is_dirty = True
|
||||||
|
|
||||||
|
if not is_dirty:
|
||||||
|
return jsonify({'error': 'No valid fields to update'}), 400
|
||||||
|
|
||||||
|
if routine.user_id is None:
|
||||||
|
new_routine = Routine(name=routine.name, points=routine.points, image_id=routine.image_id, user_id=user_id)
|
||||||
|
routine_db.insert(new_routine.to_dict())
|
||||||
|
send_event_for_current_user(
|
||||||
|
Event(EventType.ROUTINE_MODIFIED.value, RoutineModified(new_routine.id, RoutineModified.OPERATION_ADD))
|
||||||
|
)
|
||||||
|
return jsonify(new_routine.to_dict()), 200
|
||||||
|
|
||||||
|
routine_db.update(routine.to_dict(), q.id == id)
|
||||||
|
send_event_for_current_user(
|
||||||
|
Event(EventType.ROUTINE_MODIFIED.value, RoutineModified(id, RoutineModified.OPERATION_EDIT))
|
||||||
|
)
|
||||||
|
return jsonify(routine.to_dict()), 200
|
||||||
|
|
||||||
|
|
||||||
|
@routine_api.route('/routine/<id>', methods=['DELETE'])
|
||||||
|
def delete_routine(id):
|
||||||
|
user_id = get_validated_user_id()
|
||||||
|
if not user_id:
|
||||||
|
return jsonify({'error': 'Unauthorized', 'code': 'UNAUTHORIZED'}), 401
|
||||||
|
|
||||||
|
q = Query()
|
||||||
|
routine = routine_db.get(q.id == id)
|
||||||
|
if not routine:
|
||||||
|
return jsonify({'error': 'Routine not found'}), 404
|
||||||
|
|
||||||
|
if routine.get('user_id') is None:
|
||||||
|
return jsonify({'error': 'System routines cannot be deleted.'}), 403
|
||||||
|
|
||||||
|
removed = routine_db.remove((q.id == id) & (q.user_id == user_id))
|
||||||
|
if not removed:
|
||||||
|
return jsonify({'error': 'Routine not found'}), 404
|
||||||
|
|
||||||
|
delete_overrides_for_entity(id)
|
||||||
|
delete_for_routine(id)
|
||||||
|
delete_schedules_for_routine(id)
|
||||||
|
delete_extensions_for_routine(id)
|
||||||
|
|
||||||
|
pending_q = Query()
|
||||||
|
pending_confirmations_db.remove(
|
||||||
|
(pending_q.entity_id == id) & (pending_q.entity_type == 'routine') & (pending_q.user_id == user_id)
|
||||||
|
)
|
||||||
|
|
||||||
|
child_q = Query()
|
||||||
|
children = child_db.search(child_q.user_id == user_id)
|
||||||
|
for child in children:
|
||||||
|
routine_ids = child.get('routines', [])
|
||||||
|
if id in routine_ids:
|
||||||
|
routine_ids = [rid for rid in routine_ids if rid != id]
|
||||||
|
child_db.update({'routines': routine_ids}, child_q.id == child.get('id'))
|
||||||
|
send_event_for_current_user(
|
||||||
|
Event(EventType.CHILD_ROUTINES_SET.value, ChildRoutinesSet(child.get('id'), routine_ids))
|
||||||
|
)
|
||||||
|
|
||||||
|
send_event_for_current_user(
|
||||||
|
Event(EventType.ROUTINE_MODIFIED.value, RoutineModified(id, RoutineModified.OPERATION_DELETE))
|
||||||
|
)
|
||||||
|
return jsonify({'message': f'Routine {id} deleted.'}), 200
|
||||||
126
backend/api/routine_item_api.py
Normal file
126
backend/api/routine_item_api.py
Normal file
@@ -0,0 +1,126 @@
|
|||||||
|
from flask import Blueprint, request, jsonify
|
||||||
|
from tinydb import Query
|
||||||
|
|
||||||
|
from api.utils import get_validated_user_id, send_event_for_current_user
|
||||||
|
from db.db import routine_db
|
||||||
|
from db.routine_items import add_item, delete_item, get_item, get_items_for_routine, update_item
|
||||||
|
from events.types.event import Event
|
||||||
|
from events.types.event_types import EventType
|
||||||
|
from events.types.routine_modified import RoutineModified
|
||||||
|
from models.routine_item import RoutineItem
|
||||||
|
|
||||||
|
routine_item_api = Blueprint('routine_item_api', __name__)
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_routine_owned_by_user(routine_id: str, user_id: str):
|
||||||
|
q = Query()
|
||||||
|
return routine_db.get((q.id == routine_id) & ((q.user_id == user_id) | (q.user_id == None)))
|
||||||
|
|
||||||
|
|
||||||
|
@routine_item_api.route('/routine/<routine_id>/item/add', methods=['PUT'])
|
||||||
|
def add_routine_item(routine_id):
|
||||||
|
user_id = get_validated_user_id()
|
||||||
|
if not user_id:
|
||||||
|
return jsonify({'error': 'Unauthorized', 'code': 'UNAUTHORIZED'}), 401
|
||||||
|
|
||||||
|
routine = _validate_routine_owned_by_user(routine_id, user_id)
|
||||||
|
if not routine:
|
||||||
|
return jsonify({'error': 'Routine not found'}), 404
|
||||||
|
|
||||||
|
data = request.get_json() or {}
|
||||||
|
name = data.get('name', '').strip()
|
||||||
|
image_id = data.get('image_id')
|
||||||
|
|
||||||
|
if not name:
|
||||||
|
return jsonify({'error': 'name is required'}), 400
|
||||||
|
|
||||||
|
existing_items = get_items_for_routine(routine_id)
|
||||||
|
order = data.get('order', len(existing_items))
|
||||||
|
|
||||||
|
item = RoutineItem(routine_id=routine_id, name=name, image_id=image_id, order=order)
|
||||||
|
add_item(item)
|
||||||
|
|
||||||
|
send_event_for_current_user(
|
||||||
|
Event(EventType.ROUTINE_MODIFIED.value, RoutineModified(routine_id, RoutineModified.OPERATION_EDIT))
|
||||||
|
)
|
||||||
|
return jsonify(item.to_dict()), 201
|
||||||
|
|
||||||
|
|
||||||
|
@routine_item_api.route('/routine/<routine_id>/item/<item_id>/edit', methods=['PUT'])
|
||||||
|
def edit_routine_item(routine_id, item_id):
|
||||||
|
user_id = get_validated_user_id()
|
||||||
|
if not user_id:
|
||||||
|
return jsonify({'error': 'Unauthorized', 'code': 'UNAUTHORIZED'}), 401
|
||||||
|
|
||||||
|
routine = _validate_routine_owned_by_user(routine_id, user_id)
|
||||||
|
if not routine:
|
||||||
|
return jsonify({'error': 'Routine not found'}), 404
|
||||||
|
|
||||||
|
existing = get_item(item_id)
|
||||||
|
if not existing or existing.routine_id != routine_id:
|
||||||
|
return jsonify({'error': 'Item not found'}), 404
|
||||||
|
|
||||||
|
data = request.get_json(force=True) or {}
|
||||||
|
is_dirty = False
|
||||||
|
|
||||||
|
if 'name' in data:
|
||||||
|
name = data.get('name', '').strip()
|
||||||
|
if not name:
|
||||||
|
return jsonify({'error': 'name cannot be empty'}), 400
|
||||||
|
existing.name = name
|
||||||
|
is_dirty = True
|
||||||
|
|
||||||
|
if 'image_id' in data:
|
||||||
|
existing.image_id = data.get('image_id')
|
||||||
|
is_dirty = True
|
||||||
|
|
||||||
|
if 'order' in data:
|
||||||
|
order = data.get('order')
|
||||||
|
if not isinstance(order, int) or order < 0:
|
||||||
|
return jsonify({'error': 'order must be a non-negative integer'}), 400
|
||||||
|
existing.order = order
|
||||||
|
is_dirty = True
|
||||||
|
|
||||||
|
if not is_dirty:
|
||||||
|
return jsonify({'error': 'No valid fields to update'}), 400
|
||||||
|
|
||||||
|
update_item(existing)
|
||||||
|
send_event_for_current_user(
|
||||||
|
Event(EventType.ROUTINE_MODIFIED.value, RoutineModified(routine_id, RoutineModified.OPERATION_EDIT))
|
||||||
|
)
|
||||||
|
return jsonify(existing.to_dict()), 200
|
||||||
|
|
||||||
|
|
||||||
|
@routine_item_api.route('/routine/<routine_id>/item/<item_id>', methods=['DELETE'])
|
||||||
|
def delete_routine_item(routine_id, item_id):
|
||||||
|
user_id = get_validated_user_id()
|
||||||
|
if not user_id:
|
||||||
|
return jsonify({'error': 'Unauthorized', 'code': 'UNAUTHORIZED'}), 401
|
||||||
|
|
||||||
|
routine = _validate_routine_owned_by_user(routine_id, user_id)
|
||||||
|
if not routine:
|
||||||
|
return jsonify({'error': 'Routine not found'}), 404
|
||||||
|
|
||||||
|
item = get_item(item_id)
|
||||||
|
if not item or item.routine_id != routine_id:
|
||||||
|
return jsonify({'error': 'Item not found'}), 404
|
||||||
|
|
||||||
|
delete_item(item_id)
|
||||||
|
send_event_for_current_user(
|
||||||
|
Event(EventType.ROUTINE_MODIFIED.value, RoutineModified(routine_id, RoutineModified.OPERATION_EDIT))
|
||||||
|
)
|
||||||
|
return jsonify({'message': 'Item deleted'}), 200
|
||||||
|
|
||||||
|
|
||||||
|
@routine_item_api.route('/routine/<routine_id>/items', methods=['GET'])
|
||||||
|
def list_routine_items(routine_id):
|
||||||
|
user_id = get_validated_user_id()
|
||||||
|
if not user_id:
|
||||||
|
return jsonify({'error': 'Unauthorized', 'code': 'UNAUTHORIZED'}), 401
|
||||||
|
|
||||||
|
routine = _validate_routine_owned_by_user(routine_id, user_id)
|
||||||
|
if not routine:
|
||||||
|
return jsonify({'error': 'Routine not found'}), 404
|
||||||
|
|
||||||
|
items = [item.to_dict() for item in get_items_for_routine(routine_id)]
|
||||||
|
return jsonify({'items': items, 'count': len(items)}), 200
|
||||||
178
backend/api/routine_schedule_api.py
Normal file
178
backend/api/routine_schedule_api.py
Normal file
@@ -0,0 +1,178 @@
|
|||||||
|
from flask import Blueprint, request, jsonify
|
||||||
|
from tinydb import Query
|
||||||
|
|
||||||
|
from api.error_codes import ErrorCodes
|
||||||
|
from api.utils import get_validated_user_id, send_event_for_current_user
|
||||||
|
from db.db import child_db, pending_confirmations_db
|
||||||
|
from db.routine_extensions import add_extension, delete_extension_for_child_routine, get_extension
|
||||||
|
from db.routine_schedules import delete_schedule, get_schedule, upsert_schedule
|
||||||
|
from events.types.child_routine_confirmation import ChildRoutineConfirmation
|
||||||
|
from events.types.event import Event
|
||||||
|
from events.types.event_types import EventType
|
||||||
|
from events.types.routine_schedule_modified import RoutineScheduleModified
|
||||||
|
from events.types.routine_time_extended import RoutineTimeExtended
|
||||||
|
from models.routine_extension import RoutineExtension
|
||||||
|
from models.routine_schedule import RoutineSchedule
|
||||||
|
|
||||||
|
routine_schedule_api = Blueprint('routine_schedule_api', __name__)
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_child(child_id: str, user_id: str):
|
||||||
|
q = Query()
|
||||||
|
result = child_db.search((q.id == child_id) & (q.user_id == user_id))
|
||||||
|
return result[0] if result else None
|
||||||
|
|
||||||
|
|
||||||
|
@routine_schedule_api.route('/child/<child_id>/routine/<routine_id>/schedule', methods=['GET'])
|
||||||
|
def get_routine_schedule(child_id, routine_id):
|
||||||
|
user_id = get_validated_user_id()
|
||||||
|
if not user_id:
|
||||||
|
return jsonify({'error': 'Unauthorized', 'code': ErrorCodes.UNAUTHORIZED}), 401
|
||||||
|
|
||||||
|
if not _validate_child(child_id, user_id):
|
||||||
|
return jsonify({'error': 'Child not found', 'code': ErrorCodes.CHILD_NOT_FOUND}), 404
|
||||||
|
|
||||||
|
schedule = get_schedule(child_id, routine_id)
|
||||||
|
if not schedule:
|
||||||
|
return jsonify({'error': 'Schedule not found'}), 404
|
||||||
|
|
||||||
|
return jsonify(schedule.to_dict()), 200
|
||||||
|
|
||||||
|
|
||||||
|
@routine_schedule_api.route('/child/<child_id>/routine/<routine_id>/schedule', methods=['PUT'])
|
||||||
|
def set_routine_schedule(child_id, routine_id):
|
||||||
|
user_id = get_validated_user_id()
|
||||||
|
if not user_id:
|
||||||
|
return jsonify({'error': 'Unauthorized', 'code': ErrorCodes.UNAUTHORIZED}), 401
|
||||||
|
|
||||||
|
if not _validate_child(child_id, user_id):
|
||||||
|
return jsonify({'error': 'Child not found', 'code': ErrorCodes.CHILD_NOT_FOUND}), 404
|
||||||
|
|
||||||
|
data = request.get_json() or {}
|
||||||
|
mode = data.get('mode')
|
||||||
|
if mode not in ('days', 'interval'):
|
||||||
|
return jsonify({'error': 'mode must be "days" or "interval"', 'code': ErrorCodes.INVALID_VALUE}), 400
|
||||||
|
|
||||||
|
enabled = data.get('enabled', True)
|
||||||
|
if not isinstance(enabled, bool):
|
||||||
|
return jsonify({'error': 'enabled must be a boolean', 'code': ErrorCodes.INVALID_VALUE}), 400
|
||||||
|
|
||||||
|
if mode == 'days':
|
||||||
|
day_configs = data.get('day_configs', [])
|
||||||
|
if not isinstance(day_configs, list):
|
||||||
|
return jsonify({'error': 'day_configs must be a list', 'code': ErrorCodes.INVALID_VALUE}), 400
|
||||||
|
|
||||||
|
schedule = RoutineSchedule(
|
||||||
|
child_id=child_id,
|
||||||
|
routine_id=routine_id,
|
||||||
|
mode='days',
|
||||||
|
day_configs=day_configs,
|
||||||
|
default_hour=data.get('default_hour', 8),
|
||||||
|
default_minute=data.get('default_minute', 0),
|
||||||
|
default_has_deadline=data.get('default_has_deadline', True),
|
||||||
|
enabled=enabled,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
interval_days = data.get('interval_days', 2)
|
||||||
|
anchor_date = data.get('anchor_date', '')
|
||||||
|
interval_has_deadline = data.get('interval_has_deadline', True)
|
||||||
|
interval_hour = data.get('interval_hour', 0)
|
||||||
|
interval_minute = data.get('interval_minute', 0)
|
||||||
|
|
||||||
|
if not isinstance(interval_days, int) or not (1 <= interval_days <= 7):
|
||||||
|
return jsonify({'error': 'interval_days must be an integer between 1 and 7', 'code': ErrorCodes.INVALID_VALUE}), 400
|
||||||
|
|
||||||
|
schedule = RoutineSchedule(
|
||||||
|
child_id=child_id,
|
||||||
|
routine_id=routine_id,
|
||||||
|
mode='interval',
|
||||||
|
interval_days=interval_days,
|
||||||
|
anchor_date=anchor_date,
|
||||||
|
interval_has_deadline=interval_has_deadline,
|
||||||
|
interval_hour=interval_hour,
|
||||||
|
interval_minute=interval_minute,
|
||||||
|
enabled=enabled,
|
||||||
|
)
|
||||||
|
|
||||||
|
delete_extension_for_child_routine(child_id, routine_id)
|
||||||
|
upsert_schedule(schedule)
|
||||||
|
|
||||||
|
pending_q = Query()
|
||||||
|
pending_routines = pending_confirmations_db.search(
|
||||||
|
(pending_q.child_id == child_id) & (pending_q.entity_id == routine_id) &
|
||||||
|
(pending_q.entity_type == 'routine') & (pending_q.status == 'pending')
|
||||||
|
)
|
||||||
|
for _ in pending_routines:
|
||||||
|
pending_confirmations_db.remove(
|
||||||
|
(pending_q.child_id == child_id) & (pending_q.entity_id == routine_id) &
|
||||||
|
(pending_q.entity_type == 'routine') & (pending_q.status == 'pending')
|
||||||
|
)
|
||||||
|
send_event_for_current_user(
|
||||||
|
Event(
|
||||||
|
EventType.CHILD_ROUTINE_CONFIRMATION.value,
|
||||||
|
ChildRoutineConfirmation(child_id, routine_id, ChildRoutineConfirmation.OPERATION_RESET)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
send_event_for_current_user(
|
||||||
|
Event(
|
||||||
|
EventType.ROUTINE_SCHEDULE_MODIFIED.value,
|
||||||
|
RoutineScheduleModified(child_id, routine_id, RoutineScheduleModified.OPERATION_SET)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
return jsonify(schedule.to_dict()), 200
|
||||||
|
|
||||||
|
|
||||||
|
@routine_schedule_api.route('/child/<child_id>/routine/<routine_id>/schedule', methods=['DELETE'])
|
||||||
|
def delete_routine_schedule(child_id, routine_id):
|
||||||
|
user_id = get_validated_user_id()
|
||||||
|
if not user_id:
|
||||||
|
return jsonify({'error': 'Unauthorized', 'code': ErrorCodes.UNAUTHORIZED}), 401
|
||||||
|
|
||||||
|
if not _validate_child(child_id, user_id):
|
||||||
|
return jsonify({'error': 'Child not found', 'code': ErrorCodes.CHILD_NOT_FOUND}), 404
|
||||||
|
|
||||||
|
removed = delete_schedule(child_id, routine_id)
|
||||||
|
if not removed:
|
||||||
|
return jsonify({'error': 'Schedule not found'}), 404
|
||||||
|
|
||||||
|
send_event_for_current_user(
|
||||||
|
Event(
|
||||||
|
EventType.ROUTINE_SCHEDULE_MODIFIED.value,
|
||||||
|
RoutineScheduleModified(child_id, routine_id, RoutineScheduleModified.OPERATION_DELETED)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return jsonify({'message': 'Schedule deleted'}), 200
|
||||||
|
|
||||||
|
|
||||||
|
@routine_schedule_api.route('/child/<child_id>/routine/<routine_id>/extend', methods=['POST'])
|
||||||
|
def extend_routine_time(child_id, routine_id):
|
||||||
|
user_id = get_validated_user_id()
|
||||||
|
if not user_id:
|
||||||
|
return jsonify({'error': 'Unauthorized', 'code': ErrorCodes.UNAUTHORIZED}), 401
|
||||||
|
|
||||||
|
if not _validate_child(child_id, user_id):
|
||||||
|
return jsonify({'error': 'Child not found', 'code': ErrorCodes.CHILD_NOT_FOUND}), 404
|
||||||
|
|
||||||
|
data = request.get_json() or {}
|
||||||
|
date = data.get('date')
|
||||||
|
if not date or not isinstance(date, str):
|
||||||
|
return jsonify({'error': 'date is required (ISO date string)', 'code': ErrorCodes.MISSING_FIELD}), 400
|
||||||
|
|
||||||
|
existing = get_extension(child_id, routine_id, date)
|
||||||
|
if existing:
|
||||||
|
return jsonify({'error': 'Routine already extended for this date', 'code': 'ALREADY_EXTENDED'}), 409
|
||||||
|
|
||||||
|
delete_extension_for_child_routine(child_id, routine_id)
|
||||||
|
extension = RoutineExtension(child_id=child_id, routine_id=routine_id, date=date)
|
||||||
|
add_extension(extension)
|
||||||
|
|
||||||
|
send_event_for_current_user(
|
||||||
|
Event(
|
||||||
|
EventType.ROUTINE_TIME_EXTENDED.value,
|
||||||
|
RoutineTimeExtended(child_id, routine_id)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
return jsonify(extension.to_dict()), 200
|
||||||
@@ -69,6 +69,10 @@ class LockedTable:
|
|||||||
|
|
||||||
child_path = os.path.join(base_dir, 'children.json')
|
child_path = os.path.join(base_dir, 'children.json')
|
||||||
task_path = os.path.join(base_dir, 'tasks.json')
|
task_path = os.path.join(base_dir, 'tasks.json')
|
||||||
|
routine_path = os.path.join(base_dir, 'routines.json')
|
||||||
|
routine_items_path = os.path.join(base_dir, 'routine_items.json')
|
||||||
|
routine_schedules_path = os.path.join(base_dir, 'routine_schedules.json')
|
||||||
|
routine_extensions_path = os.path.join(base_dir, 'routine_extensions.json')
|
||||||
reward_path = os.path.join(base_dir, 'rewards.json')
|
reward_path = os.path.join(base_dir, 'rewards.json')
|
||||||
image_path = os.path.join(base_dir, 'images.json')
|
image_path = os.path.join(base_dir, 'images.json')
|
||||||
pending_reward_path = os.path.join(base_dir, 'pending_rewards.json')
|
pending_reward_path = os.path.join(base_dir, 'pending_rewards.json')
|
||||||
@@ -85,6 +89,10 @@ digest_action_tokens_path = os.path.join(base_dir, 'digest_action_tokens.json')
|
|||||||
# Use separate TinyDB instances/files for each collection
|
# Use separate TinyDB instances/files for each collection
|
||||||
_child_db = TinyDB(child_path, indent=2)
|
_child_db = TinyDB(child_path, indent=2)
|
||||||
_task_db = TinyDB(task_path, indent=2)
|
_task_db = TinyDB(task_path, indent=2)
|
||||||
|
_routine_db = TinyDB(routine_path, indent=2)
|
||||||
|
_routine_items_db = TinyDB(routine_items_path, indent=2)
|
||||||
|
_routine_schedules_db = TinyDB(routine_schedules_path, indent=2)
|
||||||
|
_routine_extensions_db = TinyDB(routine_extensions_path, indent=2)
|
||||||
_reward_db = TinyDB(reward_path, indent=2)
|
_reward_db = TinyDB(reward_path, indent=2)
|
||||||
_image_db = TinyDB(image_path, indent=2)
|
_image_db = TinyDB(image_path, indent=2)
|
||||||
_pending_rewards_db = TinyDB(pending_reward_path, indent=2)
|
_pending_rewards_db = TinyDB(pending_reward_path, indent=2)
|
||||||
@@ -101,6 +109,10 @@ _digest_action_tokens_db = TinyDB(digest_action_tokens_path, indent=2)
|
|||||||
# Expose table objects wrapped with locking
|
# Expose table objects wrapped with locking
|
||||||
child_db = LockedTable(_child_db)
|
child_db = LockedTable(_child_db)
|
||||||
task_db = LockedTable(_task_db)
|
task_db = LockedTable(_task_db)
|
||||||
|
routine_db = LockedTable(_routine_db)
|
||||||
|
routine_items_db = LockedTable(_routine_items_db)
|
||||||
|
routine_schedules_db = LockedTable(_routine_schedules_db)
|
||||||
|
routine_extensions_db = LockedTable(_routine_extensions_db)
|
||||||
reward_db = LockedTable(_reward_db)
|
reward_db = LockedTable(_reward_db)
|
||||||
image_db = LockedTable(_image_db)
|
image_db = LockedTable(_image_db)
|
||||||
pending_reward_db = LockedTable(_pending_rewards_db)
|
pending_reward_db = LockedTable(_pending_rewards_db)
|
||||||
@@ -117,6 +129,10 @@ digest_action_tokens_db = LockedTable(_digest_action_tokens_db)
|
|||||||
if os.environ.get('DB_ENV', 'prod') == 'test':
|
if os.environ.get('DB_ENV', 'prod') == 'test':
|
||||||
child_db.truncate()
|
child_db.truncate()
|
||||||
task_db.truncate()
|
task_db.truncate()
|
||||||
|
routine_db.truncate()
|
||||||
|
routine_items_db.truncate()
|
||||||
|
routine_schedules_db.truncate()
|
||||||
|
routine_extensions_db.truncate()
|
||||||
reward_db.truncate()
|
reward_db.truncate()
|
||||||
image_db.truncate()
|
image_db.truncate()
|
||||||
pending_reward_db.truncate()
|
pending_reward_db.truncate()
|
||||||
|
|||||||
41
backend/db/routine_extensions.py
Normal file
41
backend/db/routine_extensions.py
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
from tinydb import Query
|
||||||
|
from db.db import routine_extensions_db
|
||||||
|
from models.routine_extension import RoutineExtension
|
||||||
|
|
||||||
|
|
||||||
|
def get_extension(child_id: str, routine_id: str, date: str) -> RoutineExtension | None:
|
||||||
|
q = Query()
|
||||||
|
result = routine_extensions_db.search(
|
||||||
|
(q.child_id == child_id) & (q.routine_id == routine_id) & (q.date == date)
|
||||||
|
)
|
||||||
|
if not result:
|
||||||
|
return None
|
||||||
|
return RoutineExtension.from_dict(result[0])
|
||||||
|
|
||||||
|
|
||||||
|
def add_extension(extension: RoutineExtension) -> None:
|
||||||
|
routine_extensions_db.insert(extension.to_dict())
|
||||||
|
|
||||||
|
|
||||||
|
def delete_extensions_for_child(child_id: str) -> None:
|
||||||
|
q = Query()
|
||||||
|
routine_extensions_db.remove(q.child_id == child_id)
|
||||||
|
|
||||||
|
|
||||||
|
def delete_extensions_for_routine(routine_id: str) -> None:
|
||||||
|
q = Query()
|
||||||
|
routine_extensions_db.remove(q.routine_id == routine_id)
|
||||||
|
|
||||||
|
|
||||||
|
def delete_extension_for_child_routine(child_id: str, routine_id: str) -> None:
|
||||||
|
q = Query()
|
||||||
|
routine_extensions_db.remove((q.child_id == child_id) & (q.routine_id == routine_id))
|
||||||
|
|
||||||
|
|
||||||
|
def get_extension_for_child_routine(child_id: str, routine_id: str) -> RoutineExtension | None:
|
||||||
|
q = Query()
|
||||||
|
results = routine_extensions_db.search((q.child_id == child_id) & (q.routine_id == routine_id))
|
||||||
|
if not results:
|
||||||
|
return None
|
||||||
|
latest = max(results, key=lambda r: r.get('date', ''))
|
||||||
|
return RoutineExtension.from_dict(latest)
|
||||||
45
backend/db/routine_items.py
Normal file
45
backend/db/routine_items.py
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
from tinydb import Query
|
||||||
|
from db.db import routine_items_db
|
||||||
|
from models.routine_item import RoutineItem
|
||||||
|
|
||||||
|
|
||||||
|
def add_item(item: RoutineItem) -> None:
|
||||||
|
routine_items_db.insert(item.to_dict())
|
||||||
|
|
||||||
|
|
||||||
|
def get_item(item_id: str) -> RoutineItem | None:
|
||||||
|
q = Query()
|
||||||
|
result = routine_items_db.search(q.id == item_id)
|
||||||
|
if not result:
|
||||||
|
return None
|
||||||
|
return RoutineItem.from_dict(result[0])
|
||||||
|
|
||||||
|
|
||||||
|
def get_items_for_routine(routine_id: str) -> list[RoutineItem]:
|
||||||
|
q = Query()
|
||||||
|
results = routine_items_db.search(q.routine_id == routine_id)
|
||||||
|
items = [RoutineItem.from_dict(r) for r in results]
|
||||||
|
return sorted(items, key=lambda i: (i.order, i.created_at))
|
||||||
|
|
||||||
|
|
||||||
|
def update_item(item: RoutineItem) -> bool:
|
||||||
|
q = Query()
|
||||||
|
existing = routine_items_db.get(q.id == item.id)
|
||||||
|
if not existing:
|
||||||
|
return False
|
||||||
|
routine_items_db.update(item.to_dict(), q.id == item.id)
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def delete_item(item_id: str) -> bool:
|
||||||
|
q = Query()
|
||||||
|
existing = routine_items_db.get(q.id == item_id)
|
||||||
|
if not existing:
|
||||||
|
return False
|
||||||
|
routine_items_db.remove(q.id == item_id)
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def delete_for_routine(routine_id: str) -> None:
|
||||||
|
q = Query()
|
||||||
|
routine_items_db.remove(q.routine_id == routine_id)
|
||||||
42
backend/db/routine_schedules.py
Normal file
42
backend/db/routine_schedules.py
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
from tinydb import Query
|
||||||
|
from db.db import routine_schedules_db
|
||||||
|
from models.routine_schedule import RoutineSchedule
|
||||||
|
|
||||||
|
|
||||||
|
def get_schedule(child_id: str, routine_id: str) -> RoutineSchedule | None:
|
||||||
|
q = Query()
|
||||||
|
result = routine_schedules_db.search((q.child_id == child_id) & (q.routine_id == routine_id))
|
||||||
|
if not result:
|
||||||
|
return None
|
||||||
|
return RoutineSchedule.from_dict(result[0])
|
||||||
|
|
||||||
|
|
||||||
|
def upsert_schedule(schedule: RoutineSchedule) -> None:
|
||||||
|
q = Query()
|
||||||
|
existing = routine_schedules_db.get((q.child_id == schedule.child_id) & (q.routine_id == schedule.routine_id))
|
||||||
|
if existing:
|
||||||
|
routine_schedules_db.update(
|
||||||
|
schedule.to_dict(),
|
||||||
|
(q.child_id == schedule.child_id) & (q.routine_id == schedule.routine_id)
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
routine_schedules_db.insert(schedule.to_dict())
|
||||||
|
|
||||||
|
|
||||||
|
def delete_schedule(child_id: str, routine_id: str) -> bool:
|
||||||
|
q = Query()
|
||||||
|
existing = routine_schedules_db.get((q.child_id == child_id) & (q.routine_id == routine_id))
|
||||||
|
if not existing:
|
||||||
|
return False
|
||||||
|
routine_schedules_db.remove((q.child_id == child_id) & (q.routine_id == routine_id))
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def delete_schedules_for_child(child_id: str) -> None:
|
||||||
|
q = Query()
|
||||||
|
routine_schedules_db.remove(q.child_id == child_id)
|
||||||
|
|
||||||
|
|
||||||
|
def delete_schedules_for_routine(routine_id: str) -> None:
|
||||||
|
q = Query()
|
||||||
|
routine_schedules_db.remove(q.routine_id == routine_id)
|
||||||
39
backend/db/routines.py
Normal file
39
backend/db/routines.py
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
from tinydb import Query
|
||||||
|
from db.db import routine_db
|
||||||
|
from models.routine import Routine
|
||||||
|
|
||||||
|
|
||||||
|
def add_routine(routine: Routine) -> None:
|
||||||
|
routine_db.insert(routine.to_dict())
|
||||||
|
|
||||||
|
|
||||||
|
def get_routine(routine_id: str) -> Routine | None:
|
||||||
|
q = Query()
|
||||||
|
result = routine_db.search(q.id == routine_id)
|
||||||
|
if not result:
|
||||||
|
return None
|
||||||
|
return Routine.from_dict(result[0])
|
||||||
|
|
||||||
|
|
||||||
|
def update_routine(routine: Routine) -> bool:
|
||||||
|
q = Query()
|
||||||
|
existing = routine_db.get(q.id == routine.id)
|
||||||
|
if not existing:
|
||||||
|
return False
|
||||||
|
routine_db.update(routine.to_dict(), q.id == routine.id)
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def delete_routine(routine_id: str) -> bool:
|
||||||
|
q = Query()
|
||||||
|
existing = routine_db.get(q.id == routine_id)
|
||||||
|
if not existing:
|
||||||
|
return False
|
||||||
|
routine_db.remove(q.id == routine_id)
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def list_routines_for_user(user_id: str) -> list[Routine]:
|
||||||
|
q = Query()
|
||||||
|
results = routine_db.search((q.user_id == user_id) | (q.user_id == None))
|
||||||
|
return [Routine.from_dict(r) for r in results]
|
||||||
15
backend/events/types/child_routine_confirmation.py
Normal file
15
backend/events/types/child_routine_confirmation.py
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
from events.types.payload import Payload
|
||||||
|
|
||||||
|
|
||||||
|
class ChildRoutineConfirmation(Payload):
|
||||||
|
OPERATION_PENDING = "PENDING"
|
||||||
|
OPERATION_APPROVED = "APPROVED"
|
||||||
|
OPERATION_REJECTED = "REJECTED"
|
||||||
|
OPERATION_RESET = "RESET"
|
||||||
|
|
||||||
|
def __init__(self, child_id: str, routine_id: str, operation: str):
|
||||||
|
super().__init__({
|
||||||
|
'child_id': child_id,
|
||||||
|
'routine_id': routine_id,
|
||||||
|
'operation': operation
|
||||||
|
})
|
||||||
9
backend/events/types/child_routines_set.py
Normal file
9
backend/events/types/child_routines_set.py
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
from events.types.payload import Payload
|
||||||
|
|
||||||
|
|
||||||
|
class ChildRoutinesSet(Payload):
|
||||||
|
def __init__(self, child_id: str, routine_ids: list[str]):
|
||||||
|
super().__init__({
|
||||||
|
'child_id': child_id,
|
||||||
|
'routine_ids': routine_ids
|
||||||
|
})
|
||||||
@@ -28,4 +28,10 @@ class EventType(Enum):
|
|||||||
CHORE_TIME_EXTENDED = "chore_time_extended"
|
CHORE_TIME_EXTENDED = "chore_time_extended"
|
||||||
CHILD_CHORE_CONFIRMATION = "child_chore_confirmation"
|
CHILD_CHORE_CONFIRMATION = "child_chore_confirmation"
|
||||||
|
|
||||||
|
ROUTINE_MODIFIED = "routine_modified"
|
||||||
|
CHILD_ROUTINES_SET = "child_routines_set"
|
||||||
|
ROUTINE_SCHEDULE_MODIFIED = "routine_schedule_modified"
|
||||||
|
ROUTINE_TIME_EXTENDED = "routine_time_extended"
|
||||||
|
CHILD_ROUTINE_CONFIRMATION = "child_routine_confirmation"
|
||||||
|
|
||||||
FORCE_LOGOUT = "force_logout"
|
FORCE_LOGOUT = "force_logout"
|
||||||
|
|||||||
13
backend/events/types/routine_modified.py
Normal file
13
backend/events/types/routine_modified.py
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
from events.types.payload import Payload
|
||||||
|
|
||||||
|
|
||||||
|
class RoutineModified(Payload):
|
||||||
|
OPERATION_ADD = "ADD"
|
||||||
|
OPERATION_EDIT = "EDIT"
|
||||||
|
OPERATION_DELETE = "DELETE"
|
||||||
|
|
||||||
|
def __init__(self, routine_id: str, operation: str):
|
||||||
|
super().__init__({
|
||||||
|
'routine_id': routine_id,
|
||||||
|
'operation': operation
|
||||||
|
})
|
||||||
13
backend/events/types/routine_schedule_modified.py
Normal file
13
backend/events/types/routine_schedule_modified.py
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
from events.types.payload import Payload
|
||||||
|
|
||||||
|
|
||||||
|
class RoutineScheduleModified(Payload):
|
||||||
|
OPERATION_SET = 'SET'
|
||||||
|
OPERATION_DELETED = 'DELETED'
|
||||||
|
|
||||||
|
def __init__(self, child_id: str, routine_id: str, operation: str):
|
||||||
|
super().__init__({
|
||||||
|
'child_id': child_id,
|
||||||
|
'routine_id': routine_id,
|
||||||
|
'operation': operation,
|
||||||
|
})
|
||||||
9
backend/events/types/routine_time_extended.py
Normal file
9
backend/events/types/routine_time_extended.py
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
from events.types.payload import Payload
|
||||||
|
|
||||||
|
|
||||||
|
class RoutineTimeExtended(Payload):
|
||||||
|
def __init__(self, child_id: str, routine_id: str):
|
||||||
|
super().__init__({
|
||||||
|
'child_id': child_id,
|
||||||
|
'routine_id': routine_id,
|
||||||
|
})
|
||||||
@@ -14,6 +14,10 @@ from api.image_api import image_api
|
|||||||
from api.kindness_api import kindness_api
|
from api.kindness_api import kindness_api
|
||||||
from api.penalty_api import penalty_api
|
from api.penalty_api import penalty_api
|
||||||
from api.reward_api import reward_api
|
from api.reward_api import reward_api
|
||||||
|
from api.routine_api import routine_api
|
||||||
|
from api.routine_item_api import routine_item_api
|
||||||
|
from api.child_routine_api import child_routine_api
|
||||||
|
from api.routine_schedule_api import routine_schedule_api
|
||||||
from api.task_api import task_api
|
from api.task_api import task_api
|
||||||
from api.tracking_api import tracking_api
|
from api.tracking_api import tracking_api
|
||||||
from api.user_api import user_api
|
from api.user_api import user_api
|
||||||
@@ -54,6 +58,10 @@ app.register_blueprint(chore_schedule_api)
|
|||||||
app.register_blueprint(kindness_api)
|
app.register_blueprint(kindness_api)
|
||||||
app.register_blueprint(penalty_api)
|
app.register_blueprint(penalty_api)
|
||||||
app.register_blueprint(reward_api)
|
app.register_blueprint(reward_api)
|
||||||
|
app.register_blueprint(routine_api)
|
||||||
|
app.register_blueprint(routine_item_api)
|
||||||
|
app.register_blueprint(child_routine_api)
|
||||||
|
app.register_blueprint(routine_schedule_api)
|
||||||
app.register_blueprint(task_api)
|
app.register_blueprint(task_api)
|
||||||
app.register_blueprint(image_api)
|
app.register_blueprint(image_api)
|
||||||
app.register_blueprint(auth_api, url_prefix='/auth')
|
app.register_blueprint(auth_api, url_prefix='/auth')
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ class Child(BaseModel):
|
|||||||
name: str
|
name: str
|
||||||
age: int | None = None
|
age: int | None = None
|
||||||
tasks: list[str] = field(default_factory=list)
|
tasks: list[str] = field(default_factory=list)
|
||||||
|
routines: list[str] = field(default_factory=list)
|
||||||
rewards: list[str] = field(default_factory=list)
|
rewards: list[str] = field(default_factory=list)
|
||||||
points: int = 0
|
points: int = 0
|
||||||
image_id: str | None = None
|
image_id: str | None = None
|
||||||
@@ -17,6 +18,7 @@ class Child(BaseModel):
|
|||||||
name=d.get('name'),
|
name=d.get('name'),
|
||||||
age=d.get('age'),
|
age=d.get('age'),
|
||||||
tasks=d.get('tasks', []),
|
tasks=d.get('tasks', []),
|
||||||
|
routines=d.get('routines', []),
|
||||||
rewards=d.get('rewards', []),
|
rewards=d.get('rewards', []),
|
||||||
points=d.get('points', 0),
|
points=d.get('points', 0),
|
||||||
image_id=d.get('image_id'),
|
image_id=d.get('image_id'),
|
||||||
@@ -32,6 +34,7 @@ class Child(BaseModel):
|
|||||||
'name': self.name,
|
'name': self.name,
|
||||||
'age': self.age,
|
'age': self.age,
|
||||||
'tasks': self.tasks,
|
'tasks': self.tasks,
|
||||||
|
'routines': self.routines,
|
||||||
'rewards': self.rewards,
|
'rewards': self.rewards,
|
||||||
'points': self.points,
|
'points': self.points,
|
||||||
'image_id': self.image_id,
|
'image_id': self.image_id,
|
||||||
|
|||||||
@@ -16,15 +16,15 @@ class ChildOverride(BaseModel):
|
|||||||
"""
|
"""
|
||||||
child_id: str
|
child_id: str
|
||||||
entity_id: str
|
entity_id: str
|
||||||
entity_type: Literal['task', 'reward', 'chore', 'kindness', 'penalty']
|
entity_type: Literal['task', 'reward', 'chore', 'kindness', 'penalty', 'routine']
|
||||||
custom_value: int
|
custom_value: int
|
||||||
|
|
||||||
def __post_init__(self):
|
def __post_init__(self):
|
||||||
"""Validate custom_value range and entity_type."""
|
"""Validate custom_value range and entity_type."""
|
||||||
if self.custom_value < 0 or self.custom_value > 10000:
|
if self.custom_value < 0 or self.custom_value > 10000:
|
||||||
raise ValueError("custom_value must be between 0 and 10000")
|
raise ValueError("custom_value must be between 0 and 10000")
|
||||||
if self.entity_type not in ['task', 'reward', 'chore', 'kindness', 'penalty']:
|
if self.entity_type not in ['task', 'reward', 'chore', 'kindness', 'penalty', 'routine']:
|
||||||
raise ValueError("entity_type must be 'task', 'reward', 'chore', 'kindness', or 'penalty'")
|
raise ValueError("entity_type must be 'task', 'reward', 'chore', 'kindness', 'penalty', or 'routine'")
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def from_dict(cls, d: dict):
|
def from_dict(cls, d: dict):
|
||||||
@@ -52,7 +52,7 @@ class ChildOverride(BaseModel):
|
|||||||
def create_override(
|
def create_override(
|
||||||
child_id: str,
|
child_id: str,
|
||||||
entity_id: str,
|
entity_id: str,
|
||||||
entity_type: Literal['task', 'reward', 'chore', 'kindness', 'penalty'],
|
entity_type: Literal['task', 'reward', 'chore', 'kindness', 'penalty', 'routine'],
|
||||||
custom_value: int
|
custom_value: int
|
||||||
) -> 'ChildOverride':
|
) -> 'ChildOverride':
|
||||||
"""Factory method to create a new override."""
|
"""Factory method to create a new override."""
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ from typing import Literal, Optional
|
|||||||
from models.base import BaseModel
|
from models.base import BaseModel
|
||||||
|
|
||||||
|
|
||||||
PendingEntityType = Literal['chore', 'reward']
|
PendingEntityType = Literal['chore', 'reward', 'routine']
|
||||||
PendingStatus = Literal['pending', 'approved', 'rejected']
|
PendingStatus = Literal['pending', 'approved', 'rejected']
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
32
backend/models/routine.py
Normal file
32
backend/models/routine.py
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
from dataclasses import dataclass
|
||||||
|
from models.base import BaseModel
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class Routine(BaseModel):
|
||||||
|
name: str
|
||||||
|
points: int
|
||||||
|
image_id: str | None = None
|
||||||
|
user_id: str | None = None
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_dict(cls, d: dict):
|
||||||
|
return cls(
|
||||||
|
name=d.get('name'),
|
||||||
|
points=d.get('points', 0),
|
||||||
|
image_id=d.get('image_id'),
|
||||||
|
user_id=d.get('user_id'),
|
||||||
|
id=d.get('id'),
|
||||||
|
created_at=d.get('created_at'),
|
||||||
|
updated_at=d.get('updated_at')
|
||||||
|
)
|
||||||
|
|
||||||
|
def to_dict(self):
|
||||||
|
base = super().to_dict()
|
||||||
|
base.update({
|
||||||
|
'name': self.name,
|
||||||
|
'points': self.points,
|
||||||
|
'image_id': self.image_id,
|
||||||
|
'user_id': self.user_id
|
||||||
|
})
|
||||||
|
return base
|
||||||
29
backend/models/routine_extension.py
Normal file
29
backend/models/routine_extension.py
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
from dataclasses import dataclass
|
||||||
|
from models.base import BaseModel
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class RoutineExtension(BaseModel):
|
||||||
|
child_id: str
|
||||||
|
routine_id: str
|
||||||
|
date: str
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_dict(cls, d: dict) -> 'RoutineExtension':
|
||||||
|
return cls(
|
||||||
|
child_id=d.get('child_id'),
|
||||||
|
routine_id=d.get('routine_id'),
|
||||||
|
date=d.get('date'),
|
||||||
|
id=d.get('id'),
|
||||||
|
created_at=d.get('created_at'),
|
||||||
|
updated_at=d.get('updated_at'),
|
||||||
|
)
|
||||||
|
|
||||||
|
def to_dict(self) -> dict:
|
||||||
|
base = super().to_dict()
|
||||||
|
base.update({
|
||||||
|
'child_id': self.child_id,
|
||||||
|
'routine_id': self.routine_id,
|
||||||
|
'date': self.date,
|
||||||
|
})
|
||||||
|
return base
|
||||||
32
backend/models/routine_item.py
Normal file
32
backend/models/routine_item.py
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
from dataclasses import dataclass
|
||||||
|
from models.base import BaseModel
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class RoutineItem(BaseModel):
|
||||||
|
routine_id: str
|
||||||
|
name: str
|
||||||
|
image_id: str | None = None
|
||||||
|
order: int = 0
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_dict(cls, d: dict):
|
||||||
|
return cls(
|
||||||
|
routine_id=d.get('routine_id'),
|
||||||
|
name=d.get('name'),
|
||||||
|
image_id=d.get('image_id'),
|
||||||
|
order=d.get('order', 0),
|
||||||
|
id=d.get('id'),
|
||||||
|
created_at=d.get('created_at'),
|
||||||
|
updated_at=d.get('updated_at')
|
||||||
|
)
|
||||||
|
|
||||||
|
def to_dict(self):
|
||||||
|
base = super().to_dict()
|
||||||
|
base.update({
|
||||||
|
'routine_id': self.routine_id,
|
||||||
|
'name': self.name,
|
||||||
|
'image_id': self.image_id,
|
||||||
|
'order': self.order
|
||||||
|
})
|
||||||
|
return base
|
||||||
63
backend/models/routine_schedule.py
Normal file
63
backend/models/routine_schedule.py
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
from dataclasses import dataclass, field
|
||||||
|
from typing import Literal
|
||||||
|
from models.base import BaseModel
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class RoutineSchedule(BaseModel):
|
||||||
|
child_id: str
|
||||||
|
routine_id: str
|
||||||
|
mode: Literal['days', 'interval']
|
||||||
|
|
||||||
|
day_configs: list = field(default_factory=list)
|
||||||
|
default_hour: int = 8
|
||||||
|
default_minute: int = 0
|
||||||
|
default_has_deadline: bool = True
|
||||||
|
|
||||||
|
interval_days: int = 2
|
||||||
|
anchor_date: str = ""
|
||||||
|
interval_has_deadline: bool = True
|
||||||
|
interval_hour: int = 0
|
||||||
|
interval_minute: int = 0
|
||||||
|
|
||||||
|
enabled: bool = True
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_dict(cls, d: dict) -> 'RoutineSchedule':
|
||||||
|
return cls(
|
||||||
|
child_id=d.get('child_id'),
|
||||||
|
routine_id=d.get('routine_id'),
|
||||||
|
mode=d.get('mode', 'days'),
|
||||||
|
day_configs=d.get('day_configs', []),
|
||||||
|
default_hour=d.get('default_hour', 8),
|
||||||
|
default_minute=d.get('default_minute', 0),
|
||||||
|
default_has_deadline=d.get('default_has_deadline', True),
|
||||||
|
interval_days=d.get('interval_days', 2),
|
||||||
|
anchor_date=d.get('anchor_date', ''),
|
||||||
|
interval_has_deadline=d.get('interval_has_deadline', True),
|
||||||
|
interval_hour=d.get('interval_hour', 0),
|
||||||
|
interval_minute=d.get('interval_minute', 0),
|
||||||
|
enabled=d.get('enabled', True),
|
||||||
|
id=d.get('id'),
|
||||||
|
created_at=d.get('created_at'),
|
||||||
|
updated_at=d.get('updated_at'),
|
||||||
|
)
|
||||||
|
|
||||||
|
def to_dict(self) -> dict:
|
||||||
|
base = super().to_dict()
|
||||||
|
base.update({
|
||||||
|
'child_id': self.child_id,
|
||||||
|
'routine_id': self.routine_id,
|
||||||
|
'mode': self.mode,
|
||||||
|
'day_configs': self.day_configs,
|
||||||
|
'default_hour': self.default_hour,
|
||||||
|
'default_minute': self.default_minute,
|
||||||
|
'default_has_deadline': self.default_has_deadline,
|
||||||
|
'interval_days': self.interval_days,
|
||||||
|
'anchor_date': self.anchor_date,
|
||||||
|
'interval_has_deadline': self.interval_has_deadline,
|
||||||
|
'interval_hour': self.interval_hour,
|
||||||
|
'interval_minute': self.interval_minute,
|
||||||
|
'enabled': self.enabled,
|
||||||
|
})
|
||||||
|
return base
|
||||||
187
backend/tests/test_routine_feature_api.py
Normal file
187
backend/tests/test_routine_feature_api.py
Normal file
@@ -0,0 +1,187 @@
|
|||||||
|
from flask import Flask
|
||||||
|
from tinydb import Query
|
||||||
|
from werkzeug.security import generate_password_hash
|
||||||
|
|
||||||
|
from tests.conftest import TEST_SECRET_KEY, TEST_REFRESH_TOKEN_EXPIRY_DAYS
|
||||||
|
from api.auth_api import auth_api
|
||||||
|
from api.routine_api import routine_api
|
||||||
|
from api.routine_item_api import routine_item_api
|
||||||
|
from api.child_routine_api import child_routine_api
|
||||||
|
from api.routine_schedule_api import routine_schedule_api
|
||||||
|
from db.db import (
|
||||||
|
users_db,
|
||||||
|
child_db,
|
||||||
|
routine_db,
|
||||||
|
routine_items_db,
|
||||||
|
routine_schedules_db,
|
||||||
|
routine_extensions_db,
|
||||||
|
pending_confirmations_db,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
TEST_USER_ID = 'routine-user-1'
|
||||||
|
TEST_EMAIL = 'routine-user@example.com'
|
||||||
|
TEST_PASSWORD = 'testpass'
|
||||||
|
|
||||||
|
|
||||||
|
def add_test_user():
|
||||||
|
users_db.remove(Query().email == TEST_EMAIL)
|
||||||
|
users_db.insert({
|
||||||
|
'id': TEST_USER_ID,
|
||||||
|
'first_name': 'Routine',
|
||||||
|
'last_name': 'Tester',
|
||||||
|
'email': TEST_EMAIL,
|
||||||
|
'password': generate_password_hash(TEST_PASSWORD),
|
||||||
|
'verified': True,
|
||||||
|
'image_id': 'boy01',
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
def login_and_set_cookie(client):
|
||||||
|
resp = client.post('/auth/login', json={'email': TEST_EMAIL, 'password': TEST_PASSWORD})
|
||||||
|
assert resp.status_code == 200
|
||||||
|
|
||||||
|
|
||||||
|
def seed_child(child_id: str):
|
||||||
|
child_db.remove(Query().id == child_id)
|
||||||
|
child_db.insert({
|
||||||
|
'id': child_id,
|
||||||
|
'name': 'Routine Kid',
|
||||||
|
'age': 9,
|
||||||
|
'tasks': [],
|
||||||
|
'routines': [],
|
||||||
|
'rewards': [],
|
||||||
|
'points': 0,
|
||||||
|
'image_id': 'boy01',
|
||||||
|
'user_id': TEST_USER_ID,
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
def _first_routine_id():
|
||||||
|
routines = routine_db.all()
|
||||||
|
assert routines
|
||||||
|
return routines[0]['id']
|
||||||
|
|
||||||
|
|
||||||
|
def _first_confirmation_id():
|
||||||
|
confirmations = pending_confirmations_db.all()
|
||||||
|
assert confirmations
|
||||||
|
return confirmations[0]['id']
|
||||||
|
|
||||||
|
|
||||||
|
def _make_client():
|
||||||
|
app = Flask(__name__)
|
||||||
|
app.register_blueprint(auth_api, url_prefix='/auth')
|
||||||
|
app.register_blueprint(routine_api)
|
||||||
|
app.register_blueprint(routine_item_api)
|
||||||
|
app.register_blueprint(child_routine_api)
|
||||||
|
app.register_blueprint(routine_schedule_api)
|
||||||
|
app.config['TESTING'] = True
|
||||||
|
app.config['SECRET_KEY'] = TEST_SECRET_KEY
|
||||||
|
app.config['REFRESH_TOKEN_EXPIRY_DAYS'] = TEST_REFRESH_TOKEN_EXPIRY_DAYS
|
||||||
|
return app
|
||||||
|
|
||||||
|
|
||||||
|
def setup_function(_):
|
||||||
|
routine_db.truncate()
|
||||||
|
routine_items_db.truncate()
|
||||||
|
routine_schedules_db.truncate()
|
||||||
|
routine_extensions_db.truncate()
|
||||||
|
pending_confirmations_db.truncate()
|
||||||
|
child_db.truncate()
|
||||||
|
|
||||||
|
|
||||||
|
def test_routine_crud_flow():
|
||||||
|
app = _make_client()
|
||||||
|
with app.test_client() as client:
|
||||||
|
add_test_user()
|
||||||
|
login_and_set_cookie(client)
|
||||||
|
|
||||||
|
add_resp = client.put('/routine/add', json={'name': 'Morning Routine', 'points': 8, 'image_id': 'sun'})
|
||||||
|
assert add_resp.status_code == 201
|
||||||
|
|
||||||
|
rid = _first_routine_id()
|
||||||
|
|
||||||
|
list_resp = client.get('/routine/list')
|
||||||
|
assert list_resp.status_code == 200
|
||||||
|
routines = list_resp.get_json()['routines']
|
||||||
|
assert len(routines) == 1
|
||||||
|
assert routines[0]['name'] == 'Morning Routine'
|
||||||
|
|
||||||
|
edit_resp = client.put(f'/routine/{rid}/edit', json={'points': 10})
|
||||||
|
assert edit_resp.status_code == 200
|
||||||
|
assert edit_resp.get_json()['points'] == 10
|
||||||
|
|
||||||
|
delete_resp = client.delete(f'/routine/{rid}')
|
||||||
|
assert delete_resp.status_code == 200
|
||||||
|
assert routine_db.all() == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_child_routine_assignment_confirmation_and_approval():
|
||||||
|
app = _make_client()
|
||||||
|
with app.test_client() as client:
|
||||||
|
add_test_user()
|
||||||
|
login_and_set_cookie(client)
|
||||||
|
|
||||||
|
seed_child('routine-child-1')
|
||||||
|
client.put('/routine/add', json={'name': 'Evening Routine', 'points': 6, 'image_id': 'moon'})
|
||||||
|
rid = _first_routine_id()
|
||||||
|
|
||||||
|
add_item_resp = client.put(f'/routine/{rid}/item/add', json={'name': 'Brush Teeth', 'order': 0})
|
||||||
|
assert add_item_resp.status_code == 201
|
||||||
|
|
||||||
|
assign_resp = client.post('/child/routine-child-1/assign-routine', json={'routine_id': rid})
|
||||||
|
assert assign_resp.status_code == 200
|
||||||
|
|
||||||
|
list_resp = client.get('/child/routine-child-1/list-routines')
|
||||||
|
assert list_resp.status_code == 200
|
||||||
|
routines = list_resp.get_json()['routines']
|
||||||
|
assert len(routines) == 1
|
||||||
|
assert routines[0]['id'] == rid
|
||||||
|
assert routines[0]['items'][0]['name'] == 'Brush Teeth'
|
||||||
|
|
||||||
|
confirm_resp = client.post('/child/routine-child-1/confirm-routine', json={'routine_id': rid})
|
||||||
|
assert confirm_resp.status_code == 200
|
||||||
|
|
||||||
|
confirmation_id = _first_confirmation_id()
|
||||||
|
approve_resp = client.post(f'/child/routine-child-1/approve-routine/{confirmation_id}')
|
||||||
|
assert approve_resp.status_code == 200
|
||||||
|
|
||||||
|
child = child_db.get(Query().id == 'routine-child-1')
|
||||||
|
assert child['points'] == 6
|
||||||
|
|
||||||
|
|
||||||
|
def test_routine_schedule_and_extend_flow():
|
||||||
|
app = _make_client()
|
||||||
|
with app.test_client() as client:
|
||||||
|
add_test_user()
|
||||||
|
login_and_set_cookie(client)
|
||||||
|
|
||||||
|
seed_child('routine-child-2')
|
||||||
|
client.put('/routine/add', json={'name': 'School Routine', 'points': 5, 'image_id': 'book'})
|
||||||
|
rid = _first_routine_id()
|
||||||
|
|
||||||
|
client.post('/child/routine-child-2/assign-routine', json={'routine_id': rid})
|
||||||
|
|
||||||
|
set_resp = client.put(
|
||||||
|
f'/child/routine-child-2/routine/{rid}/schedule',
|
||||||
|
json={
|
||||||
|
'mode': 'days',
|
||||||
|
'day_configs': [{'day': 1, 'hour': 8, 'minute': 0}],
|
||||||
|
'default_hour': 8,
|
||||||
|
'default_minute': 0,
|
||||||
|
'default_has_deadline': True,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert set_resp.status_code == 200
|
||||||
|
|
||||||
|
get_resp = client.get(f'/child/routine-child-2/routine/{rid}/schedule')
|
||||||
|
assert get_resp.status_code == 200
|
||||||
|
assert get_resp.get_json()['mode'] == 'days'
|
||||||
|
|
||||||
|
extend_resp = client.post(
|
||||||
|
f'/child/routine-child-2/routine/{rid}/extend',
|
||||||
|
json={'date': '2026-05-04'},
|
||||||
|
)
|
||||||
|
assert extend_resp.status_code == 200
|
||||||
|
assert extend_resp.get_json()['routine_id'] == rid
|
||||||
120
frontend/e2e/plans/routines-feature.plan.md
Normal file
120
frontend/e2e/plans/routines-feature.plan.md
Normal file
@@ -0,0 +1,120 @@
|
|||||||
|
# Routines Feature E2E Plan
|
||||||
|
|
||||||
|
## Application Overview
|
||||||
|
|
||||||
|
The routines feature adds a new parent-defined checklist entity that sits between chores and rewards in child mode. A routine is confirmed as a whole (not per-item), then approved or rejected by the parent. Routine visibility and state are schedule-aware, include deadline extension support, and support per-child point overrides.
|
||||||
|
|
||||||
|
This plan is intentionally implementation-driven so we can incrementally add tests as each phase ships.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Coverage Areas
|
||||||
|
|
||||||
|
### 1. Parent Routine Library Management
|
||||||
|
|
||||||
|
File target: e2e/mode_parent/routines/routine-library.spec.ts
|
||||||
|
|
||||||
|
1. Parent can create a routine with name, points, image.
|
||||||
|
2. Parent can add multiple routine items and persist item order.
|
||||||
|
3. Parent can edit routine metadata and item metadata.
|
||||||
|
4. Parent can delete a routine item.
|
||||||
|
5. Parent can delete a routine and it disappears from routine library list.
|
||||||
|
|
||||||
|
### 2. Parent Child Assignment and Management
|
||||||
|
|
||||||
|
File target: e2e/mode_parent/routines/routine-assignment.spec.ts
|
||||||
|
|
||||||
|
1. Parent can assign routine to a child from assignable list.
|
||||||
|
2. Parent can remove routine assignment from child.
|
||||||
|
3. Parent can set/replace full routine assignment list for a child.
|
||||||
|
4. Parent can set routine point override and child-facing value reflects override.
|
||||||
|
5. Parent can set routine schedule and update it later.
|
||||||
|
6. Parent can extend routine deadline for today.
|
||||||
|
|
||||||
|
### 3. Child Routine Rendering and Navigation
|
||||||
|
|
||||||
|
File target: e2e/mode_child/routines/routine-visibility.spec.ts
|
||||||
|
|
||||||
|
1. Routines list renders between chores and rewards.
|
||||||
|
2. Clicking a routine opens routine detail route (/child/:id/routine/:routineId).
|
||||||
|
3. Detail view shows routine image, title, points, and non-interactive item list.
|
||||||
|
4. Back navigation returns to child main view.
|
||||||
|
|
||||||
|
### 4. Child Routine Confirmation Flow
|
||||||
|
|
||||||
|
File target: e2e/mode_child/routines/routine-confirmation.spec.ts
|
||||||
|
|
||||||
|
1. Child can mark routine Done; routine becomes pending.
|
||||||
|
2. Child can cancel a pending routine confirmation.
|
||||||
|
3. Child cannot submit duplicate pending confirmations.
|
||||||
|
4. Approved-today routine shows completed state on child UI.
|
||||||
|
5. Rejected routine returns to available state.
|
||||||
|
|
||||||
|
### 5. Parent Pending Confirmation Flow
|
||||||
|
|
||||||
|
File target: e2e/mode_parent/routines/routine-approval.spec.ts
|
||||||
|
|
||||||
|
1. Routine confirmation appears in parent notification/pending list.
|
||||||
|
2. Parent can approve routine; child points increase by default routine points.
|
||||||
|
3. Parent can reject routine; child points do not change.
|
||||||
|
4. Parent can reset approved/rejected routine to clear completion state.
|
||||||
|
5. If routine override exists, approval uses override points instead of base points.
|
||||||
|
|
||||||
|
### 6. Routine Scheduling and Deadline Behavior
|
||||||
|
|
||||||
|
File target: e2e/mode_child/routines/routine-schedule.spec.ts
|
||||||
|
|
||||||
|
1. Day-based schedules only show routines on scheduled days.
|
||||||
|
2. Interval schedules show/hide routines on expected interval dates.
|
||||||
|
3. Routine with deadline in the past displays TOO LATE and blocks Done.
|
||||||
|
4. Extending deadline removes TOO LATE state for that day.
|
||||||
|
5. Changing schedule resets stale pending status.
|
||||||
|
|
||||||
|
### 7. Cascade and Data Integrity
|
||||||
|
|
||||||
|
File target: e2e/mode_parent/routines/routine-cascade.spec.ts
|
||||||
|
|
||||||
|
1. Deleting routine removes it from all assigned children.
|
||||||
|
2. Deleting routine removes routine items.
|
||||||
|
3. Deleting routine removes routine schedules and extensions.
|
||||||
|
4. Deleting routine removes routine point overrides.
|
||||||
|
5. Deleting child removes child routine schedules/extensions/overrides.
|
||||||
|
|
||||||
|
### 8. SSE Reactivity
|
||||||
|
|
||||||
|
File target: e2e/multi-session/routines/routine-sse.spec.ts
|
||||||
|
|
||||||
|
1. Parent routine add/edit/delete updates child assignment views without refresh.
|
||||||
|
2. Child routine pending/approved/rejected/reset updates parent notification UI without refresh.
|
||||||
|
3. Schedule/extension changes update child routine card state without refresh.
|
||||||
|
4. Override changes update points display without refresh.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Test Data and Execution Notes
|
||||||
|
|
||||||
|
1. Use API seeding in beforeAll and cleanup in afterAll per spec file.
|
||||||
|
2. Use stable role/label-based locators only.
|
||||||
|
3. Avoid /auth/login navigation in tests; rely on global storageState.
|
||||||
|
4. For time-sensitive schedule tests, set deterministic times (or use clock controls where practical).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Unit Test Expansion Checklist
|
||||||
|
|
||||||
|
### Backend unit tests to add during implementation
|
||||||
|
|
||||||
|
1. Routine CRUD API validation and ownership checks.
|
||||||
|
2. Routine item CRUD and ordering behavior.
|
||||||
|
3. Child routine assignment list and assignable-list filtering.
|
||||||
|
4. Routine confirmation approve/reject/reset state transitions.
|
||||||
|
5. Routine schedule and extension endpoints (including duplicate extension conflict).
|
||||||
|
6. Routine deletion cascades (items/schedules/extensions/overrides/child assignments).
|
||||||
|
|
||||||
|
### Frontend unit tests to add during implementation
|
||||||
|
|
||||||
|
1. API helper coverage for routine endpoints.
|
||||||
|
2. ScheduleModal entityType routing behavior for task vs routine.
|
||||||
|
3. Child routine list sorting/filtering utility behavior.
|
||||||
|
4. Routine detail confirmation UI state transitions.
|
||||||
|
5. Parent pending confirmation card rendering for entity_type='routine'.
|
||||||
@@ -2,17 +2,21 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'
|
|||||||
import { mount } from '@vue/test-utils'
|
import { mount } from '@vue/test-utils'
|
||||||
import { nextTick } from 'vue'
|
import { nextTick } from 'vue'
|
||||||
import ScheduleModal from '../components/shared/ScheduleModal.vue'
|
import ScheduleModal from '../components/shared/ScheduleModal.vue'
|
||||||
import type { ChildTask, ChoreSchedule } from '../common/models'
|
import type { ChildTask, ChoreSchedule, RoutineSchedule } from '../common/models'
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Mocks
|
// Mocks
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
const mockSetChoreSchedule = vi.fn()
|
const mockSetChoreSchedule = vi.fn()
|
||||||
const mockDeleteChoreSchedule = vi.fn()
|
const mockDeleteChoreSchedule = vi.fn()
|
||||||
|
const mockSetRoutineSchedule = vi.fn()
|
||||||
|
const mockDeleteRoutineSchedule = vi.fn()
|
||||||
|
|
||||||
vi.mock('@/common/api', () => ({
|
vi.mock('@/common/api', () => ({
|
||||||
setChoreSchedule: (...args: unknown[]) => mockSetChoreSchedule(...args),
|
setChoreSchedule: (...args: unknown[]) => mockSetChoreSchedule(...args),
|
||||||
deleteChoreSchedule: (...args: unknown[]) => mockDeleteChoreSchedule(...args),
|
deleteChoreSchedule: (...args: unknown[]) => mockDeleteChoreSchedule(...args),
|
||||||
|
setRoutineSchedule: (...args: unknown[]) => mockSetRoutineSchedule(...args),
|
||||||
|
deleteRoutineSchedule: (...args: unknown[]) => mockDeleteRoutineSchedule(...args),
|
||||||
parseErrorResponse: vi.fn().mockResolvedValue({ msg: 'error', code: 'ERR' }),
|
parseErrorResponse: vi.fn().mockResolvedValue({ msg: 'error', code: 'ERR' }),
|
||||||
}))
|
}))
|
||||||
|
|
||||||
@@ -39,9 +43,9 @@ const DateInputFieldStub = {
|
|||||||
const TASK: ChildTask = { id: 'task-1', name: 'Clean Room', type: 'chore', points: 5, image_id: '' }
|
const TASK: ChildTask = { id: 'task-1', name: 'Clean Room', type: 'chore', points: 5, image_id: '' }
|
||||||
const CHILD_ID = 'child-1'
|
const CHILD_ID = 'child-1'
|
||||||
|
|
||||||
function mountModal(schedule: ChoreSchedule | null = null) {
|
function mountModal(schedule: ChoreSchedule | RoutineSchedule | null = null) {
|
||||||
return mount(ScheduleModal, {
|
return mount(ScheduleModal, {
|
||||||
props: { task: TASK, childId: CHILD_ID, schedule },
|
props: { entity: TASK, entityType: 'task', childId: CHILD_ID, schedule },
|
||||||
global: {
|
global: {
|
||||||
stubs: {
|
stubs: {
|
||||||
ModalDialog: ModalDialogStub,
|
ModalDialog: ModalDialogStub,
|
||||||
@@ -55,8 +59,12 @@ function mountModal(schedule: ChoreSchedule | null = null) {
|
|||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
mockSetChoreSchedule.mockReset()
|
mockSetChoreSchedule.mockReset()
|
||||||
mockDeleteChoreSchedule.mockReset()
|
mockDeleteChoreSchedule.mockReset()
|
||||||
|
mockSetRoutineSchedule.mockReset()
|
||||||
|
mockDeleteRoutineSchedule.mockReset()
|
||||||
mockSetChoreSchedule.mockResolvedValue({ ok: true })
|
mockSetChoreSchedule.mockResolvedValue({ ok: true })
|
||||||
mockDeleteChoreSchedule.mockResolvedValue({ ok: true })
|
mockDeleteChoreSchedule.mockResolvedValue({ ok: true })
|
||||||
|
mockSetRoutineSchedule.mockResolvedValue({ ok: true })
|
||||||
|
mockDeleteRoutineSchedule.mockResolvedValue({ ok: true })
|
||||||
})
|
})
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Mode toggle
|
// Mode toggle
|
||||||
@@ -488,3 +496,39 @@ describe('ScheduleModal backdrop', () => {
|
|||||||
expect(w.emitted('cancelled')).toBeFalsy()
|
expect(w.emitted('cancelled')).toBeFalsy()
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Entity type routing
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
describe('ScheduleModal entityType routing', () => {
|
||||||
|
it('uses routine schedule API when entityType is routine', async () => {
|
||||||
|
const routineEntity = { ...TASK, id: 'routine-1', items: [] }
|
||||||
|
const w = mount(ScheduleModal, {
|
||||||
|
props: {
|
||||||
|
entity: routineEntity,
|
||||||
|
entityType: 'routine',
|
||||||
|
childId: CHILD_ID,
|
||||||
|
schedule: null,
|
||||||
|
},
|
||||||
|
global: {
|
||||||
|
stubs: {
|
||||||
|
ModalDialog: ModalDialogStub,
|
||||||
|
TimePickerPopover: TimePickerPopoverStub,
|
||||||
|
DateInputField: DateInputFieldStub,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
await w.findAll('.chip')[1].trigger('click')
|
||||||
|
await nextTick()
|
||||||
|
await w.find('.btn-primary').trigger('click')
|
||||||
|
await nextTick()
|
||||||
|
|
||||||
|
expect(mockSetRoutineSchedule).toHaveBeenCalledWith(
|
||||||
|
CHILD_ID,
|
||||||
|
'routine-1',
|
||||||
|
expect.objectContaining({ mode: 'days' }),
|
||||||
|
)
|
||||||
|
expect(mockSetChoreSchedule).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|||||||
60
frontend/src/common/__tests__/api.routine.spec.ts
Normal file
60
frontend/src/common/__tests__/api.routine.spec.ts
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
||||||
|
|
||||||
|
import {
|
||||||
|
confirmRoutine,
|
||||||
|
cancelRoutineConfirmation,
|
||||||
|
setChildRoutineOverride,
|
||||||
|
setChildOverride,
|
||||||
|
} from '../api'
|
||||||
|
|
||||||
|
describe('routine api helpers', () => {
|
||||||
|
const originalFetch = globalThis.fetch
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
globalThis.fetch = vi.fn().mockResolvedValue({ ok: true, status: 200 } as Response)
|
||||||
|
})
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
globalThis.fetch = originalFetch
|
||||||
|
})
|
||||||
|
|
||||||
|
it('confirmRoutine posts to confirm endpoint', async () => {
|
||||||
|
await confirmRoutine('child-1', 'routine-1')
|
||||||
|
|
||||||
|
expect(globalThis.fetch).toHaveBeenCalledWith('/api/child/child-1/confirm-routine', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ routine_id: 'routine-1' }),
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('cancelRoutineConfirmation posts to cancel endpoint', async () => {
|
||||||
|
await cancelRoutineConfirmation('child-2', 'routine-2')
|
||||||
|
|
||||||
|
expect(globalThis.fetch).toHaveBeenCalledWith('/api/child/child-2/cancel-routine-confirmation', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ routine_id: 'routine-2' }),
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('setChildRoutineOverride delegates to override endpoint with routine entity type', async () => {
|
||||||
|
await setChildRoutineOverride('child-3', 'routine-3', 11)
|
||||||
|
|
||||||
|
expect(globalThis.fetch).toHaveBeenCalledWith('/api/child/child-3/override', {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ entity_id: 'routine-3', entity_type: 'routine', custom_value: 11 }),
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('setChildOverride supports routine entity type directly', async () => {
|
||||||
|
await setChildOverride('child-4', 'routine-4', 'routine', 12)
|
||||||
|
|
||||||
|
expect(globalThis.fetch).toHaveBeenCalledWith('/api/child/child-4/override', {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ entity_id: 'routine-4', entity_type: 'routine', custom_value: 12 }),
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -168,7 +168,7 @@ export async function getTrackingEventsForChild(params: {
|
|||||||
export async function setChildOverride(
|
export async function setChildOverride(
|
||||||
childId: string,
|
childId: string,
|
||||||
entityId: string,
|
entityId: string,
|
||||||
entityType: 'task' | 'reward',
|
entityType: 'task' | 'reward' | 'routine',
|
||||||
customValue: number,
|
customValue: number,
|
||||||
): Promise<Response> {
|
): Promise<Response> {
|
||||||
return fetch(`/api/child/${childId}/override`, {
|
return fetch(`/api/child/${childId}/override`, {
|
||||||
@@ -182,6 +182,14 @@ export async function setChildOverride(
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function setChildRoutineOverride(
|
||||||
|
childId: string,
|
||||||
|
routineId: string,
|
||||||
|
customValue: number,
|
||||||
|
): Promise<Response> {
|
||||||
|
return setChildOverride(childId, routineId, 'routine', customValue)
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get all overrides for a specific child.
|
* Get all overrides for a specific child.
|
||||||
*/
|
*/
|
||||||
@@ -231,6 +239,37 @@ export async function deleteChoreSchedule(childId: string, taskId: string): Prom
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the schedule for a specific child + routine pair.
|
||||||
|
*/
|
||||||
|
export async function getRoutineSchedule(childId: string, routineId: string): Promise<Response> {
|
||||||
|
return fetch(`/api/child/${childId}/routine/${routineId}/schedule`)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create or replace the schedule for a specific child + routine pair.
|
||||||
|
*/
|
||||||
|
export async function setRoutineSchedule(
|
||||||
|
childId: string,
|
||||||
|
routineId: string,
|
||||||
|
schedule: object,
|
||||||
|
): Promise<Response> {
|
||||||
|
return fetch(`/api/child/${childId}/routine/${routineId}/schedule`, {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(schedule),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete the schedule for a specific child + routine pair.
|
||||||
|
*/
|
||||||
|
export async function deleteRoutineSchedule(childId: string, routineId: string): Promise<Response> {
|
||||||
|
return fetch(`/api/child/${childId}/routine/${routineId}/schedule`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Extend a timed-out chore for the remainder of today only.
|
* Extend a timed-out chore for the remainder of today only.
|
||||||
* `localDate` is the client's local ISO date (e.g. '2026-02-22').
|
* `localDate` is the client's local ISO date (e.g. '2026-02-22').
|
||||||
@@ -247,6 +286,22 @@ export async function extendChoreTime(
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extend a timed-out routine for the remainder of today only.
|
||||||
|
* `localDate` is the client's local ISO date (e.g. '2026-02-22').
|
||||||
|
*/
|
||||||
|
export async function extendRoutineTime(
|
||||||
|
childId: string,
|
||||||
|
routineId: string,
|
||||||
|
localDate: string,
|
||||||
|
): Promise<Response> {
|
||||||
|
return fetch(`/api/child/${childId}/routine/${routineId}/extend`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ date: localDate }),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
// ── Chore Confirmation API ──────────────────────────────────────────────────
|
// ── Chore Confirmation API ──────────────────────────────────────────────────
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -310,3 +365,28 @@ export async function resetChore(childId: string, taskId: string): Promise<Respo
|
|||||||
export async function fetchPendingConfirmations(): Promise<Response> {
|
export async function fetchPendingConfirmations(): Promise<Response> {
|
||||||
return fetch('/api/pending-confirmations')
|
return fetch('/api/pending-confirmations')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Child confirms they completed a routine.
|
||||||
|
*/
|
||||||
|
export async function confirmRoutine(childId: string, routineId: string): Promise<Response> {
|
||||||
|
return fetch(`/api/child/${childId}/confirm-routine`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ routine_id: routineId }),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Child cancels a pending routine confirmation.
|
||||||
|
*/
|
||||||
|
export async function cancelRoutineConfirmation(
|
||||||
|
childId: string,
|
||||||
|
routineId: string,
|
||||||
|
): Promise<Response> {
|
||||||
|
return fetch(`/api/child/${childId}/cancel-routine-confirmation`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ routine_id: routineId }),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|||||||
@@ -37,6 +37,56 @@ export interface ChoreSchedule {
|
|||||||
updated_at: number
|
updated_at: number
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface Routine {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
points: number
|
||||||
|
image_id: string | null
|
||||||
|
image_url?: string | null
|
||||||
|
}
|
||||||
|
export const ROUTINE_FIELDS = ['id', 'name', 'points', 'image_id'] as const
|
||||||
|
|
||||||
|
export interface RoutineItem {
|
||||||
|
id: string
|
||||||
|
routine_id: string
|
||||||
|
name: string
|
||||||
|
image_id: string | null
|
||||||
|
order: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RoutineSchedule {
|
||||||
|
id: string
|
||||||
|
child_id: string
|
||||||
|
routine_id: string
|
||||||
|
mode: 'days' | 'interval'
|
||||||
|
day_configs: DayConfig[]
|
||||||
|
default_hour?: number
|
||||||
|
default_minute?: number
|
||||||
|
default_has_deadline?: boolean
|
||||||
|
interval_days: number
|
||||||
|
anchor_date: string
|
||||||
|
interval_has_deadline: boolean
|
||||||
|
interval_hour: number
|
||||||
|
interval_minute: number
|
||||||
|
enabled?: boolean
|
||||||
|
created_at: number
|
||||||
|
updated_at: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ChildRoutine {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
points: number
|
||||||
|
image_id: string | null
|
||||||
|
image_url?: string | null
|
||||||
|
custom_value?: number | null
|
||||||
|
schedule?: RoutineSchedule | null
|
||||||
|
extension_date?: string | null
|
||||||
|
pending_status?: 'pending' | 'approved' | null
|
||||||
|
approved_at?: string | null
|
||||||
|
items: RoutineItem[]
|
||||||
|
}
|
||||||
|
|
||||||
export interface ChildTask {
|
export interface ChildTask {
|
||||||
id: string
|
id: string
|
||||||
name: string
|
name: string
|
||||||
@@ -72,12 +122,13 @@ export interface Child {
|
|||||||
name: string
|
name: string
|
||||||
age: number
|
age: number
|
||||||
tasks: string[]
|
tasks: string[]
|
||||||
|
routines?: string[]
|
||||||
rewards: string[]
|
rewards: string[]
|
||||||
points: number
|
points: number
|
||||||
image_id: string | null
|
image_id: string | null
|
||||||
image_url?: string | null // optional, for resolved URLs
|
image_url?: string | null // optional, for resolved URLs
|
||||||
}
|
}
|
||||||
export const CHILD_FIELDS = ['id', 'name', 'age', 'tasks', 'rewards', 'points', 'image_id'] as const
|
export const CHILD_FIELDS = ['id', 'name', 'age', 'tasks', 'routines', 'rewards', 'points', 'image_id'] as const
|
||||||
|
|
||||||
export interface Reward {
|
export interface Reward {
|
||||||
id: string
|
id: string
|
||||||
@@ -114,7 +165,7 @@ export interface PendingConfirmation {
|
|||||||
child_image_id: string | null
|
child_image_id: string | null
|
||||||
child_image_url?: string | null
|
child_image_url?: string | null
|
||||||
entity_id: string
|
entity_id: string
|
||||||
entity_type: 'chore' | 'reward'
|
entity_type: 'chore' | 'reward' | 'routine'
|
||||||
entity_name: string
|
entity_name: string
|
||||||
entity_image_id: string | null
|
entity_image_id: string | null
|
||||||
entity_image_url?: string | null
|
entity_image_url?: string | null
|
||||||
@@ -151,6 +202,11 @@ export interface Event {
|
|||||||
| ChoreScheduleModifiedPayload
|
| ChoreScheduleModifiedPayload
|
||||||
| ChoreTimeExtendedPayload
|
| ChoreTimeExtendedPayload
|
||||||
| ChildChoreConfirmationPayload
|
| ChildChoreConfirmationPayload
|
||||||
|
| RoutineModifiedEventPayload
|
||||||
|
| ChildRoutinesSetEventPayload
|
||||||
|
| RoutineScheduleModifiedPayload
|
||||||
|
| RoutineTimeExtendedPayload
|
||||||
|
| ChildRoutineConfirmationPayload
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ChildModifiedEventPayload {
|
export interface ChildModifiedEventPayload {
|
||||||
@@ -194,6 +250,16 @@ export interface RewardModifiedEventPayload {
|
|||||||
operation: 'ADD' | 'DELETE' | 'EDIT'
|
operation: 'ADD' | 'DELETE' | 'EDIT'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface RoutineModifiedEventPayload {
|
||||||
|
routine_id: string
|
||||||
|
operation: 'ADD' | 'DELETE' | 'EDIT'
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ChildRoutinesSetEventPayload {
|
||||||
|
child_id: string
|
||||||
|
routine_ids: string[]
|
||||||
|
}
|
||||||
|
|
||||||
export interface TrackingEventCreatedPayload {
|
export interface TrackingEventCreatedPayload {
|
||||||
tracking_event_id: string
|
tracking_event_id: string
|
||||||
child_id: string
|
child_id: string
|
||||||
@@ -211,7 +277,7 @@ export interface ChildOverrideDeletedPayload {
|
|||||||
entity_type: string
|
entity_type: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export type EntityType = 'task' | 'reward' | 'penalty' | 'chore' | 'kindness'
|
export type EntityType = 'task' | 'reward' | 'penalty' | 'chore' | 'kindness' | 'routine'
|
||||||
export type ActionType =
|
export type ActionType =
|
||||||
| 'activated'
|
| 'activated'
|
||||||
| 'requested'
|
| 'requested'
|
||||||
@@ -254,7 +320,7 @@ export const TRACKING_EVENT_FIELDS = [
|
|||||||
'metadata',
|
'metadata',
|
||||||
] as const
|
] as const
|
||||||
|
|
||||||
export type OverrideEntityType = 'task' | 'reward' | 'chore' | 'kindness' | 'penalty'
|
export type OverrideEntityType = 'task' | 'reward' | 'chore' | 'kindness' | 'penalty' | 'routine'
|
||||||
|
|
||||||
export interface ChildOverride {
|
export interface ChildOverride {
|
||||||
id: string
|
id: string
|
||||||
@@ -292,3 +358,20 @@ export interface ChildChoreConfirmationPayload {
|
|||||||
task_id: string
|
task_id: string
|
||||||
operation: 'CONFIRMED' | 'APPROVED' | 'REJECTED' | 'CANCELLED' | 'RESET'
|
operation: 'CONFIRMED' | 'APPROVED' | 'REJECTED' | 'CANCELLED' | 'RESET'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface RoutineScheduleModifiedPayload {
|
||||||
|
child_id: string
|
||||||
|
routine_id: string
|
||||||
|
operation: 'SET' | 'DELETED'
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RoutineTimeExtendedPayload {
|
||||||
|
child_id: string
|
||||||
|
routine_id: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ChildRoutineConfirmationPayload {
|
||||||
|
child_id: string
|
||||||
|
routine_id: string
|
||||||
|
operation: 'PENDING' | 'APPROVED' | 'REJECTED' | 'RESET'
|
||||||
|
}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import ScrollingList from '../shared/ScrollingList.vue'
|
|||||||
import StatusMessage from '../shared/StatusMessage.vue'
|
import StatusMessage from '../shared/StatusMessage.vue'
|
||||||
import RewardConfirmDialog from './RewardConfirmDialog.vue'
|
import RewardConfirmDialog from './RewardConfirmDialog.vue'
|
||||||
import ChoreConfirmDialog from './ChoreConfirmDialog.vue'
|
import ChoreConfirmDialog from './ChoreConfirmDialog.vue'
|
||||||
|
import RoutineConfirmDialog from './RoutineConfirmDialog.vue'
|
||||||
import ModalDialog from '../shared/ModalDialog.vue'
|
import ModalDialog from '../shared/ModalDialog.vue'
|
||||||
import { eventBus } from '@/common/eventBus'
|
import { eventBus } from '@/common/eventBus'
|
||||||
//import '@/assets/view-shared.css'
|
//import '@/assets/view-shared.css'
|
||||||
@@ -16,17 +17,22 @@ import type {
|
|||||||
Task,
|
Task,
|
||||||
RewardStatus,
|
RewardStatus,
|
||||||
ChildTask,
|
ChildTask,
|
||||||
|
ChildRoutine,
|
||||||
ChildTaskTriggeredEventPayload,
|
ChildTaskTriggeredEventPayload,
|
||||||
ChildRewardTriggeredEventPayload,
|
ChildRewardTriggeredEventPayload,
|
||||||
ChildRewardRequestEventPayload,
|
ChildRewardRequestEventPayload,
|
||||||
ChildTasksSetEventPayload,
|
ChildTasksSetEventPayload,
|
||||||
ChildRewardsSetEventPayload,
|
ChildRewardsSetEventPayload,
|
||||||
|
ChildRoutinesSetEventPayload,
|
||||||
TaskModifiedEventPayload,
|
TaskModifiedEventPayload,
|
||||||
RewardModifiedEventPayload,
|
RewardModifiedEventPayload,
|
||||||
|
RoutineModifiedEventPayload,
|
||||||
ChildModifiedEventPayload,
|
ChildModifiedEventPayload,
|
||||||
ChoreScheduleModifiedPayload,
|
ChoreScheduleModifiedPayload,
|
||||||
ChoreTimeExtendedPayload,
|
ChoreTimeExtendedPayload,
|
||||||
|
RoutineScheduleModifiedPayload,
|
||||||
ChildChoreConfirmationPayload,
|
ChildChoreConfirmationPayload,
|
||||||
|
ChildRoutineConfirmationPayload,
|
||||||
ChildOverrideSetPayload,
|
ChildOverrideSetPayload,
|
||||||
ChildOverrideDeletedPayload,
|
ChildOverrideDeletedPayload,
|
||||||
} from '@/common/models'
|
} from '@/common/models'
|
||||||
@@ -46,6 +52,7 @@ const router = useRouter()
|
|||||||
|
|
||||||
const child = ref<Child | null>(null)
|
const child = ref<Child | null>(null)
|
||||||
const tasks = ref<string[]>([])
|
const tasks = ref<string[]>([])
|
||||||
|
const routines = ref<string[]>([])
|
||||||
const rewards = ref<string[]>([])
|
const rewards = ref<string[]>([])
|
||||||
const loading = ref(true)
|
const loading = ref(true)
|
||||||
const error = ref<string | null>(null)
|
const error = ref<string | null>(null)
|
||||||
@@ -56,6 +63,9 @@ const dialogReward = ref<RewardStatus | null>(null)
|
|||||||
const showChoreConfirmDialog = ref(false)
|
const showChoreConfirmDialog = ref(false)
|
||||||
const showChoreCancelDialog = ref(false)
|
const showChoreCancelDialog = ref(false)
|
||||||
const dialogChore = ref<ChildTask | null>(null)
|
const dialogChore = ref<ChildTask | null>(null)
|
||||||
|
const showRoutineConfirmDialog = ref(false)
|
||||||
|
const dialogRoutine = ref<ChildRoutine | null>(null)
|
||||||
|
const childRoutineListRef = ref()
|
||||||
|
|
||||||
function handleTaskTriggered(event: Event) {
|
function handleTaskTriggered(event: Event) {
|
||||||
const payload = event.payload as ChildTaskTriggeredEventPayload
|
const payload = event.payload as ChildTaskTriggeredEventPayload
|
||||||
@@ -87,6 +97,13 @@ function handleChildRewardSet(event: Event) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function handleChildRoutineSet(event: Event) {
|
||||||
|
const payload = event.payload as ChildRoutinesSetEventPayload
|
||||||
|
if (child.value && payload.child_id == child.value.id) {
|
||||||
|
routines.value = payload.routine_ids
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function handleRewardRequest(event: Event) {
|
function handleRewardRequest(event: Event) {
|
||||||
const payload = event.payload as ChildRewardRequestEventPayload
|
const payload = event.payload as ChildRewardRequestEventPayload
|
||||||
const childId = payload.child_id
|
const childId = payload.child_id
|
||||||
@@ -222,6 +239,14 @@ const triggerTask = async (task: ChildTask) => {
|
|||||||
// Kindness / Penalty: speech-only in child mode; point changes are handled in parent mode.
|
// Kindness / Penalty: speech-only in child mode; point changes are handled in parent mode.
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const handleRoutineClick = (routine: ChildRoutine) => {
|
||||||
|
// Show routine confirmation dialog
|
||||||
|
dialogRoutine.value = routine
|
||||||
|
setTimeout(() => {
|
||||||
|
showRoutineConfirmDialog.value = true
|
||||||
|
}, 150)
|
||||||
|
}
|
||||||
|
|
||||||
function isChoreCompletedToday(item: ChildTask): boolean {
|
function isChoreCompletedToday(item: ChildTask): boolean {
|
||||||
if (item.pending_status !== 'approved' || !item.approved_at) return false
|
if (item.pending_status !== 'approved' || !item.approved_at) return false
|
||||||
const approvedDate = new Date(item.approved_at)
|
const approvedDate = new Date(item.approved_at)
|
||||||
@@ -273,6 +298,30 @@ function closeChoreCancelDialog() {
|
|||||||
dialogChore.value = null
|
dialogChore.value = null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function doConfirmRoutine() {
|
||||||
|
if (!child.value?.id || !dialogRoutine.value) return
|
||||||
|
try {
|
||||||
|
const resp = await fetch(`/api/child/${child.value.id}/confirm-routine`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ routine_id: dialogRoutine.value.id }),
|
||||||
|
})
|
||||||
|
if (!resp.ok) {
|
||||||
|
console.error('Failed to confirm routine')
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to confirm routine:', err)
|
||||||
|
} finally {
|
||||||
|
showRoutineConfirmDialog.value = false
|
||||||
|
dialogRoutine.value = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeRoutineConfirmDialog() {
|
||||||
|
showRoutineConfirmDialog.value = false
|
||||||
|
dialogRoutine.value = null
|
||||||
|
}
|
||||||
|
|
||||||
const triggerReward = (reward: RewardStatus) => {
|
const triggerReward = (reward: RewardStatus) => {
|
||||||
// Cancel any pending speech to avoid conflicts
|
// Cancel any pending speech to avoid conflicts
|
||||||
if ('speechSynthesis' in window) {
|
if ('speechSynthesis' in window) {
|
||||||
@@ -545,6 +594,7 @@ onMounted(async () => {
|
|||||||
eventBus.on('child_reward_triggered', handleRewardTriggered)
|
eventBus.on('child_reward_triggered', handleRewardTriggered)
|
||||||
eventBus.on('child_tasks_set', handleChildTaskSet)
|
eventBus.on('child_tasks_set', handleChildTaskSet)
|
||||||
eventBus.on('child_rewards_set', handleChildRewardSet)
|
eventBus.on('child_rewards_set', handleChildRewardSet)
|
||||||
|
eventBus.on('child_routines_set', handleChildRoutineSet)
|
||||||
eventBus.on('task_modified', handleTaskModified)
|
eventBus.on('task_modified', handleTaskModified)
|
||||||
eventBus.on('reward_modified', handleRewardModified)
|
eventBus.on('reward_modified', handleRewardModified)
|
||||||
eventBus.on('child_modified', handleChildModified)
|
eventBus.on('child_modified', handleChildModified)
|
||||||
@@ -563,6 +613,7 @@ onMounted(async () => {
|
|||||||
if (data) {
|
if (data) {
|
||||||
child.value = data
|
child.value = data
|
||||||
tasks.value = data.tasks || []
|
tasks.value = data.tasks || []
|
||||||
|
routines.value = data.routines || []
|
||||||
rewards.value = data.rewards || []
|
rewards.value = data.rewards || []
|
||||||
}
|
}
|
||||||
loading.value = false
|
loading.value = false
|
||||||
@@ -582,6 +633,7 @@ onUnmounted(() => {
|
|||||||
eventBus.off('child_reward_triggered', handleRewardTriggered)
|
eventBus.off('child_reward_triggered', handleRewardTriggered)
|
||||||
eventBus.off('child_tasks_set', handleChildTaskSet)
|
eventBus.off('child_tasks_set', handleChildTaskSet)
|
||||||
eventBus.off('child_rewards_set', handleChildRewardSet)
|
eventBus.off('child_rewards_set', handleChildRewardSet)
|
||||||
|
eventBus.off('child_routines_set', handleChildRoutineSet)
|
||||||
eventBus.off('task_modified', handleTaskModified)
|
eventBus.off('task_modified', handleTaskModified)
|
||||||
eventBus.off('reward_modified', handleRewardModified)
|
eventBus.off('reward_modified', handleRewardModified)
|
||||||
eventBus.off('child_modified', handleChildModified)
|
eventBus.off('child_modified', handleChildModified)
|
||||||
@@ -731,6 +783,49 @@ onUnmounted(() => {
|
|||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</ScrollingList>
|
</ScrollingList>
|
||||||
|
<ScrollingList
|
||||||
|
title="Routines"
|
||||||
|
ref="childRoutineListRef"
|
||||||
|
:fetchBaseUrl="`/api/child/${child?.id}/list-routines`"
|
||||||
|
:ids="routines"
|
||||||
|
itemKey="routines"
|
||||||
|
imageField="image_id"
|
||||||
|
:isParentAuthenticated="false"
|
||||||
|
:readyItemId="readyItemId"
|
||||||
|
@item-ready="handleItemReady"
|
||||||
|
@trigger-item="handleRoutineClick"
|
||||||
|
:getItemClass="
|
||||||
|
(item: ChildRoutine) => ({
|
||||||
|
good: true,
|
||||||
|
'routine-pending': item.pending_status === 'pending',
|
||||||
|
'routine-approved': item.pending_status === 'approved',
|
||||||
|
})
|
||||||
|
"
|
||||||
|
>
|
||||||
|
<template #item="{ item }: { item: ChildRoutine }">
|
||||||
|
<span v-if="item.pending_status === 'pending'" class="chore-stamp pending-stamp"
|
||||||
|
>PENDING</span
|
||||||
|
>
|
||||||
|
<span v-else-if="item.pending_status === 'approved'" class="chore-stamp completed-stamp"
|
||||||
|
>APPROVED</span
|
||||||
|
>
|
||||||
|
<div class="item-name">{{ item.name }}</div>
|
||||||
|
<img
|
||||||
|
v-if="item.image_url"
|
||||||
|
:src="item.image_url"
|
||||||
|
alt="Routine Image"
|
||||||
|
class="item-image"
|
||||||
|
/>
|
||||||
|
<div class="item-points good-points">
|
||||||
|
{{
|
||||||
|
item.custom_value !== undefined && item.custom_value !== null
|
||||||
|
? item.custom_value
|
||||||
|
: item.points
|
||||||
|
}}
|
||||||
|
Points
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</ScrollingList>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -785,6 +880,16 @@ onUnmounted(() => {
|
|||||||
<button @click="closeChoreCancelDialog" class="btn btn-secondary">No</button>
|
<button @click="closeChoreCancelDialog" class="btn btn-secondary">No</button>
|
||||||
</div>
|
</div>
|
||||||
</ModalDialog>
|
</ModalDialog>
|
||||||
|
|
||||||
|
<!-- Routine confirm dialog -->
|
||||||
|
<RoutineConfirmDialog
|
||||||
|
:show="showRoutineConfirmDialog"
|
||||||
|
:routineName="dialogRoutine?.name ?? ''"
|
||||||
|
:imageUrl="dialogRoutine?.image_url"
|
||||||
|
:items="dialogRoutine?.items"
|
||||||
|
@confirm="doConfirmRoutine"
|
||||||
|
@cancel="closeRoutineConfirmDialog"
|
||||||
|
/>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
|
|||||||
@@ -937,6 +937,16 @@ function goToAssignRewards() {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function goToAssignRoutines() {
|
||||||
|
if (child.value?.id) {
|
||||||
|
router.push({
|
||||||
|
name: 'RoutineAssignView',
|
||||||
|
params: { id: child.value.id },
|
||||||
|
query: { name: child.value.name },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -1162,6 +1172,9 @@ function goToAssignRewards() {
|
|||||||
<button v-if="child" class="btn btn-danger" @click="goToAssignBadHabits">
|
<button v-if="child" class="btn btn-danger" @click="goToAssignBadHabits">
|
||||||
Assign Penalties
|
Assign Penalties
|
||||||
</button>
|
</button>
|
||||||
|
<button v-if="child" class="btn btn-primary" @click="goToAssignRoutines">
|
||||||
|
Assign Routines
|
||||||
|
</button>
|
||||||
<button v-if="child" class="btn btn-green" @click="goToAssignRewards">Assign Rewards</button>
|
<button v-if="child" class="btn btn-green" @click="goToAssignRewards">Assign Rewards</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -1196,7 +1209,8 @@ function goToAssignRewards() {
|
|||||||
<!-- Schedule Modal -->
|
<!-- Schedule Modal -->
|
||||||
<ScheduleModal
|
<ScheduleModal
|
||||||
v-if="showScheduleModal && scheduleTarget && child"
|
v-if="showScheduleModal && scheduleTarget && child"
|
||||||
:task="scheduleTarget"
|
:entity="scheduleTarget"
|
||||||
|
entityType="task"
|
||||||
:childId="child.id"
|
:childId="child.id"
|
||||||
:schedule="scheduleTarget.schedule ?? null"
|
:schedule="scheduleTarget.schedule ?? null"
|
||||||
@saved="onScheduleSaved"
|
@saved="onScheduleSaved"
|
||||||
|
|||||||
238
frontend/src/components/child/RoutineAssignView.vue
Normal file
238
frontend/src/components/child/RoutineAssignView.vue
Normal file
@@ -0,0 +1,238 @@
|
|||||||
|
<template>
|
||||||
|
<div class="assign-view">
|
||||||
|
<h2>Assign Routines{{ childName ? ` for ${childName}` : '' }}</h2>
|
||||||
|
<div class="list-container">
|
||||||
|
<MessageBlock v-if="routines.length === 0" message="No routines">
|
||||||
|
<span> <button class="round-btn" @click="goToCreate">Create</button> a routine </span>
|
||||||
|
</MessageBlock>
|
||||||
|
<div v-else class="routine-selection">
|
||||||
|
<div
|
||||||
|
v-for="routine in routines"
|
||||||
|
:key="routine.id"
|
||||||
|
class="routine-item"
|
||||||
|
@click="toggleRoutine(routine.id)"
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
:checked="selectedIds.includes(routine.id)"
|
||||||
|
@change="toggleRoutine(routine.id)"
|
||||||
|
:aria-label="`Select ${routine.name}`"
|
||||||
|
/>
|
||||||
|
<img v-if="routine.image_url" :src="routine.image_url" :alt="routine.name" />
|
||||||
|
<span class="name">{{ routine.name }}</span>
|
||||||
|
<span class="value">{{ routine.points }} pts</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="actions" v-if="routines.length > 0">
|
||||||
|
<button class="btn btn-secondary" @click="onCancel">Cancel</button>
|
||||||
|
<button class="btn btn-primary" @click="onSubmit" :disabled="isLoading">
|
||||||
|
{{ isLoading ? 'Saving...' : 'Submit' }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, onMounted } from 'vue'
|
||||||
|
import { useRoute, useRouter } from 'vue-router'
|
||||||
|
import MessageBlock from '../shared/MessageBlock.vue'
|
||||||
|
import '@/assets/styles.css'
|
||||||
|
import type { Routine } from '@/common/models'
|
||||||
|
|
||||||
|
const route = useRoute()
|
||||||
|
const router = useRouter()
|
||||||
|
const childId = route.params.id as string
|
||||||
|
const childName = typeof route.query.name === 'string' ? route.query.name : ''
|
||||||
|
const routines = ref<Routine[]>([])
|
||||||
|
const selectedIds = ref<string[]>([])
|
||||||
|
const isLoading = ref(false)
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
await fetchRoutines()
|
||||||
|
})
|
||||||
|
|
||||||
|
async function fetchRoutines() {
|
||||||
|
try {
|
||||||
|
// Fetch child to get currently assigned routines
|
||||||
|
const childResp = await fetch(`/api/child/${childId}`)
|
||||||
|
if (!childResp.ok) throw new Error('Failed to fetch child')
|
||||||
|
const childData = await childResp.json()
|
||||||
|
selectedIds.value = childData.routines || []
|
||||||
|
|
||||||
|
// Fetch all routines
|
||||||
|
const routinesResp = await fetch('/api/routine/list')
|
||||||
|
if (!routinesResp.ok) throw new Error('Failed to fetch routines')
|
||||||
|
const routinesData = await routinesResp.json()
|
||||||
|
routines.value = routinesData.routines || []
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to fetch routines:', error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleRoutine(routineId: string) {
|
||||||
|
const index = selectedIds.value.indexOf(routineId)
|
||||||
|
if (index > -1) {
|
||||||
|
selectedIds.value.splice(index, 1)
|
||||||
|
} else {
|
||||||
|
selectedIds.value.push(routineId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function goToCreate() {
|
||||||
|
router.push({ name: 'CreateRoutine' })
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onSubmit() {
|
||||||
|
isLoading.value = true
|
||||||
|
try {
|
||||||
|
const resp = await fetch(`/api/child/${childId}/set-routines`, {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ routine_ids: selectedIds.value }),
|
||||||
|
})
|
||||||
|
if (!resp.ok) throw new Error('Failed to update routines')
|
||||||
|
router.back()
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to update routines:', error)
|
||||||
|
alert('Failed to update routines.')
|
||||||
|
} finally {
|
||||||
|
isLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function onCancel() {
|
||||||
|
router.back()
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.assign-view {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
flex: 1 1 auto;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
padding: 1rem;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
h2 {
|
||||||
|
margin: 0;
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-size: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.list-container {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
width: 100%;
|
||||||
|
max-width: 500px;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.routine-selection {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.routine-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.75rem;
|
||||||
|
padding: 0.75rem 1rem;
|
||||||
|
border: 2px solid var(--list-item-border-good);
|
||||||
|
border-radius: 8px;
|
||||||
|
background: var(--list-item-bg-good);
|
||||||
|
cursor: pointer;
|
||||||
|
transition:
|
||||||
|
background 0.2s,
|
||||||
|
border-color 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.routine-item:hover {
|
||||||
|
background: var(--list-item-bg-good-hover, var(--list-item-bg-good));
|
||||||
|
border-color: var(--list-item-border-good-hover, var(--list-item-border-good));
|
||||||
|
}
|
||||||
|
|
||||||
|
.routine-item input[type='checkbox'] {
|
||||||
|
cursor: pointer;
|
||||||
|
width: 1.25rem;
|
||||||
|
height: 1.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.routine-item img {
|
||||||
|
width: 2.5rem;
|
||||||
|
height: 2.5rem;
|
||||||
|
border-radius: 6px;
|
||||||
|
object-fit: cover;
|
||||||
|
}
|
||||||
|
|
||||||
|
.routine-item .name {
|
||||||
|
flex: 1;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.routine-item .value {
|
||||||
|
min-width: 60px;
|
||||||
|
text-align: right;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 1rem;
|
||||||
|
padding: 1rem;
|
||||||
|
width: 100%;
|
||||||
|
max-width: 500px;
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn {
|
||||||
|
padding: 0.5rem 1.5rem;
|
||||||
|
border: none;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary {
|
||||||
|
background: var(--btn-primary);
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary:hover:not(:disabled) {
|
||||||
|
opacity: 0.9;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary:disabled {
|
||||||
|
opacity: 0.6;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-secondary {
|
||||||
|
background: var(--btn-secondary);
|
||||||
|
color: var(--btn-secondary-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-secondary:hover {
|
||||||
|
opacity: 0.8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.round-btn {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: var(--btn-primary);
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
.round-btn:hover {
|
||||||
|
opacity: 0.8;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
126
frontend/src/components/child/RoutineConfirmDialog.vue
Normal file
126
frontend/src/components/child/RoutineConfirmDialog.vue
Normal file
@@ -0,0 +1,126 @@
|
|||||||
|
<template>
|
||||||
|
<ModalDialog v-if="show" @backdrop-click="$emit('cancel')">
|
||||||
|
<template #default>
|
||||||
|
<div class="confirm-dialog">
|
||||||
|
<img v-if="imageUrl" :src="imageUrl" alt="Routine" class="routine-image" />
|
||||||
|
<p class="message">Confirm routine</p>
|
||||||
|
<p class="routine-name">{{ routineName }}?</p>
|
||||||
|
<div v-if="items && items.length > 0" class="items-preview">
|
||||||
|
<p class="items-label">Items:</p>
|
||||||
|
<ul class="items-list">
|
||||||
|
<li v-for="item in items.slice(0, 3)" :key="item.id">{{ item.name }}</li>
|
||||||
|
<li v-if="items.length > 3" class="more-items">+ {{ items.length - 3 }} more</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
<div class="actions">
|
||||||
|
<button class="btn btn-primary" @click="$emit('confirm')">Confirm</button>
|
||||||
|
<button class="btn btn-secondary" @click="$emit('cancel')">Cancel</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</ModalDialog>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import ModalDialog from '../shared/ModalDialog.vue'
|
||||||
|
import type { RoutineItem } from '@/common/models'
|
||||||
|
|
||||||
|
defineProps<{
|
||||||
|
show: boolean
|
||||||
|
routineName: string
|
||||||
|
imageUrl?: string | null
|
||||||
|
items?: RoutineItem[]
|
||||||
|
}>()
|
||||||
|
|
||||||
|
defineEmits<{
|
||||||
|
confirm: []
|
||||||
|
cancel: []
|
||||||
|
}>()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.confirm-dialog {
|
||||||
|
text-align: center;
|
||||||
|
padding: 0.5rem;
|
||||||
|
}
|
||||||
|
.routine-image {
|
||||||
|
width: 72px;
|
||||||
|
height: 72px;
|
||||||
|
object-fit: cover;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: var(--info-image-bg);
|
||||||
|
margin-bottom: 0.75rem;
|
||||||
|
}
|
||||||
|
.message {
|
||||||
|
font-size: 1.1rem;
|
||||||
|
color: var(--dialog-message);
|
||||||
|
margin-bottom: 0.25rem;
|
||||||
|
}
|
||||||
|
.routine-name {
|
||||||
|
font-size: 1.3rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--dialog-child-name);
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
.items-preview {
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
text-align: left;
|
||||||
|
background: var(--form-bg);
|
||||||
|
padding: 0.75rem;
|
||||||
|
border-radius: 6px;
|
||||||
|
}
|
||||||
|
.items-label {
|
||||||
|
font-size: 0.9rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
.items-list {
|
||||||
|
list-style: none;
|
||||||
|
padding-left: 1rem;
|
||||||
|
margin: 0;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
.items-list li {
|
||||||
|
padding: 0.25rem 0;
|
||||||
|
}
|
||||||
|
.items-list li.more-items {
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
.actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 1.5rem;
|
||||||
|
justify-content: center;
|
||||||
|
margin-top: 1.5rem;
|
||||||
|
}
|
||||||
|
.actions button {
|
||||||
|
padding: 0.7rem 1.8rem;
|
||||||
|
border-radius: 10px;
|
||||||
|
border: 0;
|
||||||
|
cursor: pointer;
|
||||||
|
font-weight: 700;
|
||||||
|
font-size: 1.05rem;
|
||||||
|
transition: background 0.18s;
|
||||||
|
min-width: 100px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary {
|
||||||
|
background: var(--btn-primary);
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary:hover {
|
||||||
|
opacity: 0.9;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-secondary {
|
||||||
|
background: var(--btn-secondary);
|
||||||
|
color: var(--btn-secondary-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-secondary:hover {
|
||||||
|
opacity: 0.8;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
122
frontend/src/components/routine/RoutineEditView.vue
Normal file
122
frontend/src/components/routine/RoutineEditView.vue
Normal file
@@ -0,0 +1,122 @@
|
|||||||
|
<template>
|
||||||
|
<div class="view">
|
||||||
|
<EntityEditForm
|
||||||
|
entityLabel="Routine"
|
||||||
|
:fields="fields"
|
||||||
|
:initialData="initialData"
|
||||||
|
:isEdit="isEdit"
|
||||||
|
:loading="loading"
|
||||||
|
:error="error"
|
||||||
|
@submit="handleSubmit"
|
||||||
|
@cancel="handleCancel"
|
||||||
|
@add-image="handleAddImage"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, onMounted, computed, nextTick } from 'vue'
|
||||||
|
import { useRouter } from 'vue-router'
|
||||||
|
import EntityEditForm from '../shared/EntityEditForm.vue'
|
||||||
|
import '@/assets/styles.css'
|
||||||
|
|
||||||
|
const props = defineProps<{ id?: string }>()
|
||||||
|
const router = useRouter()
|
||||||
|
const isEdit = computed(() => !!props.id)
|
||||||
|
|
||||||
|
const fields = [
|
||||||
|
{ name: 'name', label: 'Routine Name', type: 'text' as const, required: true, maxlength: 64 },
|
||||||
|
{ name: 'points', label: 'Points', type: 'number' as const, required: true, min: 1, max: 1000 },
|
||||||
|
{ name: 'image_id', label: 'Image', type: 'image' as const, imageType: 4 },
|
||||||
|
]
|
||||||
|
|
||||||
|
const initialData = ref({ name: '', points: 1, image_id: null })
|
||||||
|
const localImageFile = ref<File | 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/routine/${props.id}`)
|
||||||
|
if (!resp.ok) throw new Error('Failed to load routine')
|
||||||
|
const data = await resp.json()
|
||||||
|
initialData.value = {
|
||||||
|
name: data.name ?? '',
|
||||||
|
points: Number(data.points) || 1,
|
||||||
|
image_id: data.image_id ?? null,
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
error.value = 'Could not load routine.'
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
await nextTick()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
function handleAddImage({ id, file }: { id: string; file: File }) {
|
||||||
|
if (id === 'local-upload') localImageFile.value = file
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSubmit(form: { name: string; points: number; image_id: string | null }) {
|
||||||
|
let imageId = form.image_id
|
||||||
|
error.value = null
|
||||||
|
if (!form.name.trim()) {
|
||||||
|
error.value = 'Routine name is required.'
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (form.points < 1) {
|
||||||
|
error.value = 'Points must be at least 1.'
|
||||||
|
return
|
||||||
|
}
|
||||||
|
loading.value = true
|
||||||
|
|
||||||
|
if (imageId === 'local-upload' && localImageFile.value) {
|
||||||
|
const formData = new FormData()
|
||||||
|
formData.append('file', localImageFile.value)
|
||||||
|
formData.append('type', '4')
|
||||||
|
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 {
|
||||||
|
error.value = 'Failed to upload image.'
|
||||||
|
loading.value = false
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const url = isEdit.value && props.id ? `/api/routine/${props.id}/edit` : '/api/routine/add'
|
||||||
|
const resp = await fetch(url, {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ name: form.name, points: form.points, image_id: imageId }),
|
||||||
|
})
|
||||||
|
if (!resp.ok) throw new Error('Failed to save routine')
|
||||||
|
await router.push({ name: 'RoutineView' })
|
||||||
|
} catch {
|
||||||
|
error.value = 'Failed to save routine.'
|
||||||
|
}
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleCancel() {
|
||||||
|
router.back()
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.view {
|
||||||
|
max-width: 400px;
|
||||||
|
margin: 0 auto;
|
||||||
|
background: var(--form-bg);
|
||||||
|
border-radius: 12px;
|
||||||
|
box-shadow: 0 4px 24px var(--form-shadow);
|
||||||
|
padding: 2rem 2.2rem 1.5rem 2.2rem;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
119
frontend/src/components/routine/RoutineView.vue
Normal file
119
frontend/src/components/routine/RoutineView.vue
Normal file
@@ -0,0 +1,119 @@
|
|||||||
|
<template>
|
||||||
|
<div class="routine-view">
|
||||||
|
<MessageBlock v-if="countRef === 0" message="No routines">
|
||||||
|
<span> <button class="round-btn" @click="create">Create</button> a routine </span>
|
||||||
|
</MessageBlock>
|
||||||
|
|
||||||
|
<ItemList
|
||||||
|
v-else
|
||||||
|
ref="listRef"
|
||||||
|
fetchUrl="/api/routine/list"
|
||||||
|
itemKey="routines"
|
||||||
|
:itemFields="ROUTINE_FIELDS"
|
||||||
|
imageField="image_id"
|
||||||
|
deletable
|
||||||
|
@clicked="(item: Routine) => $router.push({ name: 'EditRoutine', params: { id: item.id } })"
|
||||||
|
@delete="confirmDelete"
|
||||||
|
@loading-complete="(count) => (countRef = count)"
|
||||||
|
:getItemClass="() => ({ good: true })"
|
||||||
|
>
|
||||||
|
<template #item="{ item }">
|
||||||
|
<img v-if="item.image_url" :src="item.image_url" />
|
||||||
|
<span class="name">{{ item.name }}</span>
|
||||||
|
<span class="value">{{ item.points }} pts</span>
|
||||||
|
</template>
|
||||||
|
</ItemList>
|
||||||
|
|
||||||
|
<FloatingActionButton aria-label="Create Routine" @click="create" />
|
||||||
|
|
||||||
|
<DeleteModal
|
||||||
|
:show="showConfirm"
|
||||||
|
message="Are you sure you want to delete this routine?"
|
||||||
|
@confirm="deleteItem"
|
||||||
|
@cancel="showConfirm = false"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, onMounted, onUnmounted } from 'vue'
|
||||||
|
import { useRouter } from 'vue-router'
|
||||||
|
import ItemList from '../shared/ItemList.vue'
|
||||||
|
import MessageBlock from '@/components/shared/MessageBlock.vue'
|
||||||
|
import FloatingActionButton from '../shared/FloatingActionButton.vue'
|
||||||
|
import DeleteModal from '../shared/DeleteModal.vue'
|
||||||
|
import type { Routine } from '@/common/models'
|
||||||
|
import { ROUTINE_FIELDS } from '@/common/models'
|
||||||
|
import { eventBus } from '@/common/eventBus'
|
||||||
|
|
||||||
|
const $router = useRouter()
|
||||||
|
const showConfirm = ref(false)
|
||||||
|
const itemToDelete = ref<string | null>(null)
|
||||||
|
const listRef = ref()
|
||||||
|
const countRef = ref<number>(-1)
|
||||||
|
|
||||||
|
function handleModified() {
|
||||||
|
listRef.value?.refresh()
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
eventBus.on('routine_modified', handleModified)
|
||||||
|
})
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
eventBus.off('routine_modified', handleModified)
|
||||||
|
})
|
||||||
|
|
||||||
|
function confirmDelete(id: string) {
|
||||||
|
itemToDelete.value = id
|
||||||
|
showConfirm.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
const deleteItem = async () => {
|
||||||
|
const id =
|
||||||
|
typeof itemToDelete.value === 'object' && itemToDelete.value !== null
|
||||||
|
? (itemToDelete.value as any).id
|
||||||
|
: itemToDelete.value
|
||||||
|
if (!id) return
|
||||||
|
try {
|
||||||
|
const resp = await fetch(`/api/routine/${id}`, { method: 'DELETE' })
|
||||||
|
if (!resp.ok) throw new Error(`HTTP ${resp.status}`)
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to delete routine:', err)
|
||||||
|
} finally {
|
||||||
|
showConfirm.value = false
|
||||||
|
itemToDelete.value = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const create = () => {
|
||||||
|
$router.push({ name: 'CreateRoutine' })
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.routine-view {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
flex: 1 1 auto;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
padding: 0;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
.name {
|
||||||
|
flex: 1;
|
||||||
|
text-align: left;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
.value {
|
||||||
|
min-width: 60px;
|
||||||
|
text-align: right;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
:deep(.good) {
|
||||||
|
border-color: var(--list-item-border-good);
|
||||||
|
background: var(--list-item-bg-good);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
<template>
|
<template>
|
||||||
<ModalDialog :image-url="task.image_url" :title="'Schedule Chore'" :subtitle="task.name">
|
<ModalDialog :image-url="entity.image_url" :title="scheduleTitle" :subtitle="entity.name">
|
||||||
<!-- Enable/disable toggle row -->
|
<!-- Enable/disable toggle row -->
|
||||||
<div class="schedule-toggle-row">
|
<div class="schedule-toggle-row">
|
||||||
<span class="toggle-label">{{ scheduleEnabled ? 'Enabled' : 'Paused' }}</span>
|
<span class="toggle-label">{{ scheduleEnabled ? 'Enabled' : 'Paused' }}</span>
|
||||||
@@ -165,8 +165,20 @@ import { ref, computed } from 'vue'
|
|||||||
import ModalDialog from './ModalDialog.vue'
|
import ModalDialog from './ModalDialog.vue'
|
||||||
import TimePickerPopover from './TimePickerPopover.vue'
|
import TimePickerPopover from './TimePickerPopover.vue'
|
||||||
import DateInputField from './DateInputField.vue'
|
import DateInputField from './DateInputField.vue'
|
||||||
import { setChoreSchedule, deleteChoreSchedule, parseErrorResponse } from '@/common/api'
|
import {
|
||||||
import type { ChildTask, ChoreSchedule, DayConfig } from '@/common/models'
|
setChoreSchedule,
|
||||||
|
deleteChoreSchedule,
|
||||||
|
setRoutineSchedule,
|
||||||
|
deleteRoutineSchedule,
|
||||||
|
parseErrorResponse,
|
||||||
|
} from '@/common/api'
|
||||||
|
import type {
|
||||||
|
ChildTask,
|
||||||
|
ChildRoutine,
|
||||||
|
ChoreSchedule,
|
||||||
|
RoutineSchedule,
|
||||||
|
DayConfig,
|
||||||
|
} from '@/common/models'
|
||||||
|
|
||||||
interface TimeValue {
|
interface TimeValue {
|
||||||
hour: number
|
hour: number
|
||||||
@@ -174,11 +186,16 @@ interface TimeValue {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
task: ChildTask
|
entity: ChildTask | ChildRoutine
|
||||||
|
entityType: 'task' | 'routine'
|
||||||
childId: string
|
childId: string
|
||||||
schedule: ChoreSchedule | null
|
schedule: ChoreSchedule | RoutineSchedule | null
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
|
const scheduleTitle = computed(() =>
|
||||||
|
props.entityType === 'task' ? 'Schedule Chore' : 'Schedule Routine',
|
||||||
|
)
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
(e: 'saved'): void
|
(e: 'saved'): void
|
||||||
(e: 'cancelled'): void
|
(e: 'cancelled'): void
|
||||||
@@ -386,10 +403,20 @@ async function save() {
|
|||||||
errorMsg.value = null
|
errorMsg.value = null
|
||||||
saving.value = true
|
saving.value = true
|
||||||
|
|
||||||
|
const entityId = props.entity.id
|
||||||
|
const saveEntitySchedule = (payload: object) =>
|
||||||
|
props.entityType === 'task'
|
||||||
|
? setChoreSchedule(props.childId, entityId, payload)
|
||||||
|
: setRoutineSchedule(props.childId, entityId, payload)
|
||||||
|
const deleteEntitySchedule = () =>
|
||||||
|
props.entityType === 'task'
|
||||||
|
? deleteChoreSchedule(props.childId, entityId)
|
||||||
|
: deleteRoutineSchedule(props.childId, entityId)
|
||||||
|
|
||||||
let res: Response
|
let res: Response
|
||||||
if (mode.value === 'days' && selectedDays.value.size === 0) {
|
if (mode.value === 'days' && selectedDays.value.size === 0) {
|
||||||
// 0 days = remove schedule entirely → chore becomes always active
|
// 0 days = remove schedule entirely so the item becomes always active
|
||||||
res = await deleteChoreSchedule(props.childId, props.task.id)
|
res = await deleteEntitySchedule()
|
||||||
} else if (mode.value === 'days') {
|
} else if (mode.value === 'days') {
|
||||||
const day_configs: DayConfig[] = Array.from(selectedDays.value).map((day) => {
|
const day_configs: DayConfig[] = Array.from(selectedDays.value).map((day) => {
|
||||||
const override = exceptions.value.get(day)
|
const override = exceptions.value.get(day)
|
||||||
@@ -399,7 +426,7 @@ async function save() {
|
|||||||
minute: override?.minute ?? defaultTime.value.minute,
|
minute: override?.minute ?? defaultTime.value.minute,
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
res = await setChoreSchedule(props.childId, props.task.id, {
|
res = await saveEntitySchedule({
|
||||||
mode: 'days',
|
mode: 'days',
|
||||||
enabled: scheduleEnabled.value,
|
enabled: scheduleEnabled.value,
|
||||||
day_configs,
|
day_configs,
|
||||||
@@ -408,7 +435,7 @@ async function save() {
|
|||||||
default_has_deadline: hasDefaultDeadline.value,
|
default_has_deadline: hasDefaultDeadline.value,
|
||||||
})
|
})
|
||||||
} else {
|
} else {
|
||||||
res = await setChoreSchedule(props.childId, props.task.id, {
|
res = await saveEntitySchedule({
|
||||||
mode: 'interval',
|
mode: 'interval',
|
||||||
enabled: scheduleEnabled.value,
|
enabled: scheduleEnabled.value,
|
||||||
interval_days: intervalDays.value,
|
interval_days: intervalDays.value,
|
||||||
|
|||||||
@@ -19,6 +19,12 @@
|
|||||||
>
|
>
|
||||||
Penalties
|
Penalties
|
||||||
</button>
|
</button>
|
||||||
|
<button
|
||||||
|
:class="{ active: activeTab === 'routines' }"
|
||||||
|
@click="$router.push({ name: 'RoutineView' })"
|
||||||
|
>
|
||||||
|
Routines
|
||||||
|
</button>
|
||||||
</nav>
|
</nav>
|
||||||
<div class="sub-content">
|
<div class="sub-content">
|
||||||
<router-view :key="$route.fullPath" />
|
<router-view :key="$route.fullPath" />
|
||||||
@@ -34,6 +40,8 @@ const route = useRoute()
|
|||||||
|
|
||||||
const activeTab = computed(() => {
|
const activeTab = computed(() => {
|
||||||
const name = String(route.name)
|
const name = String(route.name)
|
||||||
|
if (name.startsWith('Routine') || name === 'CreateRoutine' || name === 'EditRoutine')
|
||||||
|
return 'routines'
|
||||||
if (name.startsWith('Kindness') || name === 'CreateKindness' || name === 'EditKindness')
|
if (name.startsWith('Kindness') || name === 'CreateKindness' || name === 'EditKindness')
|
||||||
return 'kindness'
|
return 'kindness'
|
||||||
if (name.startsWith('Penalty') || name === 'CreatePenalty' || name === 'EditPenalty')
|
if (name.startsWith('Penalty') || name === 'CreatePenalty' || name === 'EditPenalty')
|
||||||
|
|||||||
@@ -9,15 +9,18 @@ import TaskSubNav from '../components/task/TaskSubNav.vue'
|
|||||||
import ChoreView from '../components/task/ChoreView.vue'
|
import ChoreView from '../components/task/ChoreView.vue'
|
||||||
import KindnessView from '../components/task/KindnessView.vue'
|
import KindnessView from '../components/task/KindnessView.vue'
|
||||||
import PenaltyView from '../components/task/PenaltyView.vue'
|
import PenaltyView from '../components/task/PenaltyView.vue'
|
||||||
|
import RoutineView from '../components/routine/RoutineView.vue'
|
||||||
import ChoreEditView from '@/components/task/ChoreEditView.vue'
|
import ChoreEditView from '@/components/task/ChoreEditView.vue'
|
||||||
import KindnessEditView from '@/components/task/KindnessEditView.vue'
|
import KindnessEditView from '@/components/task/KindnessEditView.vue'
|
||||||
import PenaltyEditView from '@/components/task/PenaltyEditView.vue'
|
import PenaltyEditView from '@/components/task/PenaltyEditView.vue'
|
||||||
|
import RoutineEditView from '@/components/routine/RoutineEditView.vue'
|
||||||
import RewardView from '../components/reward/RewardView.vue'
|
import RewardView from '../components/reward/RewardView.vue'
|
||||||
import RewardEditView from '@/components/reward/RewardEditView.vue'
|
import RewardEditView from '@/components/reward/RewardEditView.vue'
|
||||||
import ChildEditView from '@/components/child/ChildEditView.vue'
|
import ChildEditView from '@/components/child/ChildEditView.vue'
|
||||||
import ChoreAssignView from '@/components/child/ChoreAssignView.vue'
|
import ChoreAssignView from '@/components/child/ChoreAssignView.vue'
|
||||||
import KindnessAssignView from '@/components/child/KindnessAssignView.vue'
|
import KindnessAssignView from '@/components/child/KindnessAssignView.vue'
|
||||||
import PenaltyAssignView from '@/components/child/PenaltyAssignView.vue'
|
import PenaltyAssignView from '@/components/child/PenaltyAssignView.vue'
|
||||||
|
import RoutineAssignView from '@/components/child/RoutineAssignView.vue'
|
||||||
import RewardAssignView from '@/components/child/RewardAssignView.vue'
|
import RewardAssignView from '@/components/child/RewardAssignView.vue'
|
||||||
import NotificationView from '@/components/notification/NotificationView.vue'
|
import NotificationView from '@/components/notification/NotificationView.vue'
|
||||||
import AuthLayout from '@/layout/AuthLayout.vue'
|
import AuthLayout from '@/layout/AuthLayout.vue'
|
||||||
@@ -139,6 +142,11 @@ const routes = [
|
|||||||
name: 'PenaltyView',
|
name: 'PenaltyView',
|
||||||
component: PenaltyView,
|
component: PenaltyView,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: 'routines',
|
||||||
|
name: 'RoutineView',
|
||||||
|
component: RoutineView,
|
||||||
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -174,6 +182,17 @@ const routes = [
|
|||||||
component: PenaltyEditView,
|
component: PenaltyEditView,
|
||||||
props: true,
|
props: true,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: 'tasks/routines/create',
|
||||||
|
name: 'CreateRoutine',
|
||||||
|
component: RoutineEditView,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'tasks/routines/:id/edit',
|
||||||
|
name: 'EditRoutine',
|
||||||
|
component: RoutineEditView,
|
||||||
|
props: true,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: 'rewards',
|
path: 'rewards',
|
||||||
name: 'RewardView',
|
name: 'RewardView',
|
||||||
@@ -215,6 +234,12 @@ const routes = [
|
|||||||
component: RewardAssignView,
|
component: RewardAssignView,
|
||||||
props: true,
|
props: true,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: ':id/assign-routines',
|
||||||
|
name: 'RoutineAssignView',
|
||||||
|
component: RoutineAssignView,
|
||||||
|
props: true,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: 'notifications',
|
path: 'notifications',
|
||||||
name: 'NotificationView',
|
name: 'NotificationView',
|
||||||
|
|||||||
Reference in New Issue
Block a user