Commit Graph

11 Commits

Author SHA1 Message Date
football2801 dd89b3d934 feat(brand): switch user-facing copy + Mercure topic prefix to wevisto.com
CI / test (push) Has been cancelled
Mercure topic identifiers updated in lockstep across PHP publisher + TS
subscriber (and their tests). Help-page setup instructions now point to
wevisto.com. Traefik already serves both hosts; this aligns the in-app
references with the public brand.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 21:27:07 -04:00
football2801 081ca83613 fix(v2): preview rotation + crop aspect for 13.3" hardware
CI / test (push) Has been cancelled
Two related bugs that surfaced on the first 13.3" device's first photo:

1) Web-UI portrait preview was 90° sideways. DeviceApiController::
   renderBinToPng rotated whenever the device was Portrait — correct
   for V1 (landscape-native, Portrait => renderer rotated, so preview
   un-rotates) but wrong for V2 (portrait-native — the renderer
   doesn't rotate, so the preview shouldn't either). Now mirrors the
   render-pipeline check: rotate only when `orientation !==
   model->nativeOrientation()`. Two new functional tests pin the V2
   portrait and V2 landscape PNG dimensions to guard against
   regressions.

2) Cropped photo letterboxed on the 13.3" panel. CropEditor /
   StickerCanvas / FrameCard had V1 dimensions hardcoded (1600×960
   = 5:3 aspect). V2 is 4:3 (1200×1600 portrait / 1600×1200
   landscape), so a "full crop" came out the wrong shape and the
   server's white-canvas composite added bars. New `panelDims(model,
   orientation)` helper in @/types is the single source of truth on
   the frontend; matches DeviceModel::width/height on the server.
   Threaded `model` through Device serializer → Device type →
   UploadView → CropEditor / StickerCanvas, and HomeView → FrameCard.
   FrameCard tests updated to cover all four model × orientation
   placeholders.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 12:02:39 -04:00
football2801 e4f811581a feat(setup): noon-daily default + force-refresh hint + inline remove confirm
CI / test (push) Has been cancelled
Three coordinated UX changes touching defaults and the settings sheet.

1. Server defaults: DeviceService::linkToUser now sets timezone =
   user.timezone and wakeTimes = [12*60] (noon-daily) when creating a
   new Device row OR transferring ownership on takeover. Replaces the
   prior "1440-min interval anchored to last-seen-time" default that
   could land a recipient's first photo at 3 am.

2. PWA propagation note: now mentions "briefly disconnect and reconnect
   the frame's power" as the immediate-refresh gesture. Pairs with the
   existing X-Boot-Reason: cold force-resync — the firmware already
   honors a power-cycle as a deliberate refresh request, but users had
   no way to discover that.

3. Remove-this-frame: replaced the native window.confirm() with an
   in-sheet confirmation panel showing the explanatory text. Inline
   keeps the gesture inside the existing sheet flow and gives the
   destructive button a fixed location, instead of a floating native
   dialog that varies per browser. The confirm body explicitly says
   "this can't be undone" to match the irreversibility.

Tests:
  - DeviceServiceTest: new-device default, takeover-resets-with-default,
    UTC fallback when user has empty timezone.
  - SetupControllerTest: claim-takes-over-defaults updated to assert
    [12*60] wakeTimes.
  - HomeView.test: 4 cases covering open-confirm, yes-confirm, cancel,
    propagation-note text.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 16:19:51 -04:00
football2801 920de623a0 feat(devices): owner can mark a frame as sold and unlink it pre-emptively
CI / test (push) Has been cancelled
Pairs with the new claim-on-takeover checkbox: now the seller can purge
their data BEFORE handing the device over, so even if they forget to
hold the BOOT button to wipe NVS, the next owner can't accidentally pull
their photos.

Backend:
  - DELETE /api/devices/{id}: owner-only (404 for cross-tenant). Revokes
    image-device approvals, drops history rows, removes the Device row
    entirely so the MAC is unclaimed. The next poll from that physical
    frame returns 404 → setup QR for the next owner.
  - DeviceService::deleteDeviceForOwner extracts the cleanup so the
    controller stays thin.
  - Mercure publish on delete sends {id, deleted: true} so any other
    open PWA tabs splice the row out instantly.

Frontend:
  - Settings sheet (BaseBottomSheet): "Remove this frame" link below
    Save, in danger red with an explanatory hint about when to use it.
  - Native window.confirm gate — destructive + irreversible, the
    weight of native-confirm is honest. (A bespoke modal would be
    polish.)
  - useDeviceMercure: handles the {id, deleted: true} sentinel — splices
    the device out + closes its own EventSource for that topic.
  - useDevicesStore.removeDevice: DELETE + local store filter.

Tests added:
  - DeviceApiControllerTest: 4 cases — happy-path delete purges
    everything, 404 cross-tenant, anon redirects to login, and
    post-delete the device-poll endpoint 404s (fresh-MAC guarantee).
  - HomeView.test.ts: confirm-yes calls store + closes sheet,
    confirm-cancel does NOT call removeDevice.
  - useDeviceMercure.test.ts: deletion sentinel splices the device
    out and closes the EventSource.

Coverage: 99.71% lines / 98.21% methods backend, 98.31% lines frontend.
558 tests total via ddev tests.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 14:53:51 -04:00
football2801 ece0defe3f feat(setup): "Claim this frame" checkbox for previously-bound MACs
CI / test (push) Has been cancelled
Use case: old owner sells the device to a friend. Friend holds the BOOT
button to wipe NVS, joins the device's AP, sets new WiFi. The old
owner's account is still bound to the MAC server-side, so without
explicit consent the friend would silently take over (or, worse, the
old owner's photos would keep displaying until claim).

Flow now:
  - GET /setup/{mac} detects MAC bound to anyone and renders a
    "Claim this frame as my own" checkbox + a banner explaining what
    the takeover wipes. Both register and login panels carry the
    checkbox; submitting either form without it bounces back through
    the index with a session-flashed error.
  - DeviceService::linkToUser now requires allowClaim=true to
    transfer ownership. Without it, throws DeviceClaimRequiredException
    that the controller catches and turns into the bounce-with-error.
  - On a successful claim, the takeover wipes:
      * old image-device approvals
      * device_image_history rows for the device
      * name, wakeTimes, currentImage*, lockedImage, nextPollExpectedAt
    so the new owner starts from a fresh slate, not inheriting the
    seller's "Living Room / 4:30 AM" preset.
  - Already-logged-in user visiting /setup/{mac} for someone else's
    device falls through to the form (instead of silently transferring
    on page load) so the checkbox is the only path.

Test matrix:
  - SetupControllerTest: 5 new functional cases — checkbox renders for
    bound MACs, register/login without checkbox bounce + retain old
    ownership, register WITH checkbox transfers + purges, logged-in
    other-user falls through to form.
  - DeviceServiceTest: 3 new unit cases — throw without consent,
    isClaimedByAnotherUser true/false matrix, takeover resets device
    state.

Coverage: 99.70% lines / 98.19% methods backend, 333 frontend tests
green via ddev tests.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 14:45:52 -04:00
football2801 b48ed73b4e fix(rotation): isDue() compares wakeTime boundary in UTC, not device-local tz
CI / test (push) Has been cancelled
Symptom: wakeTimes schedules silently never fire on non-UTC devices.
Reported live by Matt's EDT frame: wakeTimes=[12:30 PM NY] saved,
12:30 came and went, no rotation. Same bug pattern would fire
*every* poll on east-of-UTC tzs.

Root cause: device_image_history.served_at is `timestamp without time
zone`, written by `new DateTimeImmutable()` so it stores UTC
components ("2026-05-08 16:28:50"). The boundary in isDue() was
bound through Doctrine with the device's local tz still attached,
so Doctrine's format() emitted local-tz components ("12:30:00").
Postgres compared the strings literally — for west-of-UTC tzs the
UTC timestamp is numerically larger than the local-tz boundary, so
every same-day row falsely satisfied `servedAt >= :wakeTime` and
isDue returned false.

Fix: $boundary->setTimezone(UTC) before binding. Both sides now
format in UTC components, so Postgres's literal compare is correct.

Regression test ID-TZ-01: device in America/New_York, wakeTimes
[12:30 PM NY], history at 12:00 PM NY (= 16:00 UTC). With the fix
isDue returns true; without it the test falsely-matches and fails.
Skipped before 13:00 NY since the assertion needs the wake slot to
have already passed today.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 12:41:40 -04:00
football2801 5b3e2e47d7 fix(rotation): gate poll-driven advance() behind isDue() check
CI / test (push) Has been cancelled
Symptom: with wakeTimes=[4 AM, 9 PM, 9:15 PM], the frame rotated to a
fresh photo at 10:14 AM when the device was reconnected and polled.
The wakeTimes only governed *when* the device polled (via X-Interval-Ms);
they didn't gate whether the server picked a new image when it did.
Power-on or button-press polls would always rotate.

Fix: move the existing isDue() logic from AdvanceRotationMessageHandler
into RotationService as a public method, and gate
DeviceImageController::image so off-schedule polls return the device's
current image (which 304s when X-Current-Image-Id matches) rather than
calling advance(). The scheduler-driven handler still uses the same
isDue — both code paths now share one source of truth.

Tests:
  - DeviceImageControllerTest: new test asserting an off-schedule poll
    returns 304 without rotating; existing wakeTimes tests reworked to
    use slot lists that always have a past slot regardless of run time.
  - AdvanceRotationMessageHandlerTest: existing AR-04 through AR-07
    keep covering isDue's semantics — they now go through the service.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 12:14:32 -04:00
football2801 cf6623de67 feat(rotation): per-device image-selection preferences
CI / test (push) Has been cancelled
Adds two settings exposed in the PWA frame-settings sheet:

- rotationMode (enum: random | least_recently_shown | oldest_upload |
  newest_upload). Default oldest_upload preserves the legacy
  hard-coded sort, so existing devices behave identically until the
  user changes it.
- prioritizeNeverShown (bool). When set, the candidate set is narrowed
  to never-shown images first (if any exist) before the mode runs —
  useful for "burn through new uploads before re-shuffling the catalog."

RotationService pipeline:
  1. Pull approved/ready pool.
  2. Drop the last `uniquenessWindow` served (existing).
  3. If prioritizeNeverShown AND any candidates have never been served,
     narrow to those.
  4. Apply the selection mode.

Backend: enum, entity columns + accessors, migration, serializer,
PATCH validator. Frontend: types, stores, settings sheet section
(dropdown + checkbox), test fixtures, save-flow test.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 16:37:14 -04:00
football2801 ba9625d45d feat(home): live updates via Mercure — server pushes device state to the PWA
CI / test (push) Has been cancelled
Subscribe per-device with a Symfony Mercure hub: server publishes a fresh
device payload after every poll (200/304/204), every PATCH, and every
lock/unlock. The frontend opens one EventSource per device topic and
splats inbound JSON straight into the devices store — same shape as
GET /api/devices, so no envelope handling.

Topic: https://pictureframe.edholm.me/devices/{id}

Stack mirrors aqua-iq:
- symfony/mercure-bundle + config/packages/mercure.yaml
- App\Service\MercurePublisher (errors swallowed + logged; a flaky hub
  must not break a poll response)
- App\Service\DeviceSerializer extracted as the single source of truth
  for the wire shape (REST + Mercure share it)
- Frontend useDeviceMercure() composable: opens/closes EventSources to
  match the device list reactively, reconnects on hub-side closes
- SpaController exposes MERCURE_PUBLIC_URL via window.__PF_MERCURE_URL__

Production compose adds a dunglas/mercure container with Traefik labels
for pictureframe.edholm.me/.well-known/mercure (handled separately on
the host since the file isn't in this repo).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 16:20:21 -04:00
football2801 12245759ac chore: stage all in-progress work before repo split
CI / test (push) Has been cancelled
Web app: new entities (Image, RenderedAsset, SharedImage, Token,
DeviceImageHistory), enums, repositories, controllers, message handlers,
migrations, tests, frontend upload/library/sticker UI, Vue components.

Firmware: EPD background screen binaries + gen scripts, setup_bg header.

Infra: ddev config, test bundle, gitignore coverage dir.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-06 12:11:31 -04:00
football2801 fb380c45bd feat(story-2.2+2.3): device setup page, account linking, naming & configuration
Story 2.2 — /setup/{mac} Twig page (no Vue, works JS-disabled):
- Register tab: creates account + logs in + links device → /setup/{mac}/configure
- Login tab: manual credential check via UserPasswordHasherInterface + Security::login()
  + links device → /setup/{mac}/configure
- Re-provisioning: DeviceService.linkToUser() atomically transfers ownership + stubs
  purgeDeviceHistory() (completed in Epic 3 when Image/Approval entities exist)

Story 2.3 — /setup/{mac}/configure (requires auth):
- GET: device name, orientation (landscape/portrait), rotation interval (6/12/24/48/168h),
  uniqueness window (5/10/20/50 cycles)
- POST: validates name, saves to Device entity, redirects to Vue SPA
- Device entity: mac, name, orientation (Orientation enum), rotationIntervalHours,
  uniquenessWindow, user (ManyToOne), linkedAt
- PATCH /api/devices/{id}: Vue SPA can edit any device field (Story 2.3 "edit from app")
- GET /api/devices: list authenticated user's devices
- Migration: create device table with Orientation enum column

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-28 00:47:14 -04:00