diff --git a/.github/specs/feat-notify-ending-chore.md b/.github/specs/archive/feat-notify-ending-chore.md similarity index 100% rename from .github/specs/feat-notify-ending-chore.md rename to .github/specs/archive/feat-notify-ending-chore.md diff --git a/backend/api/child_api.py b/backend/api/child_api.py index 06bb185..225bd20 100644 --- a/backend/api/child_api.py +++ b/backend/api/child_api.py @@ -10,7 +10,7 @@ from api.pending_confirmation import PendingConfirmationResponse from api.reward_status import RewardStatus from api.utils import send_event_for_current_user, get_validated_user_id 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.child_overrides import get_override, delete_override, delete_overrides_for_child 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 db.chore_schedules import get_schedule 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 child_api = Blueprint('child_api', __name__) @@ -163,6 +165,14 @@ def delete_child(id): deleted_count = delete_overrides_for_child(id) if deleted_count > 0: 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)): 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() TaskQuery = Query() + RoutineQuery = Query() ChildQuery = Query() for pr in pending_items: @@ -1122,8 +1133,12 @@ def list_pending_confirmations(): # Look up entity details based on type if pending.entity_type == 'reward': 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))) + 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: continue diff --git a/backend/api/child_override_api.py b/backend/api/child_override_api.py index 1ca898f..41992e3 100644 --- a/backend/api/child_override_api.py +++ b/backend/api/child_override_api.py @@ -2,7 +2,7 @@ from flask import Blueprint, request, jsonify from tinydb import Query from api.utils import get_validated_user_id, send_event_for_current_user from api.error_codes import ErrorCodes -from db.db import child_db, task_db, reward_db +from db.db import child_db, task_db, reward_db, routine_db from db.child_overrides import ( insert_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 # Validate entity_type - if entity_type not in ['task', 'reward']: - return jsonify({'error': 'entity_type must be "task" or "reward"', 'code': ErrorCodes.INVALID_VALUE, 'field': 'entity_type'}), 400 + if entity_type not in ['task', 'reward', 'routine']: + return jsonify({'error': 'entity_type must be "task", "reward", or "routine"', 'code': ErrorCodes.INVALID_VALUE, 'field': 'entity_type'}), 400 # Validate custom_value range 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: return jsonify({'error': 'Task not assigned to child', 'code': ErrorCodes.ENTITY_NOT_ASSIGNED}), 404 - else: # reward + elif entity_type == 'reward': EntityQuery = Query() entity_result = reward_db.search( (EntityQuery.id == entity_id) & @@ -87,6 +87,19 @@ def set_child_override(child_id): assigned_rewards = child_dict.get('rewards', []) if entity_id not in assigned_rewards: 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 try: diff --git a/backend/api/child_routine_api.py b/backend/api/child_routine_api.py new file mode 100644 index 0000000..26479b5 --- /dev/null +++ b/backend/api/child_routine_api.py @@ -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//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//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//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//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//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//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//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//approve-routine/', 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//reject-routine/', 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//reset-routine/', 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 diff --git a/backend/api/routine_api.py b/backend/api/routine_api.py new file mode 100644 index 0000000..44d352c --- /dev/null +++ b/backend/api/routine_api.py @@ -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/', 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//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/', 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 diff --git a/backend/api/routine_item_api.py b/backend/api/routine_item_api.py new file mode 100644 index 0000000..ccba31e --- /dev/null +++ b/backend/api/routine_item_api.py @@ -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//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//item//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//item/', 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//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 diff --git a/backend/api/routine_schedule_api.py b/backend/api/routine_schedule_api.py new file mode 100644 index 0000000..a697443 --- /dev/null +++ b/backend/api/routine_schedule_api.py @@ -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//routine//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//routine//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//routine//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//routine//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 diff --git a/backend/db/db.py b/backend/db/db.py index 316326c..ff5ca72 100644 --- a/backend/db/db.py +++ b/backend/db/db.py @@ -69,6 +69,10 @@ class LockedTable: child_path = os.path.join(base_dir, 'children.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') image_path = os.path.join(base_dir, 'images.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 _child_db = TinyDB(child_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) _image_db = TinyDB(image_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 child_db = LockedTable(_child_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) image_db = LockedTable(_image_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': child_db.truncate() task_db.truncate() + routine_db.truncate() + routine_items_db.truncate() + routine_schedules_db.truncate() + routine_extensions_db.truncate() reward_db.truncate() image_db.truncate() pending_reward_db.truncate() diff --git a/backend/db/routine_extensions.py b/backend/db/routine_extensions.py new file mode 100644 index 0000000..12d24b6 --- /dev/null +++ b/backend/db/routine_extensions.py @@ -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) diff --git a/backend/db/routine_items.py b/backend/db/routine_items.py new file mode 100644 index 0000000..bb06d15 --- /dev/null +++ b/backend/db/routine_items.py @@ -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) diff --git a/backend/db/routine_schedules.py b/backend/db/routine_schedules.py new file mode 100644 index 0000000..cd12a56 --- /dev/null +++ b/backend/db/routine_schedules.py @@ -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) diff --git a/backend/db/routines.py b/backend/db/routines.py new file mode 100644 index 0000000..d33e7a3 --- /dev/null +++ b/backend/db/routines.py @@ -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] diff --git a/backend/events/types/child_routine_confirmation.py b/backend/events/types/child_routine_confirmation.py new file mode 100644 index 0000000..f615e30 --- /dev/null +++ b/backend/events/types/child_routine_confirmation.py @@ -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 + }) diff --git a/backend/events/types/child_routines_set.py b/backend/events/types/child_routines_set.py new file mode 100644 index 0000000..9a6b782 --- /dev/null +++ b/backend/events/types/child_routines_set.py @@ -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 + }) diff --git a/backend/events/types/event_types.py b/backend/events/types/event_types.py index ebf5d5a..695a263 100644 --- a/backend/events/types/event_types.py +++ b/backend/events/types/event_types.py @@ -28,4 +28,10 @@ class EventType(Enum): CHORE_TIME_EXTENDED = "chore_time_extended" 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" diff --git a/backend/events/types/routine_modified.py b/backend/events/types/routine_modified.py new file mode 100644 index 0000000..357ff8b --- /dev/null +++ b/backend/events/types/routine_modified.py @@ -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 + }) diff --git a/backend/events/types/routine_schedule_modified.py b/backend/events/types/routine_schedule_modified.py new file mode 100644 index 0000000..e4c0a21 --- /dev/null +++ b/backend/events/types/routine_schedule_modified.py @@ -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, + }) diff --git a/backend/events/types/routine_time_extended.py b/backend/events/types/routine_time_extended.py new file mode 100644 index 0000000..000dfab --- /dev/null +++ b/backend/events/types/routine_time_extended.py @@ -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, + }) diff --git a/backend/main.py b/backend/main.py index cb403b8..52d4f1e 100644 --- a/backend/main.py +++ b/backend/main.py @@ -14,6 +14,10 @@ from api.image_api import image_api from api.kindness_api import kindness_api from api.penalty_api import penalty_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.tracking_api import tracking_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(penalty_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(image_api) app.register_blueprint(auth_api, url_prefix='/auth') diff --git a/backend/models/child.py b/backend/models/child.py index 4b22e16..14414b3 100644 --- a/backend/models/child.py +++ b/backend/models/child.py @@ -6,6 +6,7 @@ class Child(BaseModel): name: str age: int | None = None tasks: list[str] = field(default_factory=list) + routines: list[str] = field(default_factory=list) rewards: list[str] = field(default_factory=list) points: int = 0 image_id: str | None = None @@ -17,6 +18,7 @@ class Child(BaseModel): name=d.get('name'), age=d.get('age'), tasks=d.get('tasks', []), + routines=d.get('routines', []), rewards=d.get('rewards', []), points=d.get('points', 0), image_id=d.get('image_id'), @@ -32,6 +34,7 @@ class Child(BaseModel): 'name': self.name, 'age': self.age, 'tasks': self.tasks, + 'routines': self.routines, 'rewards': self.rewards, 'points': self.points, 'image_id': self.image_id, diff --git a/backend/models/child_override.py b/backend/models/child_override.py index 48342f0..23a5476 100644 --- a/backend/models/child_override.py +++ b/backend/models/child_override.py @@ -16,15 +16,15 @@ class ChildOverride(BaseModel): """ child_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 def __post_init__(self): """Validate custom_value range and entity_type.""" if self.custom_value < 0 or self.custom_value > 10000: raise ValueError("custom_value must be between 0 and 10000") - if self.entity_type not in ['task', 'reward', 'chore', 'kindness', 'penalty']: - raise ValueError("entity_type must be 'task', 'reward', 'chore', 'kindness', or 'penalty'") + if self.entity_type not in ['task', 'reward', 'chore', 'kindness', 'penalty', 'routine']: + raise ValueError("entity_type must be 'task', 'reward', 'chore', 'kindness', 'penalty', or 'routine'") @classmethod def from_dict(cls, d: dict): @@ -52,7 +52,7 @@ class ChildOverride(BaseModel): def create_override( child_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 ) -> 'ChildOverride': """Factory method to create a new override.""" diff --git a/backend/models/pending_confirmation.py b/backend/models/pending_confirmation.py index a56425c..55dd85c 100644 --- a/backend/models/pending_confirmation.py +++ b/backend/models/pending_confirmation.py @@ -3,7 +3,7 @@ from typing import Literal, Optional from models.base import BaseModel -PendingEntityType = Literal['chore', 'reward'] +PendingEntityType = Literal['chore', 'reward', 'routine'] PendingStatus = Literal['pending', 'approved', 'rejected'] diff --git a/backend/models/routine.py b/backend/models/routine.py new file mode 100644 index 0000000..38d6a1c --- /dev/null +++ b/backend/models/routine.py @@ -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 diff --git a/backend/models/routine_extension.py b/backend/models/routine_extension.py new file mode 100644 index 0000000..dfd0389 --- /dev/null +++ b/backend/models/routine_extension.py @@ -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 diff --git a/backend/models/routine_item.py b/backend/models/routine_item.py new file mode 100644 index 0000000..4e0beb1 --- /dev/null +++ b/backend/models/routine_item.py @@ -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 diff --git a/backend/models/routine_schedule.py b/backend/models/routine_schedule.py new file mode 100644 index 0000000..d892e74 --- /dev/null +++ b/backend/models/routine_schedule.py @@ -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 diff --git a/backend/tests/test_routine_feature_api.py b/backend/tests/test_routine_feature_api.py new file mode 100644 index 0000000..2e512e8 --- /dev/null +++ b/backend/tests/test_routine_feature_api.py @@ -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 diff --git a/frontend/e2e/plans/routines-feature.plan.md b/frontend/e2e/plans/routines-feature.plan.md new file mode 100644 index 0000000..5a63dde --- /dev/null +++ b/frontend/e2e/plans/routines-feature.plan.md @@ -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'. diff --git a/frontend/src/__tests__/ScheduleModal.spec.ts b/frontend/src/__tests__/ScheduleModal.spec.ts index 8589909..4f972f6 100644 --- a/frontend/src/__tests__/ScheduleModal.spec.ts +++ b/frontend/src/__tests__/ScheduleModal.spec.ts @@ -2,17 +2,21 @@ import { describe, it, expect, vi, beforeEach } from 'vitest' import { mount } from '@vue/test-utils' import { nextTick } from 'vue' import ScheduleModal from '../components/shared/ScheduleModal.vue' -import type { ChildTask, ChoreSchedule } from '../common/models' +import type { ChildTask, ChoreSchedule, RoutineSchedule } from '../common/models' // --------------------------------------------------------------------------- // Mocks // --------------------------------------------------------------------------- const mockSetChoreSchedule = vi.fn() const mockDeleteChoreSchedule = vi.fn() +const mockSetRoutineSchedule = vi.fn() +const mockDeleteRoutineSchedule = vi.fn() vi.mock('@/common/api', () => ({ setChoreSchedule: (...args: unknown[]) => mockSetChoreSchedule(...args), deleteChoreSchedule: (...args: unknown[]) => mockDeleteChoreSchedule(...args), + setRoutineSchedule: (...args: unknown[]) => mockSetRoutineSchedule(...args), + deleteRoutineSchedule: (...args: unknown[]) => mockDeleteRoutineSchedule(...args), 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 CHILD_ID = 'child-1' -function mountModal(schedule: ChoreSchedule | null = null) { +function mountModal(schedule: ChoreSchedule | RoutineSchedule | null = null) { return mount(ScheduleModal, { - props: { task: TASK, childId: CHILD_ID, schedule }, + props: { entity: TASK, entityType: 'task', childId: CHILD_ID, schedule }, global: { stubs: { ModalDialog: ModalDialogStub, @@ -55,8 +59,12 @@ function mountModal(schedule: ChoreSchedule | null = null) { beforeEach(() => { mockSetChoreSchedule.mockReset() mockDeleteChoreSchedule.mockReset() + mockSetRoutineSchedule.mockReset() + mockDeleteRoutineSchedule.mockReset() mockSetChoreSchedule.mockResolvedValue({ ok: true }) mockDeleteChoreSchedule.mockResolvedValue({ ok: true }) + mockSetRoutineSchedule.mockResolvedValue({ ok: true }) + mockDeleteRoutineSchedule.mockResolvedValue({ ok: true }) }) // --------------------------------------------------------------------------- // Mode toggle @@ -488,3 +496,39 @@ describe('ScheduleModal backdrop', () => { 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() + }) +}) diff --git a/frontend/src/common/__tests__/api.routine.spec.ts b/frontend/src/common/__tests__/api.routine.spec.ts new file mode 100644 index 0000000..3018d7e --- /dev/null +++ b/frontend/src/common/__tests__/api.routine.spec.ts @@ -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 }), + }) + }) +}) diff --git a/frontend/src/common/api.ts b/frontend/src/common/api.ts index 4e86c6b..9729bef 100644 --- a/frontend/src/common/api.ts +++ b/frontend/src/common/api.ts @@ -168,7 +168,7 @@ export async function getTrackingEventsForChild(params: { export async function setChildOverride( childId: string, entityId: string, - entityType: 'task' | 'reward', + entityType: 'task' | 'reward' | 'routine', customValue: number, ): Promise { 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 { + return setChildOverride(childId, routineId, 'routine', customValue) +} + /** * 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 { + 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 { + 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 { + return fetch(`/api/child/${childId}/routine/${routineId}/schedule`, { + method: 'DELETE', + }) +} + /** * Extend a timed-out chore for the remainder of today only. * `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 { + return fetch(`/api/child/${childId}/routine/${routineId}/extend`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ date: localDate }), + }) +} + // ── Chore Confirmation API ────────────────────────────────────────────────── /** @@ -310,3 +365,28 @@ export async function resetChore(childId: string, taskId: string): Promise { return fetch('/api/pending-confirmations') } + +/** + * Child confirms they completed a routine. + */ +export async function confirmRoutine(childId: string, routineId: string): Promise { + 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 { + return fetch(`/api/child/${childId}/cancel-routine-confirmation`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ routine_id: routineId }), + }) +} diff --git a/frontend/src/common/models.ts b/frontend/src/common/models.ts index e3c33e1..ae49cbf 100644 --- a/frontend/src/common/models.ts +++ b/frontend/src/common/models.ts @@ -37,6 +37,56 @@ export interface ChoreSchedule { 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 { id: string name: string @@ -72,12 +122,13 @@ export interface Child { name: string age: number tasks: string[] + routines?: string[] rewards: string[] points: number image_id: string | null 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 { id: string @@ -114,7 +165,7 @@ export interface PendingConfirmation { child_image_id: string | null child_image_url?: string | null entity_id: string - entity_type: 'chore' | 'reward' + entity_type: 'chore' | 'reward' | 'routine' entity_name: string entity_image_id: string | null entity_image_url?: string | null @@ -151,6 +202,11 @@ export interface Event { | ChoreScheduleModifiedPayload | ChoreTimeExtendedPayload | ChildChoreConfirmationPayload + | RoutineModifiedEventPayload + | ChildRoutinesSetEventPayload + | RoutineScheduleModifiedPayload + | RoutineTimeExtendedPayload + | ChildRoutineConfirmationPayload } export interface ChildModifiedEventPayload { @@ -194,6 +250,16 @@ export interface RewardModifiedEventPayload { 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 { tracking_event_id: string child_id: string @@ -211,7 +277,7 @@ export interface ChildOverrideDeletedPayload { entity_type: string } -export type EntityType = 'task' | 'reward' | 'penalty' | 'chore' | 'kindness' +export type EntityType = 'task' | 'reward' | 'penalty' | 'chore' | 'kindness' | 'routine' export type ActionType = | 'activated' | 'requested' @@ -254,7 +320,7 @@ export const TRACKING_EVENT_FIELDS = [ 'metadata', ] as const -export type OverrideEntityType = 'task' | 'reward' | 'chore' | 'kindness' | 'penalty' +export type OverrideEntityType = 'task' | 'reward' | 'chore' | 'kindness' | 'penalty' | 'routine' export interface ChildOverride { id: string @@ -292,3 +358,20 @@ export interface ChildChoreConfirmationPayload { task_id: string 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' +} diff --git a/frontend/src/components/child/ChildView.vue b/frontend/src/components/child/ChildView.vue index 63f7ca5..d8ee579 100644 --- a/frontend/src/components/child/ChildView.vue +++ b/frontend/src/components/child/ChildView.vue @@ -6,6 +6,7 @@ import ScrollingList from '../shared/ScrollingList.vue' import StatusMessage from '../shared/StatusMessage.vue' import RewardConfirmDialog from './RewardConfirmDialog.vue' import ChoreConfirmDialog from './ChoreConfirmDialog.vue' +import RoutineConfirmDialog from './RoutineConfirmDialog.vue' import ModalDialog from '../shared/ModalDialog.vue' import { eventBus } from '@/common/eventBus' //import '@/assets/view-shared.css' @@ -16,17 +17,22 @@ import type { Task, RewardStatus, ChildTask, + ChildRoutine, ChildTaskTriggeredEventPayload, ChildRewardTriggeredEventPayload, ChildRewardRequestEventPayload, ChildTasksSetEventPayload, ChildRewardsSetEventPayload, + ChildRoutinesSetEventPayload, TaskModifiedEventPayload, RewardModifiedEventPayload, + RoutineModifiedEventPayload, ChildModifiedEventPayload, ChoreScheduleModifiedPayload, ChoreTimeExtendedPayload, + RoutineScheduleModifiedPayload, ChildChoreConfirmationPayload, + ChildRoutineConfirmationPayload, ChildOverrideSetPayload, ChildOverrideDeletedPayload, } from '@/common/models' @@ -46,6 +52,7 @@ const router = useRouter() const child = ref(null) const tasks = ref([]) +const routines = ref([]) const rewards = ref([]) const loading = ref(true) const error = ref(null) @@ -56,6 +63,9 @@ const dialogReward = ref(null) const showChoreConfirmDialog = ref(false) const showChoreCancelDialog = ref(false) const dialogChore = ref(null) +const showRoutineConfirmDialog = ref(false) +const dialogRoutine = ref(null) +const childRoutineListRef = ref() function handleTaskTriggered(event: Event) { 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) { const payload = event.payload as ChildRewardRequestEventPayload 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. } +const handleRoutineClick = (routine: ChildRoutine) => { + // Show routine confirmation dialog + dialogRoutine.value = routine + setTimeout(() => { + showRoutineConfirmDialog.value = true + }, 150) +} + function isChoreCompletedToday(item: ChildTask): boolean { if (item.pending_status !== 'approved' || !item.approved_at) return false const approvedDate = new Date(item.approved_at) @@ -273,6 +298,30 @@ function closeChoreCancelDialog() { 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) => { // Cancel any pending speech to avoid conflicts if ('speechSynthesis' in window) { @@ -545,6 +594,7 @@ onMounted(async () => { eventBus.on('child_reward_triggered', handleRewardTriggered) eventBus.on('child_tasks_set', handleChildTaskSet) eventBus.on('child_rewards_set', handleChildRewardSet) + eventBus.on('child_routines_set', handleChildRoutineSet) eventBus.on('task_modified', handleTaskModified) eventBus.on('reward_modified', handleRewardModified) eventBus.on('child_modified', handleChildModified) @@ -563,6 +613,7 @@ onMounted(async () => { if (data) { child.value = data tasks.value = data.tasks || [] + routines.value = data.routines || [] rewards.value = data.rewards || [] } loading.value = false @@ -582,6 +633,7 @@ onUnmounted(() => { eventBus.off('child_reward_triggered', handleRewardTriggered) eventBus.off('child_tasks_set', handleChildTaskSet) eventBus.off('child_rewards_set', handleChildRewardSet) + eventBus.off('child_routines_set', handleChildRoutineSet) eventBus.off('task_modified', handleTaskModified) eventBus.off('reward_modified', handleRewardModified) eventBus.off('child_modified', handleChildModified) @@ -731,6 +783,49 @@ onUnmounted(() => { + + + @@ -785,6 +880,16 @@ onUnmounted(() => { + + + diff --git a/frontend/src/components/child/RoutineConfirmDialog.vue b/frontend/src/components/child/RoutineConfirmDialog.vue new file mode 100644 index 0000000..c7ecb6d --- /dev/null +++ b/frontend/src/components/child/RoutineConfirmDialog.vue @@ -0,0 +1,126 @@ + + + + + diff --git a/frontend/src/components/routine/RoutineEditView.vue b/frontend/src/components/routine/RoutineEditView.vue new file mode 100644 index 0000000..5421575 --- /dev/null +++ b/frontend/src/components/routine/RoutineEditView.vue @@ -0,0 +1,122 @@ + + + + + diff --git a/frontend/src/components/routine/RoutineView.vue b/frontend/src/components/routine/RoutineView.vue new file mode 100644 index 0000000..9c88cff --- /dev/null +++ b/frontend/src/components/routine/RoutineView.vue @@ -0,0 +1,119 @@ + + + + + diff --git a/frontend/src/components/shared/ScheduleModal.vue b/frontend/src/components/shared/ScheduleModal.vue index a352c3d..2f3c549 100644 --- a/frontend/src/components/shared/ScheduleModal.vue +++ b/frontend/src/components/shared/ScheduleModal.vue @@ -1,5 +1,5 @@