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

- 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:
2026-05-05 09:08:19 -04:00
parent 082097b4f9
commit eb775ba7d8
41 changed files with 2847 additions and 29 deletions

View File

@@ -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

View File

@@ -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:

View 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
View 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

View 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

View 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

View File

@@ -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()

View 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)

View 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)

View 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
View 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]

View 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
})

View 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
})

View File

@@ -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"

View 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
})

View 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,
})

View 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,
})

View File

@@ -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')

View File

@@ -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,

View File

@@ -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."""

View File

@@ -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
View 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

View 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

View 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

View 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

View 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

View 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'.

View File

@@ -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()
})
})

View 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 }),
})
})
})

View File

@@ -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 }),
})
}

View File

@@ -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'
}

View File

@@ -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>

View File

@@ -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"

View 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>

View 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>

View 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>

View 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>

View File

@@ -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,

View File

@@ -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')

View File

@@ -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',