# FamilyDashboard: Challenges + Calendar soft-delete hardening

Use this reference when extending FamilyDashboard with family games/challenges, map-based quiz mechanics, challenge-point ledgers, weekly Gasser-Coin conversion, or when hardening calendar event edit/delete UX.

## Calendar delete hardening pattern

- Keep calendar events append-/soft-delete style: `DELETE /api/admin/calendar-events/{event_id}` must set `active=false`, never hard-delete.
- Return the mutated object in the response, not only `{ok: true}`:
  - `{ "ok": true, "calendar_event": { ..., "active": false } }`
- Missing events should return a clean 404 JSON error; no hanging response path.
- Frontend day dialogs should store `selectedDate: string | null`, then derive `selectedDay = days.find(day.date === selectedDate)` from the current query result. Avoid long-lived `CalendarDay` object copies after PATCH/DELETE.
- After PATCH/DELETE: invalidate `['calendar']`; either keep the dialog open with the event gone from the refreshed list or close it with a small success notice.
- Dialog buttons must be `type="button"`, call `preventDefault()`/`stopPropagation()` where nested in clickable day cards, disable during mutation, and show an inline error instead of leaving the UI apparently frozen.
- For Whiteboard/imported/informative entries, keep them informative only; never feed them into timeline logic.

## Sleep marker positioning pattern

- If the sleep/moon milestone is visually too high after anchoring to Gutenachtritual, adjust only the sleep marker CSS, not global marker positioning.
- Use a targeted class such as `.timeline-marker.timeline-kind-sleep { transform: translate(-50%, -80%); }`.
- Do not move school-start markers when changing moon/sleep marker placement.

## Challenges data model pattern

Core tables:

- `ChallengePlayer`: name/avatar/color, `is_child`, optional `linked_child_id`, `active`, `sort_order`.
- `GeoChallengeTarget`: name, `target_type` (`city`, `lake`, `mountain`, `region`), latitude/longitude, canton, difficulty 1–5, info text, active, sort order.
- `GeoChallengeAttempt`: player, target, day, selected lat/lon, distance, points, awarded band, created timestamp.
- `ChallengeWeeklyConversion`: unique `(player_id, week_start)`, challenge points, coins awarded, optional `coin_transaction_id`.

Seed challenge players idempotently from children:

- Emilia and Valerie link to `Child` rows and are coin-eligible.
- Adults such as Marcel/Melanie are `is_child=false` and collect only ChallengePoints.

## Geo quiz MVP map pattern

- Do **not** use an abstract hand-drawn/CSS/canvas fantasy map for Switzerland geography; the map must be immediately recognizable as Switzerland.
- Use a real, licensed local asset and document it in `docs/THIRD_PARTY_ATTRIBUTION.md` before reporting done. A known-good source used successfully: Wikimedia Commons `Switzerland_adm_location_map.svg` by NordNordWest, CC BY-SA 3.0 DE. Download the original SVG locally under `frontend/public/maps/switzerland-map.svg` and attribute it in the app/docs.
- Never copy assets/code from geographie-spiele.com; it may inspire UX only. Avoid iframes, advertising, external live tiles, and API-key map providers.
- Prefer static local SVG/PNG + transparent click overlay over Leaflet for the first iPad MVP. Keep the game logic inside FamilyDashboard.
- Store both `x_percent`/`y_percent` and `latitude`/`longitude` for each target:
  - Use `x_percent`/`y_percent` for visual pin placement on the static map (more robust than raw GIS projection).
  - Use lat/lon only for approximate Haversine distance/scoring.
  - Add SQLite idempotent migrations for new columns on `GeoChallengeTarget` and `GeoChallengeAttempt`.
- For Wikimedia location maps, use the file page bounds when available. For `Switzerland_adm_location_map.svg`: left `5.8`, right `10.7`, top `47.9`, bottom `45.75`.
- Do not reveal solution coordinates/percentages in `GET /api/challenges/geo/today`; return only id/name/type/canton/difficulty/info. Reveal `x_percent`, `y_percent`, lat/lon only after `POST /api/challenges/geo/attempt`.
- Recommended frontend split: `ChallengesView.tsx` (player + layout), `GeoChallengeGame.tsx` (game state), `SwitzerlandPinMap.tsx` (map asset + touch overlay), `lib/challenges.ts` (API calls). Keep any old `ChallengesPage.tsx` as a wrapper only if needed.
- Make pins large and touch-friendly; allow repeated taps to move the guess before submit. Optionally draw a line between guess and solution after submit.
- Reveal solution only after submit: target coordinates/percentages, selected coordinates/percentages, distance, scoring band, points, and 1–2 sentence child-friendly info text.

