Compare commits
15 Commits
0606b71890
...
v1.0.13
| Author | SHA1 | Date | |
|---|---|---|---|
| 75d3d6dc39 | |||
| 3d882656e3 | |||
| 8308d205e8 | |||
| e77254eabf | |||
| a68a86a6a6 | |||
| 4ac83dcf17 | |||
| ab0d32c6b0 | |||
| 28f5c43349 | |||
| a2b464af7b | |||
| 6bf10fda2f | |||
| c840825549 | |||
| 9936cfd544 | |||
| b42c36ebdd | |||
| 395199f9b2 | |||
| a37b259f47 |
@@ -261,17 +261,20 @@ jobs:
|
|||||||
PREV_DIGEST_TOKEN_SECRET=""
|
PREV_DIGEST_TOKEN_SECRET=""
|
||||||
PREV_VAPID_PUBLIC_KEY=""
|
PREV_VAPID_PUBLIC_KEY=""
|
||||||
PREV_VAPID_PRIVATE_KEY=""
|
PREV_VAPID_PRIVATE_KEY=""
|
||||||
|
PREV_REFRESH_TOKEN_EXPIRY_DAYS=""
|
||||||
if [ -f .env ]; then
|
if [ -f .env ]; then
|
||||||
PREV_SECRET_KEY=$(grep '^SECRET_KEY=' .env | head -n1 | cut -d '=' -f2- || true)
|
PREV_SECRET_KEY=$(grep '^SECRET_KEY=' .env | head -n1 | cut -d '=' -f2- || true)
|
||||||
PREV_DIGEST_TOKEN_SECRET=$(grep '^DIGEST_TOKEN_SECRET=' .env | head -n1 | cut -d '=' -f2- || true)
|
PREV_DIGEST_TOKEN_SECRET=$(grep '^DIGEST_TOKEN_SECRET=' .env | head -n1 | cut -d '=' -f2- || true)
|
||||||
PREV_VAPID_PUBLIC_KEY=$(grep '^VAPID_PUBLIC_KEY=' .env | head -n1 | cut -d '=' -f2- || true)
|
PREV_VAPID_PUBLIC_KEY=$(grep '^VAPID_PUBLIC_KEY=' .env | head -n1 | cut -d '=' -f2- || true)
|
||||||
PREV_VAPID_PRIVATE_KEY=$(grep '^VAPID_PRIVATE_KEY=' .env | head -n1 | cut -d '=' -f2- || true)
|
PREV_VAPID_PRIVATE_KEY=$(grep '^VAPID_PRIVATE_KEY=' .env | head -n1 | cut -d '=' -f2- || true)
|
||||||
|
PREV_REFRESH_TOKEN_EXPIRY_DAYS=$(grep '^REFRESH_TOKEN_EXPIRY_DAYS=' .env | head -n1 | cut -d '=' -f2- || true)
|
||||||
fi
|
fi
|
||||||
|
|
||||||
SECRET_KEY_VALUE="${{ secrets.PROD_SECRET_KEY }}"
|
SECRET_KEY_VALUE="${{ secrets.PROD_SECRET_KEY }}"
|
||||||
DIGEST_TOKEN_SECRET_VALUE="${{ secrets.PROD_DIGEST_TOKEN_SECRET }}"
|
DIGEST_TOKEN_SECRET_VALUE="${{ secrets.PROD_DIGEST_TOKEN_SECRET }}"
|
||||||
VAPID_PUBLIC_KEY_VALUE="${{ secrets.PROD_VAPID_PUBLIC_KEY }}"
|
VAPID_PUBLIC_KEY_VALUE="${{ secrets.PROD_VAPID_PUBLIC_KEY }}"
|
||||||
VAPID_PRIVATE_KEY_VALUE="${{ secrets.PROD_VAPID_PRIVATE_KEY }}"
|
VAPID_PRIVATE_KEY_VALUE="${{ secrets.PROD_VAPID_PRIVATE_KEY }}"
|
||||||
|
REFRESH_TOKEN_EXPIRY_DAYS_VALUE="${{ secrets.PROD_REFRESH_TOKEN_EXPIRY_DAYS }}"
|
||||||
|
|
||||||
if [ -z "$SECRET_KEY_VALUE" ]; then
|
if [ -z "$SECRET_KEY_VALUE" ]; then
|
||||||
if [ -n "$PREV_SECRET_KEY" ]; then
|
if [ -n "$PREV_SECRET_KEY" ]; then
|
||||||
@@ -301,10 +304,25 @@ jobs:
|
|||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
if [ -z "$REFRESH_TOKEN_EXPIRY_DAYS_VALUE" ]; then
|
||||||
|
if [ -n "$PREV_REFRESH_TOKEN_EXPIRY_DAYS" ]; then
|
||||||
|
REFRESH_TOKEN_EXPIRY_DAYS_VALUE="$PREV_REFRESH_TOKEN_EXPIRY_DAYS"
|
||||||
|
else
|
||||||
|
REFRESH_TOKEN_EXPIRY_DAYS_VALUE="90"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
if ! printf '%s' "$REFRESH_TOKEN_EXPIRY_DAYS_VALUE" | grep -Eq '^[0-9]+$'; then
|
||||||
|
echo "REFRESH_TOKEN_EXPIRY_DAYS must be a positive integer, got: $REFRESH_TOKEN_EXPIRY_DAYS_VALUE"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
cat > .env << EOF
|
cat > .env << EOF
|
||||||
FRONTEND_URL=${{ secrets.PROD_FRONTEND_URL }}
|
FRONTEND_URL=${{ secrets.PROD_FRONTEND_URL }}
|
||||||
|
BACKEND_HOST_PORT=${{ secrets.PROD_BACKEND_HOST_PORT }}
|
||||||
|
FRONTEND_HOST_PORT=${{ secrets.PROD_FRONTEND_HOST_PORT }}
|
||||||
SECRET_KEY=${SECRET_KEY_VALUE}
|
SECRET_KEY=${SECRET_KEY_VALUE}
|
||||||
REFRESH_TOKEN_EXPIRY_DAYS=${{ secrets.PROD_REFRESH_TOKEN_EXPIRY_DAYS }}
|
REFRESH_TOKEN_EXPIRY_DAYS=${REFRESH_TOKEN_EXPIRY_DAYS_VALUE}
|
||||||
DIGEST_TOKEN_SECRET=${DIGEST_TOKEN_SECRET_VALUE}
|
DIGEST_TOKEN_SECRET=${DIGEST_TOKEN_SECRET_VALUE}
|
||||||
VAPID_PUBLIC_KEY=${VAPID_PUBLIC_KEY_VALUE}
|
VAPID_PUBLIC_KEY=${VAPID_PUBLIC_KEY_VALUE}
|
||||||
VAPID_PRIVATE_KEY=${VAPID_PRIVATE_KEY_VALUE}
|
VAPID_PRIVATE_KEY=${VAPID_PRIVATE_KEY_VALUE}
|
||||||
@@ -316,7 +334,52 @@ jobs:
|
|||||||
|
|
||||||
chmod 600 .env .rollback-meta
|
chmod 600 .env .rollback-meta
|
||||||
|
|
||||||
|
backend_host_port=$(grep '^BACKEND_HOST_PORT=' .env | head -n1 | cut -d '=' -f2- || true)
|
||||||
|
frontend_host_port=$(grep '^FRONTEND_HOST_PORT=' .env | head -n1 | cut -d '=' -f2- || true)
|
||||||
|
if [ -z "$backend_host_port" ]; then
|
||||||
|
backend_host_port="5001"
|
||||||
|
fi
|
||||||
|
if [ -z "$frontend_host_port" ]; then
|
||||||
|
frontend_host_port="4601"
|
||||||
|
fi
|
||||||
|
|
||||||
docker-compose down
|
docker-compose down
|
||||||
|
|
||||||
|
wait_for_container_removal() {
|
||||||
|
container_name="$1"
|
||||||
|
attempts=0
|
||||||
|
while docker ps -a --format '{{.Names}}' | grep -qx "$container_name"; do
|
||||||
|
attempts=$((attempts + 1))
|
||||||
|
if [ "$attempts" -ge 12 ]; then
|
||||||
|
echo "Container ${container_name} still exists after waiting for teardown."
|
||||||
|
docker ps -a --filter "name=^${container_name}$"
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
echo "Waiting for ${container_name} to be removed..."
|
||||||
|
sleep 5
|
||||||
|
done
|
||||||
|
}
|
||||||
|
|
||||||
|
wait_for_port_release() {
|
||||||
|
port="$1"
|
||||||
|
attempts=0
|
||||||
|
while docker ps -a --format '{{.ID}} {{.Names}} {{.Ports}} {{.Status}}' | grep -E "(^|[[:space:]])0\.0\.0\.0:${port}->|:::${port}->" >/dev/null 2>&1; do
|
||||||
|
attempts=$((attempts + 1))
|
||||||
|
if [ "$attempts" -ge 12 ]; then
|
||||||
|
echo "Port ${port} is still allocated after teardown. Blocking container(s):"
|
||||||
|
docker ps -a --format '{{.ID}} {{.Names}} {{.Ports}} {{.Status}}' | grep -E "(^|[[:space:]])0\.0\.0\.0:${port}->|:::${port}->" || true
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
echo "Waiting for port ${port} to be released..."
|
||||||
|
sleep 5
|
||||||
|
done
|
||||||
|
}
|
||||||
|
|
||||||
|
wait_for_container_removal chores-app-frontend-prod
|
||||||
|
wait_for_container_removal chores-app-backend-prod
|
||||||
|
wait_for_port_release "$frontend_host_port"
|
||||||
|
wait_for_port_release "$backend_host_port"
|
||||||
|
|
||||||
docker-compose pull
|
docker-compose pull
|
||||||
docker-compose up -d
|
docker-compose up -d
|
||||||
|
|
||||||
@@ -326,18 +389,22 @@ jobs:
|
|||||||
frontend_running=$(docker inspect -f '{{.State.Running}}' chores-app-frontend-prod 2>/dev/null || echo false)
|
frontend_running=$(docker inspect -f '{{.State.Running}}' chores-app-frontend-prod 2>/dev/null || echo false)
|
||||||
|
|
||||||
backend_ok=false
|
backend_ok=false
|
||||||
if curl -fsS http://localhost:5001/version >/dev/null 2>&1; then
|
if curl -fsS "http://localhost:${backend_host_port}/version" >/dev/null 2>&1; then
|
||||||
backend_ok=true
|
backend_ok=true
|
||||||
fi
|
fi
|
||||||
|
|
||||||
frontend_ok=false
|
frontend_ok=false
|
||||||
if curl -kfsS https://localhost/ >/dev/null 2>&1; then
|
if curl -fsS "http://localhost:${frontend_host_port}/" >/dev/null 2>&1; then
|
||||||
frontend_ok=true
|
frontend_ok=true
|
||||||
fi
|
fi
|
||||||
|
|
||||||
if [ "$backend_running" != "true" ] || [ "$frontend_running" != "true" ] || [ "$backend_ok" != "true" ] || [ "$frontend_ok" != "true" ]; then
|
if [ "$backend_running" != "true" ] || [ "$frontend_running" != "true" ] || [ "$backend_ok" != "true" ] || [ "$frontend_ok" != "true" ]; then
|
||||||
echo "Post-deploy health check failed."
|
echo "Post-deploy health check failed."
|
||||||
docker-compose ps
|
docker-compose ps
|
||||||
|
echo "--- Backend logs (last 200 lines) ---"
|
||||||
|
docker logs --tail 200 chores-app-backend-prod || true
|
||||||
|
echo "--- Frontend logs (last 120 lines) ---"
|
||||||
|
docker logs --tail 120 chores-app-frontend-prod || true
|
||||||
|
|
||||||
if [ "${{ github.event.inputs.rollback_on_failed_healthcheck }}" = "true" ]; then
|
if [ "${{ github.event.inputs.rollback_on_failed_healthcheck }}" = "true" ]; then
|
||||||
echo "Attempting rollback using predeploy-backup image tags..."
|
echo "Attempting rollback using predeploy-backup image tags..."
|
||||||
@@ -345,7 +412,7 @@ jobs:
|
|||||||
docker tag git.ryankegel.com:3000/kegel/chores/frontend:predeploy-backup git.ryankegel.com:3000/kegel/chores/frontend:latest || true
|
docker tag git.ryankegel.com:3000/kegel/chores/frontend:predeploy-backup git.ryankegel.com:3000/kegel/chores/frontend:latest || true
|
||||||
docker-compose up -d
|
docker-compose up -d
|
||||||
sleep 20
|
sleep 20
|
||||||
curl -fsS http://localhost:5001/version >/dev/null
|
curl -fsS "http://localhost:${backend_host_port}/version" >/dev/null
|
||||||
fi
|
fi
|
||||||
|
|
||||||
exit 1
|
exit 1
|
||||||
@@ -409,3 +476,28 @@ jobs:
|
|||||||
- tests: ${{ needs.tests.result }}
|
- tests: ${{ needs.tests.result }}
|
||||||
- deploy: ${{ needs.deploy.result }}
|
- deploy: ${{ needs.deploy.result }}
|
||||||
- tag: ${{ needs.create-release-tag.result }}
|
- tag: ${{ needs.create-release-tag.result }}
|
||||||
|
|
||||||
|
- name: Send email when promotion succeeds
|
||||||
|
if: ${{ needs.prepare.result == 'success' && needs.tests.result == 'success' && (needs.deploy.result == 'success' || needs.deploy.result == 'skipped') && (needs.create-release-tag.result == 'success' || needs.create-release-tag.result == 'skipped') }}
|
||||||
|
uses: dawidd6/action-send-mail@v3
|
||||||
|
with:
|
||||||
|
server_address: smtp.gmail.com
|
||||||
|
server_port: 465
|
||||||
|
username: ${{ secrets.MAIL_USER }}
|
||||||
|
password: ${{ secrets.MAIL_PASSWORD }}
|
||||||
|
secure: true
|
||||||
|
to: ${{ secrets.MAIL_TO }}
|
||||||
|
from: Gitea <git@git.ryankegel.com>
|
||||||
|
subject: Promotion succeeded - ${{ gitea.repository }} [${{ needs.prepare.outputs.target_ref }}@${{ needs.prepare.outputs.commit_sha }}]
|
||||||
|
convert_markdown: true
|
||||||
|
html_body: |
|
||||||
|
### Production promotion succeeded
|
||||||
|
|
||||||
|
- Repository: ${{ gitea.repository }}
|
||||||
|
- Ref: ${{ needs.prepare.outputs.target_ref }}
|
||||||
|
- Commit: ${{ needs.prepare.outputs.commit_sha }}
|
||||||
|
- Release tag: ${{ needs.prepare.outputs.release_tag }}
|
||||||
|
- prepare: ${{ needs.prepare.result }}
|
||||||
|
- tests: ${{ needs.tests.result }}
|
||||||
|
- deploy: ${{ needs.deploy.result }}
|
||||||
|
- tag: ${{ needs.create-release-tag.result }}
|
||||||
|
|||||||
6
.github/specs/feat-notify-ending-chore.md
vendored
6
.github/specs/feat-notify-ending-chore.md
vendored
@@ -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`: 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`: multiple chores → generic title, child_id/entity_id null
|
||||||
- [x] `_build_push_payload`: body groups chore names by child
|
- [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`: calls send_push_to_user with correct payload
|
||||||
- [x] `send_chore_expiry_notifications_for_user`: no push when no chores expiring
|
- [x] `send_chore_expiry_notifications_for_user`: no push when no chores expiring
|
||||||
- [x] `run_chore_expiry_check`: skips when DB_ENV=e2e
|
- [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)
|
**`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
|
## Frontend Tests
|
||||||
|
|
||||||
@@ -143,6 +143,6 @@ No automated tests added. The `sw.js` change is a simple conditional and is cove
|
|||||||
|
|
||||||
### Frontend
|
### 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 single-chore notification navigates to the child's chore page
|
||||||
- [x] Tapping a multi-chore notification navigates to `/parent`
|
- [x] Tapping a multi-chore notification navigates to `/parent`
|
||||||
|
|||||||
231
.github/specs/plan-routinesFeature.prompt.md
vendored
Normal file
231
.github/specs/plan-routinesFeature.prompt.md
vendored
Normal file
@@ -0,0 +1,231 @@
|
|||||||
|
# Plan: Routines Feature
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
A "Routines" system lets parents define a named checklist of simple items (e.g., "Morning Routine": Make Bed, Get Dressed, Eat Breakfast) worth X points. Children see routines in a scrollable list between Chores and Rewards in child mode. Tapping a routine opens a detail view showing the item list; a "Done" button submits it for parent approval. Points are awarded on parent approval. Supports scheduling (days/interval), deadlines, time extension, and per-child point overrides — all parallel to the existing chore system.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Key Design Decisions
|
||||||
|
|
||||||
|
- **Routine items** are their own minimal entity (name + optional image, no points). Points belong to the routine.
|
||||||
|
- **No per-item completion tracking** — detail screen is informational only. "Done" button submits the whole routine.
|
||||||
|
- **Assignment**: per-child, same pattern as tasks (child.routines: list[str]).
|
||||||
|
- **Parent approval**: approve/reject the whole routine (no item-level detail).
|
||||||
|
- **Scheduling**: new RoutineSchedule model parallel to ChoreSchedule (same structure, routine_id instead of task_id).
|
||||||
|
- **Point overrides**: reuse ChildOverride with entity_type='routine'.
|
||||||
|
- **Pending confirmations**: extend PendingConfirmation entity_type to include 'routine'.
|
||||||
|
- **Routine editor**: single-page with details section (name, points, image) + items sub-panel below.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 1: Backend Models & DB
|
||||||
|
|
||||||
|
1. Create `backend/models/routine.py` — fields: name, points, image_id, user_id (inherits BaseModel). Mirror Task model.
|
||||||
|
2. Create `backend/models/routine_item.py` — fields: routine_id, name, image_id, order (int). No points.
|
||||||
|
3. Create `backend/models/routine_schedule.py` — copy of ChoreSchedule replacing task_id→routine_id. Same DayConfig, modes, deadline fields.
|
||||||
|
4. Create `backend/models/routine_extension.py` — fields: child_id, routine_id, date (ISO). Mirror TaskExtension.
|
||||||
|
5. Extend `backend/models/pending_confirmation.py` — add 'routine' to entity_type Literal.
|
||||||
|
6. Extend `backend/models/child.py` — add `routines: list[str]` field (default=[]).
|
||||||
|
7. Create DB layers (mirror existing patterns):
|
||||||
|
- `backend/db/routines.py` — get/add/update/delete/list by user
|
||||||
|
- `backend/db/routine_items.py` — get_items_for_routine/add/update/delete/delete_for_routine
|
||||||
|
- `backend/db/routine_schedules.py` — get/upsert/delete/delete_for_child/delete_for_routine
|
||||||
|
- `backend/db/routine_extensions.py` — get/add/delete_for_child_routine
|
||||||
|
8. Extend `backend/db/child_overrides.py` entity_type to allow 'routine'.
|
||||||
|
|
||||||
|
## Phase 2: Backend SSE Events
|
||||||
|
|
||||||
|
9. Create event type files (mirror existing pattern):
|
||||||
|
- `backend/events/types/routine_modified.py` — payload: routine_id, operation (ADD|EDIT|DELETE)
|
||||||
|
- `backend/events/types/child_routines_set.py` — payload: child_id, routine_ids
|
||||||
|
- `backend/events/types/routine_schedule_modified.py` — payload: child_id, routine_id, operation (SET|DELETED)
|
||||||
|
- `backend/events/types/routine_time_extended.py` — payload: child_id, routine_id
|
||||||
|
- `backend/events/types/child_routine_confirmation.py` — payload: child_id, routine_id, operation (PENDING|APPROVED|REJECTED|RESET)
|
||||||
|
10. Update `backend/events/types/event_types.py` — add: ROUTINE_MODIFIED, CHILD_ROUTINES_SET, ROUTINE_SCHEDULE_MODIFIED, ROUTINE_TIME_EXTENDED, CHILD_ROUTINE_CONFIRMATION
|
||||||
|
|
||||||
|
## Phase 3: Backend API
|
||||||
|
|
||||||
|
11. Create `backend/api/routine_api.py`:
|
||||||
|
- `PUT /routine/add` — create routine
|
||||||
|
- `GET /routine/<id>` — fetch single
|
||||||
|
- `GET /routine/list` — list by user
|
||||||
|
- `PUT /routine/<id>/edit` — update
|
||||||
|
- `DELETE /routine/<id>` — delete (cascade: remove from all children, delete items, schedules, overrides)
|
||||||
|
12. Create `backend/api/routine_item_api.py`:
|
||||||
|
- `PUT /routine/<routine_id>/item/add` — add item
|
||||||
|
- `PUT /routine/<routine_id>/item/<item_id>/edit` — edit item name/image
|
||||||
|
- `DELETE /routine/<routine_id>/item/<item_id>` — remove item
|
||||||
|
- `GET /routine/<routine_id>/items` — list items
|
||||||
|
13. Create `backend/api/child_routine_api.py`:
|
||||||
|
- `POST /child/<id>/assign-routine` — add routine to child
|
||||||
|
- `POST /child/<id>/remove-routine` — remove from child
|
||||||
|
- `PUT /child/<id>/set-routines` — replace all assigned routines
|
||||||
|
- `GET /child/<id>/list-routines` — list assigned routines with schedule, pending_status, extension_date, image_url, custom_value, and embedded items
|
||||||
|
- `GET /child/<id>/list-assignable-routines` — routines not yet assigned
|
||||||
|
- `POST /child/<id>/confirm-routine` — child submits routine as pending (creates PendingConfirmation entity_type='routine')
|
||||||
|
- `POST /child/<id>/cancel-routine-confirmation` — cancel pending
|
||||||
|
14. Create `backend/api/routine_schedule_api.py`:
|
||||||
|
- `GET /child/<child_id>/routine/<routine_id>/schedule` — fetch
|
||||||
|
- `PUT /child/<child_id>/routine/<routine_id>/schedule` — create/update
|
||||||
|
- `DELETE /child/<child_id>/routine/<routine_id>/schedule` — delete
|
||||||
|
- `POST /child/<child_id>/routine/<routine_id>/extend` — extend deadline
|
||||||
|
15. Extend `backend/api/pending_confirmation.py`:
|
||||||
|
- Add `GET /pending-confirmations?type=routine` support (or ensure existing endpoint includes routines)
|
||||||
|
- Add `POST /child/<id>/approve-routine/<confirmation_id>` — approve (award points via override or routine.points)
|
||||||
|
- Add `POST /child/<id>/reject-routine/<confirmation_id>` — reject
|
||||||
|
- Add `POST /child/<id>/reset-routine/<confirmation_id>` — reset
|
||||||
|
16. Update `backend/api/child_api.py` — cascade delete routines/schedules/extensions when child deleted.
|
||||||
|
17. Update `backend/main.py` — register all new blueprints.
|
||||||
|
|
||||||
|
## Phase 4: TypeScript Models & API Helpers
|
||||||
|
|
||||||
|
18. Update `frontend/src/common/models.ts`:
|
||||||
|
- Add `Routine` (id, name, points, image_id)
|
||||||
|
- Add `RoutineItem` (id, routine_id, name, image_id, order)
|
||||||
|
- Add `ChildRoutine` (extends Routine + custom_value, schedule, extension_date, pending_status, approved_at, items: RoutineItem[])
|
||||||
|
- Add `RoutineSchedule` (mirror ChoreSchedule with routine_id)
|
||||||
|
- Add new SSE payload types: RoutineModifiedEventPayload, ChildRoutinesSetEventPayload, RoutineScheduleModifiedPayload, RoutineTimeExtendedPayload, ChildRoutineConfirmationPayload
|
||||||
|
19. Update `frontend/src/common/backendEvents.ts` — add new event type string constants.
|
||||||
|
20. Update `frontend/src/common/api.ts` — add helpers: confirmRoutine(), cancelRoutineConfirmation(), setChildRoutineOverride().
|
||||||
|
|
||||||
|
## Phase 5: Frontend — Child Mode
|
||||||
|
|
||||||
|
21. Create `frontend/src/components/routine/RoutineDetailView.vue`:
|
||||||
|
- Receives childId + routineId as route params
|
||||||
|
- Fetches routine data (name, points, image, items list) via `GET /child/<id>/list-routines` (or dedicated detail endpoint)
|
||||||
|
- Displays routine header (name, image, points)
|
||||||
|
- Vertical card list of items (name + image, non-interactive)
|
||||||
|
- "Done" button → RoutineConfirmDialog → calls confirmRoutine() → routine becomes 'pending'
|
||||||
|
- If pending_status='pending': "Done" shows cancel dialog instead
|
||||||
|
- If approved today: shows "COMPLETED" stamp (read-only)
|
||||||
|
- If expired: shows "TOO LATE" (no action)
|
||||||
|
- Back navigation to ChildView
|
||||||
|
- Register SSE `child_routine_confirmation` to update status reactively
|
||||||
|
22. Update `frontend/src/components/child/ChildView.vue`:
|
||||||
|
- Add `routines: ref<string[]>` and `childRoutineListRef`
|
||||||
|
- Add Routines `ScrollingList` between Chores list and Rewards list
|
||||||
|
- fetchBaseUrl: `/api/child/${child.id}/list-routines`
|
||||||
|
- itemKey: `'routines'`
|
||||||
|
- filter-fn: filter by schedule (isScheduledToday equivalent for routines)
|
||||||
|
- sort-fn: completed → pending → scheduled → general
|
||||||
|
- getItemClass: similar chore-inactive logic for expired/completed
|
||||||
|
- On routine click → navigate to RoutineDetailView route instead of triggering inline
|
||||||
|
- Register new SSE handlers: routine_modified, child_routines_set, routine_schedule_modified, routine_time_extended, child_routine_confirmation
|
||||||
|
- Expiry timer logic for routine deadlines (parallel to existing chore timer logic)
|
||||||
|
23. Add routes:
|
||||||
|
- `/child/:id/routine/:routineId` → RoutineDetailView
|
||||||
|
|
||||||
|
## Phase 6: Frontend — Parent Mode (Routine Library)
|
||||||
|
|
||||||
|
24. Create `frontend/src/components/routine/RoutineEditor.vue`:
|
||||||
|
- Top section: EntityEditForm-style fields — name (text), points (number), image picker
|
||||||
|
- Bottom section: Items sub-panel
|
||||||
|
- List of existing items (name + image thumbnail + delete button)
|
||||||
|
- "Add item" row: inline input for name + optional image picker + confirm button
|
||||||
|
- Each item has edit-in-place or edit button
|
||||||
|
- Save button submits both routine details and item changes
|
||||||
|
- On edit mode: pre-populate all fields and items
|
||||||
|
25. Create `frontend/src/views/parent/RoutinesView.vue`:
|
||||||
|
- ItemList of all routines with add/edit/delete
|
||||||
|
- Add → RoutineEditor (create mode)
|
||||||
|
- Edit → RoutineEditor (edit mode)
|
||||||
|
26. Add parent routes under TaskSubNav (4th tab — "Routines"):
|
||||||
|
- `/parent/tasks/routines` — library list (RoutinesView)
|
||||||
|
- `/parent/tasks/routines/add` — create (RoutineEditor)
|
||||||
|
- `/parent/tasks/routines/:id/edit` — edit (RoutineEditor)
|
||||||
|
27. Add tab to `frontend/src/components/task/TaskSubNav.vue` — "Routines" tab pointing to `/parent/tasks/routines`
|
||||||
|
|
||||||
|
## Phase 7: Frontend — Parent Mode (Per-Child Routine Management)
|
||||||
|
|
||||||
|
28. Extend ParentView (or child management component) to include a "Routines" section per child:
|
||||||
|
- ItemList showing child's assigned routines
|
||||||
|
- Assign button → RoutineAssignView (parallel to ChoreAssignView)
|
||||||
|
- Kebab menu per routine item:
|
||||||
|
- "Edit Routine" → navigate to `/parent/tasks/routines/:id/edit`
|
||||||
|
- "Set Schedule" → open ScheduleModal with entityType='routine'
|
||||||
|
- "Edit Points" → set ChildOverride with entity_type='routine'
|
||||||
|
- "Extend Deadline" → call extend endpoint
|
||||||
|
- Register SSE events to refresh list: routine_modified, child_routines_set, routine_schedule_modified, routine_time_extended, child_routine_confirmation, child_override_set
|
||||||
|
29. Create `frontend/src/components/routine/RoutineAssignView.vue` — selectable ItemList of unassigned routines (parallel to ChoreAssignView).
|
||||||
|
30. Extend pending confirmation approval UI — if entity_type='routine', fetch routine name and show in approval card. Approve → POST approve-routine endpoint. Reject → POST reject-routine.
|
||||||
|
|
||||||
|
## Phase 8: Push Notifications for Routine Pending
|
||||||
|
|
||||||
|
31. In `backend/api/child_routine_api.py` — after creating the PendingConfirmation for a routine, call `send_push_to_user(user_id, payload)` (mirror `child_api.py` chore confirmation pattern). Payload: `type='routine_confirmed'`, title = "Routine Pending", body = "{child_name} completed {routine_name}", include approve/reject action tokens.
|
||||||
|
|
||||||
|
## Phase 9: ScheduleModal entityType Refactor
|
||||||
|
|
||||||
|
32. Refactor `ScheduleModal.vue` — replace hard-coded `task_id` with `entityType: 'task' | 'routine'` + `entityId` props. All API calls to `.../schedule` and `.../extend` switch on `entityType` to route to either `/child/<childId>/task/<entityId>/...` or `/child/<childId>/routine/<entityId>/...`.
|
||||||
|
33. Update all current ScheduleModal usages to explicitly pass `entityType='task'` so existing behavior is preserved.
|
||||||
|
34. Routine kebab "Set Schedule" passes `entityType='routine'`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Relevant Files
|
||||||
|
|
||||||
|
**New backend:**
|
||||||
|
|
||||||
|
- `backend/models/routine.py`, `routine_item.py`, `routine_schedule.py`, `routine_extension.py`
|
||||||
|
- `backend/db/routines.py`, `routine_items.py`, `routine_schedules.py`, `routine_extensions.py`
|
||||||
|
- `backend/api/routine_api.py`, `routine_item_api.py`, `child_routine_api.py`, `routine_schedule_api.py`
|
||||||
|
- `backend/events/types/routine_modified.py`, `child_routines_set.py`, `routine_schedule_modified.py`, `routine_time_extended.py`, `child_routine_confirmation.py`
|
||||||
|
|
||||||
|
**Modified backend:**
|
||||||
|
|
||||||
|
- `backend/models/child.py` — add routines field
|
||||||
|
- `backend/models/pending_confirmation.py` — extend entity_type Literal
|
||||||
|
- `backend/db/child_overrides.py` — allow 'routine' entity_type
|
||||||
|
- `backend/api/child_api.py` — cascade deletes
|
||||||
|
- `backend/api/pending_confirmation.py` — routine approval endpoints
|
||||||
|
- `backend/events/types/event_types.py` — new constants
|
||||||
|
- `backend/main.py` — register blueprints
|
||||||
|
|
||||||
|
**New frontend:**
|
||||||
|
|
||||||
|
- `frontend/src/components/routine/RoutineEditor.vue`
|
||||||
|
- `frontend/src/components/routine/RoutineDetailView.vue`
|
||||||
|
- `frontend/src/components/routine/RoutineAssignView.vue`
|
||||||
|
- `frontend/src/views/parent/RoutinesView.vue`
|
||||||
|
|
||||||
|
**Modified frontend:**
|
||||||
|
|
||||||
|
- `frontend/src/common/models.ts` — new interfaces + SSE payload types
|
||||||
|
- `frontend/src/common/backendEvents.ts` — new event constants
|
||||||
|
- `frontend/src/common/api.ts` — new helpers
|
||||||
|
- `frontend/src/components/child/ChildView.vue` — add routines list + navigation
|
||||||
|
- `frontend/src/components/task/TaskSubNav.vue` — add Routines tab
|
||||||
|
- `frontend/src/components/shared/ScheduleModal.vue` — entityType refactor
|
||||||
|
- ParentView component — add routines section
|
||||||
|
- Router — new routes
|
||||||
|
|
||||||
|
**Reference patterns:**
|
||||||
|
|
||||||
|
- `backend/api/chore_api.py` + `chore_schedule_api.py` — mirror for routine equivalents
|
||||||
|
- `backend/models/chore_schedule.py` — copy for RoutineSchedule
|
||||||
|
- `backend/models/task_extension.py` — copy for routine_extension.py
|
||||||
|
- `frontend/src/components/shared/ScrollingList.vue` — reuse for routines in child view
|
||||||
|
- `frontend/src/components/shared/EntityEditForm.vue` — reuse in RoutineEditor top section
|
||||||
|
- `backend/utils/push_sender.py` + `child_api.py` chore confirm (~L1186) — push notification pattern
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
|
||||||
|
1. Create a routine with 2+ items via Tasks → Routines tab, assign to a child, verify it appears in child view
|
||||||
|
2. Child submits "Done" → push notification fires → pending confirmation appears in parent view → approve → points credited
|
||||||
|
3. Reject flow → no points awarded
|
||||||
|
4. Schedule a routine for specific days → verify it only appears on those days
|
||||||
|
5. Set a deadline → verify "TOO LATE" stamp after deadline passes
|
||||||
|
6. Extend deadline via kebab → verify stamp removed for that day
|
||||||
|
7. Set per-child point override → verify override value shown and applied on approval
|
||||||
|
8. Delete routine → cascades (removed from children, items/schedules/extensions/overrides deleted)
|
||||||
|
9. SSE: parent sees routine confirmation in notifications without page refresh
|
||||||
|
10. ScheduleModal: existing chore schedule still works after entityType refactor
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Out of Scope
|
||||||
|
|
||||||
|
- Reordering routine items (order field exists but no drag-and-drop UI planned)
|
||||||
|
- Per-item completion tracking
|
||||||
3
.vscode/launch.json
vendored
3
.vscode/launch.json
vendored
@@ -14,7 +14,8 @@
|
|||||||
"REFRESH_TOKEN_EXPIRY_DAYS": "90",
|
"REFRESH_TOKEN_EXPIRY_DAYS": "90",
|
||||||
"DIGEST_TOKEN_SECRET": "dev-digest-token",
|
"DIGEST_TOKEN_SECRET": "dev-digest-token",
|
||||||
"VAPID_PUBLIC_KEY": "BNKkHdq45uLigohSG7c1TwlAo7ETncoRVLQK02LxHgu2P1DgSJD9njRMfbbzUsaTQGllvLBz7An1WiWsNYQhvKE",
|
"VAPID_PUBLIC_KEY": "BNKkHdq45uLigohSG7c1TwlAo7ETncoRVLQK02LxHgu2P1DgSJD9njRMfbbzUsaTQGllvLBz7An1WiWsNYQhvKE",
|
||||||
"VAPID_PRIVATE_KEY": "jNiZJT0UO4H861KmnCt874Fg6p5jDAyYKS4V2MZf8bQ"
|
"VAPID_PRIVATE_KEY": "jNiZJT0UO4H861KmnCt874Fg6p5jDAyYKS4V2MZf8bQ",
|
||||||
|
"FRONTEND_URL": "https://macbook:5173"
|
||||||
},
|
},
|
||||||
"args": [
|
"args": [
|
||||||
"run",
|
"run",
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
# file: config/version.py
|
# file: config/version.py
|
||||||
import os
|
import os
|
||||||
|
|
||||||
BASE_VERSION = "1.0.10" # update manually when releasing features
|
BASE_VERSION = "1.0.13" # update manually when releasing features
|
||||||
|
|
||||||
def get_full_version() -> str:
|
def get_full_version() -> str:
|
||||||
"""
|
"""
|
||||||
|
|||||||
@@ -58,6 +58,13 @@ def send_push_to_user(user_id: str, payload: dict) -> int:
|
|||||||
f'Removing stale push subscription {sub.id} for user {user_id} (HTTP {status_code})'
|
f'Removing stale push subscription {sub.id} for user {user_id} (HTTP {status_code})'
|
||||||
)
|
)
|
||||||
delete_subscription_by_id(sub.id)
|
delete_subscription_by_id(sub.id)
|
||||||
|
elif status_code == 403 and 'VAPID credentials' in str(e):
|
||||||
|
# The subscription was created with a different VAPID keypair.
|
||||||
|
# Remove it so the client can register a fresh subscription next time.
|
||||||
|
logger.info(
|
||||||
|
f'Removing VAPID-mismatched subscription {sub.id} for user {user_id} (HTTP 403)'
|
||||||
|
)
|
||||||
|
delete_subscription_by_id(sub.id)
|
||||||
else:
|
else:
|
||||||
logger.error(
|
logger.error(
|
||||||
f'WebPushException for subscription {sub.id} (user {user_id}): {e}'
|
f'WebPushException for subscription {sub.id} (user {user_id}): {e}'
|
||||||
|
|||||||
@@ -29,9 +29,10 @@ services:
|
|||||||
chores-test-app-frontend: # Test frontend service name
|
chores-test-app-frontend: # Test frontend service name
|
||||||
image: git.ryankegel.com:3000/kegel/chores/frontend:next # Use latest next tag
|
image: git.ryankegel.com:3000/kegel/chores/frontend:next # Use latest next tag
|
||||||
ports:
|
ports:
|
||||||
- "446:443" # Host 446 -> Container 443 (HTTPS)
|
- "446:80" # Host 446 -> Container 80 (HTTP behind external TLS proxy)
|
||||||
environment:
|
environment:
|
||||||
- BACKEND_HOST=chores-test-app-backend # Points to internal backend service
|
- BACKEND_HOST=chores-test-app-backend # Points to internal backend service
|
||||||
|
- FRONTEND_SSL_ENABLED=false
|
||||||
depends_on:
|
depends_on:
|
||||||
- chores-test-app-backend
|
- chores-test-app-backend
|
||||||
# Add volumes, networks, etc., as needed
|
# Add volumes, networks, etc., as needed
|
||||||
|
|||||||
@@ -6,10 +6,15 @@ services:
|
|||||||
image: git.ryankegel.com:3000/kegel/chores/backend:latest # Or specific version tag
|
image: git.ryankegel.com:3000/kegel/chores/backend:latest # Or specific version tag
|
||||||
container_name: chores-app-backend-prod # Added for easy identification
|
container_name: chores-app-backend-prod # Added for easy identification
|
||||||
ports:
|
ports:
|
||||||
- "5001:5000" # Host 5001 -> Container 5000
|
- "${BACKEND_HOST_PORT:-5001}:5000" # Host port -> Container 5000
|
||||||
environment:
|
environment:
|
||||||
- FLASK_ENV=production
|
- FLASK_ENV=production
|
||||||
- FRONTEND_URL=${FRONTEND_URL}
|
- FRONTEND_URL=${FRONTEND_URL}
|
||||||
|
- SECRET_KEY=${SECRET_KEY}
|
||||||
|
- REFRESH_TOKEN_EXPIRY_DAYS=${REFRESH_TOKEN_EXPIRY_DAYS}
|
||||||
|
- DIGEST_TOKEN_SECRET=${DIGEST_TOKEN_SECRET}
|
||||||
|
- VAPID_PUBLIC_KEY=${VAPID_PUBLIC_KEY}
|
||||||
|
- VAPID_PRIVATE_KEY=${VAPID_PRIVATE_KEY}
|
||||||
volumes:
|
volumes:
|
||||||
- chores-app-backend-data:/app/data # Assuming backend data storage; adjust path as needed
|
- chores-app-backend-data:/app/data # Assuming backend data storage; adjust path as needed
|
||||||
networks:
|
networks:
|
||||||
@@ -20,9 +25,10 @@ services:
|
|||||||
image: git.ryankegel.com:3000/kegel/chores/frontend:latest # Or specific version tag
|
image: git.ryankegel.com:3000/kegel/chores/frontend:latest # Or specific version tag
|
||||||
container_name: chores-app-frontend-prod # Added for easy identification
|
container_name: chores-app-frontend-prod # Added for easy identification
|
||||||
ports:
|
ports:
|
||||||
- "443:443" # Host 443 -> Container 443 (HTTPS)
|
- "${FRONTEND_HOST_PORT:-4601}:${FRONTEND_CONTAINER_PORT:-80}" # Host port -> Container 80 (HTTP; SSL terminated by external proxy)
|
||||||
environment:
|
environment:
|
||||||
- BACKEND_HOST=chores-app-backend # Points to internal backend service
|
- BACKEND_HOST=chores-app-backend # Points to internal backend service
|
||||||
|
- FRONTEND_SSL_ENABLED=false
|
||||||
depends_on:
|
depends_on:
|
||||||
- chores-app-backend
|
- chores-app-backend
|
||||||
networks:
|
networks:
|
||||||
|
|||||||
3
frontend/.gitignore
vendored
3
frontend/.gitignore
vendored
@@ -37,6 +37,9 @@ __screenshots__/
|
|||||||
|
|
||||||
*.old
|
*.old
|
||||||
|
|
||||||
|
# Local dev TLS certs (machine-specific, generated by mkcert)
|
||||||
|
*.pem
|
||||||
|
|
||||||
# Playwright
|
# Playwright
|
||||||
/test-results/
|
/test-results/
|
||||||
/playwright-report/
|
/playwright-report/
|
||||||
|
|||||||
@@ -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-----
|
|
||||||
@@ -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-----
|
|
||||||
@@ -10,17 +10,15 @@ RUN npm run build
|
|||||||
FROM nginx:alpine
|
FROM nginx:alpine
|
||||||
COPY --from=build /app/dist /usr/share/nginx/html
|
COPY --from=build /app/dist /usr/share/nginx/html
|
||||||
COPY nginx.conf.template /etc/nginx/nginx.conf.template
|
COPY nginx.conf.template /etc/nginx/nginx.conf.template
|
||||||
# Copy SSL certificate and key
|
COPY nginx.http.conf.template /etc/nginx/nginx.http.conf.template
|
||||||
COPY 192.168.1.102+1.pem /etc/nginx/ssl/server.crt
|
COPY docker/start-nginx.sh /docker/start-nginx.sh
|
||||||
COPY 192.168.1.102+1-key.pem /etc/nginx/ssl/server.key
|
RUN chmod +x /docker/start-nginx.sh
|
||||||
|
|
||||||
EXPOSE 80
|
EXPOSE 80
|
||||||
EXPOSE 443
|
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 BACKEND_HOST=chore-app-backend
|
||||||
|
ENV FRONTEND_SSL_ENABLED=true
|
||||||
|
|
||||||
# Use sed to replace $BACKEND_HOST with the env value, then start Nginx
|
CMD ["/docker/start-nginx.sh"]
|
||||||
CMD ["/bin/sh", "-c", "sed 's/\\$BACKEND_HOST/'\"$BACKEND_HOST\"'/g' /etc/nginx/nginx.conf.template > /etc/nginx/nginx.conf && nginx -g 'daemon off;'"]
|
|
||||||
|
|||||||
18
frontend/docker/start-nginx.sh
Normal file
18
frontend/docker/start-nginx.sh
Normal 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;'
|
||||||
@@ -2,20 +2,20 @@
|
|||||||
"cookies": [
|
"cookies": [
|
||||||
{
|
{
|
||||||
"name": "refresh_token",
|
"name": "refresh_token",
|
||||||
"value": "v-K0IrmQf50MVvYCdmQfnUor8Q8e9KURnPnyVPYRN8Q",
|
"value": "mY6Ehfjz0l0gIgI1-zrA_HMfYQiNmgnNy6g7v2GoT3M",
|
||||||
"domain": "localhost",
|
"domain": "localhost",
|
||||||
"path": "/api/auth",
|
"path": "/api/auth",
|
||||||
"expires": 1785003958.597918,
|
"expires": 1785468152.319767,
|
||||||
"httpOnly": true,
|
"httpOnly": true,
|
||||||
"secure": true,
|
"secure": true,
|
||||||
"sameSite": "Strict"
|
"sameSite": "Strict"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "access_token",
|
"name": "access_token",
|
||||||
"value": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJlbWFpbCI6ImUyZS1jY0B0ZXN0LmNvbSIsInVzZXJfaWQiOiI0NDg5ZjZhNS1mZWEyLTQ0ZGEtYTczNy00MDQwNTQyMmU0YTEiLCJ0b2tlbl92ZXJzaW9uIjowLCJleHAiOjE3NzcyMjg4NTh9.V7gsUwZ-mjcRqCIwI2ppFVrGD16R18JJDQ5wsv_ul1k",
|
"value": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJlbWFpbCI6ImUyZS1jY0B0ZXN0LmNvbSIsInVzZXJfaWQiOiI0YmI3ZTQ3ZS0wNGYxLTQyMDctYjZjYy0yNDM3NDVlOGQ0ZDMiLCJ0b2tlbl92ZXJzaW9uIjowLCJleHAiOjE3Nzc3MDI5NTJ9.614NMFQB7JcIJ4k4cqKyxlpcyHmt2Hn4PbjCbvLMJ2A",
|
||||||
"domain": "localhost",
|
"domain": "localhost",
|
||||||
"path": "/",
|
"path": "/",
|
||||||
"expires": 1777228858.597862,
|
"expires": 1777702952.31857,
|
||||||
"httpOnly": true,
|
"httpOnly": true,
|
||||||
"secure": true,
|
"secure": true,
|
||||||
"sameSite": "Lax"
|
"sameSite": "Lax"
|
||||||
@@ -27,11 +27,11 @@
|
|||||||
"localStorage": [
|
"localStorage": [
|
||||||
{
|
{
|
||||||
"name": "authSyncEvent",
|
"name": "authSyncEvent",
|
||||||
"value": "{\"type\":\"logout\",\"at\":1777227958453}"
|
"value": "{\"type\":\"logout\",\"at\":1777692151952}"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "parentAuth",
|
"name": "parentAuth",
|
||||||
"value": "{\"expiresAt\":1777400758775}"
|
"value": "{\"expiresAt\":1777864952730}"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,20 +2,20 @@
|
|||||||
"cookies": [
|
"cookies": [
|
||||||
{
|
{
|
||||||
"name": "refresh_token",
|
"name": "refresh_token",
|
||||||
"value": "s8cSiajWyuCcjv9zXjqsKrmEK8cRPp-VOWV74Cz_ueQ",
|
"value": "fmbL31vJQXFd7NDUWy7eGlRZZoGh0BZipf8CqFOb8zw",
|
||||||
"domain": "localhost",
|
"domain": "localhost",
|
||||||
"path": "/api/auth",
|
"path": "/api/auth",
|
||||||
"expires": 1785003958.599099,
|
"expires": 1785468152.214735,
|
||||||
"httpOnly": true,
|
"httpOnly": true,
|
||||||
"secure": true,
|
"secure": true,
|
||||||
"sameSite": "Strict"
|
"sameSite": "Strict"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "access_token",
|
"name": "access_token",
|
||||||
"value": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJlbWFpbCI6ImUyZS1kZWxldGVAdGVzdC5jb20iLCJ1c2VyX2lkIjoiYmE3MDNmMWItMzg0Zi00MzBjLWJiNjYtN2Q0YWRhMWIwYjRiIiwidG9rZW5fdmVyc2lvbiI6MCwiZXhwIjoxNzc3MjI4ODU4fQ.FnEEIVv2wchkrJhDiRuNiDBF-lIPkyZUU2UwLmLZVHM",
|
"value": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJlbWFpbCI6ImUyZS1kZWxldGVAdGVzdC5jb20iLCJ1c2VyX2lkIjoiNzc2YjBkYjctYzYyNy00ODBiLTkzZDYtMTRlMTE4MDQ3NTE5IiwidG9rZW5fdmVyc2lvbiI6MCwiZXhwIjoxNzc3NzAyOTUyfQ.I4sfKTo0nsJgKvwDRSztxtyZpWw-oN1y3L4yCtr_CRo",
|
||||||
"domain": "localhost",
|
"domain": "localhost",
|
||||||
"path": "/",
|
"path": "/",
|
||||||
"expires": 1777228858.599061,
|
"expires": 1777702952.21406,
|
||||||
"httpOnly": true,
|
"httpOnly": true,
|
||||||
"secure": true,
|
"secure": true,
|
||||||
"sameSite": "Lax"
|
"sameSite": "Lax"
|
||||||
@@ -27,11 +27,11 @@
|
|||||||
"localStorage": [
|
"localStorage": [
|
||||||
{
|
{
|
||||||
"name": "authSyncEvent",
|
"name": "authSyncEvent",
|
||||||
"value": "{\"type\":\"logout\",\"at\":1777227958453}"
|
"value": "{\"type\":\"logout\",\"at\":1777692151844}"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "parentAuth",
|
"name": "parentAuth",
|
||||||
"value": "{\"expiresAt\":1777400758775}"
|
"value": "{\"expiresAt\":1777864952556}"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,20 +2,20 @@
|
|||||||
"cookies": [
|
"cookies": [
|
||||||
{
|
{
|
||||||
"name": "refresh_token",
|
"name": "refresh_token",
|
||||||
"value": "mqtPPFKJGGfFly1i0VW1MtJUvgIpb7LlWUEpFLn1hGc",
|
"value": "YitpHdwm093Mavj6iTYmJOLGLOIKoozGkmYXgmJMTBM",
|
||||||
"domain": "localhost",
|
"domain": "localhost",
|
||||||
"path": "/api/auth",
|
"path": "/api/auth",
|
||||||
"expires": 1785022348.96348,
|
"expires": 1785468147.453462,
|
||||||
"httpOnly": true,
|
"httpOnly": true,
|
||||||
"secure": true,
|
"secure": true,
|
||||||
"sameSite": "Strict"
|
"sameSite": "Strict"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "access_token",
|
"name": "access_token",
|
||||||
"value": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJlbWFpbCI6ImUyZUB0ZXN0LmNvbSIsInVzZXJfaWQiOiIzZDJmZmY0OS03MjNhLTRhMmUtYWRhOS1iMTE5N2VhYTI2MDkiLCJ0b2tlbl92ZXJzaW9uIjowLCJleHAiOjE3NzcyNTcxNDh9.g69Lwtay2i0jwEzAbYcRWM2oGoOm-4qpV8YC04X0v-0",
|
"value": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJlbWFpbCI6ImUyZUB0ZXN0LmNvbSIsInVzZXJfaWQiOiI4ZGNmOWU5Ni1lM2I3LTRkNzYtOWQ1NS01NmE4MjU5ZWQ5NzUiLCJ0b2tlbl92ZXJzaW9uIjowLCJleHAiOjE3Nzc3MDI5NDd9.zSetTnajvus3N5uJDBqxRYXfLksoU9ZmkWzqCS0GUvc",
|
||||||
"domain": "localhost",
|
"domain": "localhost",
|
||||||
"path": "/",
|
"path": "/",
|
||||||
"expires": 1777257148.963437,
|
"expires": 1777702947.452943,
|
||||||
"httpOnly": true,
|
"httpOnly": true,
|
||||||
"secure": true,
|
"secure": true,
|
||||||
"sameSite": "Lax"
|
"sameSite": "Lax"
|
||||||
@@ -27,11 +27,11 @@
|
|||||||
"localStorage": [
|
"localStorage": [
|
||||||
{
|
{
|
||||||
"name": "authSyncEvent",
|
"name": "authSyncEvent",
|
||||||
"value": "{\"type\":\"logout\",\"at\":1777246348801}"
|
"value": "{\"type\":\"logout\",\"at\":1777692147220}"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "parentAuth",
|
"name": "parentAuth",
|
||||||
"value": "{\"expiresAt\":1777419149107}"
|
"value": "{\"expiresAt\":1777864947663}"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,12 @@ import { fileURLToPath } from 'url'
|
|||||||
const __dirname = path.dirname(fileURLToPath(import.meta.url))
|
const __dirname = path.dirname(fileURLToPath(import.meta.url))
|
||||||
const TEST_IMAGE = path.join(__dirname, '../../.resources/crown.png')
|
const TEST_IMAGE = path.join(__dirname, '../../.resources/crown.png')
|
||||||
|
|
||||||
|
/** Navigate to the parent children list and wait for the Add Child button to be ready. */
|
||||||
|
async function gotoParentList(page: import('@playwright/test').Page): Promise<void> {
|
||||||
|
await page.goto('/')
|
||||||
|
await expect(page.getByRole('button', { name: 'Add Child' })).toBeVisible({ timeout: 10000 })
|
||||||
|
}
|
||||||
|
|
||||||
async function deleteNamedChildren(request: any, names: string[]) {
|
async function deleteNamedChildren(request: any, names: string[]) {
|
||||||
const res = await request.get('/api/child/list')
|
const res = await request.get('/api/child/list')
|
||||||
const data = await res.json()
|
const data = await res.json()
|
||||||
@@ -38,7 +44,7 @@ test.describe('Create Child', () => {
|
|||||||
await deleteNamedChildren(request, ['Alice'])
|
await deleteNamedChildren(request, ['Alice'])
|
||||||
|
|
||||||
// 1. Navigate to app root - router redirects to /parent (children list) when parent-authenticated
|
// 1. Navigate to app root - router redirects to /parent (children list) when parent-authenticated
|
||||||
await page.goto('/')
|
await gotoParentList(page)
|
||||||
await expect(page).toHaveURL('/parent')
|
await expect(page).toHaveURL('/parent')
|
||||||
|
|
||||||
// 2. Click the 'Add Child' FAB
|
// 2. Click the 'Add Child' FAB
|
||||||
@@ -123,7 +129,7 @@ test.describe('Create Child', () => {
|
|||||||
await deleteNamedChildren(request, ['Grace'])
|
await deleteNamedChildren(request, ['Grace'])
|
||||||
|
|
||||||
// 1. Navigate to app root - router redirects to /parent (children list)
|
// 1. Navigate to app root - router redirects to /parent (children list)
|
||||||
await page.goto('/')
|
await gotoParentList(page)
|
||||||
await page.getByRole('button', { name: 'Add Child' }).click()
|
await page.getByRole('button', { name: 'Add Child' }).click()
|
||||||
await expect(page.getByRole('heading', { name: 'Create Child' })).toBeVisible()
|
await expect(page.getByRole('heading', { name: 'Create Child' })).toBeVisible()
|
||||||
|
|
||||||
|
|||||||
@@ -2,10 +2,16 @@
|
|||||||
|
|
||||||
import { test, expect } from '@playwright/test'
|
import { test, expect } from '@playwright/test'
|
||||||
|
|
||||||
|
/** Navigate to the parent children list and wait for the Add Child button to be ready. */
|
||||||
|
async function gotoParentList(page: import('@playwright/test').Page): Promise<void> {
|
||||||
|
await page.goto('/')
|
||||||
|
await expect(page.getByRole('button', { name: 'Add Child' })).toBeVisible({ timeout: 10000 })
|
||||||
|
}
|
||||||
|
|
||||||
test.describe('Create Child', () => {
|
test.describe('Create Child', () => {
|
||||||
test('Cancel navigates back without saving', async ({ page }) => {
|
test('Cancel navigates back without saving', async ({ page }) => {
|
||||||
// 1. Navigate to app root - router redirects to /parent (children list)
|
// 1. Navigate to parent list and wait for it to fully load
|
||||||
await page.goto('/')
|
await gotoParentList(page)
|
||||||
await page.getByRole('button', { name: 'Add Child' }).click()
|
await page.getByRole('button', { name: 'Add Child' }).click()
|
||||||
await expect(page.getByRole('heading', { name: 'Create Child' })).toBeVisible()
|
await expect(page.getByRole('heading', { name: 'Create Child' })).toBeVisible()
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,12 @@
|
|||||||
import { test, expect } from '@playwright/test'
|
import { test, expect } from '@playwright/test'
|
||||||
import { STORAGE_STATE_CC } from '../../e2e-constants'
|
import { STORAGE_STATE_CC } from '../../e2e-constants'
|
||||||
|
|
||||||
|
/** Navigate to the parent children list and wait for the Add Child button to be ready. */
|
||||||
|
async function gotoParentList(page: import('@playwright/test').Page): Promise<void> {
|
||||||
|
await page.goto('/')
|
||||||
|
await expect(page.getByRole('button', { name: 'Add Child' })).toBeVisible({ timeout: 10000 })
|
||||||
|
}
|
||||||
|
|
||||||
test.describe('Create Child', () => {
|
test.describe('Create Child', () => {
|
||||||
test.describe.configure({ mode: 'serial' })
|
test.describe.configure({ mode: 'serial' })
|
||||||
|
|
||||||
@@ -17,7 +23,7 @@ test.describe('Create Child', () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 1. Open two browser tabs both on /parent (children list)
|
// 1. Open two browser tabs both on /parent (children list)
|
||||||
await page.goto('/')
|
await gotoParentList(page)
|
||||||
await expect(page).toHaveURL('/parent')
|
await expect(page).toHaveURL('/parent')
|
||||||
|
|
||||||
const tab2 = await context.newPage()
|
const tab2 = await context.newPage()
|
||||||
@@ -64,7 +70,7 @@ test.describe('Create Child', () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 1. Tab 1: parent mode
|
// 1. Tab 1: parent mode
|
||||||
await page.goto('/')
|
await gotoParentList(page)
|
||||||
await expect(page).toHaveURL('/parent')
|
await expect(page).toHaveURL('/parent')
|
||||||
|
|
||||||
// 2. Tab 2: isolated browser context so removing parentAuth doesn't affect Tab 1
|
// 2. Tab 2: isolated browser context so removing parentAuth doesn't affect Tab 1
|
||||||
|
|||||||
@@ -2,12 +2,18 @@
|
|||||||
|
|
||||||
import { test, expect } from '@playwright/test'
|
import { test, expect } from '@playwright/test'
|
||||||
|
|
||||||
|
/** Navigate to the parent children list and wait for the Add Child button to be ready. */
|
||||||
|
async function gotoParentList(page: import('@playwright/test').Page): Promise<void> {
|
||||||
|
await page.goto('/')
|
||||||
|
await expect(page.getByRole('button', { name: 'Add Child' })).toBeVisible({ timeout: 10000 })
|
||||||
|
}
|
||||||
|
|
||||||
test.describe('Create Child', () => {
|
test.describe('Create Child', () => {
|
||||||
test.beforeEach(async ({ page }, testInfo) => {
|
test.beforeEach(async ({ page }, testInfo) => {
|
||||||
test.skip(testInfo.project.name === 'chromium-no-pin', 'Requires parent-authenticated mode')
|
test.skip(testInfo.project.name === 'chromium-no-pin', 'Requires parent-authenticated mode')
|
||||||
|
|
||||||
// Navigate to app root - router redirects to /parent (children list) when parent-authenticated
|
// Navigate to parent list and wait for it to fully load before clicking Add Child
|
||||||
await page.goto('/')
|
await gotoParentList(page)
|
||||||
await page.getByRole('button', { name: 'Add Child' }).click()
|
await page.getByRole('button', { name: 'Add Child' }).click()
|
||||||
await expect(page.getByRole('heading', { name: 'Create Child' })).toBeVisible()
|
await expect(page.getByRole('heading', { name: 'Create Child' })).toBeVisible()
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -39,6 +39,8 @@ async function getUnauthContext(playwright: any) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
test.describe('Digest Action Token — Error Paths', () => {
|
test.describe('Digest Action Token — Error Paths', () => {
|
||||||
|
test.describe.configure({ mode: 'serial' })
|
||||||
|
|
||||||
let childId = ''
|
let childId = ''
|
||||||
let choreId = ''
|
let choreId = ''
|
||||||
|
|
||||||
|
|||||||
@@ -108,8 +108,8 @@ test.describe('Reward Notification — In-App Denial', () => {
|
|||||||
await expect(card).toHaveClass(/item-ready/, { timeout: 3000 })
|
await expect(card).toHaveClass(/item-ready/, { timeout: 3000 })
|
||||||
await card.click()
|
await card.click()
|
||||||
|
|
||||||
// Look for a Deny button in the dialog
|
// Look for the reject action in the dialog
|
||||||
const denyBtn = page.getByRole('button', { name: /deny/i })
|
const denyBtn = page.getByRole('button', { name: 'Reject', exact: true })
|
||||||
await expect(denyBtn).toBeVisible({ timeout: 5000 })
|
await expect(denyBtn).toBeVisible({ timeout: 5000 })
|
||||||
await denyBtn.click()
|
await denyBtn.click()
|
||||||
await expect(denyBtn).not.toBeVisible({ timeout: 5000 })
|
await expect(denyBtn).not.toBeVisible({ timeout: 5000 })
|
||||||
@@ -127,7 +127,7 @@ test.describe('Reward Notification — In-App Denial', () => {
|
|||||||
await card.click()
|
await card.click()
|
||||||
await expect(card).toHaveClass(/item-ready/, { timeout: 3000 })
|
await expect(card).toHaveClass(/item-ready/, { timeout: 3000 })
|
||||||
await card.click()
|
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 expect(denyBtn).toBeVisible({ timeout: 5000 })
|
||||||
await denyBtn.click()
|
await denyBtn.click()
|
||||||
await expect(denyBtn).not.toBeVisible({ timeout: 5000 })
|
await expect(denyBtn).not.toBeVisible({ timeout: 5000 })
|
||||||
|
|||||||
@@ -59,7 +59,9 @@ async function createReward(
|
|||||||
for (const r of (await pre.json()).rewards ?? []) {
|
for (const r of (await pre.json()).rewards ?? []) {
|
||||||
if (r.name === name) await request.delete(`/api/reward/${r.id}`)
|
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')
|
const list = await request.get('/api/reward/list')
|
||||||
return (await list.json()).rewards?.find((r: any) => r.name === name)?.id ?? ''
|
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 }) => {
|
test('Pending reward card shows PENDING banner', async ({ page }) => {
|
||||||
await page.goto(`/parent/${childId}`)
|
await page.goto(`/parent/${childId}`)
|
||||||
const card = rewardSection(page)
|
const card = rewardSection(page).locator('.item-card').filter({ hasText: PENDING_REWARD_NAME })
|
||||||
.locator('.item-card')
|
|
||||||
.filter({ hasText: PENDING_REWARD_NAME })
|
|
||||||
await card.waitFor({ state: 'visible' })
|
await card.waitFor({ state: 'visible' })
|
||||||
await expect(card.locator('.pending')).toHaveText('PENDING')
|
await expect(card.locator('.pending')).toHaveText('PENDING')
|
||||||
})
|
})
|
||||||
@@ -177,12 +177,12 @@ test.describe('Pending reward warning dialog', () => {
|
|||||||
|
|
||||||
await activateItem(card)
|
await activateItem(card)
|
||||||
|
|
||||||
// Pending reward warning dialog appears (has a "No" button)
|
// Pending reward warning dialog appears
|
||||||
await expect(page.getByRole('button', { name: 'No', exact: true })).toBeVisible()
|
await expect(page.getByRole('button', { name: 'Reject', exact: true })).toBeVisible()
|
||||||
await page.getByRole('button', { name: 'No', exact: true }).click()
|
await page.getByRole('button', { name: 'Reject', exact: true }).click()
|
||||||
|
|
||||||
// Dialog dismissed; task confirmation dialog must NOT have appeared
|
// 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()
|
await expect(page.getByRole('button', { name: 'Cancel' })).not.toBeVisible()
|
||||||
// Points unchanged
|
// Points unchanged
|
||||||
await expect(page.locator('.points .value')).toHaveText(String(pointsBefore))
|
await expect(page.locator('.points .value')).toHaveText(String(pointsBefore))
|
||||||
@@ -204,8 +204,8 @@ test.describe('Pending reward warning dialog', () => {
|
|||||||
await activateItem(card)
|
await activateItem(card)
|
||||||
|
|
||||||
// Pending reward warning dialog
|
// Pending reward warning dialog
|
||||||
await expect(page.getByRole('button', { name: 'No', exact: true })).toBeVisible()
|
await expect(page.getByRole('button', { name: 'Reject', exact: true })).toBeVisible()
|
||||||
await page.getByRole('button', { name: 'Yes' }).click()
|
await page.getByRole('button', { name: 'Approve', exact: true }).click()
|
||||||
|
|
||||||
// After confirming, the task confirmation dialog appears (has "Cancel" button)
|
// After confirming, the task confirmation dialog appears (has "Cancel" button)
|
||||||
await expect(page.getByRole('button', { name: 'Cancel' })).toBeVisible({ timeout: 3000 })
|
await expect(page.getByRole('button', { name: 'Cancel' })).toBeVisible({ timeout: 3000 })
|
||||||
@@ -219,7 +219,9 @@ test.describe('Pending reward warning dialog', () => {
|
|||||||
|
|
||||||
// ── kindness ──────────────────────────────────────────────────────────────
|
// ── 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}`)
|
await page.goto(`/parent/${childId}`)
|
||||||
const card = kindnessSection(page).locator('.item-card').filter({ hasText: KIND_NAME })
|
const card = kindnessSection(page).locator('.item-card').filter({ hasText: KIND_NAME })
|
||||||
await card.waitFor({ state: 'visible' })
|
await card.waitFor({ state: 'visible' })
|
||||||
@@ -227,10 +229,10 @@ test.describe('Pending reward warning dialog', () => {
|
|||||||
|
|
||||||
await activateItem(card)
|
await activateItem(card)
|
||||||
|
|
||||||
await expect(page.getByRole('button', { name: 'No', exact: true })).toBeVisible()
|
await expect(page.getByRole('button', { name: 'Reject', exact: true })).toBeVisible()
|
||||||
await page.getByRole('button', { name: 'No', exact: true }).click()
|
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.getByRole('button', { name: 'Cancel' })).not.toBeVisible()
|
||||||
await expect(page.locator('.points .value')).toHaveText(String(pointsBefore))
|
await expect(page.locator('.points .value')).toHaveText(String(pointsBefore))
|
||||||
})
|
})
|
||||||
@@ -245,8 +247,8 @@ test.describe('Pending reward warning dialog', () => {
|
|||||||
|
|
||||||
await activateItem(card)
|
await activateItem(card)
|
||||||
|
|
||||||
await expect(page.getByRole('button', { name: 'No', exact: true })).toBeVisible()
|
await expect(page.getByRole('button', { name: 'Reject', exact: true })).toBeVisible()
|
||||||
await page.getByRole('button', { name: 'Yes' }).click()
|
await page.getByRole('button', { name: 'Approve', exact: true }).click()
|
||||||
|
|
||||||
await expect(page.getByRole('button', { name: 'Cancel' })).toBeVisible({ timeout: 3000 })
|
await expect(page.getByRole('button', { name: 'Cancel' })).toBeVisible({ timeout: 3000 })
|
||||||
await page.getByRole('button', { name: 'Yes' }).click()
|
await page.getByRole('button', { name: 'Yes' }).click()
|
||||||
@@ -266,10 +268,10 @@ test.describe('Pending reward warning dialog', () => {
|
|||||||
|
|
||||||
await activateItem(card)
|
await activateItem(card)
|
||||||
|
|
||||||
await expect(page.getByRole('button', { name: 'No', exact: true })).toBeVisible()
|
await expect(page.getByRole('button', { name: 'Reject', exact: true })).toBeVisible()
|
||||||
await page.getByRole('button', { name: 'No', exact: true }).click()
|
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.getByRole('button', { name: 'Cancel' })).not.toBeVisible()
|
||||||
await expect(page.locator('.points .value')).toHaveText(String(pointsBefore))
|
await expect(page.locator('.points .value')).toHaveText(String(pointsBefore))
|
||||||
})
|
})
|
||||||
@@ -284,8 +286,8 @@ test.describe('Pending reward warning dialog', () => {
|
|||||||
|
|
||||||
await activateItem(card)
|
await activateItem(card)
|
||||||
|
|
||||||
await expect(page.getByRole('button', { name: 'No', exact: true })).toBeVisible()
|
await expect(page.getByRole('button', { name: 'Reject', exact: true })).toBeVisible()
|
||||||
await page.getByRole('button', { name: 'Yes' }).click()
|
await page.getByRole('button', { name: 'Approve', exact: true }).click()
|
||||||
|
|
||||||
await expect(page.getByRole('button', { name: 'Cancel' })).toBeVisible({ timeout: 3000 })
|
await expect(page.getByRole('button', { name: 'Cancel' })).toBeVisible({ timeout: 3000 })
|
||||||
await page.getByRole('button', { name: 'Yes' }).click()
|
await page.getByRole('button', { name: 'Yes' }).click()
|
||||||
@@ -320,10 +322,10 @@ test.describe('Pending reward warning dialog', () => {
|
|||||||
await activateItem(otherCard)
|
await activateItem(otherCard)
|
||||||
|
|
||||||
// Pending reward warning dialog appears when another reward has a pending request
|
// Pending reward warning dialog appears when another reward has a pending request
|
||||||
await expect(page.getByRole('button', { name: 'No', exact: true })).toBeVisible()
|
await expect(page.getByRole('button', { name: 'Reject', exact: true })).toBeVisible()
|
||||||
await page.getByRole('button', { name: 'No', exact: true }).click()
|
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.getByRole('button', { name: 'Cancel' })).not.toBeVisible()
|
||||||
await expect(page.locator('.points .value')).toHaveText(String(pointsBefore))
|
await expect(page.locator('.points .value')).toHaveText(String(pointsBefore))
|
||||||
// PENDING banner still visible — reward request was not cancelled
|
// PENDING banner still visible — reward request was not cancelled
|
||||||
@@ -346,11 +348,11 @@ test.describe('Pending reward warning dialog', () => {
|
|||||||
|
|
||||||
await activateItem(otherCard)
|
await activateItem(otherCard)
|
||||||
|
|
||||||
await expect(page.getByRole('button', { name: 'No', exact: true })).toBeVisible()
|
await expect(page.getByRole('button', { name: 'Reject', exact: true })).toBeVisible()
|
||||||
await page.getByRole('button', { name: 'Yes' }).click()
|
await page.getByRole('button', { name: 'Approve', exact: true }).click()
|
||||||
|
|
||||||
// Pending dialog closes; reward confirm does NOT auto-open (other reward must be re-clicked)
|
// 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()
|
await expect(page.getByRole('button', { name: 'Cancel' })).not.toBeVisible()
|
||||||
|
|
||||||
// The pending reward request was cancelled — PENDING banner should disappear
|
// The pending reward request was cancelled — PENDING banner should disappear
|
||||||
|
|||||||
@@ -152,7 +152,7 @@ test.describe('Reward redemption', () => {
|
|||||||
|
|
||||||
await activateItem(card)
|
await activateItem(card)
|
||||||
await expect(page.getByRole('button', { name: 'Yes' })).toBeVisible()
|
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.getByRole('button', { name: 'Yes' })).not.toBeVisible()
|
||||||
await expect(page.locator('.points .value')).toHaveText(String(before))
|
await expect(page.locator('.points .value')).toHaveText(String(before))
|
||||||
|
|||||||
@@ -187,11 +187,11 @@ test.describe('Reward edit cost', () => {
|
|||||||
await card.getByTitle('Edit custom value').click()
|
await card.getByTitle('Edit custom value').click()
|
||||||
|
|
||||||
// PendingRewardDialog should appear warning about the pending state
|
// 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
|
// Clicking "Reject" closes the dialog without opening the override modal
|
||||||
await page.getByRole('button', { name: 'No', exact: true }).click()
|
await page.getByRole('button', { name: 'Reject', exact: true }).click()
|
||||||
await expect(page.locator('.modal-message')).not.toBeVisible()
|
await expect(page.getByText(/currently pending/i)).not.toBeVisible()
|
||||||
await expect(page.getByRole('button', { name: 'Save' })).not.toBeVisible()
|
await expect(page.getByRole('button', { name: 'Save' })).not.toBeVisible()
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -209,10 +209,10 @@ test.describe('Reward edit cost', () => {
|
|||||||
await card.getByTitle('Edit custom value').click()
|
await card.getByTitle('Edit custom value').click()
|
||||||
|
|
||||||
// PendingRewardDialog visible
|
// 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
|
// 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 expect(page.getByRole('button', { name: 'Save' })).toBeVisible({ timeout: 5000 })
|
||||||
await page.getByRole('button', { name: 'Cancel' }).click()
|
await page.getByRole('button', { name: 'Cancel' }).click()
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,51 +0,0 @@
|
|||||||
import { test, expect } from '@playwright/test'
|
|
||||||
|
|
||||||
test('Simple chore creation test', async ({ page }) => {
|
|
||||||
// Navigate to chores
|
|
||||||
await page.goto('/parent/tasks/chores')
|
|
||||||
|
|
||||||
// Click the Create Chore FAB
|
|
||||||
await page.getByRole('button', { name: 'Create Chore' }).click()
|
|
||||||
|
|
||||||
// Wait for form to load
|
|
||||||
await expect(page.locator('text=Chore Name')).toBeVisible()
|
|
||||||
|
|
||||||
// Fill form using custom evaluation
|
|
||||||
await page.evaluate(() => {
|
|
||||||
// Fill name
|
|
||||||
const nameInput = document.querySelector('input[type="text"], input:not([type])')
|
|
||||||
if (nameInput) {
|
|
||||||
nameInput.value = 'Simple Chore Test'
|
|
||||||
nameInput.dispatchEvent(new Event('input', { bubbles: true }))
|
|
||||||
nameInput.dispatchEvent(new Event('change', { bubbles: true }))
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fill points
|
|
||||||
const pointsInput = document.querySelector('input[type="number"]')
|
|
||||||
if (pointsInput) {
|
|
||||||
pointsInput.value = '5'
|
|
||||||
pointsInput.dispatchEvent(new Event('input', { bubbles: true }))
|
|
||||||
pointsInput.dispatchEvent(new Event('change', { bubbles: true }))
|
|
||||||
}
|
|
||||||
|
|
||||||
// Click first image to select it
|
|
||||||
const firstImage = document.querySelector('img[alt*="Image"]')
|
|
||||||
if (firstImage) {
|
|
||||||
firstImage.click()
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
// Wait a moment for validation to trigger
|
|
||||||
await page.waitForTimeout(500)
|
|
||||||
|
|
||||||
// Click Create button
|
|
||||||
await page.getByRole('button', { name: 'Create' }).click()
|
|
||||||
|
|
||||||
// Verify we're back on the list page and item was created
|
|
||||||
// locate the name element, then move up to the row container
|
|
||||||
const choreName = page.locator('text=Simple Chore Test').first()
|
|
||||||
const choreRow = choreName.locator('..')
|
|
||||||
await expect(choreRow).toBeVisible()
|
|
||||||
// the row container should display the correct points value
|
|
||||||
await expect(choreRow.locator('text=5 pts')).toBeVisible()
|
|
||||||
})
|
|
||||||
@@ -32,6 +32,13 @@ async function restoreProfile(request: APIRequestContext, profile: ProfileData):
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Navigate to /parent/profile and wait for the form to finish loading. */
|
||||||
|
async function gotoProfile(page: import('@playwright/test').Page): Promise<void> {
|
||||||
|
await page.goto('/parent/profile')
|
||||||
|
// EntityEditForm hides the form behind v-if while loading=true; wait for it to render.
|
||||||
|
await expect(page.getByLabel('First Name')).toBeVisible({ timeout: 10000 })
|
||||||
|
}
|
||||||
|
|
||||||
test.describe('User Profile – editing', () => {
|
test.describe('User Profile – editing', () => {
|
||||||
test.describe.configure({ mode: 'serial' })
|
test.describe.configure({ mode: 'serial' })
|
||||||
|
|
||||||
@@ -46,7 +53,7 @@ test.describe('User Profile – editing', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
test('Profile page loads with correct data', async ({ page }) => {
|
test('Profile page loads with correct data', async ({ page }) => {
|
||||||
await page.goto('/parent/profile')
|
await gotoProfile(page)
|
||||||
|
|
||||||
await expect(page.getByRole('heading', { name: 'User Profile' })).toBeVisible()
|
await expect(page.getByRole('heading', { name: 'User Profile' })).toBeVisible()
|
||||||
await expect(page.getByLabel('First Name')).toHaveValue(E2E_FIRST_NAME)
|
await expect(page.getByLabel('First Name')).toHaveValue(E2E_FIRST_NAME)
|
||||||
@@ -68,13 +75,13 @@ test.describe('User Profile – editing', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
test('Save is disabled when form is clean (not dirty)', async ({ page }) => {
|
test('Save is disabled when form is clean (not dirty)', async ({ page }) => {
|
||||||
await page.goto('/parent/profile')
|
await gotoProfile(page)
|
||||||
|
|
||||||
await expect(page.getByRole('button', { name: 'Save' })).toBeDisabled()
|
await expect(page.getByRole('button', { name: 'Save' })).toBeDisabled()
|
||||||
})
|
})
|
||||||
|
|
||||||
test('Save is disabled when First Name is empty', async ({ page }) => {
|
test('Save is disabled when First Name is empty', async ({ page }) => {
|
||||||
await page.goto('/parent/profile')
|
await gotoProfile(page)
|
||||||
|
|
||||||
await page.getByLabel('First Name').fill('')
|
await page.getByLabel('First Name').fill('')
|
||||||
await page.getByLabel('First Name').blur()
|
await page.getByLabel('First Name').blur()
|
||||||
@@ -83,7 +90,7 @@ test.describe('User Profile – editing', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
test('Save is disabled when Last Name is empty', async ({ page }) => {
|
test('Save is disabled when Last Name is empty', async ({ page }) => {
|
||||||
await page.goto('/parent/profile')
|
await gotoProfile(page)
|
||||||
|
|
||||||
await page.getByLabel('Last Name').fill('')
|
await page.getByLabel('Last Name').fill('')
|
||||||
await page.getByLabel('Last Name').blur()
|
await page.getByLabel('Last Name').blur()
|
||||||
@@ -92,7 +99,7 @@ test.describe('User Profile – editing', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
test('Save is disabled when both name fields are empty', async ({ page }) => {
|
test('Save is disabled when both name fields are empty', async ({ page }) => {
|
||||||
await page.goto('/parent/profile')
|
await gotoProfile(page)
|
||||||
|
|
||||||
await page.getByLabel('First Name').fill('')
|
await page.getByLabel('First Name').fill('')
|
||||||
await page.getByLabel('Last Name').fill('')
|
await page.getByLabel('Last Name').fill('')
|
||||||
@@ -101,7 +108,7 @@ test.describe('User Profile – editing', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
test('Save enables when a name is changed', async ({ page }) => {
|
test('Save enables when a name is changed', async ({ page }) => {
|
||||||
await page.goto('/parent/profile')
|
await gotoProfile(page)
|
||||||
|
|
||||||
await page.getByLabel('First Name').fill('UpdatedE2E')
|
await page.getByLabel('First Name').fill('UpdatedE2E')
|
||||||
|
|
||||||
@@ -109,7 +116,7 @@ test.describe('User Profile – editing', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
test('Save persists name changes and shows confirmation modal', async ({ page }) => {
|
test('Save persists name changes and shows confirmation modal', async ({ page }) => {
|
||||||
await page.goto('/parent/profile')
|
await gotoProfile(page)
|
||||||
|
|
||||||
await page.getByLabel('First Name').fill('UpdatedE2E')
|
await page.getByLabel('First Name').fill('UpdatedE2E')
|
||||||
await page.getByLabel('Last Name').fill('UpdatedTester')
|
await page.getByLabel('Last Name').fill('UpdatedTester')
|
||||||
@@ -121,25 +128,25 @@ test.describe('User Profile – editing', () => {
|
|||||||
await dialog.getByRole('button', { name: 'OK' }).click()
|
await dialog.getByRole('button', { name: 'OK' }).click()
|
||||||
|
|
||||||
// OK navigates back; go back to profile to verify persistence
|
// OK navigates back; go back to profile to verify persistence
|
||||||
await page.goto('/parent/profile')
|
await gotoProfile(page)
|
||||||
await expect(page.getByLabel('First Name')).toHaveValue('UpdatedE2E')
|
await expect(page.getByLabel('First Name')).toHaveValue('UpdatedE2E')
|
||||||
await expect(page.getByLabel('Last Name')).toHaveValue('UpdatedTester')
|
await expect(page.getByLabel('Last Name')).toHaveValue('UpdatedTester')
|
||||||
})
|
})
|
||||||
|
|
||||||
test('Cancel discards unsaved changes', async ({ page }) => {
|
test('Cancel discards unsaved changes', async ({ page }) => {
|
||||||
await page.goto('/parent/profile')
|
await gotoProfile(page)
|
||||||
|
|
||||||
await page.getByLabel('First Name').fill('Discarded')
|
await page.getByLabel('First Name').fill('Discarded')
|
||||||
|
|
||||||
await page.getByRole('button', { name: 'Cancel' }).click()
|
await page.getByRole('button', { name: 'Cancel' }).click()
|
||||||
|
|
||||||
// Navigate back to verify no changes were saved
|
// Navigate back to verify no changes were saved
|
||||||
await page.goto('/parent/profile')
|
await gotoProfile(page)
|
||||||
await expect(page.getByLabel('First Name')).toHaveValue(E2E_FIRST_NAME)
|
await expect(page.getByLabel('First Name')).toHaveValue(E2E_FIRST_NAME)
|
||||||
})
|
})
|
||||||
|
|
||||||
test('Email field is read-only', async ({ page }) => {
|
test('Email field is read-only', async ({ page }) => {
|
||||||
await page.goto('/parent/profile')
|
await gotoProfile(page)
|
||||||
|
|
||||||
const emailInput = page.locator('#email')
|
const emailInput = page.locator('#email')
|
||||||
await expect(emailInput).toBeDisabled()
|
await expect(emailInput).toBeDisabled()
|
||||||
@@ -147,7 +154,7 @@ test.describe('User Profile – editing', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
test('Change profile image (built-in)', async ({ page }) => {
|
test('Change profile image (built-in)', async ({ page }) => {
|
||||||
await page.goto('/parent/profile')
|
await gotoProfile(page)
|
||||||
|
|
||||||
// Wait for images to load
|
// Wait for images to load
|
||||||
await page.waitForSelector('.selectable-image')
|
await page.waitForSelector('.selectable-image')
|
||||||
@@ -181,7 +188,7 @@ test.describe('User Profile – editing', () => {
|
|||||||
await page.locator('.modal-dialog').getByRole('button', { name: 'OK' }).click()
|
await page.locator('.modal-dialog').getByRole('button', { name: 'OK' }).click()
|
||||||
|
|
||||||
// Re-visit and confirm selection persists
|
// Re-visit and confirm selection persists
|
||||||
await page.goto('/parent/profile')
|
await gotoProfile(page)
|
||||||
await page.waitForSelector('.selectable-image')
|
await page.waitForSelector('.selectable-image')
|
||||||
const selectedSrc = await page.locator('.selectable-image.selected').getAttribute('src')
|
const selectedSrc = await page.locator('.selectable-image.selected').getAttribute('src')
|
||||||
expect(selectedSrc).toBeTruthy()
|
expect(selectedSrc).toBeTruthy()
|
||||||
@@ -191,7 +198,7 @@ test.describe('User Profile – editing', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
test('Upload a custom profile image', async ({ page }) => {
|
test('Upload a custom profile image', async ({ page }) => {
|
||||||
await page.goto('/parent/profile')
|
await gotoProfile(page)
|
||||||
|
|
||||||
await page.waitForSelector('.selectable-image')
|
await page.waitForSelector('.selectable-image')
|
||||||
|
|
||||||
@@ -214,7 +221,7 @@ test.describe('User Profile – editing', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
test('Change Password shows email-sent modal', async ({ page }) => {
|
test('Change Password shows email-sent modal', async ({ page }) => {
|
||||||
await page.goto('/parent/profile')
|
await gotoProfile(page)
|
||||||
|
|
||||||
await page.getByRole('button', { name: 'Change Password' }).click()
|
await page.getByRole('button', { name: 'Change Password' }).click()
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +0,0 @@
|
|||||||
import { test, expect } from '@playwright/test';
|
|
||||||
|
|
||||||
test.describe('Test group', () => {
|
|
||||||
test('seed', async ({ page }) => {
|
|
||||||
// generate code here.
|
|
||||||
});
|
|
||||||
});
|
|
||||||
28
frontend/localhost+1-key.pem
Normal file
28
frontend/localhost+1-key.pem
Normal 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
26
frontend/localhost+1.pem
Normal 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-----
|
||||||
37
frontend/nginx.http.conf.template
Normal file
37
frontend/nginx.http.conf.template
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
14
frontend/package-lock.json
generated
14
frontend/package-lock.json
generated
@@ -16,7 +16,6 @@
|
|||||||
"@tsconfig/node22": "^22.0.2",
|
"@tsconfig/node22": "^22.0.2",
|
||||||
"@types/jsdom": "^27.0.0",
|
"@types/jsdom": "^27.0.0",
|
||||||
"@types/node": "^22.18.11",
|
"@types/node": "^22.18.11",
|
||||||
"@vitejs/plugin-basic-ssl": "^2.3.0",
|
|
||||||
"@vitejs/plugin-vue": "^6.0.1",
|
"@vitejs/plugin-vue": "^6.0.1",
|
||||||
"@vitest/eslint-plugin": "^1.3.23",
|
"@vitest/eslint-plugin": "^1.3.23",
|
||||||
"@vue/eslint-config-prettier": "^10.2.0",
|
"@vue/eslint-config-prettier": "^10.2.0",
|
||||||
@@ -2172,19 +2171,6 @@
|
|||||||
"url": "https://opencollective.com/eslint"
|
"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": {
|
"node_modules/@vitejs/plugin-vue": {
|
||||||
"version": "6.0.1",
|
"version": "6.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/@vitejs/plugin-vue/-/plugin-vue-6.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/@vitejs/plugin-vue/-/plugin-vue-6.0.1.tgz",
|
||||||
|
|||||||
@@ -26,7 +26,6 @@
|
|||||||
"@tsconfig/node22": "^22.0.2",
|
"@tsconfig/node22": "^22.0.2",
|
||||||
"@types/jsdom": "^27.0.0",
|
"@types/jsdom": "^27.0.0",
|
||||||
"@types/node": "^22.18.11",
|
"@types/node": "^22.18.11",
|
||||||
"@vitejs/plugin-basic-ssl": "^2.3.0",
|
|
||||||
"@vitejs/plugin-vue": "^6.0.1",
|
"@vitejs/plugin-vue": "^6.0.1",
|
||||||
"@vitest/eslint-plugin": "^1.3.23",
|
"@vitest/eslint-plugin": "^1.3.23",
|
||||||
"@vue/eslint-config-prettier": "^10.2.0",
|
"@vue/eslint-config-prettier": "^10.2.0",
|
||||||
|
|||||||
@@ -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 */
|
/* Activate immediately — no waiting for old tabs to close */
|
||||||
self.addEventListener('install', function (event) {
|
self.addEventListener('install', function (event) {
|
||||||
@@ -16,17 +16,23 @@ self.addEventListener('push', function (event) {
|
|||||||
try {
|
try {
|
||||||
payload = event.data.json()
|
payload = event.data.json()
|
||||||
} catch (e) {
|
} 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
|
// Expiry-warning notifications are informational only — no action buttons
|
||||||
const actions =
|
const actions =
|
||||||
payload.type === 'chore_expiring_soon'
|
payload.type === 'chore_expiring_soon'
|
||||||
? []
|
? []
|
||||||
|
: isAndroidChrome
|
||||||
|
? [{ action: 'approve', title: 'Approve' }]
|
||||||
: [
|
: [
|
||||||
{ action: 'approve', title: 'Approve' },
|
{ action: 'approve', title: 'Approve' },
|
||||||
{ action: 'deny', title: 'Deny' },
|
{ action: 'deny', title: 'Reject' },
|
||||||
]
|
]
|
||||||
const options = {
|
const options = {
|
||||||
body: payload.body || '',
|
body: payload.body || '',
|
||||||
|
|||||||
@@ -224,9 +224,13 @@ const triggerTask = async (task: ChildTask) => {
|
|||||||
|
|
||||||
function isChoreCompletedToday(item: ChildTask): boolean {
|
function isChoreCompletedToday(item: ChildTask): boolean {
|
||||||
if (item.pending_status !== 'approved' || !item.approved_at) return false
|
if (item.pending_status !== 'approved' || !item.approved_at) return false
|
||||||
const approvedDate = item.approved_at.substring(0, 10)
|
const approvedDate = new Date(item.approved_at)
|
||||||
const today = new Date().toISOString().slice(0, 10)
|
const today = new Date()
|
||||||
return approvedDate === today
|
return (
|
||||||
|
approvedDate.getFullYear() === today.getFullYear() &&
|
||||||
|
approvedDate.getMonth() === today.getMonth() &&
|
||||||
|
approvedDate.getDate() === today.getDate()
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
async function doConfirmChore() {
|
async function doConfirmChore() {
|
||||||
@@ -448,6 +452,7 @@ function isChoreScheduledToday(item: ChildTask): boolean {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function isChoreExpired(item: ChildTask): boolean {
|
function isChoreExpired(item: ChildTask): boolean {
|
||||||
|
if (item.pending_status === 'pending') return false
|
||||||
if (!item.schedule) return false
|
if (!item.schedule) return false
|
||||||
const today = new Date()
|
const today = new Date()
|
||||||
const due = getDueTimeToday(item.schedule, today)
|
const due = getDueTimeToday(item.schedule, today)
|
||||||
@@ -622,10 +627,10 @@ onUnmounted(() => {
|
|||||||
:sort-fn="childChoreSort"
|
:sort-fn="childChoreSort"
|
||||||
>
|
>
|
||||||
<template #item="{ item }: { item: ChildTask }">
|
<template #item="{ item }: { item: ChildTask }">
|
||||||
<span v-if="isChoreExpired(item)" class="chore-stamp">TOO LATE</span>
|
<span v-if="isChoreCompletedToday(item)" class="chore-stamp completed-stamp"
|
||||||
<span v-else-if="isChoreCompletedToday(item)" class="chore-stamp completed-stamp"
|
|
||||||
>COMPLETED</span
|
>COMPLETED</span
|
||||||
>
|
>
|
||||||
|
<span v-else-if="isChoreExpired(item)" class="chore-stamp">TOO LATE</span>
|
||||||
<span v-else-if="item.pending_status === 'pending'" class="chore-stamp pending-stamp"
|
<span v-else-if="item.pending_status === 'pending'" class="chore-stamp pending-stamp"
|
||||||
>PENDING</span
|
>PENDING</span
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<script setup lang="ts">
|
<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 ModalDialog from '../shared/ModalDialog.vue'
|
||||||
import ScheduleModal from '../shared/ScheduleModal.vue'
|
import ScheduleModal from '../shared/ScheduleModal.vue'
|
||||||
import PendingRewardDialog from './PendingRewardDialog.vue'
|
import PendingRewardDialog from './PendingRewardDialog.vue'
|
||||||
@@ -95,6 +95,14 @@ const kebabBtnRefs = ref<Map<string, HTMLElement>>(new Map())
|
|||||||
const showScheduleModal = ref(false)
|
const showScheduleModal = ref(false)
|
||||||
const scheduleTarget = ref<ChildTask | null>(null)
|
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
|
// Expiry timers
|
||||||
const expiryTimers = ref<number[]>([])
|
const expiryTimers = ref<number[]>([])
|
||||||
|
|
||||||
@@ -112,7 +120,6 @@ function handleChoreItemReady(itemId: string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function handleTaskTriggered(event: Event) {
|
function handleTaskTriggered(event: Event) {
|
||||||
console.log('Task triggered, refreshing rewards list -> ', childRewardListRef.value)
|
|
||||||
const payload = event.payload as ChildTaskTriggeredEventPayload
|
const payload = event.payload as ChildTaskTriggeredEventPayload
|
||||||
if (child.value && payload.child_id == child.value.id) {
|
if (child.value && payload.child_id == child.value.id) {
|
||||||
child.value.points = payload.points
|
child.value.points = payload.points
|
||||||
@@ -300,8 +307,13 @@ function handleChoreConfirmation(event: Event) {
|
|||||||
|
|
||||||
function isChoreCompletedToday(item: ChildTask): boolean {
|
function isChoreCompletedToday(item: ChildTask): boolean {
|
||||||
if (item.pending_status !== 'approved' || !item.approved_at) return false
|
if (item.pending_status !== 'approved' || !item.approved_at) return false
|
||||||
const today = new Date().toISOString().slice(0, 10)
|
const approvedDate = new Date(item.approved_at)
|
||||||
if (item.approved_at.slice(0, 10) !== today) return false
|
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 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
|
if (item.schedule && !isScheduledToday(item.schedule, new Date())) return false
|
||||||
return true
|
return true
|
||||||
@@ -445,6 +457,7 @@ function isChoreScheduledToday(item: ChildTask): boolean {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function isChoreExpired(item: ChildTask): boolean {
|
function isChoreExpired(item: ChildTask): boolean {
|
||||||
|
if (item.pending_status === 'pending') return false
|
||||||
if (!item.schedule) return false
|
if (!item.schedule) return false
|
||||||
const now = new Date()
|
const now = new Date()
|
||||||
if (!isScheduledToday(item.schedule, now)) return false
|
if (!isScheduledToday(item.schedule, now)) return false
|
||||||
@@ -628,6 +641,33 @@ function applyHighlightPulse(itemId: string) {
|
|||||||
}, 200)
|
}, 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 () => {
|
onMounted(async () => {
|
||||||
try {
|
try {
|
||||||
eventBus.on('child_task_triggered', handleTaskTriggered)
|
eventBus.on('child_task_triggered', handleTaskTriggered)
|
||||||
@@ -660,12 +700,8 @@ onMounted(async () => {
|
|||||||
method: 'POST',
|
method: 'POST',
|
||||||
credentials: 'include',
|
credentials: 'include',
|
||||||
})
|
})
|
||||||
if (!res.ok) {
|
|
||||||
const data = await res.json().catch(() => ({}))
|
|
||||||
console.warn('Digest action failed:', data.error || res.status)
|
|
||||||
}
|
|
||||||
} catch (e) {
|
} 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)
|
childRewardListRef.value?.scrollToItem(scrollToId)
|
||||||
applyHighlightPulse(scrollToId)
|
applyHighlightPulse(scrollToId)
|
||||||
}
|
}
|
||||||
|
const { scrollTo: _s, entityType: _e, ...remainingQuery } = route.query
|
||||||
|
router.replace({ query: remainingQuery })
|
||||||
}, 500)
|
}, 500)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@@ -748,6 +786,7 @@ const triggerTask = (task: ChildTask) => {
|
|||||||
selectedTask.value = task
|
selectedTask.value = task
|
||||||
const pendingRewardIds = getPendingRewardIds()
|
const pendingRewardIds = getPendingRewardIds()
|
||||||
if (pendingRewardIds.length > 0) {
|
if (pendingRewardIds.length > 0) {
|
||||||
|
selectedReward.value = null
|
||||||
showPendingRewardDialog.value = true
|
showPendingRewardDialog.value = true
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -812,13 +851,13 @@ const confirmTriggerTask = async () => {
|
|||||||
|
|
||||||
const triggerReward = (reward: RewardStatus) => {
|
const triggerReward = (reward: RewardStatus) => {
|
||||||
if (reward.points_needed > 0) return
|
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
|
// If there is a pending reward and it's not this one, show the pending dialog
|
||||||
const pendingRewardIds = getPendingRewardIds()
|
const pendingRewardIds = getPendingRewardIds()
|
||||||
if (pendingRewardIds.length > 0 && !reward.redeeming) {
|
if (pendingRewardIds.length > 0 && !reward.redeeming) {
|
||||||
showPendingRewardDialog.value = true
|
showPendingRewardDialog.value = true
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
selectedReward.value = reward
|
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
showRewardConfirm.value = true
|
showRewardConfirm.value = true
|
||||||
}, 150)
|
}, 150)
|
||||||
@@ -987,14 +1026,14 @@ function goToAssignRewards() {
|
|||||||
</Teleport>
|
</Teleport>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- TOO LATE badge -->
|
|
||||||
<span v-if="isChoreExpired(item)" class="chore-stamp">TOO LATE</span>
|
|
||||||
<!-- PENDING badge -->
|
|
||||||
<span v-else-if="isChorePending(item)" class="chore-stamp pending-stamp">PENDING</span>
|
|
||||||
<!-- COMPLETED badge -->
|
<!-- COMPLETED badge -->
|
||||||
<span v-else-if="isChoreCompletedToday(item)" class="chore-stamp completed-stamp"
|
<span v-if="isChoreCompletedToday(item)" class="chore-stamp completed-stamp"
|
||||||
>COMPLETED</span
|
>COMPLETED</span
|
||||||
>
|
>
|
||||||
|
<!-- TOO LATE badge -->
|
||||||
|
<span v-else-if="isChoreExpired(item)" class="chore-stamp">TOO LATE</span>
|
||||||
|
<!-- PENDING badge -->
|
||||||
|
<span v-else-if="isChorePending(item)" class="chore-stamp pending-stamp">PENDING</span>
|
||||||
|
|
||||||
<div class="item-name">{{ item.name }}</div>
|
<div class="item-name">{{ item.name }}</div>
|
||||||
<img v-if="item.image_url" :src="item.image_url" alt="Task Image" class="item-image" />
|
<img v-if="item.image_url" :src="item.image_url" alt="Task Image" class="item-image" />
|
||||||
@@ -1129,6 +1168,16 @@ function goToAssignRewards() {
|
|||||||
<!-- Pending Reward Dialog -->
|
<!-- Pending Reward Dialog -->
|
||||||
<PendingRewardDialog
|
<PendingRewardDialog
|
||||||
v-if="showPendingRewardDialog"
|
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="
|
:message="
|
||||||
pendingEditOverrideTarget
|
pendingEditOverrideTarget
|
||||||
? 'This reward is currently pending. Changing its cost will cancel the pending request. Would you like to proceed?'
|
? '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
|
showPendingRewardDialog = false
|
||||||
pendingEditOverrideTarget = null
|
pendingEditOverrideTarget = null
|
||||||
|
selectedReward = null
|
||||||
}
|
}
|
||||||
"
|
"
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -1,11 +1,18 @@
|
|||||||
<template>
|
<template>
|
||||||
<ModalDialog title="Warning!" @backdrop-click="$emit('cancel')">
|
<ModalDialog @backdrop-click="$emit('cancel')">
|
||||||
<div class="modal-message">
|
<div class="approve-dialog">
|
||||||
{{ message }}
|
<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>
|
||||||
<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>
|
</div>
|
||||||
</ModalDialog>
|
</ModalDialog>
|
||||||
</template>
|
</template>
|
||||||
@@ -16,9 +23,17 @@ import ModalDialog from '../shared/ModalDialog.vue'
|
|||||||
withDefaults(
|
withDefaults(
|
||||||
defineProps<{
|
defineProps<{
|
||||||
message?: string
|
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?',
|
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>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.modal-message {
|
.approve-dialog {
|
||||||
margin-bottom: 1.2rem;
|
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;
|
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>
|
</style>
|
||||||
|
|||||||
@@ -1,19 +1,22 @@
|
|||||||
<template>
|
<template>
|
||||||
<ModalDialog
|
<ModalDialog v-if="reward" @backdrop-click="$emit('cancel')">
|
||||||
v-if="reward"
|
<div class="approve-dialog">
|
||||||
:imageUrl="reward.image_url"
|
<img v-if="reward.image_url" :src="reward.image_url" alt="Reward" class="reward-image" />
|
||||||
:title="reward.name"
|
<p class="item-label">{{ reward.name }}</p>
|
||||||
:subtitle="reward.points_needed === 0 ? 'Reward Ready!' : reward.points_needed + ' more points'"
|
<p class="subtitle">
|
||||||
@backdrop-click="$emit('cancel')"
|
{{ reward.points_needed === 0 ? 'Reward Ready!' : reward.points_needed + ' more points' }}
|
||||||
>
|
</p>
|
||||||
<div class="modal-message">
|
<p class="message">
|
||||||
Redeem this reward for <span class="child-name">{{ childName }}</span
|
Redeem this reward for <strong>{{ childName }}</strong
|
||||||
>?
|
>?
|
||||||
</div>
|
</p>
|
||||||
<div class="modal-actions">
|
<div class="actions">
|
||||||
<button @click="$emit('confirm')" class="btn btn-primary">Yes</button>
|
<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 v-if="reward.redeeming" @click="$emit('deny')" class="btn btn-secondary">
|
||||||
<button @click="$emit('cancel')" class="btn btn-secondary">Cancel</button>
|
Reject
|
||||||
|
</button>
|
||||||
|
<button v-else @click="$emit('cancel')" class="btn btn-secondary">No</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</ModalDialog>
|
</ModalDialog>
|
||||||
</template>
|
</template>
|
||||||
@@ -35,14 +38,54 @@ defineEmits<{
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.modal-message {
|
.approve-dialog {
|
||||||
margin-bottom: 1.2rem;
|
text-align: center;
|
||||||
font-size: 1rem;
|
padding: 0.5rem;
|
||||||
color: var(--modal-message-color, #333);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.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;
|
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>
|
</style>
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ import {
|
|||||||
subscribeToPushWithResult,
|
subscribeToPushWithResult,
|
||||||
unsubscribeFromPush,
|
unsubscribeFromPush,
|
||||||
isPushOptedOut,
|
isPushOptedOut,
|
||||||
|
ensurePushSubscriptionSynced,
|
||||||
} from '@/services/pushSubscription'
|
} from '@/services/pushSubscription'
|
||||||
|
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
@@ -66,6 +67,16 @@ async function fetchUserProfile() {
|
|||||||
if (userImageId.value) {
|
if (userImageId.value) {
|
||||||
await loadAvatarImages(userImageId.value)
|
await loadAvatarImages(userImageId.value)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Silent startup self-heal: if user has push enabled, ensure browser + backend
|
||||||
|
// subscription state are synchronized (no permission prompt).
|
||||||
|
if (
|
||||||
|
isParentAuthenticated.value &&
|
||||||
|
data.push_notifications_enabled !== false &&
|
||||||
|
!isPushOptedOut()
|
||||||
|
) {
|
||||||
|
void ensurePushSubscriptionSynced()
|
||||||
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('Error fetching user profile:', e)
|
console.error('Error fetching user profile:', e)
|
||||||
profileLoading.value = false
|
profileLoading.value = false
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="modal-backdrop" @click.self="$emit('backdrop-click')">
|
<div class="modal-backdrop" @click.self="$emit('backdrop-click')">
|
||||||
<div class="modal-dialog">
|
<div class="modal-dialog" :style="{ maxWidth: dialogMaxWidth }">
|
||||||
<div class="modal-heading">
|
<div class="modal-heading">
|
||||||
<img v-if="imageUrl" :src="imageUrl" alt="Dialog Image" class="modal-image" />
|
<img v-if="imageUrl" :src="imageUrl" alt="Dialog Image" class="modal-image" />
|
||||||
<div class="modal-details">
|
<div class="modal-details">
|
||||||
@@ -18,6 +18,7 @@ defineProps<{
|
|||||||
imageUrl?: string | null | undefined
|
imageUrl?: string | null | undefined
|
||||||
title?: string
|
title?: string
|
||||||
subtitle?: string | null | undefined
|
subtitle?: string | null | undefined
|
||||||
|
dialogMaxWidth?: string
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
defineEmits<{
|
defineEmits<{
|
||||||
|
|||||||
@@ -36,6 +36,31 @@ function urlBase64ToUint8Array(base64String: string): Uint8Array {
|
|||||||
return Uint8Array.from([...rawData].map((c) => c.charCodeAt(0)))
|
return Uint8Array.from([...rawData].map((c) => c.charCodeAt(0)))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function uint8ArraysEqual(a: Uint8Array, b: Uint8Array): boolean {
|
||||||
|
if (a.length !== b.length) return false
|
||||||
|
for (let i = 0; i < a.length; i += 1) {
|
||||||
|
if (a[i] !== b[i]) return false
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
async function postSubscriptionToBackend(subscription: PushSubscription): Promise<boolean> {
|
||||||
|
const subJson = subscription.toJSON()
|
||||||
|
const timezone = Intl.DateTimeFormat().resolvedOptions().timeZone
|
||||||
|
|
||||||
|
const res = await fetch('/api/push-subscription', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
endpoint: subJson.endpoint,
|
||||||
|
keys: subJson.keys,
|
||||||
|
timezone,
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
|
||||||
|
return res.ok
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Request notification permission and subscribe to push notifications.
|
* Request notification permission and subscribe to push notifications.
|
||||||
* Posts the subscription to the backend, including the user's timezone.
|
* Posts the subscription to the backend, including the user's timezone.
|
||||||
@@ -105,32 +130,79 @@ export async function subscribeToPushWithResult(): Promise<PushSubscribeResult>
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const subJson = subscription.toJSON()
|
|
||||||
const timezone = Intl.DateTimeFormat().resolvedOptions().timeZone
|
|
||||||
|
|
||||||
let res: Response
|
|
||||||
try {
|
try {
|
||||||
res = await fetch('/api/push-subscription', {
|
const ok = await postSubscriptionToBackend(subscription)
|
||||||
method: 'POST',
|
if (!ok) {
|
||||||
headers: { 'Content-Type': 'application/json' },
|
return { ok: false, reason: 'backend_error', detail: 'HTTP error posting subscription' }
|
||||||
body: JSON.stringify({
|
}
|
||||||
endpoint: subJson.endpoint,
|
|
||||||
keys: subJson.keys,
|
|
||||||
timezone,
|
|
||||||
}),
|
|
||||||
})
|
|
||||||
} catch (fetchErr) {
|
} catch (fetchErr) {
|
||||||
console.warn('[Push] Network error posting subscription:', fetchErr)
|
console.warn('[Push] Network error posting subscription:', fetchErr)
|
||||||
return { ok: false, reason: 'backend_error', detail: String(fetchErr) }
|
return { ok: false, reason: 'backend_error', detail: String(fetchErr) }
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!res.ok) {
|
return { ok: true }
|
||||||
const body = await res.text().catch(() => '')
|
|
||||||
console.warn(`[Push] Backend rejected subscription: HTTP ${res.status}`, body)
|
|
||||||
return { ok: false, reason: 'backend_error', detail: `HTTP ${res.status}: ${body}` }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return { ok: true }
|
/**
|
||||||
|
* Silent self-heal path used on app startup/login.
|
||||||
|
* - Never prompts for permission.
|
||||||
|
* - If already subscribed with a stale VAPID key, rotates the browser subscription.
|
||||||
|
* - Ensures backend has the current endpoint/keys.
|
||||||
|
*/
|
||||||
|
export async function ensurePushSubscriptionSynced(): Promise<boolean> {
|
||||||
|
if (!('serviceWorker' in navigator) || !('PushManager' in window)) return false
|
||||||
|
if (Notification.permission !== 'granted') return false
|
||||||
|
|
||||||
|
const vapidKey = await fetchVapidPublicKey()
|
||||||
|
if (!vapidKey) return false
|
||||||
|
|
||||||
|
let registration: ServiceWorkerRegistration
|
||||||
|
try {
|
||||||
|
await navigator.serviceWorker.register('/sw.js')
|
||||||
|
registration = await Promise.race([
|
||||||
|
navigator.serviceWorker.ready,
|
||||||
|
new Promise<never>((_, reject) =>
|
||||||
|
setTimeout(() => reject(new Error('SW ready timeout')), 10_000),
|
||||||
|
),
|
||||||
|
])
|
||||||
|
} catch (err) {
|
||||||
|
console.warn('[Push] Service worker not ready for self-heal:', err)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
const desiredServerKey = urlBase64ToUint8Array(vapidKey)
|
||||||
|
let subscription = await registration.pushManager.getSubscription()
|
||||||
|
|
||||||
|
if (subscription) {
|
||||||
|
const existingServerKeyBuffer = subscription.options?.applicationServerKey
|
||||||
|
if (existingServerKeyBuffer) {
|
||||||
|
const existingServerKey = new Uint8Array(existingServerKeyBuffer)
|
||||||
|
const keyMatches = uint8ArraysEqual(existingServerKey, desiredServerKey)
|
||||||
|
if (!keyMatches) {
|
||||||
|
await subscription.unsubscribe()
|
||||||
|
subscription = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!subscription) {
|
||||||
|
try {
|
||||||
|
subscription = await registration.pushManager.subscribe({
|
||||||
|
userVisibleOnly: true,
|
||||||
|
applicationServerKey: desiredServerKey,
|
||||||
|
})
|
||||||
|
} catch (err) {
|
||||||
|
console.warn('[Push] Failed to create subscription in self-heal:', err)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
return await postSubscriptionToBackend(subscription)
|
||||||
|
} catch (err) {
|
||||||
|
console.warn('[Push] Failed to sync subscription in self-heal:', err)
|
||||||
|
return false
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
{
|
{
|
||||||
"status": "interrupted",
|
"status": "passed",
|
||||||
"failedTests": []
|
"failedTests": []
|
||||||
}
|
}
|
||||||
@@ -1,23 +1,44 @@
|
|||||||
import { fileURLToPath, URL } from 'node:url'
|
import { fileURLToPath, URL } from 'node:url'
|
||||||
import { defineConfig, loadEnv } from 'vite'
|
import { defineConfig, loadEnv } from 'vite'
|
||||||
import vue from '@vitejs/plugin-vue'
|
import vue from '@vitejs/plugin-vue'
|
||||||
import basicSsl from '@vitejs/plugin-basic-ssl'
|
|
||||||
import fs from 'fs'
|
import fs from 'fs'
|
||||||
|
|
||||||
export default defineConfig(({ mode }) => {
|
export default defineConfig(({ mode }) => {
|
||||||
const env = loadEnv(mode, process.cwd(), '')
|
const env = loadEnv(mode, process.cwd(), '')
|
||||||
const backendHost = env.VITE_BACKEND_HOST ?? '127.0.0.1'
|
const backendHost = env.VITE_BACKEND_HOST ?? '127.0.0.1'
|
||||||
|
|
||||||
const httpsConfig =
|
// Use a trusted mkcert cert when present; otherwise fall back to plain HTTP.
|
||||||
fs.existsSync('./key.pem') && fs.existsSync('./cert.pem')
|
// Browsers treat localhost as a secure context even over HTTP, so service
|
||||||
? { key: fs.readFileSync('./key.pem'), cert: fs.readFileSync('./cert.pem') }
|
// workers and push notifications work without any cert on localhost.
|
||||||
: undefined
|
//
|
||||||
|
// 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 {
|
return {
|
||||||
plugins: [vue(), basicSsl()],
|
plugins: [vue()],
|
||||||
server: {
|
server: {
|
||||||
host: '0.0.0.0',
|
host: '0.0.0.0',
|
||||||
https: httpsConfig ?? true,
|
https: httpsConfig,
|
||||||
proxy: {
|
proxy: {
|
||||||
'/api': {
|
'/api': {
|
||||||
target: `http://${backendHost}:5000`,
|
target: `http://${backendHost}:5000`,
|
||||||
@@ -25,7 +46,7 @@ export default defineConfig(({ mode }) => {
|
|||||||
rewrite: (path) => path.replace(/^\/api/, ''),
|
rewrite: (path) => path.replace(/^\/api/, ''),
|
||||||
},
|
},
|
||||||
'/events': {
|
'/events': {
|
||||||
target: 'http://192.168.1.102:5000',
|
target: `http://${backendHost}:5000`,
|
||||||
changeOrigin: true,
|
changeOrigin: true,
|
||||||
secure: false,
|
secure: false,
|
||||||
},
|
},
|
||||||
|
|||||||
Reference in New Issue
Block a user