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>
This commit is contained in:
2026-05-07 16:37:14 -04:00
parent ba9625d45d
commit cf6623de67
26 changed files with 320 additions and 31 deletions
+1 -1
View File
@@ -26,7 +26,7 @@ export const useDevicesStore = defineStore('devices', () => {
}
}
async function updateDevice(id: number, patch: Partial<Pick<Device, 'name' | 'orientation' | 'rotationIntervalMinutes' | 'wakeTimes' | 'timezone' | 'uniquenessWindow'>>) {
async function updateDevice(id: number, patch: Partial<Pick<Device, 'name' | 'orientation' | 'rotationIntervalMinutes' | 'wakeTimes' | 'timezone' | 'uniquenessWindow' | 'rotationMode' | 'prioritizeNeverShown'>>) {
const res = await fetch(`/api/devices/${id}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
@@ -41,6 +41,8 @@ const makeDevice = (overrides: Partial<Device> = {}): Device => ({
wakeTimes: [],
timezone: 'UTC',
uniquenessWindow: 30,
rotationMode: 'oldest_upload',
prioritizeNeverShown: false,
linkedAt: '2026-01-01T00:00:00Z',
lastSeenAt: null,
nextPollExpectedAt: null,
@@ -30,6 +30,8 @@ const makeDevice = (overrides: Partial<Device> = {}): Device => ({
wakeTimes: [],
timezone: 'America/Chicago',
uniquenessWindow: 30,
rotationMode: 'oldest_upload',
prioritizeNeverShown: false,
linkedAt: '2026-01-01T00:00:00Z',
lastSeenAt: null,
nextPollExpectedAt: null,
+2
View File
@@ -12,6 +12,8 @@ const makeDevice = (overrides: Partial<Device> = {}): Device => ({
wakeTimes: [],
timezone: 'America/Chicago',
uniquenessWindow: 30,
rotationMode: 'oldest_upload',
prioritizeNeverShown: false,
linkedAt: '2026-01-01T00:00:00Z',
lastSeenAt: null,
nextPollExpectedAt: null,
+35
View File
@@ -69,6 +69,8 @@ const makeDevice = (overrides: Partial<Device> = {}): Device => ({
wakeTimes: [],
timezone: 'America/Chicago',
uniquenessWindow: 30,
rotationMode: 'oldest_upload',
prioritizeNeverShown: false,
linkedAt: '2026-01-01T00:00:00Z',
lastSeenAt: null,
nextPollExpectedAt: null,
@@ -691,6 +693,39 @@ describe('HomeView', () => {
.toMatch(/never connect|when the frame next/i)
})
it('save sends rotationMode + prioritizeNeverShown along with the other fields', async () => {
const devicesStore = useDevicesStore()
devicesStore.devices = [makeDevice({ id: 5, rotationMode: 'oldest_upload', prioritizeNeverShown: false })]
vi.spyOn(devicesStore, 'fetchDevices').mockResolvedValue()
const updateSpy = vi.spyOn(devicesStore, 'updateDevice').mockResolvedValue(makeDevice({ id: 5 }))
const wrapper = mountView()
await flushPromises()
await wrapper.findComponent({ name: 'FrameCard' }).vm.$emit('edit', 5)
await flushPromises()
// The image-selection mode select is the third <select.home-view__mode-select>
// in the sheet (frequency mode + tz are the others); querying by aria-label
// is more robust than position.
const modeSelect = wrapper.find('select[aria-label="Image selection mode"]')
;(modeSelect.element as HTMLSelectElement).value = 'random'
await modeSelect.trigger('change')
const checkbox = wrapper.find('.home-view__rotation-checkbox input[type="checkbox"]')
;(checkbox.element as HTMLInputElement).checked = true
await checkbox.trigger('change')
const saveBtn = wrapper.findAllComponents({ name: 'BaseButton' })
.find(b => b.text().toLowerCase().includes('sav'))!
await saveBtn.trigger('click')
await flushPromises()
expect(updateSpy).toHaveBeenCalledWith(5, expect.objectContaining({
rotationMode: 'random',
prioritizeNeverShown: true,
}))
})
it('shows the propagation note in the settings sheet', async () => {
const devicesStore = useDevicesStore()
devicesStore.devices = [makeDevice({ id: 5 })]
@@ -83,6 +83,8 @@ const makeDevice = (overrides: Partial<Device> = {}): Device => ({
wakeTimes: [],
timezone: 'America/Chicago',
uniquenessWindow: 30,
rotationMode: 'oldest_upload',
prioritizeNeverShown: false,
linkedAt: '2026-01-01T00:00:00Z',
lastSeenAt: null,
nextPollExpectedAt: null,
@@ -59,6 +59,8 @@ const makeDevice = (overrides: Partial<Device> = {}): Device => ({
wakeTimes: [],
timezone: 'UTC',
uniquenessWindow: 30,
rotationMode: 'oldest_upload',
prioritizeNeverShown: false,
linkedAt: '2026-01-01T00:00:00Z',
lastSeenAt: null,
nextPollExpectedAt: null,
+2
View File
@@ -16,6 +16,8 @@ export interface Device {
wakeTimes: number[]
timezone: string
uniquenessWindow: number
rotationMode: 'random' | 'least_recently_shown' | 'oldest_upload' | 'newest_upload'
prioritizeNeverShown: boolean
linkedAt: string
lastSeenAt: string | null
/** Server-stamped expected next poll time. Drives the "next sync" label. */
+47
View File
@@ -192,6 +192,29 @@
</p>
</div>
<div class="home-view__sheet-field">
<p class="home-view__sheet-label">Image selection</p>
<select
class="home-view__mode-select"
v-model="editRotationMode"
aria-label="Image selection mode"
>
<option value="oldest_upload">Oldest upload first</option>
<option value="newest_upload">Newest upload first</option>
<option value="least_recently_shown">Least recently shown</option>
<option value="random">Random</option>
</select>
<label class="home-view__rotation-checkbox">
<input
type="checkbox"
v-model="editPrioritizeNeverShown"
/>
<span>Show never-shown images first</span>
</label>
</div>
<BaseButton
variant="primary"
class="home-view__sheet-save"
@@ -465,6 +488,8 @@ const editFrequencyMode = ref<FrequencyMode>('interval')
const editWakeTimes = ref<number[]>([])
const editIntervalMinutes = ref<number>(60)
const editTimezone = ref('UTC')
const editRotationMode = ref<Device['rotationMode']>('oldest_upload')
const editPrioritizeNeverShown = ref<boolean>(false)
// Default candidates tried (in order) when adding a new time slot — picks the
// first one that isn't already in the list, so repeated +Add gives a sensible
@@ -602,6 +627,8 @@ function onEdit(deviceId: number) {
editIntervalMinutes.value = device.rotationIntervalMinutes
editWakeTimes.value = [...device.wakeTimes]
editFrequencyMode.value = device.wakeTimes.length > 0 ? 'times' : 'interval'
editRotationMode.value = device.rotationMode
editPrioritizeNeverShown.value = device.prioritizeNeverShown
sheetOpen.value = true
}
@@ -613,6 +640,8 @@ async function saveSettings() {
name: editName.value.trim() || editingDevice.value.name,
orientation: editOrientation.value,
timezone: editTimezone.value,
rotationMode: editRotationMode.value,
prioritizeNeverShown: editPrioritizeNeverShown.value,
}
if (editFrequencyMode.value === 'times') {
patch.wakeTimes = [...editWakeTimes.value]
@@ -908,6 +937,24 @@ async function saveSettings() {
color: var(--color-text);
}
&__rotation-checkbox {
margin-top: var(--space-3);
display: flex;
align-items: center;
gap: var(--space-2);
font-size: var(--text-sm);
cursor: pointer;
min-height: var(--touch-min);
input[type="checkbox"] {
// Bigger than browser default so it remains a comfortable touch target.
width: 22px;
height: 22px;
cursor: pointer;
accent-color: var(--color-primary);
}
}
&__propagation-note {
margin-top: var(--space-1);
font-size: var(--text-xs);
+30
View File
@@ -0,0 +1,30 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
final class Version20260507230003 extends AbstractMigration
{
public function getDescription(): string
{
return 'Add device.rotation_mode + device.prioritize_never_shown — image-selection preferences exposed in the PWA settings sheet';
}
public function up(Schema $schema): void
{
// Default 'oldest_upload' preserves the legacy hard-coded behavior so
// existing devices keep their current rotation order with no surprise.
$this->addSql("ALTER TABLE device ADD rotation_mode VARCHAR(32) NOT NULL DEFAULT 'oldest_upload'");
$this->addSql('ALTER TABLE device ADD prioritize_never_shown BOOLEAN NOT NULL DEFAULT FALSE');
}
public function down(Schema $schema): void
{
$this->addSql('ALTER TABLE device DROP COLUMN rotation_mode');
$this->addSql('ALTER TABLE device DROP COLUMN prioritize_never_shown');
}
}
File diff suppressed because one or more lines are too long
@@ -1 +1 @@
import{_ as e,d as t,f as n,g as r,j as i,k as a,m as o,pt as s,s as c,t as l,u,v as d,z as f}from"./_plugin-vue_export-helper-DRLwVS0w.js";import{n as p,t as m}from"./BaseBottomSheet-CV7IMg-N.js";var h={class:`device-picker__list`},g=[`checked`,`onChange`],_={class:`device-picker__name`},v={class:`device-picker__orientation`},y=l(d({__name:`DevicePicker`,props:{modelValue:{type:Boolean},devices:{},selected:{},uploading:{type:Boolean}},emits:[`update:modelValue`,`update:selected`,`confirm`],setup(l,{emit:d}){let y=l,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=u(()=>{let e=y.selected.length;return e===0?`Add to frame`:`Add to ${e} frame${e>1?`s`:``}`});return(u,d)=>(a(),n(m,{"model-value":l.modelValue,label:`Choose frames`,"onUpdate:modelValue":d[1]||=e=>u.$emit(`update:modelValue`,e)},{default:f(()=>[d[2]||=t(`h2`,{class:`device-picker__title`},`Add to frames`,-1),d[3]||=t(`p`,{class:`device-picker__sub`},`Choose which frames will show this photo.`,-1),t(`div`,h,[(a(!0),o(c,null,i(l.devices,e=>(a(),o(`label`,{key:e.id,class:`device-picker__row`},[t(`input`,{type:`checkbox`,class:`device-picker__check`,checked:l.selected.includes(e.id),onChange:t=>x(e.id)},null,40,g),t(`span`,_,s(e.name),1),t(`span`,v,s(e.orientation),1)]))),128))]),e(p,{variant:`primary`,class:`device-picker__confirm`,disabled:l.selected.length===0||l.uploading,onClick:d[0]||=e=>u.$emit(`confirm`)},{default:f(()=>[r(s(l.uploading?`Uploading…`:S.value),1)]),_:1},8,[`disabled`])]),_:1},8,[`model-value`]))}}),[[`__scopeId`,`data-v-a6466fa5`]]);export{y as t};
import{_ as e,d as t,f as n,g as r,j as i,k as a,m as o,pt as s,s as c,t as l,u,v as d,z as f}from"./_plugin-vue_export-helper-DRLwVS0w.js";import{n as p,t as m}from"./BaseBottomSheet-CHak0WUZ.js";var h={class:`device-picker__list`},g=[`checked`,`onChange`],_={class:`device-picker__name`},v={class:`device-picker__orientation`},y=l(d({__name:`DevicePicker`,props:{modelValue:{type:Boolean},devices:{},selected:{},uploading:{type:Boolean}},emits:[`update:modelValue`,`update:selected`,`confirm`],setup(l,{emit:d}){let y=l,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=u(()=>{let e=y.selected.length;return e===0?`Add to frame`:`Add to ${e} frame${e>1?`s`:``}`});return(u,d)=>(a(),n(m,{"model-value":l.modelValue,label:`Choose frames`,"onUpdate:modelValue":d[1]||=e=>u.$emit(`update:modelValue`,e)},{default:f(()=>[d[2]||=t(`h2`,{class:`device-picker__title`},`Add to frames`,-1),d[3]||=t(`p`,{class:`device-picker__sub`},`Choose which frames will show this photo.`,-1),t(`div`,h,[(a(!0),o(c,null,i(l.devices,e=>(a(),o(`label`,{key:e.id,class:`device-picker__row`},[t(`input`,{type:`checkbox`,class:`device-picker__check`,checked:l.selected.includes(e.id),onChange:t=>x(e.id)},null,40,g),t(`span`,_,s(e.name),1),t(`span`,v,s(e.orientation),1)]))),128))]),e(p,{variant:`primary`,class:`device-picker__confirm`,disabled:l.selected.length===0||l.uploading,onClick:d[0]||=e=>u.$emit(`confirm`)},{default:f(()=>[r(s(l.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{K as e,d as t,dt as n,ft as r,j as i,k as a,m as o,p as s,pt as c,s as l,t as u,u as d,v as f}from"./_plugin-vue_export-helper-DRLwVS0w.js";import{n as p,r as m,t as h}from"./index-CJkFKvc-.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(f({__name:`SettingsView`,setup(u){let f=m(),{saveTheme:T}=p(),E=d(()=>f.user?.theme??`warm-craft`);function D(e){T(e)}return(u,d)=>(a(),o(`main`,g,[d[5]||=t(`h1`,{class:`settings__title`},`Settings`,-1),t(`section`,_,[d[1]||=t(`h2`,{class:`settings__section-title`},`Theme`,-1),t(`div`,v,[(a(!0),o(l,null,i(e(h),e=>(a(),o(`button`,{key:e.id,type:`button`,role:`radio`,"aria-checked":E.value===e.id,"aria-label":e.label,class:n([`theme-swatch`,{"theme-swatch--active":E.value===e.id}]),style:r({"--swatch-bg":e.bg,"--swatch-primary":e.primary,"--swatch-text":e.text}),onClick:t=>D(e.id)},[d[0]||=t(`span`,{class:`theme-swatch__preview`,"aria-hidden":`true`},[t(`span`,{class:`theme-swatch__bar`}),t(`span`,{class:`theme-swatch__dot`})],-1),t(`span`,b,c(e.label),1),E.value===e.id?(a(),o(`span`,x,``)):s(``,!0)],14,y))),128))])]),t(`section`,S,[d[3]||=t(`h2`,{class:`settings__section-title`},`Account`,-1),t(`div`,C,[d[2]||=t(`span`,{class:`settings__row-label`},`Signed in as`,-1),t(`span`,w,c(e(f).user?.email),1)]),d[4]||=t(`a`,{href:`/logout`,class:`settings__logout`},`Sign out`,-1)])]))}}),[[`__scopeId`,`data-v-76ec3881`]]);export{T as default};
import{K as e,d as t,dt as n,ft as r,j as i,k as a,m as o,p as s,pt as c,s as l,t as u,u as d,v as f}from"./_plugin-vue_export-helper-DRLwVS0w.js";import{n as p,r as m,t as h}from"./index-DNhLsB-t.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(f({__name:`SettingsView`,setup(u){let f=m(),{saveTheme:T}=p(),E=d(()=>f.user?.theme??`warm-craft`);function D(e){T(e)}return(u,d)=>(a(),o(`main`,g,[d[5]||=t(`h1`,{class:`settings__title`},`Settings`,-1),t(`section`,_,[d[1]||=t(`h2`,{class:`settings__section-title`},`Theme`,-1),t(`div`,v,[(a(!0),o(l,null,i(e(h),e=>(a(),o(`button`,{key:e.id,type:`button`,role:`radio`,"aria-checked":E.value===e.id,"aria-label":e.label,class:n([`theme-swatch`,{"theme-swatch--active":E.value===e.id}]),style:r({"--swatch-bg":e.bg,"--swatch-primary":e.primary,"--swatch-text":e.text}),onClick:t=>D(e.id)},[d[0]||=t(`span`,{class:`theme-swatch__preview`,"aria-hidden":`true`},[t(`span`,{class:`theme-swatch__bar`}),t(`span`,{class:`theme-swatch__dot`})],-1),t(`span`,b,c(e.label),1),E.value===e.id?(a(),o(`span`,x,``)):s(``,!0)],14,y))),128))])]),t(`section`,S,[d[3]||=t(`h2`,{class:`settings__section-title`},`Account`,-1),t(`div`,C,[d[2]||=t(`span`,{class:`settings__row-label`},`Signed in as`,-1),t(`span`,w,c(e(f).user?.email),1)]),d[4]||=t(`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
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="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-CJkFKvc-.js"></script>
<script type="module" crossorigin src="/build/assets/index-DNhLsB-t.js"></script>
<link rel="modulepreload" crossorigin href="/build/assets/_plugin-vue_export-helper-DRLwVS0w.js">
<link rel="stylesheet" crossorigin href="/build/assets/index-BlLBHR1q.css">
</head>
+13
View File
@@ -10,6 +10,7 @@ use App\Entity\RenderedAsset;
use App\Entity\User;
use App\Enum\Orientation;
use App\Enum\RenderStatus;
use App\Enum\RotationMode;
use App\Service\DeviceSerializer;
use App\Service\MercurePublisher;
use Doctrine\ORM\EntityManagerInterface;
@@ -119,6 +120,18 @@ class DeviceApiController extends AbstractController
$device->setUniquenessWindow(max(1, (int) $body['uniquenessWindow']));
}
if (isset($body['rotationMode'])) {
$mode = RotationMode::tryFrom((string) $body['rotationMode']);
if (!$mode) {
return $this->json(['error' => 'Invalid rotation mode'], Response::HTTP_UNPROCESSABLE_ENTITY);
}
$device->setRotationMode($mode);
}
if (array_key_exists('prioritizeNeverShown', $body)) {
$device->setPrioritizeNeverShown((bool) $body['prioritizeNeverShown']);
}
$em->flush();
$payload = $this->serializer->serialize($device);
$this->mercure->publishDevice((int) $device->getId(), $payload);
+19
View File
@@ -7,6 +7,7 @@ namespace App\Entity;
use App\Enum\DeviceModel;
use App\Entity\Image;
use App\Enum\Orientation;
use App\Enum\RotationMode;
use App\Repository\DeviceRepository;
use Doctrine\ORM\Mapping as ORM;
@@ -52,6 +53,18 @@ class Device
#[ORM\Column]
private int $uniquenessWindow = 10;
/** How RotationService picks the next image once the uniqueness filter has run. */
#[ORM\Column(length: 32, enumType: RotationMode::class)]
private RotationMode $rotationMode = RotationMode::OldestUpload;
/**
* When true and the candidate set contains any never-shown images, the
* selection narrows to those before the rotation mode runs. Lets users
* burn through new uploads before the catalog re-shuffles.
*/
#[ORM\Column]
private bool $prioritizeNeverShown = false;
#[ORM\ManyToOne(inversedBy: 'devices')]
#[ORM\JoinColumn(nullable: false)]
private ?User $user = null;
@@ -147,6 +160,12 @@ class Device
public function getUniquenessWindow(): int { return $this->uniquenessWindow; }
public function setUniquenessWindow(int $w): static { $this->uniquenessWindow = $w; return $this; }
public function getRotationMode(): RotationMode { return $this->rotationMode; }
public function setRotationMode(RotationMode $m): static { $this->rotationMode = $m; return $this; }
public function isPrioritizeNeverShown(): bool { return $this->prioritizeNeverShown; }
public function setPrioritizeNeverShown(bool $v): static { $this->prioritizeNeverShown = $v; return $this; }
public function getUser(): ?User { return $this->user; }
public function setUser(?User $user): static { $this->user = $user; return $this; }
+26
View File
@@ -0,0 +1,26 @@
<?php
declare(strict_types=1);
namespace App\Enum;
/**
* Selection strategy for the next image to serve to a device. The chosen mode
* runs *after* the uniqueness-window filter (don't repeat the last N) and
* *after* the optional "prioritize never-shown" narrowing so it only ever
* sees the surviving candidate set.
*/
enum RotationMode: string
{
/** Uniform random pick from the candidates. */
case Random = 'random';
/** Image with the oldest most-recent served-at timestamp first; never-shown sorts first. */
case LeastRecentlyShown = 'least_recently_shown';
/** Sort by upload time ascending — oldest photo first. The legacy default. */
case OldestUpload = 'oldest_upload';
/** Sort by upload time descending — newest photo first. */
case NewestUpload = 'newest_upload';
}
+2
View File
@@ -26,6 +26,8 @@ final class DeviceSerializer
'wakeTimes' => $d->getWakeTimes(),
'timezone' => $d->getTimezone(),
'uniquenessWindow' => $d->getUniquenessWindow(),
'rotationMode' => $d->getRotationMode()->value,
'prioritizeNeverShown' => $d->isPrioritizeNeverShown(),
'linkedAt' => $d->getLinkedAt()->format(\DateTimeInterface::ATOM),
'lastSeenAt' => $d->getLastSeenAt()?->format(\DateTimeInterface::ATOM),
'nextPollExpectedAt' => $d->getNextPollExpectedAt()?->format(\DateTimeInterface::ATOM),
+111 -6
View File
@@ -8,6 +8,7 @@ use App\Entity\Device;
use App\Entity\DeviceImageHistory;
use App\Entity\Image;
use App\Enum\RenderStatus;
use App\Enum\RotationMode;
use Doctrine\ORM\EntityManagerInterface;
use Psr\Log\LoggerInterface;
@@ -21,6 +22,14 @@ class RotationService
/**
* Select the next image for the device, record history, update currentImage.
* Returns null if no ready images exist in the pool.
*
* Pipeline:
* 1. Pull the device's ready/approved pool.
* 2. Drop the last `uniquenessWindow` served fall back to the full pool
* if that empties the candidate set.
* 3. If `prioritizeNeverShown` is on AND any candidates have never been
* served on this device, narrow to those.
* 4. Apply the selection mode to pick exactly one image.
*/
public function advance(Device $device): ?Image
{
@@ -43,24 +52,101 @@ class RotationService
$candidates = $pool;
}
usort($candidates, static fn(Image $a, Image $b) => $a->getUploadedAt() <=> $b->getUploadedAt());
$neverShownPreferred = false;
if ($device->isPrioritizeNeverShown()) {
$shownIds = $this->everShownImageIds($device);
$neverShown = array_values(array_filter(
$candidates,
static fn(Image $i) => !in_array($i->getId(), $shownIds, true),
));
if (!empty($neverShown)) {
$candidates = $neverShown;
$neverShownPreferred = true;
}
}
$image = $candidates[0];
$image = $this->pickByMode($device, $candidates);
$this->em->persist(new DeviceImageHistory($device, $image));
$device->setCurrentImage($image);
$this->em->flush();
$this->logger->info('rotation.advanced', [
'device_id' => $device->getId(),
'image_id' => $image->getId(),
'pool_size' => count($pool),
'recent_ids' => $recentIds,
'device_id' => $device->getId(),
'image_id' => $image->getId(),
'pool_size' => count($pool),
'recent_ids' => $recentIds,
'mode' => $device->getRotationMode()->value,
'never_shown_preferred' => $neverShownPreferred,
]);
return $image;
}
/** @param Image[] $candidates */
private function pickByMode(Device $device, array $candidates): Image
{
return match ($device->getRotationMode()) {
RotationMode::Random => $candidates[array_rand($candidates)],
RotationMode::LeastRecentlyShown => $this->pickLeastRecentlyShown($device, $candidates),
RotationMode::OldestUpload => $this->sortedByUpload($candidates, ascending: true)[0],
RotationMode::NewestUpload => $this->sortedByUpload($candidates, ascending: false)[0],
};
}
/**
* @param Image[] $candidates
* @return Image[]
*/
private function sortedByUpload(array $candidates, bool $ascending): array
{
usort(
$candidates,
$ascending
? static fn(Image $a, Image $b) => $a->getUploadedAt() <=> $b->getUploadedAt()
: static fn(Image $a, Image $b) => $b->getUploadedAt() <=> $a->getUploadedAt(),
);
return $candidates;
}
/**
* Pick the candidate whose most recent serve on this device is oldest.
* Never-shown candidates win because they have no history row at all.
*
* @param Image[] $candidates
*/
private function pickLeastRecentlyShown(Device $device, array $candidates): Image
{
$imageIds = array_map(static fn(Image $i) => $i->getId(), $candidates);
$rows = $this->em->createQueryBuilder()
->select('IDENTITY(h.image) AS image_id', 'MAX(h.servedAt) AS last_served')
->from(DeviceImageHistory::class, 'h')
->where('h.device = :device')
->andWhere('h.image IN (:image_ids)')
->groupBy('h.image')
->setParameter('device', $device)
->setParameter('image_ids', $imageIds)
->getQuery()
->getScalarResult();
$lastServed = [];
foreach ($rows as $row) {
$lastServed[(int) $row['image_id']] = $row['last_served']; // 'Y-m-d H:i:s' or null
}
usort($candidates, static function (Image $a, Image $b) use ($lastServed) {
$la = $lastServed[$a->getId()] ?? null;
$lb = $lastServed[$b->getId()] ?? null;
if ($la === null && $lb === null) return $a->getId() <=> $b->getId();
if ($la === null) return -1; // never-shown sorts first
if ($lb === null) return 1;
return strcmp($la, $lb); // ISO-ish strings sort lexically = chronologically
});
return $candidates[0];
}
/** @return Image[] */
private function readyPool(Device $device): array
{
@@ -82,6 +168,25 @@ class RotationService
->getResult();
}
/**
* All image ids ever served on this device. Used when the user wants
* never-shown images to be picked first.
*
* @return int[]
*/
private function everShownImageIds(Device $device): array
{
$rows = $this->em->createQueryBuilder()
->select('DISTINCT IDENTITY(h.image) AS image_id')
->from(DeviceImageHistory::class, 'h')
->where('h.device = :device')
->setParameter('device', $device)
->getQuery()
->getScalarResult();
return array_map('intval', array_column($rows, 'image_id'));
}
/** @return int[] */
private function recentImageIds(Device $device, int $limit): array
{