## Scoring and limits

- City/place radii:
  - `0–10 km`: `+1.0`
  - `>10–25 km`: `+0.5`
  - `>25–50 km`: `0`
  - `>50–90 km`: `-0.25`
  - `>90 km`: `-0.5`
- Lake/region radii:
  - `0–15 km`: `+1.0`
  - `>15–35 km`: `+0.5`
  - `>35–70 km`: `0`
  - `>70–120 km`: `-0.25`
  - `>120 km`: `-0.5`
- Difficulty bonus applies only on gold hits:
  - difficulty 3: `+0.25`
  - difficulty 4–5: `+0.5`
- Current play limit: 10 geography attempts per player per rolling 60 minutes.
- Current point limit: max 3 credited ChallengePoints per player per day. Keep `GeoChallengeAttempt.raw_points` as distance/scoring output and `credited_points` as the points that count for daily/weekly totals and weekly coin conversion.
- Once the daily points cap is reached, allow practice attempts while the hourly limit permits them, but store `credited_points=0` and return `practice_only=true`; still reveal solution/distance/info after submit.
- On the frontend, keep the result screen in explicit phase/state (`question|result|limit`) and do **not** invalidate/refetch `geo-today` immediately after submit. Refetch only leaderboard/summary after submit; fetch the next target only when the child taps “Nächste Frage”.

## Weekly conversion to Gasser-Coins

- Add `CoinTransactionType.challenge_reward` or equivalent ledger type; keep the old ledger immutable.
- Weekly conversion is admin-triggered and idempotent via `challenge-weekly:{player_id}:{week_start}` plus unique `(player_id, week_start)`.
- Only child-linked players can receive Gasser-Coins.
- Calculate weekly points from `GeoChallengeAttempt.credited_points`, not raw scoring points.
- `coins_awarded = min(3, floor(max(0, weekly_points)))`.
- Adults receive 0 coins even with positive points.
- Coin ledger should label the transaction clearly, e.g. `Challenge-Wochenbonus Geografie`.

## API shape

Family-internal routes:

- `GET /api/challenges/players`
- `GET /api/challenges/geo/today?player_id=...`
- `POST /api/challenges/geo/attempt`
- `GET /api/challenges/leaderboard`
- `GET /api/challenges/geo/summary?player_id=...`

Admin routes:

- `POST /api/admin/challenges/weekly-convert`
- `GET /api/admin/challenges/targets`
- `POST /api/admin/challenges/targets`
- `PATCH /api/admin/challenges/targets/{id}`
- `DELETE /api/admin/challenges/targets/{id}` should normally soft-disable targets.

## Verification checklist

- Backend tests:
  - Haversine distance sanity.
  - City and lake scoring bands.
  - Attempt creates point row and returns solution only after submit.
  - Daily limit blocks the 4th attempt.
  - Weekly conversion caps at 3 coins.
  - Weekly conversion is idempotent.
  - Adults do not receive Gasser-Coins.
  - Calendar event DELETE returns `active=false` and removes event from public calendar.
- Frontend:
  - `/challenges` navigation works without reload.
  - Player selection is saved in LocalStorage.
  - Real Switzerland map asset is local (`/maps/switzerland-map.svg` or equivalent) and visibly recognizable; no fantasy map, iframe, or external tiles.
  - Map tap sets/moves pin using `x_percent`/`y_percent`; submit reveals solution and leaderboard refreshes.
  - `GET /geo/today` does not leak solution coordinates/percentages before submit.
  - Admin Challenge tab can trigger weekly conversion and list targets/leaderboard.
- Documentation:
  - `docs/THIRD_PARTY_ATTRIBUTION.md` records map source, author, license URL, download date, permitted use, and required attribution.
- Always run the normal FamilyDashboard gates after changes: Docker rebuild/up, `/api/health`, frontend HTTP smoke, backend `pytest`, frontend build, Playwright. Add a Playwright smoke for `/challenges` that selects a player, taps the map, submits, and sees distance/points when daily limit permits.
