12 Commits

Author SHA1 Message Date
082097b4f9 feat: update BASE_VERSION to 1.0.14-a for feature release
All checks were successful
Chore App Build, Test, and Push Docker Images / build-and-push (push) Successful in 2m28s
2026-05-04 14:43:54 -04:00
2e1a0ab2fa feat: add functions to validate today's timestamps and update pending status logic
All checks were successful
Chore App Build, Test, and Push Docker Images / build-and-push (push) Successful in 2m20s
2026-05-03 12:26:21 -04:00
ce3d1b3d54 feat: add functions to validate today's timestamps and update pending status logic
All checks were successful
Chore App Build, Test, and Push Docker Images / build-and-push (push) Successful in 2m52s
2026-05-03 11:53:21 -04:00
75d3d6dc39 feat: set FRONTEND_SSL_ENABLED to false in docker-compose.yml
All checks were successful
Chore App Build, Test, and Push Docker Images / build-and-push (push) Successful in 2m19s
Co-authored-by: Copilot <copilot@github.com>
2026-05-02 14:00:28 -04:00
3d882656e3 feat: set FRONTEND_SSL_ENABLED to false in docker-compose.yml
All checks were successful
Chore App Build, Test, and Push Docker Images / build-and-push (push) Successful in 2m19s
2026-05-02 13:41:55 -04:00
8308d205e8 feat: update reward version to 1.0.13 and modify dialog button labels for clarity
All checks were successful
Chore App Build, Test, and Push Docker Images / build-and-push (push) Successful in 2m31s
2026-05-01 23:32:17 -04:00
e77254eabf feat: add dynamic dialog max width to ModalDialog component
All checks were successful
Chore App Build, Test, and Push Docker Images / build-and-push (push) Successful in 2m45s
2026-05-01 18:49:48 -04:00
a68a86a6a6 feat: add dynamic dialog max width to ModalDialog component
Some checks failed
Chore App Build, Test, and Push Docker Images / build-and-push (push) Has been cancelled
2026-05-01 18:49:40 -04:00
4ac83dcf17 feat: enhance push notification service worker for chore expirations and update related configurations
All checks were successful
Chore App Build, Test, and Push Docker Images / build-and-push (push) Successful in 2m49s
2026-05-01 15:39:01 -04:00
ab0d32c6b0 feat: enhance push notification service worker for chore expirations and update related configurations
Some checks failed
Chore App Build, Test, and Push Docker Images / build-and-push (push) Failing after 2m22s
2026-05-01 15:34:56 -04:00
28f5c43349 fix: improve date comparison for chore completion status
All checks were successful
Chore App Build, Test, and Push Docker Images / build-and-push (push) Successful in 2m16s
2026-04-29 14:18:23 -04:00
a2b464af7b fix: improve date comparison for chore completion status
Some checks failed
Chore App Build, Test, and Push Docker Images / build-and-push (push) Has been cancelled
2026-04-29 14:17:40 -04:00
32 changed files with 711 additions and 212 deletions

View File

@@ -394,7 +394,7 @@ jobs:
fi
frontend_ok=false
if curl -kfsS "https://localhost:${frontend_host_port}/" >/dev/null 2>&1; then
if curl -fsS "http://localhost:${frontend_host_port}/" >/dev/null 2>&1; then
frontend_ok=true
fi

View File

@@ -81,7 +81,7 @@ Import added and `start_chore_expiry_notification_scheduler(app)` called after t
- [x] `_build_push_payload`: single chore → named title, deep-link fields set
- [x] `_build_push_payload`: multiple chores → generic title, child_id/entity_id null
- [x] `_build_push_payload`: body groups chore names by child
- [x] `_build_push_payload`: no approve_token or deny_token in payload
- [x] `_build_push_payload`: no approve_token or reject_token in payload
- [x] `send_chore_expiry_notifications_for_user`: calls send_push_to_user with correct payload
- [x] `send_chore_expiry_notifications_for_user`: no push when no chores expiring
- [x] `run_chore_expiry_check`: skips when DB_ENV=e2e
@@ -111,7 +111,7 @@ Import added and `start_chore_expiry_notification_scheduler(app)` called after t
**`frontend/vue-app/public/sw.js`** (modified)
In the `push` event handler, the `actions` array passed to `showNotification` is now conditionally set: when `payload.type === 'chore_expiring_soon'` the array is empty (no buttons); otherwise Approve and Deny buttons are shown as before. The `notificationclick` handler requires no changes — tapping the notification body already navigates to `/parent/{child_id}?scrollTo={entity_id}` when those fields are present, and falls back to `/parent` when they are null (multi-chore case).
In the `push` event handler, the `actions` array passed to `showNotification` is now conditionally set: when `payload.type === 'chore_expiring_soon'` the array is empty (no buttons); otherwise Approve and Reject buttons are shown as before. The `notificationclick` handler requires no changes — tapping the notification body already navigates to `/parent/{child_id}?scrollTo={entity_id}` when those fields are present, and falls back to `/parent` when they are null (multi-chore case).
## Frontend Tests
@@ -143,6 +143,6 @@ No automated tests added. The `sw.js` change is a simple conditional and is cove
### Frontend
- [x] `sw.js` updated — `chore_expiring_soon` notifications show no Approve/Deny action buttons
- [x] `sw.js` updated — `chore_expiring_soon` notifications show no Approve/Reject action buttons
- [x] Tapping a single-chore notification navigates to the child's chore page
- [x] Tapping a multi-chore notification navigates to `/parent`

3
.vscode/launch.json vendored
View File

