feat: add enable/disable toggle for chore scheduling in ScheduleModal
All checks were successful
Chore App Build, Test, and Push Docker Images / build-and-push (push) Successful in 2m39s

- Introduced a toggle button to enable or disable chores in the scheduler.
- The toggle will be available in both types of schedulers.
- Added UI design proposal and considerations for mobile dimensions.
- Included E2E tests to ensure toggle state persistence and correct behavior in child view.
This commit is contained in:
2026-03-20 16:42:13 -04:00
parent db6e0a7ce8
commit ef9cb01d92
45 changed files with 3543 additions and 232 deletions

View File

@@ -43,6 +43,12 @@ ACCESS_TOKEN_EXPIRY_MINUTES = 15
E2E_TEST_EMAIL = 'e2e@test.com'
E2E_TEST_PASSWORD = 'E2eTestPass1!'
E2E_TEST_PIN = '1234'
E2E_DELETE_EMAIL = 'e2e-delete@test.com'
E2E_DELETE_PASSWORD = 'E2eDeletePass1!'
E2E_DELETE_PIN = '5678'
E2E_CC_EMAIL = 'e2e-cc@test.com'
E2E_CC_PASSWORD = 'E2eCCPass1!'
E2E_CC_PIN = '3456'
def send_verification_email(to_email, token):
@@ -470,6 +476,52 @@ def logout():
return resp, 200
@auth_api.route('/e2e-create-delete-user', methods=['POST'])
def e2e_create_delete_user():
"""Create a secondary e2e test user for deletion testing. Only available outside production."""
if os.environ.get('DB_ENV', 'prod') == 'prod':
return jsonify({'error': 'Not available in production'}), 403
norm_email = normalize_email(E2E_DELETE_EMAIL)
users_db.remove(UserQuery.email == norm_email)
user = User(
first_name='E2E',
last_name='Delete',
email=norm_email,
password=generate_password_hash(E2E_DELETE_PASSWORD),
verified=True,
role='user',
pin=E2E_DELETE_PIN,
)
users_db.insert(user.to_dict())
return jsonify({'email': norm_email}), 201
@auth_api.route('/e2e-create-cc-user', methods=['POST'])
def e2e_create_cc_user():
"""Create an isolated e2e test user for create-child tests. Only available outside production."""
if os.environ.get('DB_ENV', 'prod') == 'prod':
return jsonify({'error': 'Not available in production'}), 403
norm_email = normalize_email(E2E_CC_EMAIL)
# Remove old user and all their children so deleteAllChildren() starts clean.
existing = users_db.get(UserQuery.email == norm_email)
if existing:
child_db.remove(Query().user_id == existing.get('id'))
users_db.remove(UserQuery.email == norm_email)
user = User(
first_name='E2E',
last_name='CreateChild',
email=norm_email,
password=generate_password_hash(E2E_CC_PASSWORD),
verified=True,
role='user',
pin=E2E_CC_PIN,
)
users_db.insert(user.to_dict())
return jsonify({'email': norm_email}), 201
@auth_api.route('/e2e-seed', methods=['POST'])
def e2e_seed():
"""Reset the database and insert a verified test user. Only available outside production."""

View File

@@ -54,6 +54,10 @@ def set_chore_schedule(child_id, task_id):
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):
@@ -69,6 +73,7 @@ def set_chore_schedule(child_id, task_id):
default_hour=default_hour,
default_minute=default_minute,
default_has_deadline=default_has_deadline,
enabled=enabled,
)
else:
interval_days = data.get('interval_days', 2)
@@ -91,6 +96,7 @@ def set_chore_schedule(child_id, task_id):
interval_has_deadline=interval_has_deadline,
interval_hour=interval_hour,
interval_minute=interval_minute,
enabled=enabled,
)
delete_extension_for_child_task(child_id, task_id)

View File

@@ -64,7 +64,7 @@ def sse_response_for_user(user_id: str):
# This prevents Werkzeug's dev server from starving other connections.
message = user_queue.get(timeout=15)
yield message
logger.info(f"Sent message to {user_id} connection {connection_id}")
logger.debug(f"Sent message to {user_id} connection {connection_id}")
except queue.Empty:
# Send an SSE comment as a keepalive ping to maintain the connection.
yield b': ping\n\n'

View File

@@ -35,6 +35,9 @@ logging.basicConfig(
)
logger = logging.getLogger(__name__)
logging.getLogger("werkzeug").setLevel(logging.WARNING)
logging.getLogger("events.sse").setLevel(logging.WARNING)
app = Flask(__name__)
#CORS(app, resources={r"/api/*": {"origins": ["http://localhost:3000", "http://localhost:5173"]}})
#Todo - add prefix to all these routes instead of in each blueprint

View File

@@ -44,6 +44,8 @@ class ChoreSchedule(BaseModel):
interval_hour: int = 0
interval_minute: int = 0
enabled: bool = True # False = schedule paused; chore won't appear for child
@classmethod
def from_dict(cls, d: dict) -> 'ChoreSchedule':
return cls(
@@ -59,6 +61,7 @@ class ChoreSchedule(BaseModel):
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'),
@@ -79,5 +82,6 @@ class ChoreSchedule(BaseModel):
'interval_has_deadline': self.interval_has_deadline,
'interval_hour': self.interval_hour,
'interval_minute': self.interval_minute,
'enabled': self.enabled,
})
return base

View File

