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