fix(home): "next sync" must reflect the schedule the device is *on*
CI / test (push) Has been cancelled

The card's "next sync" was computed locally as `lastSeenAt + interval`,
which broke the moment the user PATCHed a new interval: the device is
still asleep on whatever schedule was active at its last poll, but the
local record now has the new interval, so we'd display a misleading
"in 2m" after a 5→3 min change.

Fix: server stamps `nextPollExpectedAt` on every poll (200/304/204),
PWA reads it directly. The timestamp doesn't move when settings are
edited — only when the device actually polls and picks up a new
schedule. Same field also drives the settings-sheet "Next update"
preview, which had the same flaw.

Side effects:
- `markSeen()` now flushes on the 204 paths too — they previously
  set lastSeenAt without flushing (latent bug for devices with no
  approved images / missing assets).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-07 15:52:04 -04:00
parent eedd50b95c
commit 995445ed9e
22 changed files with 136 additions and 26 deletions
@@ -43,6 +43,7 @@ const makeDevice = (overrides: Partial<Device> = {}): Device => ({
uniquenessWindow: 30, uniquenessWindow: 30,
linkedAt: '2026-01-01T00:00:00Z', linkedAt: '2026-01-01T00:00:00Z',
lastSeenAt: null, lastSeenAt: null,
nextPollExpectedAt: null,
lockedImageId: null, lockedImageId: null,
currentImageId: null, currentImageId: null,
...overrides, ...overrides,
@@ -32,6 +32,7 @@ const makeDevice = (overrides: Partial<Device> = {}): Device => ({
uniquenessWindow: 30, uniquenessWindow: 30,
linkedAt: '2026-01-01T00:00:00Z', linkedAt: '2026-01-01T00:00:00Z',
lastSeenAt: null, lastSeenAt: null,
nextPollExpectedAt: null,
lockedImageId: null, lockedImageId: null,
currentImageId: null, currentImageId: null,
...overrides, ...overrides,
+1
View File
@@ -14,6 +14,7 @@ const makeDevice = (overrides: Partial<Device> = {}): Device => ({
uniquenessWindow: 30, uniquenessWindow: 30,
linkedAt: '2026-01-01T00:00:00Z', linkedAt: '2026-01-01T00:00:00Z',
lastSeenAt: null, lastSeenAt: null,
nextPollExpectedAt: null,
lockedImageId: null, lockedImageId: null,
currentImageId: null, currentImageId: null,
...overrides, ...overrides,
+28
View File
@@ -71,6 +71,7 @@ const makeDevice = (overrides: Partial<Device> = {}): Device => ({
uniquenessWindow: 30, uniquenessWindow: 30,
linkedAt: '2026-01-01T00:00:00Z', linkedAt: '2026-01-01T00:00:00Z',
lastSeenAt: null, lastSeenAt: null,
nextPollExpectedAt: null,
lockedImageId: null, lockedImageId: null,
currentImageId: null, currentImageId: null,
...overrides, ...overrides,
@@ -586,6 +587,33 @@ describe('HomeView', () => {
})) }))
}) })
// The card's "next sync" must use the server-stamped nextPollExpectedAt
// when present, ignoring the locally-saved (but not yet device-applied)
// schedule. This is the bug Matt hit: changing 5 min → 3 min in the sheet
// made the card jump to "in 3m" even though the device is still asleep
// on the 5-min schedule and will wake at lastSeenAt + 5 min.
it('nextSync uses server nextPollExpectedAt when present, not local schedule', async () => {
const devicesStore = useDevicesStore()
devicesStore.devices = [makeDevice({
id: 1,
// Locally we just saved every-3-min, but the device is still on
// every-5-min until it next polls. The server's expected-next-poll
// (set under the old schedule at the last poll) is 4 minutes out.
rotationIntervalMinutes: 3,
wakeTimes: [],
lastSeenAt: new Date(Date.now() - 60_000).toISOString(),
nextPollExpectedAt: new Date(Date.now() + 4 * 60_000).toISOString(),
})]
vi.spyOn(devicesStore, 'fetchDevices').mockResolvedValue()
const wrapper = mountView()
await flushPromises()
const props = wrapper.findComponent({ name: 'FrameCard' }).props()
// 4 minutes — what the server actually plans, NOT 2 min from
// (lastSeenAt + 3 min - now), which would be the bug.
expect(props.nextSync).toMatch(/in 4m/)
})
// The "next update" preview must reflect when the device will *actually* // The "next update" preview must reflect when the device will *actually*
// next sync — that's when it picks up the new settings, not the first hit // next sync — that's when it picks up the new settings, not the first hit
// of the new schedule. The device is asleep on its CURRENT schedule. // of the new schedule. The device is asleep on its CURRENT schedule.
@@ -85,6 +85,7 @@ const makeDevice = (overrides: Partial<Device> = {}): Device => ({
uniquenessWindow: 30, uniquenessWindow: 30,
linkedAt: '2026-01-01T00:00:00Z', linkedAt: '2026-01-01T00:00:00Z',
lastSeenAt: null, lastSeenAt: null,
nextPollExpectedAt: null,
lockedImageId: null, lockedImageId: null,
currentImageId: null, currentImageId: null,
...overrides, ...overrides,
@@ -61,6 +61,7 @@ const makeDevice = (overrides: Partial<Device> = {}): Device => ({
uniquenessWindow: 30, uniquenessWindow: 30,
linkedAt: '2026-01-01T00:00:00Z', linkedAt: '2026-01-01T00:00:00Z',
lastSeenAt: null, lastSeenAt: null,
nextPollExpectedAt: null,
lockedImageId: null, lockedImageId: null,
currentImageId: null, currentImageId: null,
...overrides, ...overrides,
+2
View File
@@ -18,6 +18,8 @@ export interface Device {
uniquenessWindow: number uniquenessWindow: number
linkedAt: string linkedAt: string
lastSeenAt: string | null lastSeenAt: string | null
/** Server-stamped expected next poll time. Drives the "next sync" label. */
nextPollExpectedAt: string | null
lockedImageId: number | null lockedImageId: number | null
currentImageId: number | null currentImageId: number | null
} }
+38 -13
View File
@@ -277,19 +277,38 @@ function nextWakeMatch(times: number[], tz: string): { minutes: number; today: b
return best return best
} }
// Prefer the server-stamped nextPollExpectedAt that's the schedule the
// device is *actually* on, set every poll. Falls back to a local computation
// for devices that haven't polled since the column was added.
function nextSyncLabel(device: Device): string | null { function nextSyncLabel(device: Device): string | null {
if (device.wakeTimes.length > 0) { let nextMs: number | null = null
if (device.nextPollExpectedAt) {
nextMs = new Date(device.nextPollExpectedAt).getTime()
} else if (device.wakeTimes.length > 0) {
const next = nextWakeMatch(device.wakeTimes, device.timezone || 'UTC') const next = nextWakeMatch(device.wakeTimes, device.timezone || 'UTC')
if (!next) return null if (!next) return null
return `next sync ~${formatTime(next.minutes)} ${next.today ? 'today' : 'tomorrow'}` return `next sync ~${formatTime(next.minutes)} ${next.today ? 'today' : 'tomorrow'}`
} else if (device.lastSeenAt) {
nextMs = new Date(device.lastSeenAt).getTime() + device.rotationIntervalMinutes * 60_000
} else {
return null
} }
if (!device.lastSeenAt) return null
const next = new Date(device.lastSeenAt).getTime() + device.rotationIntervalMinutes * 60_000 const fromNow = nextMs - Date.now()
const fromNow = next - Date.now()
if (fromNow <= 0) return null if (fromNow <= 0) return null
if (fromNow < 60_000) return 'next sync in <1m' if (fromNow < 60_000) return 'next sync in <1m'
if (fromNow < 3_600_000) return `next sync in ${Math.round(fromNow / 60_000)}m` if (fromNow < 3_600_000) return `next sync in ${Math.round(fromNow / 60_000)}m`
return `next sync in ${Math.round(fromNow / 3_600_000)}h` if (fromNow < 86_400_000) {
// Long horizons read better as a clock time than "in 14h".
const tz = device.timezone || 'UTC'
const minOfDay = getMinuteOfDayInTz(new Date(nextMs), tz)
const dayDelta = daysFromTodayInTz(new Date(nextMs), tz)
const dayLabel = dayDelta === 0 ? 'today'
: dayDelta === 1 ? 'tomorrow'
: `in ${dayDelta}d`
return `next sync ~${formatTime(minOfDay)} ${dayLabel}`
}
return `next sync in ${Math.round(fromNow / 86_400_000)}d`
} }
// Home shows what's actually on the frame right now the last image the // Home shows what's actually on the frame right now the last image the
@@ -507,17 +526,23 @@ const nextUpdatePreview = computed<string>(() => {
// The preview is about when the device will *next sync* it does NOT // The preview is about when the device will *next sync* it does NOT
// depend on the proposed new settings, only on the device's current saved // depend on the proposed new settings, only on the device's current saved
// schedule. The "no update times yet" hint already lives in the time list. // schedule. The "no update times yet" hint already lives in the time list.
if (!device.lastSeenAt) { const tz = device.timezone || 'UTC'
return 'Next update: when the frame next connects'
}
const tz = device.timezone || 'UTC' // Prefer the server-stamped expected-next-poll: that timestamp was set
const lastSeen = new Date(device.lastSeenAt).getTime() // under the schedule active at the device's last poll, and isn't disturbed
// by the user's PATCHes exactly what we want for "when will the new
// settings reach the frame?"
let nextPollMs: number let nextPollMs: number
if (device.wakeTimes.length > 0) { if (device.nextPollExpectedAt) {
nextPollMs = nextWakeAfter(lastSeen, device.wakeTimes, tz) nextPollMs = new Date(device.nextPollExpectedAt).getTime()
} else if (!device.lastSeenAt) {
return 'Next update: when the frame next connects'
} else { } else {
nextPollMs = lastSeen + device.rotationIntervalMinutes * 60_000 // Legacy fallback for devices that haven't polled since the column was added.
const lastSeen = new Date(device.lastSeenAt).getTime()
nextPollMs = device.wakeTimes.length > 0
? nextWakeAfter(lastSeen, device.wakeTimes, tz)
: lastSeen + device.rotationIntervalMinutes * 60_000
} }
// Already-overdue device: it'll poll any moment now. // Already-overdue device: it'll poll any moment now.
if (nextPollMs < Date.now()) nextPollMs = Date.now() if (nextPollMs < Date.now()) nextPollMs = Date.now()
+27
View File
@@ -0,0 +1,27 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
final class Version20260507230002 extends AbstractMigration
{
public function getDescription(): string
{
return 'Add device.next_poll_expected_at — server stamps it on every poll so the PWA shows the *real* next sync time, unaffected by locally-edited settings that the device has not yet picked up';
}
public function up(Schema $schema): void
{
$this->addSql('ALTER TABLE device ADD next_poll_expected_at TIMESTAMP(0) WITHOUT TIME ZONE DEFAULT NULL');
$this->addSql("COMMENT ON COLUMN device.next_poll_expected_at IS '(DC2Type:datetime_immutable)'");
}
public function down(Schema $schema): void
{
$this->addSql('ALTER TABLE device DROP COLUMN next_poll_expected_at');
}
}
File diff suppressed because one or more lines are too long
@@ -1 +1 @@
import{A as e,O as t,R as n,_ as r,d as i,ft as a,g as o,h as s,l as c,o as l,p as u,t as d,u as f}from"./_plugin-vue_export-helper-CeYnMxKK.js";import{n as p,t as m}from"./BaseBottomSheet-DohSPmvo.js";var h={class:`device-picker__list`},g=[`checked`,`onChange`],_={class:`device-picker__name`},v={class:`device-picker__orientation`},y=d(r({__name:`DevicePicker`,props:{modelValue:{type:Boolean},devices:{},selected:{},uploading:{type:Boolean}},emits:[`update:modelValue`,`update:selected`,`confirm`],setup(r,{emit:d}){let y=r,b=d;function x(e){y.selected.includes(e)?b(`update:selected`,y.selected.filter(t=>t!==e)):b(`update:selected`,[...y.selected,e])}let S=c(()=>{let e=y.selected.length;return e===0?`Add to frame`:`Add to ${e} frame${e>1?`s`:``}`});return(c,d)=>(t(),i(m,{"model-value":r.modelValue,label:`Choose frames`,"onUpdate:modelValue":d[1]||=e=>c.$emit(`update:modelValue`,e)},{default:n(()=>[d[2]||=f(`h2`,{class:`device-picker__title`},`Add to frames`,-1),d[3]||=f(`p`,{class:`device-picker__sub`},`Choose which frames will show this photo.`,-1),f(`div`,h,[(t(!0),u(l,null,e(r.devices,e=>(t(),u(`label`,{key:e.id,class:`device-picker__row`},[f(`input`,{type:`checkbox`,class:`device-picker__check`,checked:r.selected.includes(e.id),onChange:t=>x(e.id)},null,40,g),f(`span`,_,a(e.name),1),f(`span`,v,a(e.orientation),1)]))),128))]),o(p,{variant:`primary`,class:`device-picker__confirm`,disabled:r.selected.length===0||r.uploading,onClick:d[0]||=e=>c.$emit(`confirm`)},{default:n(()=>[s(a(r.uploading?`Uploading…`:S.value),1)]),_:1},8,[`disabled`])]),_:1},8,[`model-value`]))}}),[[`__scopeId`,`data-v-a6466fa5`]]);export{y as t}; import{A as e,O as t,R as n,_ as r,d as i,ft as a,g as o,h as s,l as c,o as l,p as u,t as d,u as f}from"./_plugin-vue_export-helper-CeYnMxKK.js";import{n as p,t as m}from"./BaseBottomSheet-Y2PW4H1i.js";var h={class:`device-picker__list`},g=[`checked`,`onChange`],_={class:`device-picker__name`},v={class:`device-picker__orientation`},y=d(r({__name:`DevicePicker`,props:{modelValue:{type:Boolean},devices:{},selected:{},uploading:{type:Boolean}},emits:[`update:modelValue`,`update:selected`,`confirm`],setup(r,{emit:d}){let y=r,b=d;function x(e){y.selected.includes(e)?b(`update:selected`,y.selected.filter(t=>t!==e)):b(`update:selected`,[...y.selected,e])}let S=c(()=>{let e=y.selected.length;return e===0?`Add to frame`:`Add to ${e} frame${e>1?`s`:``}`});return(c,d)=>(t(),i(m,{"model-value":r.modelValue,label:`Choose frames`,"onUpdate:modelValue":d[1]||=e=>c.$emit(`update:modelValue`,e)},{default:n(()=>[d[2]||=f(`h2`,{class:`device-picker__title`},`Add to frames`,-1),d[3]||=f(`p`,{class:`device-picker__sub`},`Choose which frames will show this photo.`,-1),f(`div`,h,[(t(!0),u(l,null,e(r.devices,e=>(t(),u(`label`,{key:e.id,class:`device-picker__row`},[f(`input`,{type:`checkbox`,class:`device-picker__check`,checked:r.selected.includes(e.id),onChange:t=>x(e.id)},null,40,g),f(`span`,_,a(e.name),1),f(`span`,v,a(e.orientation),1)]))),128))]),o(p,{variant:`primary`,class:`device-picker__confirm`,disabled:r.selected.length===0||r.uploading,onClick:d[0]||=e=>c.$emit(`confirm`)},{default:n(()=>[s(a(r.uploading?`Uploading…`:S.value),1)]),_:1},8,[`disabled`])]),_:1},8,[`model-value`]))}}),[[`__scopeId`,`data-v-a6466fa5`]]);export{y as t};
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -1 +1 @@
import{A as e,G as t,O as n,_ as r,dt as i,f as a,ft as o,l as s,o as c,p as l,t as u,u as d,ut as f}from"./_plugin-vue_export-helper-CeYnMxKK.js";import{n as p,r as m,t as h}from"./index-B5Obd-7x.js";var g={class:`settings`},_={class:`settings__section`},v={class:`theme-grid`,role:`radiogroup`,"aria-label":`Choose theme`},y=[`aria-checked`,`aria-label`,`onClick`],b={class:`theme-swatch__label`},x={key:0,class:`theme-swatch__check`,"aria-hidden":`true`},S={class:`settings__section`},C={class:`settings__row`},w={class:`settings__row-value`},T=u(r({__name:`SettingsView`,setup(r){let u=m(),{saveTheme:T}=p(),E=s(()=>u.user?.theme??`warm-craft`);function D(e){T(e)}return(r,s)=>(n(),l(`main`,g,[s[5]||=d(`h1`,{class:`settings__title`},`Settings`,-1),d(`section`,_,[s[1]||=d(`h2`,{class:`settings__section-title`},`Theme`,-1),d(`div`,v,[(n(!0),l(c,null,e(t(h),e=>(n(),l(`button`,{key:e.id,type:`button`,role:`radio`,"aria-checked":E.value===e.id,"aria-label":e.label,class:f([`theme-swatch`,{"theme-swatch--active":E.value===e.id}]),style:i({"--swatch-bg":e.bg,"--swatch-primary":e.primary,"--swatch-text":e.text}),onClick:t=>D(e.id)},[s[0]||=d(`span`,{class:`theme-swatch__preview`,"aria-hidden":`true`},[d(`span`,{class:`theme-swatch__bar`}),d(`span`,{class:`theme-swatch__dot`})],-1),d(`span`,b,o(e.label),1),E.value===e.id?(n(),l(`span`,x,``)):a(``,!0)],14,y))),128))])]),d(`section`,S,[s[3]||=d(`h2`,{class:`settings__section-title`},`Account`,-1),d(`div`,C,[s[2]||=d(`span`,{class:`settings__row-label`},`Signed in as`,-1),d(`span`,w,o(t(u).user?.email),1)]),s[4]||=d(`a`,{href:`/logout`,class:`settings__logout`},`Sign out`,-1)])]))}}),[[`__scopeId`,`data-v-76ec3881`]]);export{T as default}; import{A as e,G as t,O as n,_ as r,dt as i,f as a,ft as o,l as s,o as c,p as l,t as u,u as d,ut as f}from"./_plugin-vue_export-helper-CeYnMxKK.js";import{n as p,r as m,t as h}from"./index-DabTPrXi.js";var g={class:`settings`},_={class:`settings__section`},v={class:`theme-grid`,role:`radiogroup`,"aria-label":`Choose theme`},y=[`aria-checked`,`aria-label`,`onClick`],b={class:`theme-swatch__label`},x={key:0,class:`theme-swatch__check`,"aria-hidden":`true`},S={class:`settings__section`},C={class:`settings__row`},w={class:`settings__row-value`},T=u(r({__name:`SettingsView`,setup(r){let u=m(),{saveTheme:T}=p(),E=s(()=>u.user?.theme??`warm-craft`);function D(e){T(e)}return(r,s)=>(n(),l(`main`,g,[s[5]||=d(`h1`,{class:`settings__title`},`Settings`,-1),d(`section`,_,[s[1]||=d(`h2`,{class:`settings__section-title`},`Theme`,-1),d(`div`,v,[(n(!0),l(c,null,e(t(h),e=>(n(),l(`button`,{key:e.id,type:`button`,role:`radio`,"aria-checked":E.value===e.id,"aria-label":e.label,class:f([`theme-swatch`,{"theme-swatch--active":E.value===e.id}]),style:i({"--swatch-bg":e.bg,"--swatch-primary":e.primary,"--swatch-text":e.text}),onClick:t=>D(e.id)},[s[0]||=d(`span`,{class:`theme-swatch__preview`,"aria-hidden":`true`},[d(`span`,{class:`theme-swatch__bar`}),d(`span`,{class:`theme-swatch__dot`})],-1),d(`span`,b,o(e.label),1),E.value===e.id?(n(),l(`span`,x,``)):a(``,!0)],14,y))),128))])]),d(`section`,S,[s[3]||=d(`h2`,{class:`settings__section-title`},`Account`,-1),d(`div`,C,[s[2]||=d(`span`,{class:`settings__row-label`},`Signed in as`,-1),d(`span`,w,o(t(u).user?.email),1)]),s[4]||=d(`a`,{href:`/logout`,class:`settings__logout`},`Sign out`,-1)])]))}}),[[`__scopeId`,`data-v-76ec3881`]]);export{T as default};
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -14,7 +14,7 @@
<meta name="mobile-web-app-capable" content="yes" /> <meta name="mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="default" /> <meta name="apple-mobile-web-app-status-bar-style" content="default" />
<meta name="apple-mobile-web-app-title" content="pictureFrame" /> <meta name="apple-mobile-web-app-title" content="pictureFrame" />
<script type="module" crossorigin src="/build/assets/index-B5Obd-7x.js"></script> <script type="module" crossorigin src="/build/assets/index-DabTPrXi.js"></script>
<link rel="modulepreload" crossorigin href="/build/assets/_plugin-vue_export-helper-CeYnMxKK.js"> <link rel="modulepreload" crossorigin href="/build/assets/_plugin-vue_export-helper-CeYnMxKK.js">
<link rel="stylesheet" crossorigin href="/build/assets/index-BlLBHR1q.css"> <link rel="stylesheet" crossorigin href="/build/assets/index-BlLBHR1q.css">
</head> </head>
+1
View File
@@ -183,6 +183,7 @@ class DeviceApiController extends AbstractController
'uniquenessWindow' => $d->getUniquenessWindow(), 'uniquenessWindow' => $d->getUniquenessWindow(),
'linkedAt' => $d->getLinkedAt()->format(\DateTimeInterface::ATOM), 'linkedAt' => $d->getLinkedAt()->format(\DateTimeInterface::ATOM),
'lastSeenAt' => $d->getLastSeenAt()?->format(\DateTimeInterface::ATOM), 'lastSeenAt' => $d->getLastSeenAt()?->format(\DateTimeInterface::ATOM),
'nextPollExpectedAt' => $d->getNextPollExpectedAt()?->format(\DateTimeInterface::ATOM),
'lockedImageId' => $d->getLockedImage()?->getId(), 'lockedImageId' => $d->getLockedImage()?->getId(),
'currentImageId' => $d->getCurrentImage()?->getId(), 'currentImageId' => $d->getCurrentImage()?->getId(),
]; ];
+11 -3
View File
@@ -61,6 +61,17 @@ class DeviceImageController extends AbstractController
$currentImageId = (int) $request->headers->get('X-Current-Image-Id', '-1'); $currentImageId = (int) $request->headers->get('X-Current-Image-Id', '-1');
$device->markSeen(); $device->markSeen();
// Stamp when we expect the device to call back — the PWA reads this
// directly so its "next sync" label reflects the schedule the device
// is actually on, not the freshly-saved one that won't reach it
// until that next poll.
$device->setNextPollExpectedAt(
(new \DateTimeImmutable())->modify('+' . (int) ceil($intervalMs / 1000) . ' seconds')
);
// Flush up-front so the 204/no_image/no_asset paths persist these too
// (they previously didn't flush at all — latent bug for lastSeenAt).
$em->flush();
// Locked image bypasses rotation entirely. // Locked image bypasses rotation entirely.
$image = $device->getLockedImage() ?? $this->rotation->advance($device); $image = $device->getLockedImage() ?? $this->rotation->advance($device);
@@ -111,9 +122,6 @@ class DeviceImageController extends AbstractController
// would otherwise have set this. Without the assignment, currentImage // would otherwise have set this. Without the assignment, currentImage
// stays stale — Home would keep showing the previous photo even // stays stale — Home would keep showing the previous photo even
// though the device has been confirming the new one for cycles. // though the device has been confirming the new one for cycles.
// Also flush so markSeen() above is persisted on every 304 (lastSeenAt
// would otherwise freeze whenever the device polls and gets no
// change, causing the status badge to drift to "offline").
$device->setCurrentImage($image); $device->setCurrentImage($image);
$em->flush(); $em->flush();
+14
View File
@@ -71,6 +71,17 @@ class Device
#[ORM\Column(nullable: true)] #[ORM\Column(nullable: true)]
private ?\DateTimeImmutable $lastSeenAt = null; private ?\DateTimeImmutable $lastSeenAt = null;
/**
* Server-stamped wall-clock time at which the device is expected to poll
* next, computed as `now + computeIntervalMs($device)` at every successful
* response. The PWA reads this directly so the displayed "next sync"
* always reflects the schedule the device is *actually on* not whatever
* the user just saved through the settings sheet (which the device won't
* pick up until that next poll).
*/
#[ORM\Column(nullable: true)]
private ?\DateTimeImmutable $nextPollExpectedAt = null;
/** /**
* Orientation in effect when currentImage was last served as a 200 response. * Orientation in effect when currentImage was last served as a 200 response.
* Used alongside currentImage's id to decide whether a poll can be answered * Used alongside currentImage's id to decide whether a poll can be answered
@@ -151,6 +162,9 @@ class Device
public function getLastSeenAt(): ?\DateTimeImmutable { return $this->lastSeenAt; } public function getLastSeenAt(): ?\DateTimeImmutable { return $this->lastSeenAt; }
public function markSeen(): static { $this->lastSeenAt = new \DateTimeImmutable(); return $this; } public function markSeen(): static { $this->lastSeenAt = new \DateTimeImmutable(); return $this; }
public function getNextPollExpectedAt(): ?\DateTimeImmutable { return $this->nextPollExpectedAt; }
public function setNextPollExpectedAt(?\DateTimeImmutable $t): static { $this->nextPollExpectedAt = $t; return $this; }
public function getCurrentImageOrientation(): ?Orientation { return $this->currentImageOrientation; } public function getCurrentImageOrientation(): ?Orientation { return $this->currentImageOrientation; }
public function setCurrentImageOrientation(?Orientation $o): static { $this->currentImageOrientation = $o; return $this; } public function setCurrentImageOrientation(?Orientation $o): static { $this->currentImageOrientation = $o; return $this; }