fix(home): "next sync" must reflect the schedule the device is *on*
CI / test (push) Has been cancelled
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:
@@ -43,6 +43,7 @@ const makeDevice = (overrides: Partial<Device> = {}): Device => ({
|
||||
uniquenessWindow: 30,
|
||||
linkedAt: '2026-01-01T00:00:00Z',
|
||||
lastSeenAt: null,
|
||||
nextPollExpectedAt: null,
|
||||
lockedImageId: null,
|
||||
currentImageId: null,
|
||||
...overrides,
|
||||
|
||||
@@ -32,6 +32,7 @@ const makeDevice = (overrides: Partial<Device> = {}): Device => ({
|
||||
uniquenessWindow: 30,
|
||||
linkedAt: '2026-01-01T00:00:00Z',
|
||||
lastSeenAt: null,
|
||||
nextPollExpectedAt: null,
|
||||
lockedImageId: null,
|
||||
currentImageId: null,
|
||||
...overrides,
|
||||
|
||||
@@ -14,6 +14,7 @@ const makeDevice = (overrides: Partial<Device> = {}): Device => ({
|
||||
uniquenessWindow: 30,
|
||||
linkedAt: '2026-01-01T00:00:00Z',
|
||||
lastSeenAt: null,
|
||||
nextPollExpectedAt: null,
|
||||
lockedImageId: null,
|
||||
currentImageId: null,
|
||||
...overrides,
|
||||
|
||||
@@ -71,6 +71,7 @@ const makeDevice = (overrides: Partial<Device> = {}): Device => ({
|
||||
uniquenessWindow: 30,
|
||||
linkedAt: '2026-01-01T00:00:00Z',
|
||||
lastSeenAt: null,
|
||||
nextPollExpectedAt: null,
|
||||
lockedImageId: null,
|
||||
currentImageId: null,
|
||||
...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*
|
||||
// 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.
|
||||
|
||||
@@ -85,6 +85,7 @@ const makeDevice = (overrides: Partial<Device> = {}): Device => ({
|
||||
uniquenessWindow: 30,
|
||||
linkedAt: '2026-01-01T00:00:00Z',
|
||||
lastSeenAt: null,
|
||||
nextPollExpectedAt: null,
|
||||
lockedImageId: null,
|
||||
currentImageId: null,
|
||||
...overrides,
|
||||
|
||||
@@ -61,6 +61,7 @@ const makeDevice = (overrides: Partial<Device> = {}): Device => ({
|
||||
uniquenessWindow: 30,
|
||||
linkedAt: '2026-01-01T00:00:00Z',
|
||||
lastSeenAt: null,
|
||||
nextPollExpectedAt: null,
|
||||
lockedImageId: null,
|
||||
currentImageId: null,
|
||||
...overrides,
|
||||
|
||||
@@ -18,6 +18,8 @@ export interface Device {
|
||||
uniquenessWindow: number
|
||||
linkedAt: string
|
||||
lastSeenAt: string | null
|
||||
/** Server-stamped expected next poll time. Drives the "next sync" label. */
|
||||
nextPollExpectedAt: string | null
|
||||
lockedImageId: number | null
|
||||
currentImageId: number | null
|
||||
}
|
||||
|
||||
@@ -277,19 +277,38 @@ function nextWakeMatch(times: number[], tz: string): { minutes: number; today: b
|
||||
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 {
|
||||
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')
|
||||
if (!next) return null
|
||||
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 = next - Date.now()
|
||||
|
||||
const fromNow = nextMs - Date.now()
|
||||
if (fromNow <= 0) return null
|
||||
if (fromNow < 60_000) return 'next sync in <1m'
|
||||
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
|
||||
@@ -507,17 +526,23 @@ const nextUpdatePreview = computed<string>(() => {
|
||||
// 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
|
||||
// schedule. The "no update times yet" hint already lives in the time list.
|
||||
if (!device.lastSeenAt) {
|
||||
return 'Next update: when the frame next connects'
|
||||
}
|
||||
const tz = device.timezone || 'UTC'
|
||||
|
||||
const tz = device.timezone || 'UTC'
|
||||
const lastSeen = new Date(device.lastSeenAt).getTime()
|
||||
// Prefer the server-stamped expected-next-poll: that timestamp was set
|
||||
// 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
|
||||
if (device.wakeTimes.length > 0) {
|
||||
nextPollMs = nextWakeAfter(lastSeen, device.wakeTimes, tz)
|
||||
if (device.nextPollExpectedAt) {
|
||||
nextPollMs = new Date(device.nextPollExpectedAt).getTime()
|
||||
} else if (!device.lastSeenAt) {
|
||||
return 'Next update: when the frame next connects'
|
||||
} 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.
|
||||
if (nextPollMs < Date.now()) nextPollMs = Date.now()
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
+1
-1
File diff suppressed because one or more lines are too long
+1
-1
@@ -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};
|
||||
+1
-1
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
File diff suppressed because one or more lines are too long
+1
-1
@@ -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};
|
||||
+1
-1
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -14,7 +14,7 @@
|
||||
<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-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="stylesheet" crossorigin href="/build/assets/index-BlLBHR1q.css">
|
||||
</head>
|
||||
|
||||
@@ -183,6 +183,7 @@ class DeviceApiController extends AbstractController
|
||||
'uniquenessWindow' => $d->getUniquenessWindow(),
|
||||
'linkedAt' => $d->getLinkedAt()->format(\DateTimeInterface::ATOM),
|
||||
'lastSeenAt' => $d->getLastSeenAt()?->format(\DateTimeInterface::ATOM),
|
||||
'nextPollExpectedAt' => $d->getNextPollExpectedAt()?->format(\DateTimeInterface::ATOM),
|
||||
'lockedImageId' => $d->getLockedImage()?->getId(),
|
||||
'currentImageId' => $d->getCurrentImage()?->getId(),
|
||||
];
|
||||
|
||||
@@ -61,6 +61,17 @@ class DeviceImageController extends AbstractController
|
||||
$currentImageId = (int) $request->headers->get('X-Current-Image-Id', '-1');
|
||||
$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.
|
||||
$image = $device->getLockedImage() ?? $this->rotation->advance($device);
|
||||
|
||||
@@ -111,9 +122,6 @@ class DeviceImageController extends AbstractController
|
||||
// would otherwise have set this. Without the assignment, currentImage
|
||||
// stays stale — Home would keep showing the previous photo even
|
||||
// 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);
|
||||
$em->flush();
|
||||
|
||||
|
||||
@@ -71,6 +71,17 @@ class Device
|
||||
#[ORM\Column(nullable: true)]
|
||||
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.
|
||||
* 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 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 setCurrentImageOrientation(?Orientation $o): static { $this->currentImageOrientation = $o; return $this; }
|
||||
|
||||
|
||||
Reference in New Issue
Block a user