From 72dcee08f29218c27266a2ca0ab0350e5b8bf8dc Mon Sep 17 00:00:00 2001 From: jrnwndr Date: Sat, 12 Sep 2026 13:25:32 +0200 Subject: [PATCH 1/2] Add daily playlist refresh scheduling --- backend/prisma/schema.prisma | 9 +- .../src/api/display/display.service.test.ts | 64 +++++++++++++ backend/src/api/display/display.service.ts | 89 +++++++++++++++++-- .../src/playlists/dto/create-playlist.dto.ts | 11 ++- .../src/playlists/dto/update-playlist.dto.ts | 11 ++- backend/src/playlists/playlists.service.ts | 2 + frontend/src/pages/playlists/PlaylistForm.tsx | 33 +++++++ frontend/src/types/index.ts | 4 + 8 files changed, 212 insertions(+), 11 deletions(-) diff --git a/backend/prisma/schema.prisma b/backend/prisma/schema.prisma index 4f2fcab..74b06fc 100644 --- a/backend/prisma/schema.prisma +++ b/backend/prisma/schema.prisma @@ -88,10 +88,11 @@ model Screen { } model Playlist { - id Int @id @default(autoincrement()) - name String - description String? - isActive Boolean @default(false) @map("is_active") + id Int @id @default(autoincrement()) + name String + description String? + isActive Boolean @default(false) @map("is_active") + dailyRefreshAt String? @map("daily_refresh_at") // TRMNL X only: advance to the next screen on each device poll (middle-tap or on-schedule). advanceOnTap Boolean @default(false) @map("advance_on_tap") createdAt DateTime @default(now()) @map("created_at") diff --git a/backend/src/api/display/display.service.test.ts b/backend/src/api/display/display.service.test.ts index 901cd76..9c86c4c 100644 --- a/backend/src/api/display/display.service.test.ts +++ b/backend/src/api/display/display.service.test.ts @@ -397,6 +397,70 @@ describe('DisplayService', () => { }); }); + describe('getSecondsUntilDailyRefresh (private)', () => { + const dailyRefresh = (time: string, now: Date) => + (service as any).getSecondsUntilDailyRefresh(time, now); + + it('returns the delay until the next refresh in the configured timezone', () => { + mockConfig.get = createMock().mockImplementation((key: string) => + key === 'defaultTimezone' ? 'Europe/Amsterdam' : 'http://localhost:3002', + ); + + const result = dailyRefresh('00:00', new Date('2026-01-15T11:00:00Z')); + + expect(result).toBe(43205); + }); + + it('accounts for the start of daylight saving time', () => { + mockConfig.get = createMock().mockImplementation((key: string) => + key === 'defaultTimezone' ? 'Europe/Amsterdam' : 'http://localhost:3002', + ); + + const result = dailyRefresh('00:00', new Date('2026-03-28T23:00:10Z')); + + expect(result).toBe(82795); + }); + + it('accounts for the end of daylight saving time', () => { + mockConfig.get = createMock().mockImplementation((key: string) => + key === 'defaultTimezone' ? 'Europe/Amsterdam' : 'http://localhost:3002', + ); + + const result = dailyRefresh('00:00', new Date('2026-10-24T22:00:10Z')); + + expect(result).toBe(89995); + }); + + it('returns null for invalid values', () => { + expect(dailyRefresh('midnight', new Date())).toBeNull(); + }); + }); + + describe('getDisplayContent - daily refresh', () => { + it('uses the playlist daily refresh time instead of the screen duration', async () => { + const now = new Date(); + const targetMinutes = (now.getUTCHours() * 60 + now.getUTCMinutes() + 120) % 1440; + const dailyRefreshAt = `${String(Math.floor(targetMinutes / 60)).padStart(2, '0')}:${String(targetMinutes % 60).padStart(2, '0')}`; + mockPrisma.device.findFirst.mockResolvedValue({ + id: 1, name: 'Test', + playlist: { + dailyRefreshAt, + items: [{ duration: 60, screen: { id: 9, name: 'S', imageUrl: '/uploads/s.png' } }], + }, + refreshRate: 900, refreshPending: false, + width: 800, height: 480, sleepStartAt: null, sleepStopAt: null, + }); + mockPrisma.device.update.mockResolvedValue({ id: 1, battery: 80, wifi: -50 }); + mockPrisma.firmware.findFirst.mockResolvedValue(null); + + const result = await service.getDisplayContent('test-key'); + + expect(result.refresh_rate).toBeGreaterThan(7100); + expect(result.refresh_rate).toBeLessThanOrEqual(7210); + expect(result.refresh_at).toBeGreaterThan(Date.now() + 7100 * 1000); + }); + }); + describe('getDisplayContent - quiet hours', () => { const windowAroundNow = () => { const hhmm = (offsetMin: number) => { diff --git a/backend/src/api/display/display.service.ts b/backend/src/api/display/display.service.ts index dbcc778..a73cf24 100644 --- a/backend/src/api/display/display.service.ts +++ b/backend/src/api/display/display.service.ts @@ -420,8 +420,15 @@ export class DisplayService { // Quiet hours (freeze mode): keep the current screen but make the device // sleep until the wake time instead of refreshing at the normal interval. - const finalRefreshRate = sleepSeconds ?? effectiveRefreshRate; - const finalRefreshAt = sleepSeconds !== null ? Date.now() + sleepSeconds * 1000 : nextRefreshAt; + const dailyRefreshSeconds = device.playlist.dailyRefreshAt + ? this.getSecondsUntilDailyRefresh(device.playlist.dailyRefreshAt) + : null; + const finalRefreshRate = sleepSeconds ?? dailyRefreshSeconds ?? effectiveRefreshRate; + const scheduledRefreshAt = sleepSeconds !== null + ? Date.now() + sleepSeconds * 1000 + : dailyRefreshSeconds !== null + ? Date.now() + dailyRefreshSeconds * 1000 + : nextRefreshAt; // Handle both regular screens and designed screens if (currentScreen.screen) { @@ -457,7 +464,7 @@ export class DisplayService { special_function: '', temperature_profile: 'default', maximum_compatibility: screenChanged, - refresh_at: finalRefreshAt, + refresh_at: scheduledRefreshAt, battery: updatedDevice.battery, wifi: updatedDevice.wifi, }; @@ -504,7 +511,7 @@ export class DisplayService { special_function: '', temperature_profile: 'default', maximum_compatibility: screenChanged, - refresh_at: finalRefreshAt, + refresh_at: scheduledRefreshAt, battery: updatedDevice.battery, wifi: updatedDevice.wifi, }; @@ -534,7 +541,7 @@ export class DisplayService { special_function: '', temperature_profile: 'default', maximum_compatibility: screenChanged, - refresh_at: finalRefreshAt, + refresh_at: scheduledRefreshAt, battery: updatedDevice.battery, wifi: updatedDevice.wifi, }; @@ -816,6 +823,78 @@ export class DisplayService { return hour * 3600 + get('minute') * 60 + get('second'); } + /** + * Calculate the delay until the playlist's next daily refresh. The target is + * resolved in DEFAULT_TIMEZONE and converted back to UTC iteratively so DST + * transitions do not introduce a one-hour drift. + */ + private getSecondsUntilDailyRefresh(refreshAt: string, now = new Date()): number | null { + const target = this.parseTimeToSeconds(refreshAt); + if (target === null) { + return null; + } + + const timeZone = this.config.get('defaultTimezone', 'UTC'); + const formatter = new Intl.DateTimeFormat('en-US', { + timeZone, + hour12: false, + year: 'numeric', + month: '2-digit', + day: '2-digit', + hour: '2-digit', + minute: '2-digit', + second: '2-digit', + }); + const getParts = (date: Date) => { + const parts = formatter.formatToParts(date); + const get = (type: string) => parseInt(parts.find((part) => part.type === type)?.value || '0', 10); + return { + year: get('year'), + month: get('month'), + day: get('day'), + hour: get('hour') % 24, + minute: get('minute'), + second: get('second'), + }; + }; + + const localNow = getParts(now); + const targetHour = Math.floor(target / 3600); + const targetMinute = Math.floor((target % 3600) / 60); + const boundaryBufferSeconds = 5; + const localNowSeconds = localNow.hour * 3600 + localNow.minute * 60 + localNow.second; + const targetPassed = localNowSeconds >= target + boundaryBufferSeconds; + const localTarget = new Date(Date.UTC( + localNow.year, + localNow.month - 1, + localNow.day + (targetPassed ? 1 : 0), + targetHour, + targetMinute, + boundaryBufferSeconds, + )); + const desiredTimestamp = localTarget.getTime(); + let candidateTimestamp = desiredTimestamp; + + for (let attempt = 0; attempt < 4; attempt += 1) { + const candidate = getParts(new Date(candidateTimestamp)); + const representedTimestamp = Date.UTC( + candidate.year, + candidate.month - 1, + candidate.day, + candidate.hour, + candidate.minute, + candidate.second, + ); + const correction = desiredTimestamp - representedTimestamp; + candidateTimestamp += correction; + if (correction === 0) { + break; + } + } + + return Math.max(10, Math.ceil((candidateTimestamp - now.getTime()) / 1000)); + } + /** * Check if a screen design contains a clock widget */ diff --git a/backend/src/playlists/dto/create-playlist.dto.ts b/backend/src/playlists/dto/create-playlist.dto.ts index 9546789..54b2912 100644 --- a/backend/src/playlists/dto/create-playlist.dto.ts +++ b/backend/src/playlists/dto/create-playlist.dto.ts @@ -1,5 +1,5 @@ import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; -import { IsString, IsOptional, IsBoolean, IsArray, ValidateNested, MaxLength } from 'class-validator'; +import { IsString, IsOptional, IsBoolean, IsArray, ValidateNested, MaxLength, Matches } from 'class-validator'; import { Type } from 'class-transformer'; class PlaylistScreenDto { @@ -39,6 +39,15 @@ export class CreatePlaylistDto { @IsBoolean() isActive?: boolean; + @ApiPropertyOptional({ + example: '00:00', + description: 'Daily refresh time in HH:MM format, evaluated in the server timezone', + nullable: true, + }) + @IsOptional() + @Matches(/^([01]\d|2[0-3]):[0-5]\d$/, { message: 'dailyRefreshAt must be in HH:MM format' }) + dailyRefreshAt?: string | null; + @ApiPropertyOptional({ example: false, description: 'TRMNL X only: advance to the next screen on each device poll (middle tap / on schedule)', diff --git a/backend/src/playlists/dto/update-playlist.dto.ts b/backend/src/playlists/dto/update-playlist.dto.ts index 12c10eb..0063dfb 100644 --- a/backend/src/playlists/dto/update-playlist.dto.ts +++ b/backend/src/playlists/dto/update-playlist.dto.ts @@ -1,5 +1,5 @@ import { ApiPropertyOptional } from '@nestjs/swagger'; -import { IsString, IsOptional, IsBoolean, IsArray, ValidateNested, MaxLength } from 'class-validator'; +import { IsString, IsOptional, IsBoolean, IsArray, ValidateNested, MaxLength, Matches } from 'class-validator'; import { Type } from 'class-transformer'; class PlaylistScreenDto { @@ -40,6 +40,15 @@ export class UpdatePlaylistDto { @IsBoolean() isActive?: boolean; + @ApiPropertyOptional({ + example: '00:00', + description: 'Daily refresh time in HH:MM format, evaluated in the server timezone', + nullable: true, + }) + @IsOptional() + @Matches(/^([01]\d|2[0-3]):[0-5]\d$/, { message: 'dailyRefreshAt must be in HH:MM format' }) + dailyRefreshAt?: string | null; + @ApiPropertyOptional({ example: false, description: 'TRMNL X only: advance to the next screen on each device poll (middle tap / on schedule)', diff --git a/backend/src/playlists/playlists.service.ts b/backend/src/playlists/playlists.service.ts index ff59905..6820d7f 100644 --- a/backend/src/playlists/playlists.service.ts +++ b/backend/src/playlists/playlists.service.ts @@ -32,6 +32,7 @@ export class PlaylistsService { name: playlistData.name, description: playlistData.description, isActive: playlistData.isActive ?? true, + dailyRefreshAt: playlistData.dailyRefreshAt, advanceOnTap: playlistData.advanceOnTap ?? false, }, include: { @@ -348,6 +349,7 @@ export class PlaylistsService { name: playlistData.name, description: playlistData.description, isActive: playlistData.isActive, + dailyRefreshAt: playlistData.dailyRefreshAt, advanceOnTap: playlistData.advanceOnTap, }, include: { diff --git a/frontend/src/pages/playlists/PlaylistForm.tsx b/frontend/src/pages/playlists/PlaylistForm.tsx index b64b11e..d182dc8 100644 --- a/frontend/src/pages/playlists/PlaylistForm.tsx +++ b/frontend/src/pages/playlists/PlaylistForm.tsx @@ -38,6 +38,7 @@ export function PlaylistForm() { description: '', screens: [], isActive: true, // Always active by default + dailyRefreshAt: null, advanceOnTap: false, }); @@ -200,6 +201,7 @@ export function PlaylistForm() { description: playlist.description || '', screens: playlist.screens || [], isActive: playlist.isActive, + dailyRefreshAt: playlist.dailyRefreshAt ?? null, advanceOnTap: playlist.advanceOnTap ?? false, }); } @@ -375,6 +377,37 @@ export function PlaylistForm() { /> +
+ + {formData.dailyRefreshAt && ( +
+ setFormData((prev) => ({ ...prev, dailyRefreshAt: e.target.value }))} + required + /> +
+ )} +
+
)} + {formData.dailyRefreshAt && ( +

+ Screen durations are inactive while daily refresh is enabled. +

+ )} + {/* Add screen section */}
{/* Screen Selection Preview */} @@ -512,7 +518,7 @@ export function PlaylistForm() { ))}
-
+
setDurationInput(e.target.value)} onBlur={() => setDurationInput(String(formData.advanceOnTap ? Math.max(0, Number(durationInput) || 0) : Math.max(60, Number(durationInput) || 60)))} min={60} + disabled={Boolean(formData.dailyRefreshAt)} />
@@ -592,14 +599,15 @@ export function PlaylistForm() { )}
-
+
handleUpdateScreenDuration(index, e.target.value)} onBlur={() => handleBlurScreenDuration(index)} min={60} - className="w-16 px-2 py-1 text-sm text-center border border-border-light rounded focus:outline-none focus:ring-1 focus:ring-accent" + disabled={Boolean(formData.dailyRefreshAt)} + className="w-16 px-2 py-1 text-sm text-center border border-border-light rounded focus:outline-none focus:ring-1 focus:ring-accent disabled:bg-bg-card disabled:text-text-muted disabled:cursor-not-allowed" /> s