@@ -319,3 +319,96 @@ def test_extend_chore_time_missing_date(client):
def test_extend_chore_time_bad_child(client):
resp = client.post('/child/bad-child/task/bad-task/extend', json={"date": "2025-01-15"})
assert resp.status_code == 404
# ---------------------------------------------------------------------------
# enabled field
# ---------------------------------------------------------------------------
def test_create_schedule_enabled_by_default(client):
"""Omitting 'enabled' should persist as True."""
payload = {"mode": "days", "day_configs": [{"day": 1, "hour": 8, "minute": 0}]}
resp = client.put(f'/child/{TEST_CHILD_ID}/task/{TEST_TASK_ID}/schedule', json=payload)
assert resp.status_code == 200
assert resp.get_json()["enabled"] is True
def test_create_schedule_with_enabled_false(client):
"""Explicitly setting enabled=False persists correctly."""
payload = {
"mode": "days",
"day_configs": [{"day": 1, "hour": 8, "minute": 0}],
"enabled": False,
}
resp = client.put(f'/child/{TEST_CHILD_ID}/task/{TEST_TASK_ID}/schedule', json=payload)
assert resp.status_code == 200
assert resp.get_json()["enabled"] is False
def test_update_schedule_toggle_enabled(client):
"""Toggle enabled off, then back on; both changes persist."""
base = {"mode": "days", "day_configs": [{"day": 2, "hour": 9, "minute": 0}]}
r1 = client.put(f'/child/{TEST_CHILD_ID}/task/{TEST_TASK_ID}/schedule', json={**base, "enabled": True})
assert r1.get_json()["enabled"] is True
r2 = client.put(f'/child/{TEST_CHILD_ID}/task/{TEST_TASK_ID}/schedule', json={**base, "enabled": False})
assert r2.get_json()["enabled"] is False
r3 = client.put(f'/child/{TEST_CHILD_ID}/task/{TEST_TASK_ID}/schedule', json={**base, "enabled": True})
assert r3.get_json()["enabled"] is True
def test_enabled_field_in_get_response(client):
"""GET response includes the 'enabled' field."""
payload = {"mode": "interval", "interval_days": 2, "anchor_date": "", "enabled": False}
client.put(f'/child/{TEST_CHILD_ID}/task/{TEST_TASK_ID}/schedule', json=payload)
resp = client.get(f'/child/{TEST_CHILD_ID}/task/{TEST_TASK_ID}/schedule')
assert resp.status_code == 200
data = resp.get_json()
assert "enabled" in data
assert data["enabled"] is False
def test_enabled_invalid_value_returns_400(client):
"""Non-boolean value for enabled returns 400."""
payload = {"mode": "days", "day_configs": [], "enabled": "yes"}
resp = client.put(f'/child/{TEST_CHILD_ID}/task/{TEST_TASK_ID}/schedule', json=payload)
assert resp.status_code == 400
# ---------------------------------------------------------------------------
# ChoreSchedule model: enabled serialization
# ---------------------------------------------------------------------------
def test_chore_schedule_from_dict_defaults_enabled():
"""from_dict without 'enabled' defaults to True."""
from models.chore_schedule import ChoreSchedule
s = ChoreSchedule.from_dict({
"child_id": "c1", "task_id": "t1", "mode": "days",
"day_configs": [], "interval_days": 2, "anchor_date": "",
"interval_has_deadline": True, "interval_hour": 0, "interval_minute": 0,
})
assert s.enabled is True
def test_chore_schedule_from_dict_enabled_false():
"""from_dict with enabled=False preserves the value."""
from models.chore_schedule import ChoreSchedule
s = ChoreSchedule.from_dict({
"child_id": "c1", "task_id": "t1", "mode": "days",
"day_configs": [], "interval_days": 2, "anchor_date": "",
"interval_has_deadline": True, "interval_hour": 0, "interval_minute": 0,
"enabled": False,
})
assert s.enabled is False
def test_chore_schedule_to_dict_includes_enabled():
"""to_dict includes the 'enabled' key with the correct value."""
from models.chore_schedule import ChoreSchedule
s = ChoreSchedule(child_id="c1", task_id="t1", mode="days", enabled=False)
d = s.to_dict()
assert "enabled" in d
assert d["enabled"] is False

View File

@@ -1,5 +1,6 @@
from flask import current_app
from flask_mail import Mail, Message
import os
def send_verification_email(to_email: str, token: str) -> None:
verify_url = f"{current_app.config['FRONTEND_URL']}/auth/verify?token={token}"
@@ -11,6 +12,8 @@ def send_verification_email(to_email: str, token: str) -> None:
sender=current_app.config.get('MAIL_DEFAULT_SENDER', 'no-reply@reward-app.local')
)
try:
if os.environ.get('DB_ENV') =='e2e':
return # Skip sending emails during e2e tests to avoid cluttering inboxes and logs
Mail(current_app).send(msg)
print(f"[EMAIL to {to_email}] Verification: {verify_url}")
except Exception:
@@ -26,6 +29,8 @@ def send_reset_password_email(to_email: str, token: str) -> None:
sender=current_app.config.get('MAIL_DEFAULT_SENDER', 'no-reply@reward-app.local')
)
try:
if os.environ.get('DB_ENV') =='e2e':
return # Skip sending emails during e2e tests to avoid cluttering inboxes and logs
Mail(current_app).send(msg)
print(f"[EMAIL to {to_email}] Reset password: {reset_url}")
except Exception:
@@ -50,6 +55,8 @@ def send_pin_setup_email(to_email: str, code: str) -> None:
sender=current_app.config.get('MAIL_DEFAULT_SENDER', 'no-reply@reward-app.local')
)
try:
if os.environ.get('DB_ENV') =='e2e':
return # Skip sending emails during e2e tests to avoid cluttering inboxes and logs
Mail(current_app).send(msg)
print(f"[EMAIL to {to_email}] Parent PIN setup code: {code}")
except Exception: