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);