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