@@ -14,7 +14,8 @@
"REFRESH_TOKEN_EXPIRY_DAYS": "90",
"DIGEST_TOKEN_SECRET": "dev-digest-token",
"VAPID_PUBLIC_KEY": "BNKkHdq45uLigohSG7c1TwlAo7ETncoRVLQK02LxHgu2P1DgSJD9njRMfbbzUsaTQGllvLBz7An1WiWsNYQhvKE",
"VAPID_PRIVATE_KEY": "jNiZJT0UO4H861KmnCt874Fg6p5jDAyYKS4V2MZf8bQ"
"VAPID_PRIVATE_KEY": "jNiZJT0UO4H861KmnCt874Fg6p5jDAyYKS4V2MZf8bQ",
"FRONTEND_URL": "https://macbook:5173"
},
"args": [
"run",

View File

@@ -40,6 +40,19 @@ import logging
child_api = Blueprint('child_api', __name__)
logger = logging.getLogger(__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
@child_api.route('/child/<name>', methods=['GET'])
@child_api.route('/child/<id>', methods=['GET'])
def get_child(id):
@@ -313,8 +326,23 @@ def list_child_tasks(id):
(PendingQuery.entity_type == 'chore') & (PendingQuery.user_id == user_id)
)
if pending:
ct_dict['pending_status'] = pending.get('status')
ct_dict['approved_at'] = pending.get('approved_at')
status = pending.get('status')
approved_at = pending.get('approved_at')
created_at = pending.get('created_at')
today_utc = datetime.now(timezone.utc).strftime('%Y-%m-%d')
if status == 'approved' and _is_iso_timestamp_today_utc(approved_at, today_utc):
ct_dict['pending_status'] = 'approved'
ct_dict['approved_at'] = approved_at
elif status == 'pending' and _is_epoch_timestamp_today_utc(created_at, today_utc):
ct_dict['pending_status'] = 'pending'
ct_dict['approved_at'] = None
else:
pending_id = pending.get('id')
if pending_id:
pending_confirmations_db.remove(PendingQuery.id == pending_id)
ct_dict['pending_status'] = None
ct_dict['approved_at'] = None
else:
ct_dict['pending_status'] = None
ct_dict['approved_at'] = None
@@ -872,7 +900,17 @@ def reward_status(id):
(pending_query.child_id == child.id) & (pending_query.entity_id == reward.id) &
(pending_query.entity_type == 'reward') & (pending_query.user_id == user_id)
)
status = RewardStatus(reward.id, reward.name, points_needed, cost_value, pending is not None, reward.image_id)
redeeming = False
if pending and pending.get('status') == 'pending':
today_utc = datetime.now(timezone.utc).strftime('%Y-%m-%d')
if _is_epoch_timestamp_today_utc(pending.get('created_at'), today_utc):
redeeming = True
else:
pending_id = pending.get('id')
if pending_id:
pending_confirmations_db.remove(pending_query.id == pending_id)
status = RewardStatus(reward.id, reward.name, points_needed, cost_value, redeeming, reward.image_id)
status_dict = status.to_dict()
if override:
status_dict['custom_value'] = override.custom_value
@@ -927,7 +965,12 @@ def request_reward(id):
(DupQuery.user_id == user_id)
)
if duplicate:
today_utc = datetime.now(timezone.utc).strftime('%Y-%m-%d')
if _is_epoch_timestamp_today_utc(duplicate.get('created_at'), today_utc):
return jsonify({'error': 'Reward request already pending', 'code': 'DUPLICATE_REWARD_REQUEST'}), 409
pending_id = duplicate.get('id')
if pending_id:
pending_confirmations_db.remove(DupQuery.id == pending_id)
pending = PendingConfirmation(child_id=child.id, entity_id=reward.id, entity_type='reward', user_id=user_id)
pending_confirmations_db.insert(pending.to_dict())
@@ -1053,6 +1096,7 @@ def list_pending_confirmations():
if not user_id:
return jsonify({'error': 'Unauthorized', 'code': 'UNAUTHORIZED'}), 401
PendingQuery = Query()
today_utc = datetime.now(timezone.utc).strftime('%Y-%m-%d')
pending_items = pending_confirmations_db.search(
(PendingQuery.user_id == user_id) & (PendingQuery.status == 'pending')
)
@@ -1065,6 +1109,10 @@ def list_pending_confirmations():
for pr in pending_items:
pending = PendingConfirmation.from_dict(pr)
if not _is_epoch_timestamp_today_utc(pending.created_at, today_utc):
pending_confirmations_db.remove(PendingQuery.id == pending.id)
continue
# Look up child details
child_result = child_db.get(ChildQuery.id == pending.child_id)
if not child_result:
@@ -1137,13 +1185,20 @@ def confirm_chore(id):
(PendingQuery.entity_type == 'chore') & (PendingQuery.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': 'Chore already pending confirmation', 'code': 'CHORE_ALREADY_PENDING'}), 400
pending_id = existing.get('id')
if pending_id:
pending_confirmations_db.remove(PendingQuery.id == pending_id)
if existing.get('status') == 'approved':
approved_at = existing.get('approved_at', '')
today_utc = datetime.now(timezone.utc).strftime('%Y-%m-%d')
if approved_at and approved_at[:10] == today_utc:
if _is_iso_timestamp_today_utc(approved_at, today_utc):
return jsonify({'error': 'Chore already completed today', 'code': 'CHORE_ALREADY_COMPLETED'}), 400
pending_id = existing.get('id')
if pending_id:
pending_confirmations_db.remove(PendingQuery.id == pending_id)
confirmation = PendingConfirmation(
child_id=id, entity_id=task_id, entity_type='chore', user_id=user_id

View File

@@ -2,7 +2,7 @@
# file: config/version.py
import os
BASE_VERSION = "1.0.11" # update manually when releasing features
BASE_VERSION = "1.0.14-a" # update manually when releasing features
def get_full_version() -> str:
"""

View File

@@ -10,7 +10,7 @@ from tinydb import Query
from models.child import Child
import jwt
from werkzeug.security import generate_password_hash
from datetime import date as date_type
from datetime import date as date_type, datetime, timedelta, timezone
# Test user credentials
@@ -382,6 +382,7 @@ def _setup_sched_child_and_tasks(task_db, child_db):
})
chore_schedules_db.remove(Query().child_id == CHILD_SCHED_ID)
task_extensions_db.remove(Query().child_id == CHILD_SCHED_ID)
pending_confirmations_db.remove(Query().child_id == CHILD_SCHED_ID)
def test_list_child_tasks_always_has_schedule_and_extension_date_keys(client):
@@ -517,6 +518,113 @@ def test_list_child_tasks_no_server_side_filtering(client):
assert extra_id in returned_ids
def test_list_child_tasks_shows_pending_for_today(client):
"""A chore confirmed today should return pending_status='pending'."""
_setup_sched_child_and_tasks(task_db, child_db)
now_ts = datetime.now(timezone.utc).timestamp()
pending_confirmations_db.insert({
'id': 'pend_today_chore',
'child_id': CHILD_SCHED_ID,
'entity_id': TASK_GOOD_ID,
'entity_type': 'chore',
'user_id': 'testuserid',
'status': 'pending',
'approved_at': None,
'created_at': now_ts,
'updated_at': now_ts,
})
resp = client.get(f'/child/{CHILD_SCHED_ID}/list-tasks')
assert resp.status_code == 200
tasks = {t['id']: t for t in resp.get_json()['tasks']}
assert tasks[TASK_GOOD_ID]['pending_status'] == 'pending'
assert tasks[TASK_GOOD_ID]['approved_at'] is None
def test_list_child_tasks_clears_stale_approved_and_pending(client):
"""Yesterday's chore pending/approved records should be reset and ignored."""
_setup_sched_child_and_tasks(task_db, child_db)
old_ts = (datetime.now(timezone.utc) - timedelta(days=2)).timestamp()
old_approved = (datetime.now(timezone.utc) - timedelta(days=2)).isoformat()
pending_confirmations_db.insert({
'id': 'pend_old_chore_pending',
'child_id': CHILD_SCHED_ID,
'entity_id': TASK_GOOD_ID,
'entity_type': 'chore',
'user_id': 'testuserid',
'status': 'pending',
'approved_at': None,
'created_at': old_ts,
'updated_at': old_ts,
})
resp = client.get(f'/child/{CHILD_SCHED_ID}/list-tasks')
assert resp.status_code == 200
tasks = {t['id']: t for t in resp.get_json()['tasks']}
assert tasks[TASK_GOOD_ID]['pending_status'] is None
assert tasks[TASK_GOOD_ID]['approved_at'] is None
# Reinsert as stale approved and ensure it is also cleared.
pending_confirmations_db.insert({
'id': 'pend_old_chore_approved',
'child_id': CHILD_SCHED_ID,
'entity_id': TASK_GOOD_ID,
'entity_type': 'chore',
'user_id': 'testuserid',
'status': 'approved',
'approved_at': old_approved,
'created_at': old_ts,
'updated_at': old_ts,
})
resp2 = client.get(f'/child/{CHILD_SCHED_ID}/list-tasks')
assert resp2.status_code == 200
tasks2 = {t['id']: t for t in resp2.get_json()['tasks']}
assert tasks2[TASK_GOOD_ID]['pending_status'] is None
assert tasks2[TASK_GOOD_ID]['approved_at'] is None
def test_confirm_chore_allows_when_previous_pending_is_stale(client):
"""A stale pending chore record from a prior day must not block confirm-chore."""
old_ts = (datetime.now(timezone.utc) - timedelta(days=2)).timestamp()
task_db.insert({'id': 't_stale_chore', 'name': 'Stale Chore', 'points': 2, 'type': 'chore', 'user_id': 'testuserid'})
child_db.insert({
'id': 'child_stale_chore',
'name': 'Stale Chore Kid',
'age': 8,
'points': 0,
'tasks': ['t_stale_chore'],
'rewards': [],
'user_id': 'testuserid',
})
pending_confirmations_db.insert({
'id': 'pend_stale_chore',
'child_id': 'child_stale_chore',
'entity_id': 't_stale_chore',
'entity_type': 'chore',
'user_id': 'testuserid',
'status': 'pending',
'approved_at': None,
'created_at': old_ts,
'updated_at': old_ts,
})
resp = client.post('/child/child_stale_chore/confirm-chore', json={'task_id': 't_stale_chore'})
assert resp.status_code == 200
active_pending = pending_confirmations_db.search(
(Query().child_id == 'child_stale_chore') & (Query().entity_id == 't_stale_chore') &
(Query().entity_type == 'chore') & (Query().status == 'pending')
)
assert len(active_pending) == 1
assert active_pending[0].get('id') != 'pend_stale_chore'
pending_confirmations_db.remove(Query().child_id == 'child_stale_chore')
child_db.remove(Query().id == 'child_stale_chore')
task_db.remove(Query().id == 't_stale_chore')
# ---------------------------------------------------------------------------
# request-reward: duplicate guard
# ---------------------------------------------------------------------------
@@ -547,6 +655,82 @@ def test_request_reward_duplicate_returns_409(client):
reward_db.remove(Query().id == 'r_dup')
def test_request_reward_allows_new_when_stale_pending_exists(client):
"""A stale pending reward from a prior day must not block a new request."""
old_ts = (datetime.now(timezone.utc) - timedelta(days=2)).timestamp()
reward_db.insert({'id': 'r_stale', 'name': 'Stale Reward', 'cost': 5, 'user_id': 'testuserid'})
child_db.insert({
'id': 'child_stale',
'name': 'Stale Kid',
'age': 8,
'points': 20,
'tasks': [],
'rewards': ['r_stale'],
'user_id': 'testuserid',
})
pending_confirmations_db.insert({
'id': 'pend_stale_reward',
'child_id': 'child_stale',
'entity_id': 'r_stale',
'entity_type': 'reward',
'user_id': 'testuserid',
'status': 'pending',
'approved_at': None,
'created_at': old_ts,
'updated_at': old_ts,
})
resp = client.post('/child/child_stale/request-reward', json={'reward_id': 'r_stale'})
assert resp.status_code == 200
active_pending = pending_confirmations_db.search(
(Query().child_id == 'child_stale') & (Query().entity_id == 'r_stale') &
(Query().entity_type == 'reward') & (Query().status == 'pending')
)
assert len(active_pending) == 1
assert active_pending[0].get('id') != 'pend_stale_reward'
pending_confirmations_db.remove(Query().child_id == 'child_stale')
child_db.remove(Query().id == 'child_stale')
reward_db.remove(Query().id == 'r_stale')
def test_reward_status_ignores_stale_pending_reward(client):
"""reward-status should not mark a reward as redeeming if pending is stale."""
old_ts = (datetime.now(timezone.utc) - timedelta(days=2)).timestamp()
reward_db.insert({'id': 'r_status_stale', 'name': 'Status Reward', 'cost': 4, 'user_id': 'testuserid'})
child_db.insert({
'id': 'child_status_stale',
'name': 'Status Kid',
'age': 9,
'points': 10,
'tasks': [],
'rewards': ['r_status_stale'],
'user_id': 'testuserid',
})
pending_confirmations_db.insert({
'id': 'pend_status_stale',
'child_id': 'child_status_stale',
'entity_id': 'r_status_stale',
'entity_type': 'reward',
'user_id': 'testuserid',
'status': 'pending',
'approved_at': None,
'created_at': old_ts,
'updated_at': old_ts,
})
resp = client.get('/child/child_status_stale/reward-status')
assert resp.status_code == 200
statuses = {s['id']: s for s in resp.get_json()['reward_status']}
assert statuses['r_status_stale']['redeeming'] is False
assert pending_confirmations_db.get(Query().id == 'pend_status_stale') is None
pending_confirmations_db.remove(Query().child_id == 'child_status_stale')
child_db.remove(Query().id == 'child_status_stale')
reward_db.remove(Query().id == 'r_status_stale')
# ---------------------------------------------------------------------------
# deny-reward-request endpoint
# ---------------------------------------------------------------------------

View File

@@ -29,9 +29,10 @@ services:
chores-test-app-frontend: # Test frontend service name
image: git.ryankegel.com:3000/kegel/chores/frontend:next # Use latest next tag
ports:
- "446:443" # Host 446 -> Container 443 (HTTPS)
- "446:80" # Host 446 -> Container 80 (HTTP behind external TLS proxy)
environment:
- BACKEND_HOST=chores-test-app-backend # Points to internal backend service
- FRONTEND_SSL_ENABLED=false
depends_on:
- chores-test-app-backend
# Add volumes, networks, etc., as needed

View File

@@ -25,9 +25,10 @@ services:
image: git.ryankegel.com:3000/kegel/chores/frontend:latest # Or specific version tag
container_name: chores-app-frontend-prod # Added for easy identification
ports:
- "${FRONTEND_HOST_PORT:-4601}:443" # Host port -> Container 443 (HTTPS)
- "${FRONTEND_HOST_PORT:-4601}:${FRONTEND_CONTAINER_PORT:-80}" # Host port -> Container 80 (HTTP; SSL terminated by external proxy)
environment:
- BACKEND_HOST=chores-app-backend # Points to internal backend service
- FRONTEND_SSL_ENABLED=false
depends_on:
- chores-app-backend
networks:

3
frontend/.gitignore vendored
View File

@@ -37,6 +37,9 @@ __screenshots__/
*.old
# Local dev TLS certs (machine-specific, generated by mkcert)
*.pem
# Playwright
/test-results/
/playwright-report/

View File

@@ -1,28 +0,0 @@
-----BEGIN PRIVATE KEY-----
MIIEvAIBADANBgkqhkiG9w0BAQEFAASCBKYwggSiAgEAAoIBAQCh96Qoz16kpDde
puFkARSFp9PDFs8kUBGCJ0DcBDqJIUuLJj7RT5vxfUj3O7uUUjTtVAVCMJ84/eq/
IQnyhch1B2Fe5bas51kEgwybzxxEN1bcj3yokbd4aOCpxkpM382TKMPuGH1BXUO8
ZqqikbvKySJ9glboPImLRA4UvH1cx9Db0kQE9scBl98qqE28exlBqMBsIOjh+mJ0
rWt3JO+A50OjOSuxO7hdjINlJg5X+jmI1wF3GWE2i9GrCJXh9JVYixnjvsFMIKJo
Zu9wAZCmtqHvicvwdzDxdDAU8GfACOg4w4hlMOSfIU4pAONxW479Gf5pSFVTw7Zu
RNrTDsd9AgMBAAECggEAGICASL0wLdt61dqMg8anDl4Yaq2nafCj6WrbRL1uBoMv
LK59N8hhiKuBj4cthg9WmuWIQx5cY/CDo+TRXqsu60dRy1uYYjlATe6uSF7RQZ+W
iBi7zLt4hCJnhD9vS4hazs2OsFTrk+kSP2zPmPbPcCqzyUVftNO9ogAKWkg2dcO1
9KjDnR0pSn17rwj064mfVNoLYiN11nwQ21zyJFLYGM5mXYYW9b+EI9ZnWWyz6Lvo
+qrYhZh2hR8ul8rpURc0flqFvORaO4FhaQeX5+r5FZFQkRWQYbRfBbAJnIrFVa+N
8O002E0FLHWI0S7YPsbnBqE+eHRM9fzz1q0p4YrjEQKBgQDDz9n2BLwwQCreXglG
V2T1SBdi1+MDTMUCHEcak3Gk4ZRIU4r2ofoTdYagEaYCMBGL7/noYECpEfkzF4B5
u1nXUTtFODq0m1Uc18iL8WMJy8AkoT+LZEmF0y5WDI/fLzzxuQKXpTDyeUwTYf/W
9xHHi4gckc3wcf1tKtNSswCcMQKBgQDTwJxki8/2OEtIRcPlhhXNQRpSnp6cjgoB
xQ4J83idR3/HBLWMI9snx47SE1AxGFY4UHIQNvMN3icKt5C7QyfrMfZCpUcRryv6
1dBFVbvxm0Hvqj/pWsgtjbq+dbiOLtH52zc9nsPAF++FhlYtZz9fJmwsgxsnab1O
B9dUyXMpDQKBgDj3LRfPhNgcstwCS3x1TF+3W2ZcHCUHnoDgrSbkIjmvjq4D7/eU
Y+ZpWIMU31Dfnxsw82lRJz6IhhEBE1VW1eo4LaATnbCRSA+eDy/3R7K/3eRKLOxm
fqU6LM7H1Ms/OOGxyzlGy5ifBSzWY9GsCzYcN7roCBudbfbmcJgsj07hAoGAd2p1
CCLsub9PfUeSzTrLyr//Nz6q1kEoFY1qeGQszg3HWpYmSAzkh897lK89lyJRZVrA
qLJEabqxq9KPtXuO5I19gmIw7SErnT69QIyz+/IBwkXx2wjOQRpfiQ9ccBqpYc2l
noONgyQ8eMGkkeBbFa7WbFfXlWeFUZ8MaY1d+3UCgYBguVXJ9JTvMxeXx5cjnfMI
eVRhr2BbARTdCJXKUpo4rUUqLrCCSB6BrT4H+DYtRVKO/ZIaXARQIN1ACo03C78o
U+Rg+mGTyvGh8pVH63zZE9tsohtT+/bSraYm/dLKUmaK3Z9wG75wmOpNLlAEiUqW
mzkW/UN0WWzg5gyODVbQKQ==
-----END PRIVATE KEY-----

View File

@@ -1,26 +0,0 @@
-----BEGIN CERTIFICATE-----
MIIEZDCCAsygAwIBAgIQIM51sKaVetu2bhpqghM+2TANBgkqhkiG9w0BAQsFADCB
kzEeMBwGA1UEChMVbWtjZXJ0IGRldmVsb3BtZW50IENBMTQwMgYDVQQLDCtyeWFu
QG1hY2Jvb2sudGFpbGExNDZiMy50cy5uZXQgKFJ5YW4gS2VnZWwpMTswOQYDVQQD
DDJta2NlcnQgcnlhbkBtYWNib29rLnRhaWxhMTQ2YjMudHMubmV0IChSeWFuIEtl
Z2VsKTAeFw0yNjA0MTYyMjEwMTNaFw0yODA3MTYyMjEwMTNaMFMxJzAlBgNVBAoT
Hm1rY2VydCBkZXZlbG9wbWVudCBjZXJ0aWZpY2F0ZTEoMCYGA1UECwwfcnlhbkBy
eWFucy1haXIubGFuIChSeWFuIEtlZ2VsKTCCASIwDQYJKoZIhvcNAQEBBQADggEP
ADCCAQoCggEBAKH3pCjPXqSkN16m4WQBFIWn08MWzyRQEYInQNwEOokhS4smPtFP
m/F9SPc7u5RSNO1UBUIwnzj96r8hCfKFyHUHYV7ltqznWQSDDJvPHEQ3VtyPfKiR
t3ho4KnGSkzfzZMow+4YfUFdQ7xmqqKRu8rJIn2CVug8iYtEDhS8fVzH0NvSRAT2
xwGX3yqoTbx7GUGowGwg6OH6YnSta3ck74DnQ6M5K7E7uF2Mg2UmDlf6OYjXAXcZ
YTaL0asIleH0lViLGeO+wUwgomhm73ABkKa2oe+Jy/B3MPF0MBTwZ8AI6DjDiGUw
5J8hTikA43Fbjv0Z/mlIVVPDtm5E2tMOx30CAwEAAaNzMHEwDgYDVR0PAQH/BAQD
AgWgMBMGA1UdJQQMMAoGCCsGAQUFBwMBMB8GA1UdIwQYMBaAFLM+akEIDt75nyHZ
rbkUs/4IQWesMCkGA1UdEQQiMCCCCWxvY2FsaG9zdIINZGV2c2VydmVyLmxhbocE
wKgBZjANBgkqhkiG9w0BAQsFAAOCAYEAi4pT3LNzoM5mmD3ujX+G+cXx8MOAljvR
XGrzorysNOujR79+zR5tPPyiLwFfEYASJhWlHarM8n6DG+IgARNt9PuDq0x8+oDp
V/t9grCS6eV/SQf6y7JBQrywdIBgfYGJbbx6xzVlj187M+7yNV73f3ldS3wPFKlS
Gp6p50Vt9gmfsiqzW4NDr0FIvdbcBesNfr2DwRccZNgNAcLsr9ys2yVUcUtMnqPF
IPyarDbREuBLLLpnsYZhY3BJQgI3gDS9QRoviFCMjcbWKNnv0D2W0rVdZkJf9WOj
ro9X1K2f2C/t61EnFsM6ncqAgMksbBmlORiVi4thAgB44FDydyHX6anf7Hbzfn1t
sjsiZ03rO9xvFzUb2T9guFMBkU6pBDEhCdN3twYPY3uKPXByHnSmj3q38NJVbULu
szC1q91VF+2U2UoeoXaNxrXVleKpLiWEUJ/u/iGrbApeuov5ZTktA/y/d/+TDB+h
Jlo+Ofg3zgZBHpN+fsGTxhRLlJyXSynI
-----END CERTIFICATE-----

View File

@@ -10,17 +10,15 @@ RUN npm run build
FROM nginx:alpine
COPY --from=build /app/dist /usr/share/nginx/html
COPY nginx.conf.template /etc/nginx/nginx.conf.template
# Copy SSL certificate and key
COPY 192.168.1.102+1.pem /etc/nginx/ssl/server.crt
COPY 192.168.1.102+1-key.pem /etc/nginx/ssl/server.key
COPY nginx.http.conf.template /etc/nginx/nginx.http.conf.template
COPY docker/start-nginx.sh /docker/start-nginx.sh
RUN chmod +x /docker/start-nginx.sh
EXPOSE 80
EXPOSE 443
# Copy nginx.conf
COPY nginx.conf.template /etc/nginx/nginx.conf.template
# Set default BACKEND_HOST (can be overridden at runtime)
# Set default runtime values (can be overridden at runtime)
ENV BACKEND_HOST=chore-app-backend
ENV FRONTEND_SSL_ENABLED=true
# Use sed to replace $BACKEND_HOST with the env value, then start Nginx
CMD ["/bin/sh", "-c", "sed 's/\\$BACKEND_HOST/'\"$BACKEND_HOST\"'/g' /etc/nginx/nginx.conf.template > /etc/nginx/nginx.conf && nginx -g 'daemon off;'"]
CMD ["/docker/start-nginx.sh"]

View File

@@ -0,0 +1,18 @@
#!/bin/sh
set -eu
backend_host="${BACKEND_HOST:-chore-app-backend}"
ssl_enabled="$(printf '%s' "${FRONTEND_SSL_ENABLED:-true}" | tr '[:upper:]' '[:lower:]')"
template_file="/etc/nginx/nginx.http.conf.template"
case "$ssl_enabled" in
1|true|yes|on)
template_file="/etc/nginx/nginx.conf.template"
;;
esac
sed "s|\$BACKEND_HOST|${backend_host}|g" "$template_file" > /etc/nginx/nginx.conf
nginx -t
exec nginx -g 'daemon off;'

View File

@@ -2,20 +2,20 @@
"cookies": [
{
"name": "refresh_token",
"value": "1aCKS1DujEPmHYGCELY7JiJ-LfxGjw7_HK1uCymlbh8",
"value": "pGEIC6CXp8cemhcGtW276YUSMY6zSzOVlhDL-bNhrYk",
"domain": "localhost",
"path": "/api/auth",
"expires": 1785207783.273047,
"expires": 1785601199.302565,
"httpOnly": true,
"secure": true,
"sameSite": "Strict"
},
{
"name": "access_token",
"value": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJlbWFpbCI6ImUyZS1jY0B0ZXN0LmNvbSIsInVzZXJfaWQiOiJhNzY5YzY4Zi04MWQ5LTRiNjctODQwYS1mY2Q5NTVkNzAwNzYiLCJ0b2tlbl92ZXJzaW9uIjowLCJleHAiOjE3Nzc0NDI1ODN9.vdmfmIx0Dg4zKq9HTV7N7tjDbSGaO3EOeGqJbhFSOcQ",
"value": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJlbWFpbCI6ImUyZS1jY0B0ZXN0LmNvbSIsInVzZXJfaWQiOiJhNzljMGEzOS1jYmNlLTQzMmQtYTQ1Yy02YjY2N2Q5ZDg3ODMiLCJ0b2tlbl92ZXJzaW9uIjowLCJleHAiOjE3Nzc4MzU5OTl9.3Aucvm-BqEldAY3FG7Th2Y3-AEdUttqAMM6Z2wC56b8",
"domain": "localhost",
"path": "/",
"expires": 1777442583.273,
"expires": 1777835999.301909,
"httpOnly": true,
"secure": true,
"sameSite": "Lax"
@@ -27,11 +27,11 @@
"localStorage": [
{
"name": "authSyncEvent",
"value": "{\"type\":\"logout\",\"at\":1777431783134}"
"value": "{\"type\":\"logout\",\"at\":1777825199047}"
},
{
"name": "parentAuth",
"value": "{\"expiresAt\":1777604583443}"
"value": "{\"expiresAt\":1777997999649}"
}
]
}

View File

@@ -2,20 +2,20 @@
"cookies": [
{
"name": "refresh_token",
"value": "eDlr3-DaFNFoWQE3EDAybLFpw7TiR3Maj05kLIkZfu0",
"value": "iFMh4JztGf12rfM3hYnVAxUTxCCaqvf8fXuNt4AcX5E",
"domain": "localhost",
"path": "/api/auth",
"expires": 1785207783.382249,
"expires": 1785601199.525198,
"httpOnly": true,
"secure": true,
"sameSite": "Strict"
},
{
"name": "access_token",
"value": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJlbWFpbCI6ImUyZS1kZWxldGVAdGVzdC5jb20iLCJ1c2VyX2lkIjoiNGYwNmY0YTMtMGQwMC00NzJhLWJkMjQtYjM1YjdkMjg4ZDFiIiwidG9rZW5fdmVyc2lvbiI6MCwiZXhwIjoxNzc3NDQyNTgzfQ.F2TXov3xbhFa20P5NLt6J5HfNDlY2aXlnPoSYYDJpoQ",
"value": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJlbWFpbCI6ImUyZS1kZWxldGVAdGVzdC5jb20iLCJ1c2VyX2lkIjoiNjMwMjY4MzMtOTMwYi00Y2Y4LThkMWQtNGRmYmM4YjZhNDNiIiwidG9rZW5fdmVyc2lvbiI6MCwiZXhwIjoxNzc3ODM1OTk5fQ.puE7cpjjfxckURcACEIQRDTdySwJm0gaIwKPAoeg6e4",
"domain": "localhost",
"path": "/",
"expires": 1777442583.38218,
"expires": 1777835999.524669,
"httpOnly": true,
"secure": true,
"sameSite": "Lax"
@@ -27,11 +27,11 @@
"localStorage": [
{
"name": "authSyncEvent",
"value": "{\"type\":\"logout\",\"at\":1777431783225}"
"value": "{\"type\":\"logout\",\"at\":1777825199229}"
},
{
"name": "parentAuth",
"value": "{\"expiresAt\":1777604583575}"
"value": "{\"expiresAt\":1777997999821}"
}
]
}

View File

@@ -2,20 +2,20 @@
"cookies": [
{
"name": "refresh_token",
"value": "BGnvmJr7XNXaCsXlzh1k6RcJPIGVxac6_LBXS22gXIA",
"value": "o8DJDh2Vlh2aC6VLSIEdO9pY74AceX8kLDiBhzyJ1i4",
"domain": "localhost",
"path": "/api/auth",
"expires": 1785207781.614965,
"expires": 1785601195.078177,
"httpOnly": true,
"secure": true,
"sameSite": "Strict"
},
{
"name": "access_token",
"value": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJlbWFpbCI6ImUyZUB0ZXN0LmNvbSIsInVzZXJfaWQiOiI4YmQxMWY5NC1jNWJhLTRhNmEtOGZhMi1hZjZkYzk3Y2YyMjgiLCJ0b2tlbl92ZXJzaW9uIjowLCJleHAiOjE3Nzc0NDI1ODF9.WVDBt-zfwVMHCWr78maSGWAyfQZhWOSobX5oXKXLHwg",
"value": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJlbWFpbCI6ImUyZUB0ZXN0LmNvbSIsInVzZXJfaWQiOiI5OWQwMzUxZC03YTg1LTQ4ZDUtOTgzZC01YWJlZTMxOGFhZDMiLCJ0b2tlbl92ZXJzaW9uIjowLCJleHAiOjE3Nzc4MzU5OTV9.QKaquZmzjFHwx08IgGVCyrQeBw2M5-8yVKc30_b9GFM",
"domain": "localhost",
"path": "/",
"expires": 1777442581.61492,
"expires": 1777835995.077514,
"httpOnly": true,
"secure": true,
"sameSite": "Lax"
@@ -27,11 +27,11 @@
"localStorage": [
{
"name": "authSyncEvent",
"value": "{\"type\":\"logout\",\"at\":1777431781473}"
"value": "{\"type\":\"logout\",\"at\":1777825194855}"
},
{
"name": "parentAuth",
"value": "{\"expiresAt\":1777604581743}"
"value": "{\"expiresAt\":1777997995266}"
}
]
}

View File

@@ -108,8 +108,8 @@ test.describe('Reward Notification — In-App Denial', () => {
await expect(card).toHaveClass(/item-ready/, { timeout: 3000 })
await card.click()
// Look for a Deny button in the dialog
const denyBtn = page.getByRole('button', { name: /deny/i })
// Look for the reject action in the dialog
const denyBtn = page.getByRole('button', { name: 'Reject', exact: true })
await expect(denyBtn).toBeVisible({ timeout: 5000 })
await denyBtn.click()
await expect(denyBtn).not.toBeVisible({ timeout: 5000 })
@@ -127,7 +127,7 @@ test.describe('Reward Notification — In-App Denial', () => {
await card.click()
await expect(card).toHaveClass(/item-ready/, { timeout: 3000 })
await card.click()
const denyBtn = page.getByRole('button', { name: /deny/i })
const denyBtn = page.getByRole('button', { name: 'Reject', exact: true })
await expect(denyBtn).toBeVisible({ timeout: 5000 })
await denyBtn.click()
await expect(denyBtn).not.toBeVisible({ timeout: 5000 })

View File

@@ -59,7 +59,9 @@ async function createReward(
for (const r of (await pre.json()).rewards ?? []) {
if (r.name === name) await request.delete(`/api/reward/${r.id}`)
}
await request.put('/api/reward/add', { data: { name, description: 'E2E pending-warn test', cost } })
await request.put('/api/reward/add', {
data: { name, description: 'E2E pending-warn test', cost },
})
const list = await request.get('/api/reward/list')
return (await list.json()).rewards?.find((r: any) => r.name === name)?.id ?? ''
}
@@ -160,9 +162,7 @@ test.describe('Pending reward warning dialog', () => {
test('Pending reward card shows PENDING banner', async ({ page }) => {
await page.goto(`/parent/${childId}`)
const card = rewardSection(page)
.locator('.item-card')
.filter({ hasText: PENDING_REWARD_NAME })
const card = rewardSection(page).locator('.item-card').filter({ hasText: PENDING_REWARD_NAME })
await card.waitFor({ state: 'visible' })
await expect(card.locator('.pending')).toHaveText('PENDING')
})
@@ -177,12 +177,12 @@ test.describe('Pending reward warning dialog', () => {
await activateItem(card)
// Pending reward warning dialog appears (has a "No" button)
await expect(page.getByRole('button', { name: 'No', exact: true })).toBeVisible()
await page.getByRole('button', { name: 'No', exact: true }).click()
// Pending reward warning dialog appears
await expect(page.getByRole('button', { name: 'Reject', exact: true })).toBeVisible()
await page.getByRole('button', { name: 'Reject', exact: true }).click()
// Dialog dismissed; task confirmation dialog must NOT have appeared
await expect(page.getByRole('button', { name: 'No', exact: true })).not.toBeVisible()
await expect(page.getByRole('button', { name: 'Reject', exact: true })).not.toBeVisible()
await expect(page.getByRole('button', { name: 'Cancel' })).not.toBeVisible()
// Points unchanged
await expect(page.locator('.points .value')).toHaveText(String(pointsBefore))
@@ -204,8 +204,8 @@ test.describe('Pending reward warning dialog', () => {
await activateItem(card)
// Pending reward warning dialog
await expect(page.getByRole('button', { name: 'No', exact: true })).toBeVisible()
await page.getByRole('button', { name: 'Yes' }).click()
await expect(page.getByRole('button', { name: 'Reject', exact: true })).toBeVisible()
await page.getByRole('button', { name: 'Approve', exact: true }).click()
// After confirming, the task confirmation dialog appears (has "Cancel" button)
await expect(page.getByRole('button', { name: 'Cancel' })).toBeVisible({ timeout: 3000 })
@@ -219,7 +219,9 @@ test.describe('Pending reward warning dialog', () => {
// ── kindness ──────────────────────────────────────────────────────────────
test('Kindness activation — Cancel on pending dialog leaves state unchanged', async ({ page }) => {
test('Kindness activation — Cancel on pending dialog leaves state unchanged', async ({
page,
}) => {
await page.goto(`/parent/${childId}`)
const card = kindnessSection(page).locator('.item-card').filter({ hasText: KIND_NAME })
await card.waitFor({ state: 'visible' })
@@ -227,10 +229,10 @@ test.describe('Pending reward warning dialog', () => {
await activateItem(card)
await expect(page.getByRole('button', { name: 'No', exact: true })).toBeVisible()
await page.getByRole('button', { name: 'No', exact: true }).click()
await expect(page.getByRole('button', { name: 'Reject', exact: true })).toBeVisible()
await page.getByRole('button', { name: 'Reject', exact: true }).click()
await expect(page.getByRole('button', { name: 'No', exact: true })).not.toBeVisible()
await expect(page.getByRole('button', { name: 'Reject', exact: true })).not.toBeVisible()
await expect(page.getByRole('button', { name: 'Cancel' })).not.toBeVisible()
await expect(page.locator('.points .value')).toHaveText(String(pointsBefore))
})
@@ -245,8 +247,8 @@ test.describe('Pending reward warning dialog', () => {
await activateItem(card)
await expect(page.getByRole('button', { name: 'No', exact: true })).toBeVisible()
await page.getByRole('button', { name: 'Yes' }).click()
await expect(page.getByRole('button', { name: 'Reject', exact: true })).toBeVisible()
await page.getByRole('button', { name: 'Approve', exact: true }).click()
await expect(page.getByRole('button', { name: 'Cancel' })).toBeVisible({ timeout: 3000 })
await page.getByRole('button', { name: 'Yes' }).click()
@@ -266,10 +268,10 @@ test.describe('Pending reward warning dialog', () => {
await activateItem(card)
await expect(page.getByRole('button', { name: 'No', exact: true })).toBeVisible()
await page.getByRole('button', { name: 'No', exact: true }).click()
await expect(page.getByRole('button', { name: 'Reject', exact: true })).toBeVisible()
await page.getByRole('button', { name: 'Reject', exact: true }).click()
await expect(page.getByRole('button', { name: 'No', exact: true })).not.toBeVisible()
await expect(page.getByRole('button', { name: 'Reject', exact: true })).not.toBeVisible()
await expect(page.getByRole('button', { name: 'Cancel' })).not.toBeVisible()
await expect(page.locator('.points .value')).toHaveText(String(pointsBefore))
})
@@ -284,8 +286,8 @@ test.describe('Pending reward warning dialog', () => {
await activateItem(card)
await expect(page.getByRole('button', { name: 'No', exact: true })).toBeVisible()
await page.getByRole('button', { name: 'Yes' }).click()
await expect(page.getByRole('button', { name: 'Reject', exact: true })).toBeVisible()
await page.getByRole('button', { name: 'Approve', exact: true }).click()
await expect(page.getByRole('button', { name: 'Cancel' })).toBeVisible({ timeout: 3000 })
await page.getByRole('button', { name: 'Yes' }).click()
@@ -320,10 +322,10 @@ test.describe('Pending reward warning dialog', () => {
await activateItem(otherCard)
// Pending reward warning dialog appears when another reward has a pending request
await expect(page.getByRole('button', { name: 'No', exact: true })).toBeVisible()
await page.getByRole('button', { name: 'No', exact: true }).click()
await expect(page.getByRole('button', { name: 'Reject', exact: true })).toBeVisible()
await page.getByRole('button', { name: 'Reject', exact: true }).click()
await expect(page.getByRole('button', { name: 'No', exact: true })).not.toBeVisible()
await expect(page.getByRole('button', { name: 'Reject', exact: true })).not.toBeVisible()
await expect(page.getByRole('button', { name: 'Cancel' })).not.toBeVisible()
await expect(page.locator('.points .value')).toHaveText(String(pointsBefore))
// PENDING banner still visible — reward request was not cancelled
@@ -346,11 +348,11 @@ test.describe('Pending reward warning dialog', () => {
await activateItem(otherCard)
await expect(page.getByRole('button', { name: 'No', exact: true })).toBeVisible()
await page.getByRole('button', { name: 'Yes' }).click()
await expect(page.getByRole('button', { name: 'Reject', exact: true })).toBeVisible()
await page.getByRole('button', { name: 'Approve', exact: true }).click()
// Pending dialog closes; reward confirm does NOT auto-open (other reward must be re-clicked)
await expect(page.getByRole('button', { name: 'No', exact: true })).not.toBeVisible()
await expect(page.getByRole('button', { name: 'Reject', exact: true })).not.toBeVisible()
await expect(page.getByRole('button', { name: 'Cancel' })).not.toBeVisible()
// The pending reward request was cancelled — PENDING banner should disappear

View File

@@ -152,7 +152,7 @@ test.describe('Reward redemption', () => {
await activateItem(card)
await expect(page.getByRole('button', { name: 'Yes' })).toBeVisible()
await page.getByRole('button', { name: 'Cancel' }).click()
await page.getByRole('button', { name: 'No', exact: true }).click()
await expect(page.getByRole('button', { name: 'Yes' })).not.toBeVisible()
await expect(page.locator('.points .value')).toHaveText(String(before))

View File

@@ -45,6 +45,28 @@ async function openEditModal(page: Page, card: Locator): Promise<void> {
await expect(page.getByRole('button', { name: 'Save' })).toBeVisible()
}
async function seedPendingReward(
request: APIRequestContext,
childId: string,
rewardId: string,
rewardCost: number,
): Promise<void> {
await request.put(`/api/child/${childId}/edit`, { data: { points: rewardCost } })
const requestResp = await request.post(`/api/child/${childId}/request-reward`, {
data: { reward_id: rewardId },
})
if (requestResp.status() === 409) {
await request.post(`/api/child/${childId}/cancel-request-reward`, {
data: { reward_id: rewardId },
})
await request.post(`/api/child/${childId}/request-reward`, {
data: { reward_id: rewardId },
})
}
}
test.describe('Reward edit cost', () => {
test.describe.configure({ mode: 'serial' })
@@ -172,9 +194,8 @@ test.describe('Reward edit cost', () => {
page,
request,
}) => {
// Give child enough points to satisfy the original reward cost, then create a pending request
await request.put(`/api/child/${childId}/edit`, { data: { points: REWARD_COST } })
await request.post(`/api/child/${childId}/request-reward`, { data: { reward_id: rewardId } })
// Ensure a fresh pending request exists for this test.
await seedPendingReward(request, childId, rewardId, REWARD_COST)
await page.goto(`/parent/${childId}`)
const card = rewardSection(page).locator('.item-card').filter({ hasText: REWARD_NAME })
@@ -187,18 +208,21 @@ test.describe('Reward edit cost', () => {
await card.getByTitle('Edit custom value').click()
// PendingRewardDialog should appear warning about the pending state
await expect(page.locator('.modal-message')).toContainText('pending')
await expect(page.getByText(/currently pending/i)).toBeVisible()
// Clicking "No" closes the dialog without opening the override modal
await page.getByRole('button', { name: 'No', exact: true }).click()
await expect(page.locator('.modal-message')).not.toBeVisible()
// Clicking "Reject" closes the dialog without opening the override modal
await page.getByRole('button', { name: 'Reject', exact: true }).click()
await expect(page.getByText(/currently pending/i)).not.toBeVisible()
await expect(page.getByRole('button', { name: 'Save' })).not.toBeVisible()
})
test('Editing a pending reward — confirming the warning opens the override modal', async ({
page,
request,
}) => {
// Pending state was established in the previous test; navigate fresh
// Seed pending state in this test to avoid cross-test coupling.
await seedPendingReward(request, childId, rewardId, REWARD_COST)
await page.goto(`/parent/${childId}`)
const card = rewardSection(page).locator('.item-card').filter({ hasText: REWARD_NAME })
await card.waitFor({ state: 'visible' })
@@ -209,10 +233,10 @@ test.describe('Reward edit cost', () => {
await card.getByTitle('Edit custom value').click()
// PendingRewardDialog visible
await expect(page.locator('.modal-message')).toContainText('pending')
await expect(page.getByText(/currently pending/i)).toBeVisible()
// Confirming cancels the pending request and opens the override modal
await page.getByRole('button', { name: 'Yes' }).click()
await page.getByRole('button', { name: 'Approve', exact: true }).click()
await expect(page.getByRole('button', { name: 'Save' })).toBeVisible({ timeout: 5000 })
await page.getByRole('button', { name: 'Cancel' }).click()
})

View File

@@ -0,0 +1,28 @@
-----BEGIN PRIVATE KEY-----
MIIEvAIBADANBgkqhkiG9w0BAQEFAASCBKYwggSiAgEAAoIBAQC3VWwSC3U5hnOx
kznjtzIKmzuWJ/BtEg+6lh9n1KSqjnF06Q2jXj3uxfQmqZBrIDhlhbt6ZfMsHZW9
lIPsGX2vSwzNhZd9AqMdgwZK7NeLh+Hd3FDTlr4xgDrt5Rb6FQHs8rg7Dh1etwfn
6ZTKE05jtiZ1PKEci59YkgwvYfrv9PYUpXMrAVNW+OHtnHuRb32zdmfQrNEqmWqb
v7UY2+FJxTSHGhYW92asbnbuctnlbRLg66p3PjGWYHoBWF0tVwaaCKkRcHqeQQu+
bfrXWY8APUo4osrI5tl6LkvmIq85zTZ9zq92l9LG+nRegwFKNHbhSB2Qf62D4YH9
X91k5b5/AgMBAAECggEAAY+aofSzBNBeP87PGV8/79MuiLLCW0tiEuagUdP/quwX
jzVExnVQ9a19zK546lCV6ldfJ0Wi8mc2FS0kimgVJ97ttvpCNfBFz0SEUzL9CtUX
WTo8/fA0oltDJS9kKLDxGUFfzDDskxff21ujxqyvaC3u2eSwQnv12V00+VpONqjN
lbpOJhKBfvzziMtgYVqqhG12d6gavLobmoz/xBeLrmg6AG6ZCdXnhZlsC4v+GRKM
WvmImHhfqB/kkVsUO5cgXMpZhRrmtByts0PzJ1Be8fwJLkXR2oJTlNf5gPYEIOqG
LMcxvJfGso5QwV9oV4ObtXhN9jXSgW5w8liyvM7LIQKBgQDNDUhan0EDFSYCJ4vt
TvDmBUIvARncZWSClFFA1/wwUUHGPMcJo5FkOB59JWhW8+QCG/3TRszLkDJUy4O2
MEGzivrEgjycxGkLZJ8KG1Jjuj14c7fCuUVTHtIJD4eBAsPfTypcuhoXl8YAHwxH
hiQbCEORnjoys9dVO1jZgHFR5wKBgQDk4rYn+KQRLW6evk5pONa8zH4iRphH4xk6
TrmMhuykJYQpK8URweBXGQpncsXbdV4z2wBjkxhWMm4Ona1iyCida2bBBkrkugM2
g0JcbVX15Embfc6Hc5AMs6ZsnfADlFQUhS6jlFeE5VXNZQJTfloeyz+ICjoj/JDi
Eu+ULShLqQKBgGxZpm/sUugUFr9wsim1WunQwYYg6M9i7Fdrk/vVpTbK2RytJOdc
/Qid9s5eI+I+ga7zp44qjTDLgyz3VSPCIBWFTLjlsK2Nw4v3oWovwbtcv/qT+vfz
+kPPt2B+SjXLhkDLjjDtTbhFxKRvw4dPxGhcV4fsugfsq84ny+0yR67lAoGAQaiH
eI/rAMJ3qTIObEDR2PcQd+SoanbLFd7fe2B5Id1hPC5CKgXjxRh505MpDvtsOpPo
WKgpoxB0Ydz5kAy7Ge1lXJnhghuaMFkXAEydDBygwOomBNUxzXL7mszzvRMfy4Mp
DePP91+SbYk8UZc9YvgLEYtdglVBepjUAT2zAYECgYAzSQC+vGDdBlSRemwy72Nm
SJ1/2VKS94cMjCLfJ7+Q/TKxbH4t4PM8mbPYM21wkWZk+vUlDtK2p1DEnoFIIFdF
f+oHJc6TpDuLAEmbbOkYv+QeXLNXC31AFlpusUYWPs/uqhq7R7Ymp61Y7XIcWH/t
SW09F1L0b28LWYZOh38aug==
-----END PRIVATE KEY-----

26
frontend/localhost+1.pem Normal file
View File

@@ -0,0 +1,26 @@
-----BEGIN CERTIFICATE-----
MIIEdDCCAtygAwIBAgIQZzySCYorx8yxVddqZLN0tDANBgkqhkiG9w0BAQsFADCB
kzEeMBwGA1UEChMVbWtjZXJ0IGRldmVsb3BtZW50IENBMTQwMgYDVQQLDCtyeWFu
QG1hY2Jvb2sudGFpbGExNDZiMy50cy5uZXQgKFJ5YW4gS2VnZWwpMTswOQYDVQQD
DDJta2NlcnQgcnlhbkBtYWNib29rLnRhaWxhMTQ2YjMudHMubmV0IChSeWFuIEtl
Z2VsKTAeFw0yNjA0MzAwMTQyMjhaFw0yODA3MzAwMTQyMjhaMF8xJzAlBgNVBAoT
Hm1rY2VydCBkZXZlbG9wbWVudCBjZXJ0aWZpY2F0ZTE0MDIGA1UECwwrcnlhbkBt
YWNib29rLnRhaWxhMTQ2YjMudHMubmV0IChSeWFuIEtlZ2VsKTCCASIwDQYJKoZI
hvcNAQEBBQADggEPADCCAQoCggEBALdVbBILdTmGc7GTOeO3MgqbO5Yn8G0SD7qW
H2fUpKqOcXTpDaNePe7F9CapkGsgOGWFu3pl8ywdlb2Ug+wZfa9LDM2Fl30Cox2D
Bkrs14uH4d3cUNOWvjGAOu3lFvoVAezyuDsOHV63B+fplMoTTmO2JnU8oRyLn1iS
DC9h+u/09hSlcysBU1b44e2ce5FvfbN2Z9Cs0SqZapu/tRjb4UnFNIcaFhb3Zqxu
du5y2eVtEuDrqnc+MZZgegFYXS1XBpoIqRFwep5BC75t+tdZjwA9Sjiiysjm2Xou
S+YirznNNn3Or3aX0sb6dF6DAUo0duFIHZB/rYPhgf1f3WTlvn8CAwEAAaN3MHUw
DgYDVR0PAQH/BAQDAgWgMBMGA1UdJQQMMAoGCCsGAQUFBwMBMB8GA1UdIwQYMBaA
FLM+akEIDt75nyHZrbkUs/4IQWesMC0GA1UdEQQmMCSCCWxvY2FsaG9zdIIXUnlh
bnMtTWFjQm9vay1BaXIubG9jYWwwDQYJKoZIhvcNAQELBQADggGBABF6bepj6zob
9aZI69drzaKpYZ2kGFBuK91GawcNvGWfcP69ClT0ZfFG/UP7gkPyJgn4lCR8b909
iGK9M8CZvAaNdZKtwXgfH+GNZZO+YiwymKrwX+/Vf+VgoDDdlhwtoQWX0Pn8nLL3
qQCZcg5NduxybTtlZPZ5miAPPu63AvtUAwFlV1otrQPUs0baoEbY4URBwzRYn4Si
NL3/It1PAYx6ulxc+ctEIDernaQbVzd22MWtY3hFOt/eFKSXPZfuCTk6PVmwGR/Q
+RPuDO7sGCzcSWpKEk/liMNhm3neVSXgFMouhMch0mgW3uqLV9A45MXs0+JVZcoK
cJeo1p3GjJyWgtHSEPAcKtvaYyErU9tg18/pK9hKOxdVYl/OtusmNRfLQI8Vne/e
tl7Id/hfP1lb1FAQFENEvlHHcOVeZFMgVcRnA3dV3HEaEFKXWAnYIaLVE7WPnasq
E/774GpF3ONxJ0kGvy0f4gcrnO5UHwMgtbYL88UAvxNMa6kUd9+REw==
-----END CERTIFICATE-----

View File

@@ -0,0 +1,37 @@
events {}
http {
include /etc/nginx/mime.types;
default_type application/octet-stream;
server {
client_max_body_size 2M;
listen 80;
server_name _;
root /usr/share/nginx/html;
location /api/ {
proxy_pass http://$BACKEND_HOST:5000/;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
location /events {
proxy_pass http://$BACKEND_HOST:5000/events;
proxy_set_header Host $host;
proxy_set_header Connection '';
proxy_http_version 1.1;
chunked_transfer_encoding off;
proxy_buffering off;
proxy_cache off;
proxy_read_timeout 36000s;
proxy_send_timeout 36000s;
}
location / {
try_files $uri $uri/ /index.html;
}
}
}

View File

@@ -16,7 +16,6 @@
"@tsconfig/node22": "^22.0.2",
"@types/jsdom": "^27.0.0",
"@types/node": "^22.18.11",
"@vitejs/plugin-basic-ssl": "^2.3.0",
"@vitejs/plugin-vue": "^6.0.1",
"@vitest/eslint-plugin": "^1.3.23",
"@vue/eslint-config-prettier": "^10.2.0",
@@ -2172,19 +2171,6 @@
"url": "https://opencollective.com/eslint"
}
},
"node_modules/@vitejs/plugin-basic-ssl": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/@vitejs/plugin-basic-ssl/-/plugin-basic-ssl-2.3.0.tgz",
"integrity": "sha512-bdyo8rB3NnQbikdMpHaML9Z1OZPBu6fFOBo+OtxsBlvMJtysWskmBcnbIDhUqgC8tcxNv/a+BcV5U+2nQMm1OQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": "^18.0.0 || ^20.0.0 || >=22.0.0"
},
"peerDependencies": {
"vite": "^6.0.0 || ^7.0.0 || ^8.0.0"
}
},
"node_modules/@vitejs/plugin-vue": {
"version": "6.0.1",
"resolved": "https://registry.npmjs.org/@vitejs/plugin-vue/-/plugin-vue-6.0.1.tgz",

View File

@@ -26,7 +26,6 @@
"@tsconfig/node22": "^22.0.2",
"@types/jsdom": "^27.0.0",
"@types/node": "^22.18.11",
"@vitejs/plugin-basic-ssl": "^2.3.0",
"@vitejs/plugin-vue": "^6.0.1",
"@vitest/eslint-plugin": "^1.3.23",
"@vue/eslint-config-prettier": "^10.2.0",

View File

@@ -1,4 +1,4 @@
/* Service Worker for Reward App push notifications */
/* Service Worker for Chorly push notifications */
/* Activate immediately — no waiting for old tabs to close */
self.addEventListener('install', function (event) {
@@ -16,17 +16,23 @@ self.addEventListener('push', function (event) {
try {
payload = event.data.json()
} catch (e) {
payload = { title: 'Reward App', body: event.data.text() }
payload = { title: 'Chorly', body: event.data.text() }
}
const title = payload.title || 'Reward App'
const title = payload.title || 'Chorly'
// Android Chrome has a bug where event.action is unreliable — only show Approve
// so tapping it always executes the correct action.
const isAndroidChrome =
/Android/.test(self.navigator.userAgent) && /Chrome\//.test(self.navigator.userAgent)
// Expiry-warning notifications are informational only — no action buttons
const actions =
payload.type === 'chore_expiring_soon'
? []
: isAndroidChrome
? [{ action: 'approve', title: 'Approve' }]
: [
{ action: 'approve', title: 'Approve' },
{ action: 'deny', title: 'Deny' },
{ action: 'deny', title: 'Reject' },
]
const options = {
body: payload.body || '',

View File

@@ -224,9 +224,13 @@ const triggerTask = async (task: ChildTask) => {
function isChoreCompletedToday(item: ChildTask): boolean {
if (item.pending_status !== 'approved' || !item.approved_at) return false
const approvedDate = item.approved_at.substring(0, 10)
const today = new Date().toISOString().slice(0, 10)
return approvedDate === today
const approvedDate = new Date(item.approved_at)
const today = new Date()
return (
approvedDate.getFullYear() === today.getFullYear() &&
approvedDate.getMonth() === today.getMonth() &&
approvedDate.getDate() === today.getDate()
)
}
async function doConfirmChore() {
@@ -448,6 +452,7 @@ function isChoreScheduledToday(item: ChildTask): boolean {
}
function isChoreExpired(item: ChildTask): boolean {
if (item.pending_status === 'pending') return false
if (!item.schedule) return false
const today = new Date()
const due = getDueTimeToday(item.schedule, today)

View File

@@ -1,5 +1,5 @@
<script setup lang="ts">
import { ref, onMounted, onUnmounted, watch, nextTick } from 'vue'
import { computed, ref, onMounted, onUnmounted, watch, nextTick } from 'vue'
import ModalDialog from '../shared/ModalDialog.vue'
import ScheduleModal from '../shared/ScheduleModal.vue'
import PendingRewardDialog from './PendingRewardDialog.vue'
@@ -95,6 +95,14 @@ const kebabBtnRefs = ref<Map<string, HTMLElement>>(new Map())
const showScheduleModal = ref(false)
const scheduleTarget = ref<ChildTask | null>(null)
const pendingDialogReward = computed<Reward | RewardStatus | null>(() => {
if (pendingEditOverrideTarget.value?.type === 'reward') {
return pendingEditOverrideTarget.value.entity as Reward
}
return selectedReward.value
})
// Expiry timers
const expiryTimers = ref<number[]>([])
@@ -112,7 +120,6 @@ function handleChoreItemReady(itemId: string) {
}
function handleTaskTriggered(event: Event) {
console.log('Task triggered, refreshing rewards list -> ', childRewardListRef.value)
const payload = event.payload as ChildTaskTriggeredEventPayload
if (child.value && payload.child_id == child.value.id) {
child.value.points = payload.points
@@ -300,8 +307,13 @@ function handleChoreConfirmation(event: Event) {
function isChoreCompletedToday(item: ChildTask): boolean {
if (item.pending_status !== 'approved' || !item.approved_at) return false
const today = new Date().toISOString().slice(0, 10)
if (item.approved_at.slice(0, 10) !== today) return false
const approvedDate = new Date(item.approved_at)
const today = new Date()
const sameDay =
approvedDate.getFullYear() === today.getFullYear() &&
approvedDate.getMonth() === today.getMonth() &&
approvedDate.getDate() === today.getDate()
if (!sameDay) return false
// If the task has a schedule and today is not a scheduled day, don't show as completed
if (item.schedule && !isScheduledToday(item.schedule, new Date())) return false
return true
@@ -445,6 +457,7 @@ function isChoreScheduledToday(item: ChildTask): boolean {
}
function isChoreExpired(item: ChildTask): boolean {
if (item.pending_status === 'pending') return false
if (!item.schedule) return false
const now = new Date()
if (!isScheduledToday(item.schedule, now)) return false
@@ -628,6 +641,33 @@ function applyHighlightPulse(itemId: string) {
}, 200)
}
// Handle digestToken appearing in route query (e.g. SW navigates to same route with new token)
watch(
() => route.query.digestToken,
async (token) => {
if (typeof token !== 'string' || !token) return
try {
const res = await fetch(`/api/digest-action/${token}`, {
method: 'POST',
credentials: 'include',
})
const data = await res.json().catch(() => ({}))
console.log('[ParentView] digest-action POST status=', res.status, 'body=', data)
} catch (e) {
console.warn('[ParentView] Digest action request failed:', e)
}
// Refresh chore and reward lists to reflect the action result
childChoreListRef.value?.refresh()
childRewardListRef.value?.refresh()
if (child.value?.id) {
const updated = await fetchChildData(child.value.id)
if (updated) {
child.value = updated
}
}
},
)
onMounted(async () => {
try {
eventBus.on('child_task_triggered', handleTaskTriggered)
@@ -660,12 +700,8 @@ onMounted(async () => {
method: 'POST',
credentials: 'include',
})
if (!res.ok) {
const data = await res.json().catch(() => ({}))
console.warn('Digest action failed:', data.error || res.status)
}
} catch (e) {
console.warn('Digest action request failed:', e)
console.warn('[ParentView] onMounted digest action request failed:', e)
}
}
@@ -687,6 +723,8 @@ onMounted(async () => {
childRewardListRef.value?.scrollToItem(scrollToId)
applyHighlightPulse(scrollToId)
}
const { scrollTo: _s, entityType: _e, ...remainingQuery } = route.query
router.replace({ query: remainingQuery })
}, 500)
}
})
@@ -748,6 +786,7 @@ const triggerTask = (task: ChildTask) => {
selectedTask.value = task
const pendingRewardIds = getPendingRewardIds()
if (pendingRewardIds.length > 0) {
selectedReward.value = null
showPendingRewardDialog.value = true
return
}
@@ -812,13 +851,13 @@ const confirmTriggerTask = async () => {
const triggerReward = (reward: RewardStatus) => {
if (reward.points_needed > 0) return
selectedReward.value = reward
// If there is a pending reward and it's not this one, show the pending dialog
const pendingRewardIds = getPendingRewardIds()
if (pendingRewardIds.length > 0 && !reward.redeeming) {
showPendingRewardDialog.value = true
return
}
selectedReward.value = reward
setTimeout(() => {
showRewardConfirm.value = true
}, 150)
@@ -1129,6 +1168,16 @@ function goToAssignRewards() {
<!-- Pending Reward Dialog -->
<PendingRewardDialog
v-if="showPendingRewardDialog"
:reward-name="pendingDialogReward?.name"
:child-name="pendingDialogReward ? child?.name : undefined"
:image-url="pendingDialogReward?.image_url ?? null"
:subtitle="
pendingDialogReward
? pendingDialogReward.points_needed === 0
? 'Reward Ready!'
: pendingDialogReward.points_needed + ' more points'
: undefined
"
:message="
pendingEditOverrideTarget
? 'This reward is currently pending. Changing its cost will cancel the pending request. Would you like to proceed?'
@@ -1139,6 +1188,7 @@ function goToAssignRewards() {
() => {
showPendingRewardDialog = false
pendingEditOverrideTarget = null
selectedReward = null
}
"
/>

View File

@@ -1,11 +1,18 @@
<template>
<ModalDialog title="Warning!" @backdrop-click="$emit('cancel')">
<div class="modal-message">
{{ message }}
<ModalDialog @backdrop-click="$emit('cancel')">
<div class="approve-dialog">
<img v-if="imageUrl" :src="imageUrl" alt="Reward" class="reward-image" />
<p v-if="rewardName" class="item-label">{{ rewardName }}</p>
<p v-if="subtitle" class="subtitle">{{ subtitle }}</p>
<p v-if="rewardName && childName" class="message">
Redeem this reward for <strong>{{ childName }}</strong
>?
</p>
<p class="message">{{ message }}</p>
<div class="actions">
<button @click="$emit('confirm')" class="btn btn-primary">Approve</button>
<button @click="$emit('cancel')" class="btn btn-secondary">Reject</button>
</div>
<div class="modal-actions">
<button @click="$emit('confirm')" class="btn btn-primary">Yes</button>
<button @click="$emit('cancel')" class="btn btn-secondary">No</button>
</div>
</ModalDialog>
</template>
@@ -16,9 +23,17 @@ import ModalDialog from '../shared/ModalDialog.vue'
withDefaults(
defineProps<{
message?: string
rewardName?: string
childName?: string
imageUrl?: string | null
subtitle?: string
}>(),
{
message: 'A reward is currently pending. It will be cancelled. Would you like to proceed?',
rewardName: undefined,
childName: undefined,
imageUrl: null,
subtitle: undefined,
},
)
@@ -29,9 +44,58 @@ defineEmits<{
</script>
<style scoped>
.modal-message {
margin-bottom: 1.2rem;
.approve-dialog {
text-align: center;
padding: 0.5rem;
}
.reward-image {
width: 72px;
height: 72px;
object-fit: cover;
border-radius: 8px;
background: var(--info-image-bg);
margin-bottom: 0.75rem;
}
.item-label {
font-size: 1.2rem;
font-weight: 700;
color: var(--dialog-child-name);
margin-bottom: 0.15rem;
}
.subtitle {
font-size: 1rem;
color: var(--modal-message-color, #333);
font-weight: 600;
color: var(--dialog-child-name);
margin-bottom: 1rem;
}
.message {
font-size: 1rem;
color: var(--dialog-message);
margin-bottom: 0.15rem;
}
.message:last-of-type {
margin-bottom: 1.5rem;
}
.actions {
display: flex;
gap: 1.5rem;
justify-content: center;
}
.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;
}
</style>

View File

@@ -1,19 +1,22 @@
<template>
<ModalDialog
v-if="reward"
:imageUrl="reward.image_url"
:title="reward.name"
:subtitle="reward.points_needed === 0 ? 'Reward Ready!' : reward.points_needed + ' more points'"
@backdrop-click="$emit('cancel')"
>
<div class="modal-message">
Redeem this reward for <span class="child-name">{{ childName }}</span
<ModalDialog v-if="reward" @backdrop-click="$emit('cancel')">
<div class="approve-dialog">
<img v-if="reward.image_url" :src="reward.image_url" alt="Reward" class="reward-image" />
<p class="item-label">{{ reward.name }}</p>
<p class="subtitle">
{{ reward.points_needed === 0 ? 'Reward Ready!' : reward.points_needed + ' more points' }}
</p>
<p class="message">
Redeem this reward for <strong>{{ childName }}</strong
>?
</div>
<div class="modal-actions">
</p>
<div class="actions">
<button @click="$emit('confirm')" class="btn btn-primary">Yes</button>
<button v-if="reward?.redeeming" @click="$emit('deny')" class="btn btn-danger">Deny</button>
<button @click="$emit('cancel')" class="btn btn-secondary">Cancel</button>
<button v-if="reward.redeeming" @click="$emit('deny')" class="btn btn-secondary">
Reject
</button>
<button v-else @click="$emit('cancel')" class="btn btn-secondary">No</button>
</div>
</div>
</ModalDialog>
</template>
@@ -35,14 +38,54 @@ defineEmits<{
</script>
<style scoped>
.modal-message {
margin-bottom: 1.2rem;
font-size: 1rem;
color: var(--modal-message-color, #333);
.approve-dialog {
text-align: center;
padding: 0.5rem;
}
.child-name {
.reward-image {
width: 72px;
height: 72px;
object-fit: cover;
border-radius: 8px;
background: var(--info-image-bg);
margin-bottom: 0.75rem;
}
.item-label {
font-size: 1.2rem;
font-weight: 700;
color: var(--dialog-child-name);
margin-bottom: 0.15rem;
}
.subtitle {
font-size: 1rem;
font-weight: 600;
color: var(--text-primary, #333);
color: var(--dialog-child-name);
margin-bottom: 1rem;
}
.message {
font-size: 1rem;
color: var(--dialog-message);
margin-bottom: 1.5rem;
}
.actions {
display: flex;
gap: 1.5rem;
justify-content: center;
}
.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;
}
</style>

View File

@@ -1,6 +1,6 @@
<template>
<div class="modal-backdrop" @click.self="$emit('backdrop-click')">
<div class="modal-dialog">
<div class="modal-dialog" :style="{ maxWidth: dialogMaxWidth }">
<div class="modal-heading">
<img v-if="imageUrl" :src="imageUrl" alt="Dialog Image" class="modal-image" />
<div class="modal-details">
@@ -18,6 +18,7 @@ defineProps<{
imageUrl?: string | null | undefined
title?: string
subtitle?: string | null | undefined
dialogMaxWidth?: string
}>()
defineEmits<{

View File

@@ -1,23 +1,44 @@
import { fileURLToPath, URL } from 'node:url'
import { defineConfig, loadEnv } from 'vite'
import vue from '@vitejs/plugin-vue'
import basicSsl from '@vitejs/plugin-basic-ssl'
import fs from 'fs'
export default defineConfig(({ mode }) => {
const env = loadEnv(mode, process.cwd(), '')
const backendHost = env.VITE_BACKEND_HOST ?? '127.0.0.1'
const httpsConfig =
fs.existsSync('./key.pem') && fs.existsSync('./cert.pem')
? { key: fs.readFileSync('./key.pem'), cert: fs.readFileSync('./cert.pem') }
: undefined
// Use a trusted mkcert cert when present; otherwise fall back to plain HTTP.
// Browsers treat localhost as a secure context even over HTTP, so service
// workers and push notifications work without any cert on localhost.
//
// For cross-device LAN testing (e.g. from a phone), run mkcert once per
// network — the IP in the cert doesn't matter to Vite, just drop the files
// in frontend/ and they'll be picked up automatically:
//
// mkcert localhost <your-current-lan-ip>
// # files are auto-detected; no renaming needed
//
// All *.pem files are gitignored so they stay machine-local.
const httpsConfig = (() => {
// Prefer explicit key.pem / cert.pem (e.g. custom or renamed certs)
if (fs.existsSync('./key.pem') && fs.existsSync('./cert.pem'))
return { key: fs.readFileSync('./key.pem'), cert: fs.readFileSync('./cert.pem') }
// Auto-detect any mkcert-generated *-key.pem / *.pem pair in this directory.
// mkcert always names files <host>[-key].pem regardless of which IPs are included.
const keyFile = fs.readdirSync('.').find((f) => f.endsWith('-key.pem'))
if (keyFile) {
const certFile = keyFile.replace('-key.pem', '.pem')
if (fs.existsSync(certFile))
return { key: fs.readFileSync(keyFile), cert: fs.readFileSync(certFile) }
}
return undefined
})()
return {
plugins: [vue(), basicSsl()],
plugins: [vue()],
server: {
host: '0.0.0.0',
https: httpsConfig ?? true,
https: httpsConfig,
proxy: {
'/api': {
target: `http://${backendHost}:5000`,
@@ -25,7 +46,7 @@ export default defineConfig(({ mode }) => {
rewrite: (path) => path.replace(/^\/api/, ''),
},
'/events': {
target: 'http://192.168.1.102:5000',
target: `http://${backendHost}:5000`,
changeOrigin: true,
secure: false,
},