feat(scheduling): per-item schedule blocks (#74 dayparting, #75 auto-expire)
Some checks are pending
CI / Unit tests (node --test) (push) Waiting to run
CI / Android unit tests (Kotlin schedule evaluator vectors) (push) Waiting to run
CI / Boot smoke + version check (push) Waiting to run

Each playlist item can carry schedule blocks (active days, start/end
time-of-day, optional start/end dates). An item plays when the screen's
local "now" matches at least one block; an item with no blocks always
plays. #74 covers time-of-day/day-of-week windows including overnight
wrap; #75 covers inclusive date ranges (auto-expiry). Evaluation is
on-device, so dayparting and expiry work offline.

- Shared evaluator contract: shared/schedule-vectors.json (39 vectors —
  DST US+AU, overnight-wrap anchoring, timezone correctness, date
  boundaries). Canonical JS evaluator in server/lib/schedule-eval.js;
  Kotlin and Tizen ports kept in lockstep by drift guards (Tizen byte-diff
  test, Kotlin JUnit reads the shared JSON, new android-test CI job).
- All three players (web, Android, Tizen) filter by schedule against their
  own clock, idle with a "Nothing scheduled" message + 30s re-check when
  everything is filtered, and fail open on any evaluator error.
- Editor: per-item schedule modal + row badge in the playlist editor;
  client validation mirrors the server; editing marks the playlist draft.
- Part B (behaviour change): device/group schedule overrides now evaluate
  in each device's effective timezone instead of server-local time.
- Device detail shows the reported timezone + a clock-skew warning.
- i18n for en/es/fr/de/pt across all new strings (namespaced itemsched.*
  to avoid colliding with the device-schedule calendar's schedule.*).
- CHANGELOG documents the feature, the Part B change, the fail-open
  guarantee, and the scheduled-single-video re-render tradeoff.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
ScreenTinker 2026-06-11 15:34:30 -05:00 committed by screentinker
parent c8e664e66c
commit 2ccf3264a9
39 changed files with 1374 additions and 61 deletions

View file

@ -33,6 +33,23 @@ jobs:
- run: npm ci
- run: npm test
android-test:
name: Android unit tests (Kotlin schedule evaluator vectors)
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- uses: actions/setup-java@v4
with:
distribution: temurin
java-version: '17'
- uses: android-actions/setup-android@v3
# ScheduleEvalTest reads the SHARED shared/schedule-vectors.json (wired via
# the test task in app/build.gradle.kts), so a ScheduleEval.kt change that
# breaks the contract fails here.
- name: Kotlin evaluator vector conformance
working-directory: android
run: ./gradlew :app:testDebugUnitTest --no-daemon
smoke:
name: Boot smoke + version check
runs-on: ubuntu-latest

50
CHANGELOG.md Normal file
View file

@ -0,0 +1,50 @@
# Changelog
## Unreleased — Per-item scheduling (#74 dayparting + #75 auto-expire)
### Added
- **Per-playlist-item schedules.** Each playlist item can carry one or more schedule
blocks — active days, a start/end time-of-day, and optional start/end dates. An item
plays when the screen's local "now" matches at least one block; an item with no
blocks always plays. Edit per item via the clock icon in the playlist editor (a badge
summarises the schedule on each row).
- **#74 dayparting:** time-of-day + day-of-week windows, including overnight windows
that cross midnight (a Fri 22:0002:00 block is active Sat 01:00).
- **#75 auto-expire:** inclusive start/end dates; an item past its end date stops
showing automatically — even on offline screens, because evaluation is on-device.
- All three players (web, Android, Tizen) evaluate schedules client-side against their
own clock, so dayparting and expiry work offline. They share one evaluator contract,
`shared/schedule-vectors.json` — 39 conformance vectors covering DST (US + AU),
overnight-wrap day anchoring, timezone correctness, and date boundaries. CI runs the
vectors against the JS evaluator (node) and the Kotlin port (Gradle/JUnit); the Tizen
copy is byte-identical to the JS source and checked under node.
- Device detail now shows the screen's reported timezone and clock, with a **clock-skew
warning** when the device clock differs from the server by more than 2 minutes (a bad
device clock makes schedules fire at the wrong local time).
### Changed — device-level schedule timezone (behaviour change)
- Device/group **schedule overrides** (the existing calendar feature) are now evaluated
in each device's effective timezone instead of the server's local time. Previously the
`schedules.timezone` field was never applied and "07:00" meant the *server's* 07:00.
Now "07:00" means the *screen's* 07:00 — which is what was intended.
- **Who is affected:** self-hosters whose server timezone differs from their screens'
timezone — their existing device schedules will shift to fire at the screens' local
time. Single-timezone deployments (server and screens in the same zone) are
unaffected. A device with no timezone set and not reporting one falls back to the
server clock (unchanged from before).
### Notes
- **Scheduling fails open.** If the on-device evaluator ever errors (bad timezone id,
malformed block), the item **plays** rather than being hidden. A blank screen is worse
than an over-running promo — this is a guarantee, enforced in all three players.
- Windows are enforced at **item boundaries**: a long item finishes before the schedule
is re-checked, so it can overshoot its window by up to its own duration.
- **A single video *with a schedule* now re-renders at each loop boundary** so its window
can be re-evaluated; seamless native looping still applies to unscheduled single videos.
Deliberate tradeoff — a brief seam each loop for a scheduled lone video, in exchange for
its daypart/expiry actually being honoured.
- **Re-publish required:** editing a schedule puts the playlist into draft; publish to
push schedules to devices. Existing published playlists keep playing unchanged until
re-published.
- Players that predate this release ignore the new fields and keep playing everything
(graceful degradation) — update players to honour schedules.

View file

@ -75,4 +75,15 @@ dependencies {
// Coroutines
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.3")
// #74/#75: unit tests for the Kotlin schedule evaluator (vector drift guard)
testImplementation("junit:junit:4.13.2")
}
// #74/#75: point the evaluator drift-guard test at the SHARED vector contract
// (shared/schedule-vectors.json, the single source - no snapshot). rootProject is
// the android/ Gradle root; its parent is the repo root. Any ScheduleEval.kt edit
// that breaks a vector fails ScheduleEvalTest in CI.
tasks.withType<Test> {
systemProperty("scheduleVectors", File(rootProject.projectDir.parentFile, "shared/schedule-vectors.json").absolutePath)
}

View file

@ -137,8 +137,10 @@ class MainActivity : AppCompatActivity() {
// Setup playlist controller
playlistController = PlaylistController(
onItemChanged = { item -> item?.let { playItem(it) } },
onPlaylistEmpty = { showStatus("Waiting for content...") },
onRequestRefresh = { wsService?.requestPlaylistRefresh() }
// #74/#75: clear the last frame when going idle (else a now-filtered item lingers on screen)
onPlaylistEmpty = { if (::mediaPlayer.isInitialized) mediaPlayer.stop(); showStatus(getString(R.string.waiting_for_content)) },
onRequestRefresh = { wsService?.requestPlaylistRefresh() },
onNothingScheduled = { if (::mediaPlayer.isInitialized) mediaPlayer.stop(); showStatus(getString(R.string.nothing_scheduled)) }
)
// Setup media player
@ -166,6 +168,8 @@ class MainActivity : AppCompatActivity() {
val assignments = cached.getJSONArray("assignments")
if (assignments.length() > 0) {
Log.i("MainActivity", "Restoring cached playlist: ${assignments.length()} items")
// #74/#75: restore the cached effective timezone too (offline schedules)
playlistController.setTimezone(if (cached.isNull("timezone")) null else cached.optString("timezone", "").ifEmpty { null })
playlistController.updatePlaylist(assignments)
playlistController.startIfNeeded()
}
@ -245,6 +249,11 @@ class MainActivity : AppCompatActivity() {
val assignments = data.getJSONArray("assignments")
// #74/#75: device-effective IANA timezone for per-item schedule evaluation
val effectiveTz = if (data.isNull("timezone")) null else data.optString("timezone", "").ifEmpty { null }
playlistController.setTimezone(effectiveTz)
zoneManager?.setTimezone(effectiveTz)
// Cache playlist JSON for offline cold-start
config.cachedPlaylist = data.toString()

View file

@ -19,7 +19,8 @@ data class PlaylistItem(
val remoteUrl: String? = null,
val muted: Boolean = false,
val widgetId: String? = null,
val widgetType: String? = null
val widgetType: String? = null,
val schedules: List<ScheduleEval.Block> = emptyList()
) {
val isRemote: Boolean get() = !remoteUrl.isNullOrEmpty()
// Widget assignments have a widget_id and no downloadable content file.
@ -29,16 +30,23 @@ data class PlaylistItem(
class PlaylistController(
private val onItemChanged: (PlaylistItem?) -> Unit,
private val onPlaylistEmpty: () -> Unit,
private val onRequestRefresh: (() -> Unit)? = null
private val onRequestRefresh: (() -> Unit)? = null,
private val onNothingScheduled: (() -> Unit)? = null
) {
private val items = mutableListOf<PlaylistItem>()
private var currentIndex = -1
private val handler = Handler(Looper.getMainLooper())
private var advanceRunnable: Runnable? = null
private var isRunning = false
// #74/#75: per-item scheduling state
@Volatile private var effectiveTimezone: String? = null
private var retryRunnable: Runnable? = null
val isPlaying: Boolean get() = isRunning && currentIndex >= 0
/** #74/#75: device-effective IANA timezone for per-item schedule evaluation. */
fun setTimezone(tz: String?) { effectiveTimezone = tz }
val currentItem: PlaylistItem?
get() = if (currentIndex in items.indices) items[currentIndex] else null
@ -67,15 +75,22 @@ class PlaylistController(
remoteUrl = if (obj.isNull("remote_url")) null else obj.optString("remote_url", "").ifEmpty { null },
muted = obj.optInt("muted", 0) == 1,
widgetId = if (obj.isNull("widget_id")) null else obj.optString("widget_id", "").ifEmpty { null },
widgetType = if (obj.isNull("widget_type")) null else obj.optString("widget_type", "").ifEmpty { null }
widgetType = if (obj.isNull("widget_type")) null else obj.optString("widget_type", "").ifEmpty { null },
schedules = parseSchedules(obj.optJSONArray("schedules"))
)
)
}
// Check if playlist actually changed (key on content OR widget id, since
// widget items share an empty contentId).
val oldContentIds = items.map { it.contentId + "|" + (it.widgetId ?: "") }
val newContentIds = newItems.map { it.contentId + "|" + (it.widgetId ?: "") }
// #74/#75: a schedule edit changes playback even when content is identical, so
// the change signature must include schedules (else updated blocks are dropped).
fun sig(it: PlaylistItem) = it.contentId + "|" + (it.widgetId ?: "") + "|" +
it.schedules.joinToString(";") { b ->
b.days.sorted().joinToString(",") + "@" + b.start + "-" + b.end + ":" + (b.startDate ?: "") + "~" + (b.endDate ?: "")
}
val oldContentIds = items.map(::sig)
val newContentIds = newItems.map(::sig)
val playlistChanged = oldContentIds != newContentIds
if (!playlistChanged && items.isNotEmpty()) {
@ -106,9 +121,10 @@ class PlaylistController(
return
}
}
// Current item was removed or nothing was playing - start from beginning
currentIndex = 0
playCurrentItem()
// Current item was removed or nothing was playing - start from the first
// schedule-active item; idle if none are active right now.
val idx = firstActiveIndex()
if (idx >= 0) { currentIndex = idx; playCurrentItem() } else showNothingScheduled()
} else {
currentIndex = 0
}
@ -130,12 +146,12 @@ class PlaylistController(
fun start() {
isRunning = true
if (items.isNotEmpty()) {
if (currentIndex < 0) currentIndex = 0
playCurrentItem()
} else {
onPlaylistEmpty()
}
if (items.isEmpty()) { onPlaylistEmpty(); return }
// #74/#75: begin on the first schedule-active item; idle if none.
val idx = firstActiveIndex()
if (idx < 0) { showNothingScheduled(); return }
currentIndex = idx
playCurrentItem()
}
fun startIfNeeded() {
@ -156,13 +172,17 @@ class PlaylistController(
fun stop() {
isRunning = false
cancelAdvance()
cancelRetry()
}
fun next() {
if (items.isEmpty()) return
currentIndex = (currentIndex + 1) % items.size
// Request a playlist refresh between plays so new content gets picked up
onRequestRefresh?.invoke()
// #74/#75: advance to the next item the schedule allows now; idle if none.
val idx = nextActiveIndex(currentIndex)
if (idx < 0) { showNothingScheduled(); return }
currentIndex = idx
playCurrentItem()
}
@ -173,6 +193,7 @@ class PlaylistController(
private fun playCurrentItem() {
cancelAdvance()
cancelRetry()
val item = currentItem ?: return
Log.i("PlaylistController", "Playing: ${item.filename} (index $currentIndex)")
onItemChanged(item)
@ -194,4 +215,64 @@ class PlaylistController(
advanceRunnable?.let { handler.removeCallbacks(it) }
advanceRunnable = null
}
private fun cancelRetry() {
retryRunnable?.let { handler.removeCallbacks(it) }
retryRunnable = null
}
// #74/#75 schedule helpers ---------------------------------------------------
private fun scheduleAllows(item: PlaylistItem): Boolean =
item.schedules.isEmpty() ||
ScheduleEval.isItemActiveNow(item.schedules, System.currentTimeMillis(), effectiveTimezone)
private fun firstActiveIndex(): Int {
for (i in items.indices) if (scheduleAllows(items[i])) return i
return -1
}
private fun nextActiveIndex(from: Int): Int {
if (items.isEmpty()) return -1
for (i in 1..items.size) {
val idx = (from + i) % items.size
if (scheduleAllows(items[idx])) return idx
}
return -1
}
// Every item filtered out: show the idle screen and re-check shortly, since a
// daypart may open. (Boundary re-evaluation otherwise happens on advance.)
private fun showNothingScheduled() {
cancelAdvance()
(onNothingScheduled ?: onPlaylistEmpty)()
cancelRetry()
retryRunnable = Runnable {
if (isRunning && items.isNotEmpty()) {
val idx = firstActiveIndex()
if (idx >= 0) { currentIndex = idx; playCurrentItem() } else showNothingScheduled()
}
}
handler.postDelayed(retryRunnable!!, 30_000L)
}
private fun parseSchedules(arr: JSONArray?): List<ScheduleEval.Block> {
if (arr == null) return emptyList()
val out = ArrayList<ScheduleEval.Block>(arr.length())
for (j in 0 until arr.length()) {
val s = arr.getJSONObject(j)
val d = s.getJSONArray("days")
val days = HashSet<Int>(d.length())
for (k in 0 until d.length()) days.add(d.getInt(k))
out.add(
ScheduleEval.Block(
days = days,
start = s.getString("start"),
end = s.getString("end"),
startDate = if (s.isNull("start_date")) null else s.optString("start_date").ifEmpty { null },
endDate = if (s.isNull("end_date")) null else s.optString("end_date").ifEmpty { null }
)
)
}
return out
}
}

View file

@ -0,0 +1,80 @@
package com.remotedisplay.player.player
import java.time.Instant
import java.time.LocalDate
import java.time.ZoneId
/**
* Canonical per-playlist-item schedule evaluator (#74 dayparting + #75 expiry) -
* Kotlin port of server/lib/schedule-eval.js.
*
* CONTRACT: shared/schedule-vectors.json. This must agree with the JS evaluator
* (server/web/Tizen) on every vector. If it disagrees with a vector, this is wrong.
*
* Time model: instants are UTC; blocks are LOCAL wall-clock rules interpreted in
* the device's effective IANA timezone (DST handled by java.time). Blocks are never
* converted to UTC.
*
* Block semantics:
* - within a block, day AND date AND time must all pass; blocks OR together
* - zero blocks = always on ("no schedule = always plays")
* - time window is [start, end): start inclusive, end exclusive ("24:00" = end of day)
* - start > end crosses midnight; the day/date test anchors to the day the window STARTED
*
* FAILS OPEN: any error (bad timezone, malformed block) returns true so the item
* PLAYS. A blank screen is worse than an over-running promo.
*/
object ScheduleEval {
data class Block(
val days: Set<Int>, // 0=Sun .. 6=Sat
val start: String, // "HH:MM"
val end: String, // "HH:MM" or "24:00"
val startDate: String?, // "YYYY-MM-DD" or null = no lower bound
val endDate: String? // "YYYY-MM-DD" or null = no upper bound
)
fun isItemActiveNow(blocks: List<Block>?, utcNowMs: Long, ianaTz: String?): Boolean {
if (blocks.isNullOrEmpty()) return true
return try {
val zone = if (ianaTz.isNullOrBlank()) ZoneId.systemDefault() else ZoneId.of(ianaTz)
val zdt = Instant.ofEpochMilli(utcNowMs).atZone(zone)
val dow = zdt.dayOfWeek.value % 7 // java Mon=1..Sun=7 -> Sun=0..Sat=6
val nowMin = zdt.hour * 60 + zdt.minute
val date = zdt.toLocalDate()
blocks.any { blockMatches(it, dow, nowMin, date) }
} catch (e: Exception) {
true // fail open
}
}
private fun hm(s: String): Int { val p = s.split(":"); return p[0].toInt() * 60 + p[1].toInt() } // "24:00" -> 1440
private fun dayOk(dow: Int, days: Set<Int>): Boolean = days.contains(dow)
private fun dateOk(date: LocalDate, startDate: String?, endDate: String?): Boolean {
if (startDate != null && date.isBefore(LocalDate.parse(startDate))) return false
if (endDate != null && date.isAfter(LocalDate.parse(endDate))) return false // inclusive
return true
}
private fun blockMatches(b: Block, dow: Int, nowMin: Int, date: LocalDate): Boolean {
val s = hm(b.start); val e = hm(b.end)
if (s <= e) {
// same-day window [s, e), anchored to today
if (nowMin < s || nowMin >= e) return false
return dayOk(dow, b.days) && dateOk(date, b.startDate, b.endDate)
}
// overnight wrap
if (nowMin >= s) {
// before-midnight portion: anchor = today
return dayOk(dow, b.days) && dateOk(date, b.startDate, b.endDate)
}
if (nowMin < e) {
// after-midnight portion: anchor = the day it started = yesterday
val y = date.minusDays(1)
return dayOk((dow + 6) % 7, b.days) && dateOk(y, b.startDate, b.endDate)
}
return false
}
}

View file

@ -51,6 +51,10 @@ class ZoneManager(
private set
var lastAssignmentSig: String? = null
// #74/#75: device-effective IANA timezone for per-item schedule evaluation.
@Volatile private var effectiveTimezone: String? = null
fun setTimezone(tz: String?) { effectiveTimezone = tz }
fun hasZones(): Boolean = zones.isNotEmpty()
fun setupZones(zonesJson: JSONArray, layoutId: String? = null) {
@ -120,6 +124,35 @@ class ZoneManager(
Log.i(TAG, "Rendered ${zoneViews.size} zone views")
}
// #74/#75 zone schedule helpers.
private fun assignmentAllows(a: JSONObject): Boolean {
val arr = a.optJSONArray("schedules") ?: return true
if (arr.length() == 0) return true
val blocks = ArrayList<ScheduleEval.Block>(arr.length())
for (j in 0 until arr.length()) {
val s = arr.getJSONObject(j)
val d = s.getJSONArray("days")
val days = HashSet<Int>(d.length())
for (k in 0 until d.length()) days.add(d.getInt(k))
blocks.add(
ScheduleEval.Block(
days, s.getString("start"), s.getString("end"),
if (s.isNull("start_date")) null else s.optString("start_date").ifEmpty { null },
if (s.isNull("end_date")) null else s.optString("end_date").ifEmpty { null }
)
)
}
return ScheduleEval.isItemActiveNow(blocks, System.currentTimeMillis(), effectiveTimezone)
}
private fun zoneNextActive(assignments: List<JSONObject>, from: Int): Int {
for (i in assignments.indices) {
val idx = (from + i) % assignments.size
if (assignmentAllows(assignments[idx])) return idx
}
return -1
}
// Render assignment[index] in a zone, replacing its current view. If the zone
// has more than one assignment it rotates: images/widgets advance on a duration
// timer; videos advance when they end (single-item zones loop the video).
@ -128,9 +161,17 @@ class ZoneManager(
zoneViews.remove(zone.id)?.let { container.removeView(it) }
zoneExoPlayers.remove(zone.id)?.release()
val a = assignments[index % assignments.size]
val multi = assignments.size > 1
val advance: () -> Unit = { showZoneItem(zone, assignments, index + 1, params) }
// #74/#75: skip items whose schedule excludes them now; blank-idle the zone
// and re-check shortly (a daypart may open) if none are active.
val activeIdx = zoneNextActive(assignments, index)
if (activeIdx < 0) {
scheduleZoneAdvance(zone.id, 30_000L) { showZoneItem(zone, assignments, 0, params) }
return
}
val a = assignments[activeIdx]
// Scheduled zones cycle even with one active item so windows re-evaluate.
val multi = assignments.size > 1 || assignments.any { (it.optJSONArray("schedules")?.length() ?: 0) > 0 }
val advance: () -> Unit = { showZoneItem(zone, assignments, activeIdx + 1, params) }
val mimeType = a.optString("mime_type", "")
val remoteUrl = if (a.isNull("remote_url")) null else a.optString("remote_url", null)
@ -143,7 +184,7 @@ class ZoneManager(
// Per-zone content switch log (fires on initial render AND each rotation), so
// the live debug panel shows each zone advancing on its own interval.
val label = a.optString("filename", "").ifEmpty { widgetType?.let { "widget:$it" } ?: mimeType.ifEmpty { "item" } }
com.remotedisplay.player.util.DebugLog.i("Zone", "'${zone.name}' [${(index % assignments.size) + 1}/${assignments.size}] -> $label (${durationMs / 1000}s)")
com.remotedisplay.player.util.DebugLog.i("Zone", "'${zone.name}' [${activeIdx + 1}/${assignments.size}] -> $label (${durationMs / 1000}s)")
when {
// Widget - render in WebView

View file

@ -30,6 +30,9 @@ class DeviceInfo(private val context: Context) {
put("wifi_ssid", getWifiSSID())
put("wifi_rssi", getWifiRSSI())
put("uptime_seconds", getUptimeSeconds())
// #74/#75: OS timezone + UTC clock (effective-tz resolution + dashboard skew indicator)
put("timezone", java.util.TimeZone.getDefault().id)
put("device_utc", System.currentTimeMillis())
}
}

View file

@ -2,4 +2,6 @@
<resources>
<string name="app_name">RemoteDisplay</string>
<string name="accessibility_description">RemoteDisplay nutzt die Bedienungshilfen, um Fernsteuerung der Stromzufuhr und Systemnavigation zu ermöglichen.</string>
<string name="nothing_scheduled">Derzeit ist nichts geplant</string>
<string name="waiting_for_content">Warte auf Inhalte…</string>
</resources>

View file

@ -2,4 +2,6 @@
<resources>
<string name="app_name">RemoteDisplay</string>
<string name="accessibility_description">RemoteDisplay usa accesibilidad para habilitar el control remoto de encendido y la navegación del sistema.</string>
<string name="nothing_scheduled">No hay nada programado en este momento</string>
<string name="waiting_for_content">Esperando contenido…</string>
</resources>

View file

@ -2,4 +2,6 @@
<resources>
<string name="app_name">RemoteDisplay</string>
<string name="accessibility_description">RemoteDisplay utilise l\'accessibilité pour activer les contrôles d\'alimentation à distance et la navigation système.</string>
<string name="nothing_scheduled">Rien de programmé pour le moment</string>
<string name="waiting_for_content">En attente de contenu…</string>
</resources>

View file

@ -2,4 +2,6 @@
<resources>
<string name="app_name">RemoteDisplay</string>
<string name="accessibility_description">RemoteDisplay usa acessibilidade para habilitar controles remotos de energia e navegação do sistema.</string>
<string name="nothing_scheduled">Nada programado no momento</string>
<string name="waiting_for_content">Aguardando conteúdo…</string>
</resources>

View file

@ -2,4 +2,6 @@
<resources>
<string name="app_name">RemoteDisplay</string>
<string name="accessibility_description">RemoteDisplay uses accessibility to enable remote power controls and system navigation.</string>
<string name="nothing_scheduled">Nothing scheduled right now</string>
<string name="waiting_for_content">Waiting for content…</string>
</resources>

View file

@ -0,0 +1,50 @@
package com.remotedisplay.player.player
import com.google.gson.JsonParser
import org.junit.Assert.assertEquals
import org.junit.Test
import java.io.File
import java.time.Instant
/**
* Drift guard (#74/#75): the Kotlin evaluator must agree with the SHARED contract
* at shared/schedule-vectors.json - the SAME file the JS server, web player, and
* Tizen player are held to. No snapshot is taken: the test task points
* `scheduleVectors` at the single source (see app/build.gradle.kts), so any future
* ScheduleEval.kt change that breaks a vector fails CI.
*/
class ScheduleEvalTest {
@Test
fun conformsToSharedVectors() {
val path = System.getProperty("scheduleVectors")
?: error("scheduleVectors system property not set (configured in app/build.gradle.kts)")
val vectors = JsonParser.parseString(File(path).readText()).asJsonObject.getAsJsonArray("vectors")
val failures = StringBuilder()
var count = 0
for (el in vectors) {
val v = el.asJsonObject
val blocks = v.getAsJsonArray("blocks").map { b ->
val o = b.asJsonObject
ScheduleEval.Block(
days = o.getAsJsonArray("days").map { it.asInt }.toSet(),
start = o.get("start").asString,
end = o.get("end").asString,
startDate = o.get("start_date").let { if (it.isJsonNull) null else it.asString },
endDate = o.get("end_date").let { if (it.isJsonNull) null else it.asString }
)
}
val utcMs = Instant.parse(v.get("utc_now").asString).toEpochMilli()
val got = ScheduleEval.isItemActiveNow(blocks, utcMs, v.get("timezone").asString)
val expected = v.get("expected").asBoolean
count++
if (got != expected) {
failures.append("\n[${v.get("utc_now").asString} ${v.get("timezone").asString}] " +
"expected $expected got $got :: ${v.get("description").asString}")
}
}
println("Kotlin JUnit schedule vectors: ${count - failures.count { it == '\n' }}/$count passed")
assertEquals("schedule vectors failed:$failures", 0, failures.length)
}
}

View file

@ -146,6 +146,9 @@ export const api = {
updatePlaylistItem: (id, itemId, data) => request(`/playlists/${id}/items/${itemId}`, { method: 'PUT', body: JSON.stringify(data) }),
deletePlaylistItem: (id, itemId) => request(`/playlists/${id}/items/${itemId}`, { method: 'DELETE' }),
reorderPlaylistItems: (id, order) => request(`/playlists/${id}/items/reorder`, { method: 'POST', body: JSON.stringify({ order }) }),
// #74/#75 per-item schedule blocks
getItemSchedules: (id, itemId) => request(`/playlists/${id}/items/${itemId}/schedules`),
setItemSchedules: (id, itemId, blocks) => request(`/playlists/${id}/items/${itemId}/schedules`, { method: 'PUT', body: JSON.stringify({ blocks }) }),
assignPlaylistToDevice: (playlistId, device_id) => request(`/playlists/${playlistId}/assign`, { method: 'POST', body: JSON.stringify({ device_id }) }),
publishPlaylist: (id) => request(`/playlists/${id}/publish`, { method: 'POST' }),
discardPlaylistDraft: (id) => request(`/playlists/${id}/discard`, { method: 'POST' }),

View file

@ -691,6 +691,36 @@ export default {
'playlist.adding': 'Wird hinzugefügt...',
'playlist.added': 'Hinzugefügt',
// Per-item schedule editor (#74/#75)
'itemsched.title': 'Zeitplan',
'itemsched.hint': 'Elemente ohne Zeitplan werden immer abgespielt. Fügen Sie Blöcke hinzu, um einzuschränken, wann dieses Element erscheint — ausgewertet in der lokalen Zeit jedes Bildschirms.',
'itemsched.none': 'Kein Zeitplan — dieses Element wird immer abgespielt.',
'itemsched.block': 'Block {n}',
'itemsched.remove_block': 'Block entfernen',
'itemsched.from': 'Von',
'itemsched.to': 'Bis',
'itemsched.end_of_day': 'Tagesende',
'itemsched.starts': 'Beginnt',
'itemsched.ends': 'Endet',
'itemsched.dates_hint': '(Daten optional, einschließlich)',
'itemsched.add_block': '+ Zeitplanblock hinzufügen',
'itemsched.cancel': 'Abbrechen',
'itemsched.save': 'Zeitplan speichern',
'itemsched.toast.saved': 'Zeitplan gespeichert — veröffentlichen Sie die Playlist, um sie an die Geräte zu senden',
'itemsched.toast.cleared': 'Zeitplan gelöscht — veröffentlichen, um die Geräte zu aktualisieren',
'itemsched.every_day': 'Täglich',
'itemsched.mon_fri': 'Mo-Fr',
'itemsched.sat_sun': 'Sa-So',
'itemsched.dow_short': 'So,Mo,Di,Mi,Do,Fr,Sa',
'itemsched.err.days': 'Jeder Zeitplanblock benötigt mindestens einen aktiven Tag',
'itemsched.err.start': 'Startzeit muss HH:MM sein',
'itemsched.err.end': 'Endzeit muss HH:MM sein (oder Tagesende)',
'itemsched.err.start_date': 'Startdatum muss JJJJ-MM-TT sein',
'itemsched.err.end_date': 'Enddatum muss JJJJ-MM-TT sein',
'device.clock.label': 'Geräteuhr',
'device.clock.reported': '{time} gemeldet',
'device.clock.skew': '⚠ Uhr weicht um {amount} ab — Zeitpläne können zur falschen lokalen Zeit auslösen',
// Onboarding
'onboarding.back': 'Zurück',
'onboarding.next': 'Weiter',

View file

@ -775,6 +775,36 @@ export default {
'playlist.adding': 'Adding...',
'playlist.added': 'Added',
// Per-item schedule editor (#74/#75)
'itemsched.title': 'Schedule',
'itemsched.hint': "Items without a schedule always play. Add blocks to limit when this item appears — evaluated in each screen's local time.",
'itemsched.none': 'No schedule — this item always plays.',
'itemsched.block': 'Block {n}',
'itemsched.remove_block': 'Remove block',
'itemsched.from': 'From',
'itemsched.to': 'To',
'itemsched.end_of_day': 'end of day',
'itemsched.starts': 'Starts',
'itemsched.ends': 'Ends',
'itemsched.dates_hint': '(dates optional, inclusive)',
'itemsched.add_block': '+ Add schedule block',
'itemsched.cancel': 'Cancel',
'itemsched.save': 'Save schedule',
'itemsched.toast.saved': 'Schedule saved — publish the playlist to push it to devices',
'itemsched.toast.cleared': 'Schedule cleared — publish to update devices',
'itemsched.every_day': 'Every day',
'itemsched.mon_fri': 'Mon-Fri',
'itemsched.sat_sun': 'Sat-Sun',
'itemsched.dow_short': 'Sun,Mon,Tue,Wed,Thu,Fri,Sat',
'itemsched.err.days': 'Each schedule block needs at least one active day',
'itemsched.err.start': 'Start time must be HH:MM',
'itemsched.err.end': 'End time must be HH:MM (or end of day)',
'itemsched.err.start_date': 'Start date must be YYYY-MM-DD',
'itemsched.err.end_date': 'End date must be YYYY-MM-DD',
'device.clock.label': 'Device clock',
'device.clock.reported': '{time} reported',
'device.clock.skew': '⚠ clock off by {amount} — schedules may fire at the wrong local time',
// Onboarding
'onboarding.back': 'Back',
'onboarding.next': 'Next',

View file

@ -690,6 +690,36 @@ export default {
'playlist.adding': 'Agregando...',
'playlist.added': 'Agregado',
// Per-item schedule editor (#74/#75)
'itemsched.title': 'Programación',
'itemsched.hint': 'Los elementos sin programación siempre se reproducen. Agrega bloques para limitar cuándo aparece este elemento — se evalúa en la hora local de cada pantalla.',
'itemsched.none': 'Sin programación — este elemento siempre se reproduce.',
'itemsched.block': 'Bloque {n}',
'itemsched.remove_block': 'Eliminar bloque',
'itemsched.from': 'Desde',
'itemsched.to': 'Hasta',
'itemsched.end_of_day': 'fin del día',
'itemsched.starts': 'Empieza',
'itemsched.ends': 'Termina',
'itemsched.dates_hint': '(fechas opcionales, inclusivas)',
'itemsched.add_block': '+ Agregar bloque de programación',
'itemsched.cancel': 'Cancelar',
'itemsched.save': 'Guardar programación',
'itemsched.toast.saved': 'Programación guardada — publica la lista de reproducción para enviarla a los dispositivos',
'itemsched.toast.cleared': 'Programación borrada — publica para actualizar los dispositivos',
'itemsched.every_day': 'Todos los días',
'itemsched.mon_fri': 'Lun-Vie',
'itemsched.sat_sun': 'Sáb-Dom',
'itemsched.dow_short': 'Dom,Lun,Mar,Mié,Jue,Vie,Sáb',
'itemsched.err.days': 'Cada bloque de programación necesita al menos un día activo',
'itemsched.err.start': 'La hora de inicio debe ser HH:MM',
'itemsched.err.end': 'La hora de fin debe ser HH:MM (o fin del día)',
'itemsched.err.start_date': 'La fecha de inicio debe ser AAAA-MM-DD',
'itemsched.err.end_date': 'La fecha de fin debe ser AAAA-MM-DD',
'device.clock.label': 'Reloj del dispositivo',
'device.clock.reported': '{time} reportado',
'device.clock.skew': '⚠ reloj desfasado en {amount} — las programaciones pueden activarse a la hora local incorrecta',
// Onboarding
'onboarding.back': 'Atrás',
'onboarding.next': 'Siguiente',

View file

@ -691,6 +691,36 @@ export default {
'playlist.adding': 'Ajout...',
'playlist.added': 'Ajouté',
// Per-item schedule editor (#74/#75)
'itemsched.title': 'Programmation',
'itemsched.hint': 'Les éléments sans programmation sont toujours diffusés. Ajoutez des blocs pour limiter quand cet élément apparaît — évalué dans lheure locale de chaque écran.',
'itemsched.none': 'Aucune programmation — cet élément est toujours diffusé.',
'itemsched.block': 'Bloc {n}',
'itemsched.remove_block': 'Supprimer le bloc',
'itemsched.from': 'De',
'itemsched.to': 'À',
'itemsched.end_of_day': 'fin de journée',
'itemsched.starts': 'Début',
'itemsched.ends': 'Fin',
'itemsched.dates_hint': '(dates facultatives, incluses)',
'itemsched.add_block': '+ Ajouter un bloc de programmation',
'itemsched.cancel': 'Annuler',
'itemsched.save': 'Enregistrer la programmation',
'itemsched.toast.saved': 'Programmation enregistrée — publiez la playlist pour lenvoyer aux appareils',
'itemsched.toast.cleared': 'Programmation effacée — publiez pour mettre à jour les appareils',
'itemsched.every_day': 'Tous les jours',
'itemsched.mon_fri': 'Lun-Ven',
'itemsched.sat_sun': 'Sam-Dim',
'itemsched.dow_short': 'Dim,Lun,Mar,Mer,Jeu,Ven,Sam',
'itemsched.err.days': 'Chaque bloc de programmation nécessite au moins un jour actif',
'itemsched.err.start': 'Lheure de début doit être HH:MM',
'itemsched.err.end': 'Lheure de fin doit être HH:MM (ou fin de journée)',
'itemsched.err.start_date': 'La date de début doit être AAAA-MM-JJ',
'itemsched.err.end_date': 'La date de fin doit être AAAA-MM-JJ',
'device.clock.label': 'Horloge de lappareil',
'device.clock.reported': '{time} signalé',
'device.clock.skew': '⚠ horloge décalée de {amount} — les programmations peuvent se déclencher à la mauvaise heure locale',
// Onboarding
'onboarding.back': 'Retour',
'onboarding.next': 'Suivant',

View file

@ -691,6 +691,36 @@ export default {
'playlist.adding': 'Adicionando...',
'playlist.added': 'Adicionado',
// Per-item schedule editor (#74/#75)
'itemsched.title': 'Programação',
'itemsched.hint': 'Itens sem programação sempre são reproduzidos. Adicione blocos para limitar quando este item aparece — avaliado no horário local de cada tela.',
'itemsched.none': 'Sem programação — este item sempre é reproduzido.',
'itemsched.block': 'Bloco {n}',
'itemsched.remove_block': 'Remover bloco',
'itemsched.from': 'De',
'itemsched.to': 'Até',
'itemsched.end_of_day': 'fim do dia',
'itemsched.starts': 'Início',
'itemsched.ends': 'Fim',
'itemsched.dates_hint': '(datas opcionais, inclusivas)',
'itemsched.add_block': '+ Adicionar bloco de programação',
'itemsched.cancel': 'Cancelar',
'itemsched.save': 'Salvar programação',
'itemsched.toast.saved': 'Programação salva — publique a playlist para enviá-la aos dispositivos',
'itemsched.toast.cleared': 'Programação limpa — publique para atualizar os dispositivos',
'itemsched.every_day': 'Todos os dias',
'itemsched.mon_fri': 'Seg-Sex',
'itemsched.sat_sun': 'Sáb-Dom',
'itemsched.dow_short': 'Dom,Seg,Ter,Qua,Qui,Sex,Sáb',
'itemsched.err.days': 'Cada bloco de programação precisa de pelo menos um dia ativo',
'itemsched.err.start': 'A hora de início deve ser HH:MM',
'itemsched.err.end': 'A hora de fim deve ser HH:MM (ou fim do dia)',
'itemsched.err.start_date': 'A data de início deve ser AAAA-MM-DD',
'itemsched.err.end_date': 'A data de fim deve ser AAAA-MM-DD',
'device.clock.label': 'Relógio do dispositivo',
'device.clock.reported': '{time} reportado',
'device.clock.skew': '⚠ relógio defasado em {amount} — as programações podem disparar no horário local errado',
// Onboarding
'onboarding.back': 'Voltar',
'onboarding.next': 'Próximo',

View file

@ -28,6 +28,24 @@ function formatUptime(seconds) {
return `${m}m`;
}
// #74/#75: device clock + skew indicator. Compares the device's reported UTC to the
// server's receipt time; a gap > 2 min means the device clock is wrong, so per-item
// schedules will fire at the wrong local time — surface it instead of a support mystery.
function renderDeviceClock(device) {
const tz = device.reported_timezone || device.timezone || '--';
if (!device.reported_utc || !device.reported_at) return tz;
const skewSec = Math.abs(Math.round(device.reported_utc / 1000) - device.reported_at);
let local = '';
try {
local = new Date(device.reported_utc).toLocaleString(undefined,
{ timeZone: device.reported_timezone || undefined, hour: '2-digit', minute: '2-digit', month: 'short', day: 'numeric' });
} catch (e) { /* bad tz id -> skip local render */ }
const warn = skewSec > 120
? `<div style="color:#f59e0b;font-size:11px;margin-top:2px">${t('device.clock.skew', { amount: skewSec >= 3600 ? Math.round(skewSec / 3600) + 'h' : Math.round(skewSec / 60) + 'm' })}</div>`
: '';
return `${tz}${local ? `<div style="font-size:11px;color:var(--text-muted)">${t('device.clock.reported', { time: local })}</div>` : ''}${warn}`;
}
export function render(container, deviceId) {
container.innerHTML = `
<div class="device-detail">
@ -288,6 +306,10 @@ async function loadDevice(deviceId, activeTab = null) {
<div class="info-card-label">${t('device.info.screen_resolution')}</div>
<div class="info-card-value small">${device.screen_width && device.screen_height ? device.screen_width + 'x' + device.screen_height : '--'}</div>
</div>
<div class="info-card">
<div class="info-card-label">${t('device.clock.label')}</div>
<div class="info-card-value small">${renderDeviceClock(device)}</div>
</div>
${device.android_version && !device.android_version.startsWith('Web/') ? `
<div class="info-card">
<div class="info-card-label">${t('device.info.ram')}</div>

View file

@ -14,6 +14,39 @@ function getTypeIcon(item) {
return '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="3" width="18" height="18" rx="2" ry="2"/><circle cx="8.5" cy="8.5" r="1.5"/><polyline points="21 15 16 10 5 21"/></svg>';
}
// #74/#75 per-item schedule editor helpers. Client validation MIRRORS the server
// (server/routes/playlists.js validateBlocks): same time/date regexes, non-empty days.
const SCHED_TIME_RE = /^([01]\d|2[0-3]):[0-5]\d$/;
const SCHED_DATE_RE = /^\d{4}-\d{2}-\d{2}$/;
function daysSummary(days) {
const labels = t('itemsched.dow_short').split(',');
const s = [...days].sort((a, b) => a - b);
if (s.length === 7) return t('itemsched.every_day');
if (s.length === 5 && [1, 2, 3, 4, 5].every(d => s.includes(d))) return t('itemsched.mon_fri');
if (s.length === 2 && s.includes(0) && s.includes(6)) return t('itemsched.sat_sun');
return s.map(d => labels[d]).join(' ');
}
function blockSummary(b) {
let s = `${daysSummary(b.days)} ${b.start}-${b.end}`;
if (b.start_date || b.end_date) s += ` · ${b.start_date || '…'}${b.end_date || '…'}`;
return s;
}
function scheduleSummary(schedules) {
if (!schedules || !schedules.length) return '';
return schedules.length === 1 ? blockSummary(schedules[0]) : `${blockSummary(schedules[0])} +${schedules.length - 1}`;
}
function validateScheduleBlocks(blocks) {
for (const b of blocks) {
if (!b.days || !b.days.length) return t('itemsched.err.days');
if (!SCHED_TIME_RE.test(b.start)) return t('itemsched.err.start');
if (!(SCHED_TIME_RE.test(b.end) || b.end === '24:00')) return t('itemsched.err.end');
if (b.start_date && !SCHED_DATE_RE.test(b.start_date)) return t('itemsched.err.start_date');
if (b.end_date && !SCHED_DATE_RE.test(b.end_date)) return t('itemsched.err.end_date');
}
return null;
}
let currentPlaylistId = null;
export function render(container) {
@ -296,7 +329,10 @@ function renderItems(items) {
</div>
<div style="flex:1;min-width:0">
<div style="font-size:14px;color:var(--text-primary);white-space:nowrap;overflow:hidden;text-overflow:ellipsis">${esc(item.filename || item.widget_name || t('common.unknown'))}</div>
<div style="font-size:12px;color:var(--text-muted)">${item.widget_id ? t('playlist.item_widget') : esc(item.mime_type || t('playlist.unknown_type'))}</div>
<div style="font-size:12px;color:var(--text-muted);display:flex;align-items:center;gap:8px;min-width:0">
<span style="white-space:nowrap">${item.widget_id ? t('playlist.item_widget') : esc(item.mime_type || t('playlist.unknown_type'))}</span>
${item.schedules && item.schedules.length ? `<span style="font-size:11px;padding:1px 6px;border-radius:4px;background:#0c2a3f;color:#7dd3fc;white-space:nowrap;overflow:hidden;text-overflow:ellipsis" title="${esc(scheduleSummary(item.schedules))}">🕐 ${esc(scheduleSummary(item.schedules))}</span>` : ''}
</div>
</div>
<div style="display:flex;align-items:center;gap:8px;flex-shrink:0">
<label style="font-size:12px;color:var(--text-muted)">${t('playlist.duration')}</label>
@ -304,6 +340,9 @@ function renderItems(items) {
<span style="font-size:12px;color:var(--text-muted)">${t('playlist.sec')}</span>
</div>
<div style="display:flex;align-items:center;gap:4px;flex-shrink:0">
<button class="btn-icon item-schedule" data-item-id="${item.id}" title="${t('itemsched.title')}" aria-label="${t('itemsched.title')}" style="color:${item.schedules && item.schedules.length ? '#38bdf8' : 'var(--text-muted)'};background:none;border:none;cursor:pointer;padding:4px;border-radius:4px">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="9"/><polyline points="12 7 12 12 15 14"/></svg>
</button>
<button class="btn-icon item-move" data-item-id="${item.id}" data-dir="up" title="${t('playlist.move_up')}" aria-label="${t('playlist.move_up')}" ${i === 0 ? 'disabled' : ''} style="color:var(--text-muted);background:none;border:none;cursor:pointer;padding:4px;border-radius:4px;${i === 0 ? 'opacity:0.3;cursor:not-allowed' : ''}">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="18 15 12 9 6 15"/></svg>
</button>
@ -346,6 +385,14 @@ function renderItems(items) {
});
});
itemsEl.querySelectorAll('.item-schedule').forEach(btn => {
btn.addEventListener('click', (e) => {
const itemId = e.currentTarget.dataset.itemId;
const item = items.find(it => String(it.id) === String(itemId));
if (item) showScheduleModal(item);
});
});
itemsEl.querySelectorAll('.item-move').forEach(btn => {
btn.addEventListener('click', async (e) => {
if (btn.disabled) return;
@ -597,3 +644,110 @@ async function showAddItemModal(playlistId) {
renderTab();
}
// #74/#75: per-item schedule editor. Multiple blocks (days + time window + optional
// date range) OR together; an item with no blocks always plays. Client validation
// mirrors the server; saving marks the playlist DRAFT (must re-publish to reach devices).
function showScheduleModal(item) {
let blocks = (item.schedules || []).map(b => ({
days: Array.isArray(b.days) ? [...b.days] : [],
start: b.start || '00:00',
end: b.end || '24:00',
start_date: b.start_date || '',
end_date: b.end_date || ''
}));
const modal = document.createElement('div');
modal.style.cssText = 'position:fixed;inset:0;background:rgba(0,0,0,0.6);display:flex;align-items:center;justify-content:center;z-index:1000';
document.body.appendChild(modal);
function blockRow(b, idx) {
const eod = b.end === '24:00';
const dayLabels = t('itemsched.dow_short').split(',');
return `
<div style="border:1px solid var(--border);border-radius:var(--radius);padding:12px;margin-bottom:10px">
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:8px">
<strong style="font-size:13px">${t('itemsched.block', { n: idx + 1 })}</strong>
<button class="sched-remove" data-idx="${idx}" title="${t('itemsched.remove_block')}" style="color:var(--text-muted);background:none;border:none;cursor:pointer;font-size:14px"></button>
</div>
<div style="display:flex;gap:4px;flex-wrap:wrap;margin-bottom:10px">
${dayLabels.map((lbl, d) => `<button class="sched-day" data-idx="${idx}" data-day="${d}" style="padding:4px 9px;border-radius:6px;font-size:12px;cursor:pointer;border:1px solid var(--border);background:${b.days.includes(d) ? 'var(--accent)' : 'var(--bg-input)'};color:${b.days.includes(d) ? '#000' : 'var(--text-muted)'}">${lbl}</button>`).join('')}
</div>
<div style="display:flex;gap:12px;flex-wrap:wrap;align-items:center">
<label style="font-size:12px;color:var(--text-muted)">${t('itemsched.from')} <input type="time" class="input sched-start" data-idx="${idx}" value="${esc(b.start)}" style="width:118px"></label>
<label style="font-size:12px;color:var(--text-muted)">${t('itemsched.to')} <input type="time" class="input sched-end" data-idx="${idx}" value="${esc(eod ? '00:00' : b.end)}" ${eod ? 'disabled' : ''} style="width:118px"></label>
<label style="font-size:12px;color:var(--text-muted)"><input type="checkbox" class="sched-eod" data-idx="${idx}" ${eod ? 'checked' : ''}> ${t('itemsched.end_of_day')}</label>
</div>
<div style="display:flex;gap:12px;flex-wrap:wrap;align-items:center;margin-top:10px">
<label style="font-size:12px;color:var(--text-muted)">${t('itemsched.starts')} <input type="date" class="input sched-sd" data-idx="${idx}" value="${esc(b.start_date)}" style="width:150px"></label>
<label style="font-size:12px;color:var(--text-muted)">${t('itemsched.ends')} <input type="date" class="input sched-ed" data-idx="${idx}" value="${esc(b.end_date)}" style="width:150px"></label>
<span style="font-size:11px;color:var(--text-muted)">${t('itemsched.dates_hint')}</span>
</div>
</div>`;
}
function render() {
modal.innerHTML = `
<div style="background:var(--bg-card);border:1px solid var(--border);border-radius:var(--radius-lg);padding:24px;width:580px;max-width:94vw;max-height:88vh;overflow:auto">
<h3 style="margin:0 0 4px">${t('itemsched.title')}</h3>
<p style="font-size:12px;color:var(--text-muted);margin:0 0 10px">${esc(item.filename || item.widget_name || 'item')}</p>
<p style="font-size:12px;color:#7dd3fc;background:#0c2a3f;border-radius:6px;padding:8px 10px;margin:0 0 16px">${t('itemsched.hint')}</p>
<div>${blocks.length ? blocks.map(blockRow).join('') : `<p style="font-size:13px;color:var(--text-muted);margin:0 0 10px">${t('itemsched.none')}</p>`}</div>
<button class="btn btn-secondary btn-sm" id="schedAddBlock" style="margin-bottom:4px">${t('itemsched.add_block')}</button>
<div style="display:flex;justify-content:flex-end;gap:8px;margin-top:20px">
<button class="btn btn-secondary" id="schedCancel">${t('itemsched.cancel')}</button>
<button class="btn" id="schedSave" style="background:#f59e0b;color:#000;font-weight:600;border:none">${t('itemsched.save')}</button>
</div>
</div>`;
wire();
}
function wire() {
modal.querySelectorAll('.sched-day').forEach(btn => btn.addEventListener('click', () => {
const i = +btn.dataset.idx, d = +btn.dataset.day;
const set = new Set(blocks[i].days);
if (set.has(d)) set.delete(d); else set.add(d);
blocks[i].days = [...set];
render();
}));
modal.querySelectorAll('.sched-start').forEach(el => el.addEventListener('change', () => { blocks[+el.dataset.idx].start = el.value; }));
modal.querySelectorAll('.sched-end').forEach(el => el.addEventListener('change', () => { blocks[+el.dataset.idx].end = el.value; }));
modal.querySelectorAll('.sched-eod').forEach(el => el.addEventListener('change', () => {
blocks[+el.dataset.idx].end = el.checked ? '24:00' : '17:00';
render();
}));
modal.querySelectorAll('.sched-sd').forEach(el => el.addEventListener('change', () => { blocks[+el.dataset.idx].start_date = el.value; }));
modal.querySelectorAll('.sched-ed').forEach(el => el.addEventListener('change', () => { blocks[+el.dataset.idx].end_date = el.value; }));
modal.querySelectorAll('.sched-remove').forEach(btn => btn.addEventListener('click', () => { blocks.splice(+btn.dataset.idx, 1); render(); }));
document.getElementById('schedAddBlock').addEventListener('click', () => {
blocks.push({ days: [0, 1, 2, 3, 4, 5, 6], start: '09:00', end: '17:00', start_date: '', end_date: '' });
render();
});
document.getElementById('schedCancel').addEventListener('click', () => modal.remove());
document.getElementById('schedSave').addEventListener('click', doSave);
}
async function doSave() {
const payload = blocks.map(b => ({
days: b.days, start: b.start, end: b.end,
start_date: b.start_date || null, end_date: b.end_date || null
}));
const err = validateScheduleBlocks(payload);
if (err) { showToast(err, 'error'); return; }
try {
const saved = await api.setItemSchedules(currentPlaylistId, item.id, payload);
item.schedules = saved;
modal.remove();
// Saving makes the playlist a DRAFT — surface the re-publish step explicitly.
showToast(payload.length ? t('itemsched.toast.saved') : t('itemsched.toast.cleared'));
const playlist = await api.getPlaylist(currentPlaylistId);
renderItems(playlist.items || []);
refreshAfterMutation();
} catch (e) {
showToast(e.message, 'error');
}
}
modal.addEventListener('click', (e) => { if (e.target === modal) modal.remove(); });
render();
}

View file

@ -73,6 +73,12 @@ const migrations = [
// Layout & zone support on devices and assignments
'ALTER TABLE devices ADD COLUMN layout_id TEXT',
'ALTER TABLE devices ADD COLUMN timezone TEXT DEFAULT \'UTC\'',
// #74/#75: player-reported clock, for effective-timezone resolution + the
// dashboard clock-skew indicator. reported_timezone = player OS IANA zone;
// reported_utc = device's claimed UTC (ms); reported_at = server receipt (s).
'ALTER TABLE devices ADD COLUMN reported_timezone TEXT',
'ALTER TABLE devices ADD COLUMN reported_utc INTEGER',
'ALTER TABLE devices ADD COLUMN reported_at INTEGER',
'ALTER TABLE devices ADD COLUMN wall_id TEXT',
'ALTER TABLE devices ADD COLUMN team_id TEXT',
'ALTER TABLE assignments ADD COLUMN zone_id TEXT',
@ -205,6 +211,11 @@ for (const sql of migrations) {
}
if (_migApplied > 0) console.log(`[migrate] applied ${_migApplied} new column migration(s)`);
// #74/#75 per-item schedules: the playlist_item_schedules table is created
// idempotently by schema.sql (CREATE TABLE IF NOT EXISTS, run every boot, so it
// self-applies on upgrade). Record it in schema_migrations for observability.
try { db.prepare("INSERT OR IGNORE INTO schema_migrations (id) VALUES ('phase7_playlist_item_schedules')").run(); } catch { /* schema_migrations not ready yet */ }
// Fix assignments table: make content_id nullable (SQLite requires table rebuild)
try {
const colInfo = db.prepare("PRAGMA table_info(assignments)").all();

View file

@ -354,6 +354,26 @@ CREATE TABLE IF NOT EXISTS playlist_items (
updated_at INTEGER NOT NULL DEFAULT (strftime('%s','now'))
);
-- Per-playlist-item schedule blocks (#74 dayparting + #75 expiry). 1-to-many:
-- an item with ZERO rows here is always on; otherwise it shows when device-local
-- "now" matches at least one block. Wall-clock rules (local HH:MM + local dates),
-- evaluated on the device via the shared evaluator (server/lib/schedule-eval.js).
-- Pure child of playlist_items: cascade-deleted, and tenant isolation flows
-- through the parent item/playlist, so no workspace_id is needed here.
CREATE TABLE IF NOT EXISTS playlist_item_schedules (
id TEXT PRIMARY KEY,
playlist_item_id INTEGER NOT NULL REFERENCES playlist_items(id) ON DELETE CASCADE,
active_days TEXT NOT NULL DEFAULT '0,1,2,3,4,5,6', -- comma-separated 0(Sun)-6(Sat)
start_time TEXT NOT NULL DEFAULT '00:00', -- local HH:MM
end_time TEXT NOT NULL DEFAULT '24:00', -- local HH:MM ("24:00" = end of day)
start_date TEXT, -- local YYYY-MM-DD, nullable = no lower bound
end_date TEXT, -- local YYYY-MM-DD, nullable = no upper bound
sort_order INTEGER NOT NULL DEFAULT 0,
created_at INTEGER NOT NULL DEFAULT (strftime('%s','now')),
updated_at INTEGER NOT NULL DEFAULT (strftime('%s','now'))
);
CREATE INDEX IF NOT EXISTS idx_playlist_item_schedules_item ON playlist_item_schedules(playlist_item_id);
-- ===================== ACTIVITY LOG =====================
CREATE TABLE IF NOT EXISTS activity_log (

100
server/lib/schedule-eval.js Normal file
View file

@ -0,0 +1,100 @@
// Canonical per-playlist-item schedule evaluator (#74 dayparting + #75 expiry).
//
// CONTRACT: shared/schedule-vectors.json. The JS server, the web player, and the
// Tizen player all consume this exact module; the Android (Kotlin) port must agree
// with the same vectors. If an implementation disagrees with a vector, the
// implementation is wrong.
//
// Time model: instants are UTC; schedule blocks are LOCAL wall-clock rules. We take
// utc_now, convert to device-local wall-clock via the device's IANA timezone (DST
// handled by Intl), then test the block(s). Blocks are never stored/transmitted in
// UTC - that would break across DST and zone changes.
//
// Block = { days:[0-6 (0=Sun)], start:"HH:MM", end:"HH:MM"|"24:00",
// start_date:"YYYY-MM-DD"|null, end_date:"YYYY-MM-DD"|null }
// - within a block: day AND date AND time must all pass
// - blocks OR together; >=1 match = active
// - zero blocks = always active (this is the "no schedule = always plays" fallback)
// - time window is [start, end): start inclusive, end exclusive ("24:00" = end of day)
// - start > end means the window crosses midnight; the day/date test anchors to the
// day the window STARTED (a Fri 22:00-02:00 block is active Sat 01:00).
//
// Dependency-free UMD: Node (require) + browser/Tizen (window.ScheduleEval).
(function (root, factory) {
if (typeof module === 'object' && module.exports) module.exports = factory();
else root.ScheduleEval = factory();
})(typeof self !== 'undefined' ? self : this, function () {
'use strict';
var DOW = { Sun: 0, Mon: 1, Tue: 2, Wed: 3, Thu: 4, Fri: 5, Sat: 6 };
// UTC instant -> device-local {y, mo(1-12), day, dow(0-6), min(0-1439)}.
// ianaTz falsy -> trust the runtime's own local clock as-is (the device's OS time).
function localParts(utcNow, ianaTz) {
var d = (utcNow instanceof Date) ? utcNow : new Date(utcNow);
if (!ianaTz) {
return { y: d.getFullYear(), mo: d.getMonth() + 1, day: d.getDate(), dow: d.getDay(), min: d.getHours() * 60 + d.getMinutes() };
}
var fmt = new Intl.DateTimeFormat('en-US', {
timeZone: ianaTz, year: 'numeric', month: '2-digit', day: '2-digit',
hour: '2-digit', minute: '2-digit', hourCycle: 'h23', weekday: 'short'
});
var p = {}, parts = fmt.formatToParts(d);
for (var i = 0; i < parts.length; i++) p[parts[i].type] = parts[i].value;
var hh = parseInt(p.hour, 10) % 24; // h23 yields 00-23; guard against env quirks
return { y: +p.year, mo: +p.month, day: +p.day, dow: DOW[p.weekday], min: hh * 60 + (+p.minute) };
}
function hm(s) { var a = String(s).split(':'); return (+a[0]) * 60 + (+a[1]); } // "24:00" -> 1440
function ymd(y, mo, day) { function p2(n) { return (n < 10 ? '0' : '') + n; } return y + '-' + p2(mo) + '-' + p2(day); }
// Pure calendar arithmetic (UTC Date used only for date math, never time/DST).
function addDays(y, mo, day, delta) {
var d = new Date(Date.UTC(y, mo - 1, day));
d.setUTCDate(d.getUTCDate() + delta);
return { y: d.getUTCFullYear(), mo: d.getUTCMonth() + 1, day: d.getUTCDate() };
}
function dayOk(dow, days) {
if (!days || !days.length) return false;
for (var i = 0; i < days.length; i++) if (days[i] === dow) return true;
return false;
}
function dateOk(dateStr, startDate, endDate) {
if (startDate && dateStr < startDate) return false; // ISO YYYY-MM-DD sorts lexicographically
if (endDate && dateStr > endDate) return false; // inclusive on both ends
return true;
}
function blockMatches(b, L) {
var s = hm(b.start), e = hm(b.end), now = L.min;
if (s <= e) {
// same-day window [s, e), anchored to today
if (now < s || now >= e) return false;
return dayOk(L.dow, b.days) && dateOk(ymd(L.y, L.mo, L.day), b.start_date, b.end_date);
}
// overnight wrap
if (now >= s) {
// before-midnight portion: anchor = today
return dayOk(L.dow, b.days) && dateOk(ymd(L.y, L.mo, L.day), b.start_date, b.end_date);
}
if (now < e) {
// after-midnight portion: anchor = the day it started = yesterday (device-local)
var y = addDays(L.y, L.mo, L.day, -1);
return dayOk((L.dow + 6) % 7, b.days) && dateOk(ymd(y.y, y.mo, y.day), b.start_date, b.end_date);
}
return false;
}
function isItemActiveNow(blocks, utcNow, ianaTz) {
if (!blocks || blocks.length === 0) return true; // no schedule = always on
var L = localParts(utcNow, ianaTz);
for (var i = 0; i < blocks.length; i++) if (blockMatches(blocks[i], L)) return true;
return false;
}
return { isItemActiveNow: isItemActiveNow, _localParts: localParts, _blockMatches: blockMatches };
});

View file

@ -218,6 +218,7 @@
</div>
<script src="/socket.io/socket.io.js"></script>
<script src="/player/schedule-eval.js"></script>
<script>
// ==================== i18n ====================
// Lightweight inline i18n for the player. The player is a standalone page
@ -251,18 +252,19 @@
info_nothing: 'Nothing',
info_na: 'N/A',
info_sw: 'Service Worker',
nothing_scheduled: 'Nothing scheduled right now',
},
es: {
web_player: 'Reproductor web', server_url: 'URL del servidor', server_url_placeholder: 'https://signage.tudominio.com', connect: 'Conectar', pairing_code: 'Código de vinculación', pairing_hint: 'Ingresa este código en el panel para vincular esta pantalla', connecting: 'Conectando...', connecting_muted: 'Conectando (audio silenciado)...', info_title: 'Reproductor web ScreenTinker', info_close_hint: 'Presiona Atrás de nuevo o haz clic para cerrar', info_device_id: 'ID del dispositivo', info_device_name: 'Nombre del dispositivo', info_server: 'Servidor', info_status: 'Estado', info_now_playing: 'Reproduciendo', info_resolution: 'Resolución', info_uptime: 'Tiempo activo', info_platform: 'Plataforma', info_cache: 'Caché', info_connected: 'Conectado', info_disconnected: 'Desconectado', info_active: 'Activo', info_inactive: 'Inactivo', info_nothing: 'Nada', info_na: 'N/D', info_sw: 'Service Worker',
web_player: 'Reproductor web', server_url: 'URL del servidor', server_url_placeholder: 'https://signage.tudominio.com', connect: 'Conectar', pairing_code: 'Código de vinculación', pairing_hint: 'Ingresa este código en el panel para vincular esta pantalla', connecting: 'Conectando...', connecting_muted: 'Conectando (audio silenciado)...', info_title: 'Reproductor web ScreenTinker', info_close_hint: 'Presiona Atrás de nuevo o haz clic para cerrar', info_device_id: 'ID del dispositivo', info_device_name: 'Nombre del dispositivo', info_server: 'Servidor', info_status: 'Estado', info_now_playing: 'Reproduciendo', info_resolution: 'Resolución', info_uptime: 'Tiempo activo', info_platform: 'Plataforma', info_cache: 'Caché', info_connected: 'Conectado', info_disconnected: 'Desconectado', info_active: 'Activo', info_inactive: 'Inactivo', info_nothing: 'Nada', info_na: 'N/D', info_sw: 'Service Worker', nothing_scheduled: 'No hay nada programado en este momento',
},
fr: {
web_player: 'Lecteur web', server_url: 'URL du serveur', server_url_placeholder: 'https://signage.votredomaine.com', connect: 'Connecter', pairing_code: 'Code dappairage', pairing_hint: 'Saisissez ce code dans le tableau de bord pour apparier cet écran', connecting: 'Connexion...', connecting_muted: 'Connexion (audio coupé)...', info_title: 'Lecteur web ScreenTinker', info_close_hint: 'Appuyez à nouveau sur Retour ou cliquez pour fermer', info_device_id: 'ID de lappareil', info_device_name: 'Nom de lappareil', info_server: 'Serveur', info_status: 'État', info_now_playing: 'En lecture', info_resolution: 'Résolution', info_uptime: 'Disponibilité', info_platform: 'Plateforme', info_cache: 'Cache', info_connected: 'Connecté', info_disconnected: 'Déconnecté', info_active: 'Actif', info_inactive: 'Inactif', info_nothing: 'Rien', info_na: 'N/D', info_sw: 'Service Worker',
web_player: 'Lecteur web', server_url: 'URL du serveur', server_url_placeholder: 'https://signage.votredomaine.com', connect: 'Connecter', pairing_code: 'Code dappairage', pairing_hint: 'Saisissez ce code dans le tableau de bord pour apparier cet écran', connecting: 'Connexion...', connecting_muted: 'Connexion (audio coupé)...', info_title: 'Lecteur web ScreenTinker', info_close_hint: 'Appuyez à nouveau sur Retour ou cliquez pour fermer', info_device_id: 'ID de lappareil', info_device_name: 'Nom de lappareil', info_server: 'Serveur', info_status: 'État', info_now_playing: 'En lecture', info_resolution: 'Résolution', info_uptime: 'Disponibilité', info_platform: 'Plateforme', info_cache: 'Cache', info_connected: 'Connecté', info_disconnected: 'Déconnecté', info_active: 'Actif', info_inactive: 'Inactif', info_nothing: 'Rien', info_na: 'N/D', info_sw: 'Service Worker', nothing_scheduled: 'Rien de programmé pour le moment',
},
de: {
web_player: 'Web-Player', server_url: 'Server-URL', server_url_placeholder: 'https://signage.ihredomain.com', connect: 'Verbinden', pairing_code: 'Kopplungscode', pairing_hint: 'Geben Sie diesen Code im Dashboard ein, um diesen Bildschirm zu koppeln', connecting: 'Verbindung wird hergestellt...', connecting_muted: 'Verbindung (Audio stummgeschaltet)...', info_title: 'ScreenTinker Web-Player', info_close_hint: 'Erneut Zurück drücken oder klicken zum Schließen', info_device_id: 'Geräte-ID', info_device_name: 'Gerätename', info_server: 'Server', info_status: 'Status', info_now_playing: 'Aktuelle Wiedergabe', info_resolution: 'Auflösung', info_uptime: 'Betriebszeit', info_platform: 'Plattform', info_cache: 'Cache', info_connected: 'Verbunden', info_disconnected: 'Getrennt', info_active: 'Aktiv', info_inactive: 'Inaktiv', info_nothing: 'Nichts', info_na: 'N/V', info_sw: 'Service Worker',
web_player: 'Web-Player', server_url: 'Server-URL', server_url_placeholder: 'https://signage.ihredomain.com', connect: 'Verbinden', pairing_code: 'Kopplungscode', pairing_hint: 'Geben Sie diesen Code im Dashboard ein, um diesen Bildschirm zu koppeln', connecting: 'Verbindung wird hergestellt...', connecting_muted: 'Verbindung (Audio stummgeschaltet)...', info_title: 'ScreenTinker Web-Player', info_close_hint: 'Erneut Zurück drücken oder klicken zum Schließen', info_device_id: 'Geräte-ID', info_device_name: 'Gerätename', info_server: 'Server', info_status: 'Status', info_now_playing: 'Aktuelle Wiedergabe', info_resolution: 'Auflösung', info_uptime: 'Betriebszeit', info_platform: 'Plattform', info_cache: 'Cache', info_connected: 'Verbunden', info_disconnected: 'Getrennt', info_active: 'Aktiv', info_inactive: 'Inaktiv', info_nothing: 'Nichts', info_na: 'N/V', info_sw: 'Service Worker', nothing_scheduled: 'Derzeit ist nichts geplant',
},
pt: {
web_player: 'Player web', server_url: 'URL do servidor', server_url_placeholder: 'https://sign.seudominio.com', connect: 'Conectar', pairing_code: 'Código de pareamento', pairing_hint: 'Digite este código no painel para parear esta tela', connecting: 'Conectando...', connecting_muted: 'Conectando (áudio mudo)...', info_title: 'Player web ScreenTinker', info_close_hint: 'Pressione Voltar novamente ou clique para fechar', info_device_id: 'ID do dispositivo', info_device_name: 'Nome do dispositivo', info_server: 'Servidor', info_status: 'Status', info_now_playing: 'Reproduzindo', info_resolution: 'Resolução', info_uptime: 'Tempo ativo', info_platform: 'Plataforma', info_cache: 'Cache', info_connected: 'Conectado', info_disconnected: 'Desconectado', info_active: 'Ativo', info_inactive: 'Inativo', info_nothing: 'Nada', info_na: 'N/D', info_sw: 'Service Worker',
web_player: 'Player web', server_url: 'URL do servidor', server_url_placeholder: 'https://sign.seudominio.com', connect: 'Conectar', pairing_code: 'Código de pareamento', pairing_hint: 'Digite este código no painel para parear esta tela', connecting: 'Conectando...', connecting_muted: 'Conectando (áudio mudo)...', info_title: 'Player web ScreenTinker', info_close_hint: 'Pressione Voltar novamente ou clique para fechar', info_device_id: 'ID do dispositivo', info_device_name: 'Nome do dispositivo', info_server: 'Servidor', info_status: 'Status', info_now_playing: 'Reproduzindo', info_resolution: 'Resolução', info_uptime: 'Tempo ativo', info_platform: 'Plataforma', info_cache: 'Cache', info_connected: 'Conectado', info_disconnected: 'Desconectado', info_active: 'Ativo', info_inactive: 'Inativo', info_nothing: 'Nada', info_na: 'N/D', info_sw: 'Service Worker', nothing_scheduled: 'Nada programado no momento',
},
};
const PLAYER_LANG = (() => {
@ -326,6 +328,8 @@
let playlist = [];
let currentIndex = -1;
let isPlaying = false;
let playerTimezone = null; // #74/#75: device-effective IANA tz for schedule eval
let scheduleRetryTimer = null; // re-check when every item is filtered out
let heartbeatTimer = null;
let refreshTimer = null;
let remoteStreaming = false;
@ -530,10 +534,8 @@
console.log('Restored cached playlist:', cachedPlaylist.length, 'items');
playlist = cachedPlaylist;
layout = loadLayoutCache();
currentIndex = 0;
isPlaying = true;
document.getElementById('setupScreen').style.display = 'none';
playCurrentItem();
startPlaybackAt(0); // #74/#75: honour schedules from the first frame on cold-start
}
// Always show the tap overlay on cold load. Browser autoplay policy is
@ -876,6 +878,9 @@
wifi_ssid: 'Web Player',
wifi_rssi: null,
uptime_seconds: Math.floor(performance.now() / 1000),
// #74/#75: report OS timezone + UTC clock (effective-tz resolution + skew indicator)
timezone: (function () { try { return Intl.DateTimeFormat().resolvedOptions().timeZone || null; } catch (e) { return null; } })(),
device_utc: Date.now(),
}
});
}, HEARTBEAT_INTERVAL);
@ -1040,8 +1045,9 @@
}
const newItems = data.assignments || [];
// Build fingerprint from id + url + filename to detect any content change
const fingerprint = (items) => items.map(a => `${a.content_id || ''}|${a.widget_id || ''}|${a.remote_url || ''}|${a.filepath || ''}|${a.filename || ''}`).join(',');
// Build fingerprint from id + url + filename to detect any content change.
// #74/#75: include schedules so a schedule edit (same content) is detected too.
const fingerprint = (items) => items.map(a => `${a.content_id || ''}|${a.widget_id || ''}|${a.remote_url || ''}|${a.filepath || ''}|${a.filename || ''}|${JSON.stringify(a.schedules || [])}`).join(',');
const newFp = fingerprint(newItems);
const oldFp = fingerprint(playlist);
@ -1075,6 +1081,7 @@
layout = data.layout || null;
saveLayoutCache(layout);
playerTimezone = data.timezone || null; // #74/#75: effective tz for schedule eval
if (newFp === oldFp && playlist.length > 0 && !wallChanged) {
console.log('Playlist unchanged');
@ -1127,9 +1134,44 @@
}
if (nextIdx === -1) nextIdx = 0;
currentIndex = nextIdx;
isPlaying = true;
playCurrentItem();
startPlaybackAt(nextIdx);
}
// #74/#75: per-item schedule gate. No blocks = always on. Evaluated in the
// device's effective timezone via the shared evaluator. Never let a scheduling
// hiccup stop playback.
function scheduleAllows(item) {
if (!item || !item.schedules || !item.schedules.length) return true;
try { return window.ScheduleEval ? ScheduleEval.isItemActiveNow(item.schedules, Date.now(), playerTimezone) : true; }
catch (e) { return true; }
}
function nextActiveIndex(from) {
if (!playlist.length) return -1;
for (let i = 1; i <= playlist.length; i++) {
const idx = (from + i) % playlist.length;
if (scheduleAllows(playlist[idx])) return idx;
}
return -1;
}
// Every item filtered out: show the idle screen and re-check shortly (a daypart
// may begin). Re-evaluated at item boundaries otherwise, per the locked design.
function showNothingScheduled() {
teardownCurrentMedia();
showStatus(_t('nothing_scheduled'));
isPlaying = false;
clearTimeout(scheduleRetryTimer);
scheduleRetryTimer = setTimeout(() => {
const idx = nextActiveIndex(currentIndex);
if (idx !== -1) { currentIndex = idx; isPlaying = true; playCurrentItem(); }
else showNothingScheduled();
}, 30000);
}
function startPlaybackAt(idx) {
clearTimeout(scheduleRetryTimer);
if (scheduleAllows(playlist[idx])) { currentIndex = idx; isPlaying = true; playCurrentItem(); return; }
const a = nextActiveIndex(idx);
if (a !== -1) { currentIndex = a; isPlaying = true; playCurrentItem(); }
else { currentIndex = idx; showNothingScheduled(); }
}
function playCurrentItem() {
@ -1177,7 +1219,11 @@
});
}
currentIndex = (currentIndex + 1) % playlist.length;
// #74/#75: advance to the next item whose schedule allows it now (skip
// filtered items); idle if none are active.
const idx = nextActiveIndex(currentIndex);
if (idx === -1) { showNothingScheduled(); return; }
currentIndex = idx;
playCurrentItem();
}
@ -1479,6 +1525,20 @@
}
}
// #74/#75 zone-level schedule helpers.
function zoneNextActive(items, from) {
for (let i = 0; i < items.length; i++) {
const idx = (from + i) % items.length;
if (scheduleAllows(items[idx])) return idx;
}
return -1;
}
function showZoneEmpty(zone, div, items) {
div.querySelectorAll('video').forEach(v => { try { v.pause(); } catch (e) {} });
div.innerHTML = '';
zoneTimers[zone.id] = setTimeout(() => showZoneItem(zone, div, items, 0), 30000);
}
function renderZones(container, defaultItem) {
clearZoneTimers();
// Group assignments by zone, ordered by sort_order so each zone rotates its
@ -1513,8 +1573,14 @@
// loop / don't advance.
function showZoneItem(zone, div, items, index) {
if (zoneTimers[zone.id]) { clearTimeout(zoneTimers[zone.id]); delete zoneTimers[zone.id]; }
// #74/#75: skip items whose schedule excludes them now; idle the zone if none.
const activeIdx = zoneNextActive(items, index);
if (activeIdx === -1) { showZoneEmpty(zone, div, items); return; }
index = activeIdx;
const a = items[index % items.length];
const multi = items.length > 1;
// Scheduled zones must cycle (even a lone active item) so windows re-evaluate
// at each transition rather than a loop ignoring the window end.
const multi = items.length > 1 || items.some(it => it.schedules && it.schedules.length);
const advance = () => showZoneItem(zone, div, items, index + 1);
// Tear down any prior media in this zone before swapping.
div.querySelectorAll('video').forEach(v => { try { v.onended = null; v.pause(); v.removeAttribute('src'); v.load(); } catch (e) {} });

View file

@ -65,8 +65,8 @@ function requirePlaylistWrite(req, res, next) {
// Build the snapshot item list for a playlist (denormalized for device payload)
function buildSnapshotItems(playlistId) {
return db.prepare(`
SELECT pi.content_id, pi.widget_id, pi.zone_id, pi.sort_order, pi.duration_sec,
const items = db.prepare(`
SELECT pi.id AS _iid, pi.content_id, pi.widget_id, pi.zone_id, pi.sort_order, pi.duration_sec,
COALESCE(c.filename, w.name) as filename, c.mime_type, c.filepath, c.file_size,
c.duration_sec as content_duration, c.remote_url,
w.name as widget_name, w.widget_type, w.config as widget_config
@ -76,6 +76,29 @@ function buildSnapshotItems(playlistId) {
WHERE pi.playlist_id = ?
ORDER BY pi.sort_order ASC
`).all(playlistId);
// #74/#75: attach per-item schedule blocks (the player honours these in its own
// local time via the shared evaluator). An item with zero blocks gets no
// `schedules` field -> always on. Additive: old players ignore the field. _iid is
// only used here to fetch blocks and is then dropped (snapshot stays id-free).
for (const it of items) {
const blocks = schedulesForItem(it._iid);
if (blocks.length) it.schedules = blocks;
delete it._iid;
}
return items;
}
// Map an item's schedule rows into the evaluator's block shape.
function schedulesForItem(itemId) {
return db.prepare(
'SELECT active_days, start_time, end_time, start_date, end_date FROM playlist_item_schedules WHERE playlist_item_id = ? ORDER BY sort_order ASC, created_at ASC'
).all(itemId).map(r => ({
days: String(r.active_days || '').split(',').filter(s => s !== '').map(Number),
start: r.start_time,
end: r.end_time,
start_date: r.start_date || null,
end_date: r.end_date || null,
}));
}
// Mark playlist as draft (called after item mutations from the playlist detail UI)
@ -273,9 +296,52 @@ router.get('/:id/items', requirePlaylistRead, (req, res) => {
WHERE pi.playlist_id = ?
ORDER BY pi.sort_order ASC
`).all(req.params.id);
for (const it of items) it.schedules = schedulesForItem(it.id); // #74/#75: editor needs the blocks
res.json(items);
});
// --- Per-item schedule blocks (#74 dayparting + #75 expiry) ---
// Same permission as editing items (requirePlaylistWrite). Block shape mirrors the
// evaluator: { days:[0-6], start:"HH:MM", end:"HH:MM"|"24:00", start_date, end_date }.
const TIME_RE = /^([01]\d|2[0-3]):[0-5]\d$/;
const DATE_RE = /^\d{4}-\d{2}-\d{2}$/;
function validateBlocks(blocks) {
if (!Array.isArray(blocks)) return 'blocks must be an array';
for (const b of blocks) {
if (!b || typeof b !== 'object') return 'each block must be an object';
if (!Array.isArray(b.days) || b.days.length === 0 || !b.days.every(d => Number.isInteger(d) && d >= 0 && d <= 6)) return 'days must be a non-empty array of integers 0-6';
if (!TIME_RE.test(b.start)) return 'start must be HH:MM (00:00-23:59)';
if (!(TIME_RE.test(b.end) || b.end === '24:00')) return 'end must be HH:MM or 24:00';
for (const k of ['start_date', 'end_date']) if (b[k] != null && !DATE_RE.test(b[k])) return `${k} must be YYYY-MM-DD or null`;
}
return null;
}
function itemInPlaylist(itemId, playlistId) {
return db.prepare('SELECT id FROM playlist_items WHERE id = ? AND playlist_id = ?').get(itemId, playlistId);
}
router.get('/:id/items/:itemId/schedules', requirePlaylistRead, (req, res) => {
const item = itemInPlaylist(req.params.itemId, req.params.id);
if (!item) return res.status(404).json({ error: 'Item not found' });
res.json(schedulesForItem(item.id));
});
// Replace an item's schedule blocks wholesale ([] = no schedule = always on).
router.put('/:id/items/:itemId/schedules', requirePlaylistWrite, (req, res) => {
const item = itemInPlaylist(req.params.itemId, req.params.id);
if (!item) return res.status(404).json({ error: 'Item not found' });
const blocks = req.body.blocks;
const err = validateBlocks(blocks);
if (err) return res.status(400).json({ error: err });
const ins = db.prepare('INSERT INTO playlist_item_schedules (id, playlist_item_id, active_days, start_time, end_time, start_date, end_date, sort_order) VALUES (?,?,?,?,?,?,?,?)');
db.transaction(() => {
db.prepare('DELETE FROM playlist_item_schedules WHERE playlist_item_id = ?').run(item.id);
blocks.forEach((b, i) => ins.run(uuidv4(), item.id, b.days.join(','), b.start, b.end, b.start_date || null, b.end_date || null, i));
})();
markDraft(req.params.id); // schedule changes affect playback -> draft until re-published
res.json(schedulesForItem(item.id));
});
// Phase 2.2k: add item closes 2 pre-existing cross-tenant leaks:
// 1. Content gate: today checks content.user_id == caller. A workspace_admin
// who owns content in another workspace could push it into a playlist

View file

@ -0,0 +1,55 @@
#!/usr/bin/env node
// Local, headless demonstration of per-item scheduling (#74 dayparting + #75 expiry).
// node scripts/demo-schedule.js
//
// Builds a 3-item playlist and shows, using the REAL shared evaluator
// (server/lib/schedule-eval.js) and the same "next active item" rule the three
// players use, exactly which items rotate at four moments. No server or browser
// needed - this is the deterministic proof. Live web-player repro steps are printed
// at the end (and in the feature report).
const { isItemActiveNow } = require('../lib/schedule-eval');
const TZ = 'Australia/Sydney'; // Bold Media's zone; set as the device timezone override
// 'Yesterday' relative to the demo's reference day (Fri 2026-06-12 in TZ) is 06-11;
// the expired item ends 06-10 so it is dead on the 12th and 13th.
const playlist = [
{ id: 'A', label: 'Dayparted promo (Mon-Fri 09:00-17:00)', schedules: [{ days: [1, 2, 3, 4, 5], start: '09:00', end: '17:00', start_date: null, end_date: null }] },
{ id: 'B', label: 'Expired sale (ended 2026-06-10)', schedules: [{ days: [0, 1, 2, 3, 4, 5, 6], start: '00:00', end: '24:00', start_date: null, end_date: '2026-06-10' }] },
{ id: 'C', label: 'Filler (no schedule, always)', schedules: [] },
];
function rotationAt(utcIso) {
const active = playlist.filter(it => isItemActiveNow(it.schedules, utcIso, TZ));
return active.length ? active.map(it => it.id).join(' -> ') : '(idle: "Nothing scheduled right now")';
}
const scenarios = [
['INSIDE the daypart window (Fri 10:00 local)', '2026-06-12T00:00:00Z'],
['Window JUST opened (Fri 09:00 local)', '2026-06-11T23:00:00Z'],
['OUTSIDE the window (Fri 20:00 local)', '2026-06-12T10:00:00Z'],
['Weekend, window closed (Sat 10:00 local)', '2026-06-13T00:00:00Z'],
];
console.log('\n Per-item scheduling demo — device timezone = ' + TZ + '\n');
for (const it of playlist) console.log(' ' + it.id + ' ' + it.label);
console.log('\n ' + 'Moment'.padEnd(48) + 'Items that rotate');
console.log(' ' + '-'.repeat(48) + '-----------------');
for (const [label, utc] of scenarios) {
console.log(' ' + label.padEnd(48) + rotationAt(utc));
}
console.log('\n Notes:');
console.log(' - B (expired) never appears on any day after 2026-06-10. (#75)');
console.log(' - Inside the window: filler C + dayparted A rotate. Outside: C only. (#74)');
console.log(' - The players re-evaluate at each item boundary, and re-check every 30s');
console.log(' while idle, so A appears within 30s of 09:00 local — controllable on a');
console.log(' test screen via the device timezone override.\n');
console.log(' Live web-player repro:');
console.log(' 1. cd server && DATA_DIR=/tmp/st-demo SELF_HOSTED=true node server.js');
console.log(' 2. Dashboard -> create a playlist with 3 items (any content); on item A open the');
console.log(' clock icon and add a block for the next few minutes in your screen\'s local time,');
console.log(' on item B set an end date of yesterday, leave C unscheduled. Publish.');
console.log(' 3. Device detail -> set Timezone to ' + TZ + ' (or your zone) to control "local now".');
console.log(' 4. Open /player on the paired screen: B never shows; outside A\'s window only C');
console.log(' plays; within 30s of A\'s window opening, A joins the rotation.\n');

View file

@ -228,6 +228,14 @@ app.get(['/player', '/player/', '/player/index.html'], (req, res) => {
});
});
// #74/#75: serve the canonical schedule evaluator to the web player from the
// single source (server/lib/schedule-eval.js) so it can never drift from the
// server/Node-test copy. Registered before the static handler so it wins.
app.get('/player/schedule-eval.js', (req, res) => {
res.type('application/javascript').setHeader('Cache-Control', 'no-cache');
res.sendFile(path.join(__dirname, 'lib', 'schedule-eval.js'));
});
// Serve web player at /player (same no-cache for JS/HTML). The index.html
// route above intercepts the HTML requests; everything else still falls
// through to this static handler (debug-overlay.js, sw.js, manifest, etc).

View file

@ -1,4 +1,5 @@
const { db } = require('../db/database');
const { _localParts } = require('../lib/schedule-eval');
let io = null;
@ -36,7 +37,7 @@ function evaluateSchedules() {
s.created_at ASC
`).all(device.id, device.id);
const active = schedules.find(s => isScheduleActiveNow(s, now));
const active = schedules.find(s => isScheduleActiveNow(s, now, deviceTz(device)));
const override = activeOverrides.get(device.id);
let changed = false;
@ -65,28 +66,42 @@ function evaluateSchedules() {
}
}
function isScheduleActiveNow(schedule, now) {
const start = new Date(schedule.start_time);
const end = new Date(schedule.end_time);
// #74/#75 Part B: device-level schedules are evaluated in the DEVICE's effective
// timezone, not the server's. We reuse the canonical UTC->local conversion
// (_localParts from schedule-eval.js) - no second conversion path. start_time/end_time
// are stored as device-local wall-clock datetimes, so we compare them to a device-local
// "now". tz === null (no override AND no reported zone) falls back to the server clock,
// preserving the pre-existing behaviour for un-migrated / non-reporting devices.
function deviceTz(device) {
const override = (device.timezone && device.timezone !== 'UTC') ? device.timezone : null;
return override || device.reported_timezone || null;
}
function localStamp(parts) {
const p2 = (n) => (n < 10 ? '0' : '') + n;
const hh = Math.floor(parts.min / 60), mm = parts.min % 60;
return `${parts.y}-${p2(parts.mo)}-${p2(parts.day)}T${p2(hh)}:${p2(mm)}`;
}
function isScheduleActiveNow(schedule, now, tz) {
const L = _localParts(now, tz);
const nowStamp = localStamp(L); // device-local "YYYY-MM-DDTHH:MM"
const startStamp = String(schedule.start_time).slice(0, 16);
const endStamp = String(schedule.end_time).slice(0, 16);
if (!schedule.recurrence) {
return now >= start && now <= end;
return nowStamp >= startStamp && nowStamp <= endStamp;
}
// For recurring schedules, check if current time-of-day falls within range
// and current day matches recurrence pattern
const rule = parseSimpleRRule(schedule.recurrence);
if (!rule) return now >= start && now <= end;
if (!rule) return nowStamp >= startStamp && nowStamp <= endStamp;
// Check day of week
if (rule.byDay && !rule.byDay.includes(now.getDay())) return false;
// Day-of-week in the device's local zone.
if (rule.byDay && !rule.byDay.includes(L.dow)) return false;
// Check time of day
const nowMinutes = now.getHours() * 60 + now.getMinutes();
const startMinutes = start.getHours() * 60 + start.getMinutes();
const endMinutes = end.getHours() * 60 + end.getMinutes();
return nowMinutes >= startMinutes && nowMinutes <= endMinutes;
// Time-of-day window in the device's local zone (HH:MM string compare).
const nowHM = nowStamp.slice(11), startHM = startStamp.slice(11), endHM = endStamp.slice(11);
return nowHM >= startHM && nowHM <= endHM;
}
function parseSimpleRRule(rrule) {

View file

@ -0,0 +1,23 @@
// Drives the canonical evaluator against the shared conformance vectors
// (shared/schedule-vectors.json). The same file is consumed by the Kotlin JUnit
// suite and the Tizen-JS-under-Node test, so all three implementations are held to
// one contract.
const { test } = require('node:test');
const assert = require('node:assert');
const path = require('node:path');
const fs = require('node:fs');
const { isItemActiveNow } = require('../lib/schedule-eval');
const vectorsPath = path.join(__dirname, '..', '..', 'shared', 'schedule-vectors.json');
const data = JSON.parse(fs.readFileSync(vectorsPath, 'utf8'));
test('schedule evaluator conforms to every shared vector', () => {
const failures = [];
for (const v of data.vectors) {
const got = isItemActiveNow(v.blocks, v.utc_now, v.timezone);
if (got !== v.expected) failures.push(` [${v.utc_now} ${v.timezone}] expected ${v.expected} got ${got} :: ${v.description}`);
}
if (failures.length) console.error('\n' + failures.join('\n'));
console.log(`schedule vectors: ${data.vectors.length - failures.length}/${data.vectors.length} passed`);
assert.strictEqual(failures.length, 0, `${failures.length} vector(s) failed`);
});

View file

@ -0,0 +1,26 @@
// Drift guard (#74/#75): the Tizen player bundles the evaluator, and per the
// design directive it must be the BYTE-IDENTICAL canonical UMD (server/lib/
// schedule-eval.js), not a hand-port. This test (run by `npm test`, i.e. in CI)
// fails the moment tizen/js/schedule-eval.js diverges from the source, and also
// re-checks that the bundled copy still passes every shared vector.
const { test } = require('node:test');
const assert = require('node:assert');
const path = require('node:path');
const fs = require('node:fs');
const canonical = path.join(__dirname, '..', 'lib', 'schedule-eval.js');
const tizenCopy = path.join(__dirname, '..', '..', 'tizen', 'js', 'schedule-eval.js');
test('tizen evaluator is byte-identical to the canonical evaluator', () => {
assert.ok(fs.existsSync(tizenCopy), `tizen copy missing: ${tizenCopy}`);
const a = fs.readFileSync(canonical);
const b = fs.readFileSync(tizenCopy);
assert.ok(a.equals(b), 'tizen/js/schedule-eval.js has drifted from server/lib/schedule-eval.js — re-copy it (the .wgt build does this automatically)');
});
test('bundled tizen evaluator passes every shared vector', () => {
const { isItemActiveNow } = require(tizenCopy);
const data = JSON.parse(fs.readFileSync(path.join(__dirname, '..', '..', 'shared', 'schedule-vectors.json'), 'utf8'));
const failures = data.vectors.filter(v => isItemActiveNow(v.blocks, v.utc_now, v.timezone) !== v.expected);
assert.strictEqual(failures.length, 0, `${failures.length} vector(s) failed in the tizen copy`);
});

View file

@ -75,7 +75,7 @@ function logDeviceStatus(deviceId, status) {
// Build playlist payload with layout and zones
// Reads from published_snapshot (Phase 3) so draft edits don't affect live devices
function buildPlaylistPayload(deviceId) {
const device = db.prepare('SELECT playlist_id, layout_id, orientation, wall_id FROM devices WHERE id = ?').get(deviceId);
const device = db.prepare('SELECT playlist_id, layout_id, orientation, wall_id, timezone, reported_timezone FROM devices WHERE id = ?').get(deviceId);
let assignments = [];
if (device?.playlist_id) {
@ -164,7 +164,12 @@ function buildPlaylistPayload(deviceId) {
assignments = assignments.map(a => (a && a.zone_id != null ? { ...a, zone_id: null } : a));
}
return { assignments, layout, orientation: device?.orientation || 'landscape', wall_config };
// #74/#75: the effective IANA timezone the player evaluates schedule blocks in.
// An explicit (non-default) devices.timezone override wins; otherwise the player's
// last OS-reported zone; otherwise null = the player trusts its own OS clock.
const tzOverride = (device?.timezone && device.timezone !== 'UTC') ? device.timezone : null;
const timezone = tzOverride || device?.reported_timezone || null;
return { assignments, layout, orientation: device?.orientation || 'landscape', wall_config, timezone };
}
// Check if a device should show trial expired screen
@ -485,6 +490,13 @@ module.exports = function setupDeviceSocket(io) {
);
pruneTelemetry(device_id);
// #74/#75: capture the player's reported clock (OS IANA zone + its UTC time)
// for effective-timezone resolution and the dashboard clock-skew indicator.
if (telemetry.timezone || telemetry.device_utc != null) {
db.prepare("UPDATE devices SET reported_timezone = COALESCE(?, reported_timezone), reported_utc = ?, reported_at = strftime('%s','now') WHERE id = ?")
.run(telemetry.timezone || null, telemetry.device_utc ?? null, device_id);
}
emitToDeviceWorkspace(dashboardNs, device_id, 'dashboard:device-status', {
device_id,
status: 'online',

View file

@ -0,0 +1,52 @@
{
"_about": "Conformance vectors for per-playlist-item schedule evaluation. The contract: every implementation (JS server/web, Kotlin, Tizen JS) must agree with every vector. If an implementation disagrees, the implementation is wrong.",
"_conventions": {
"days": "active days as a set of integers 0-6, where 0=Sunday .. 6=Saturday (matches JS Date.getDay()).",
"time": "start/end are local wall-clock HH:MM (24h). A window is [start, end): start inclusive, end exclusive (so adjacent dayparts 07:00-11:00 and 11:00-14:00 do not overlap at 11:00). end may be \"24:00\" meaning end-of-day (whole day when paired with 00:00).",
"overnight": "if start > end the window crosses midnight: active [start,24:00) on the start day and [00:00,end) on the next day. The day-of-week AND date-range tests apply to the day the window STARTED (a Fri 22:00-02:00 block is active Sat 01:00 because it anchors to Friday).",
"dates": "start_date/end_date are local YYYY-MM-DD, inclusive on both ends, evaluated against the device-LOCAL calendar date. null start_date = no lower bound; null end_date = no upper bound.",
"block_match": "within a block, day AND date AND time must all pass. blocks OR together. >=1 matching block = active. zero blocks = always active.",
"evaluation": "take utc_now, convert to device-local via the IANA timezone, then test the wall-clock block(s)."
},
"vectors": [
{ "description": "plain daytime window, inside (Syd Fri 08:00 in Mon-Fri 07:00-11:00)", "utc_now": "2026-06-11T22:00:00Z", "timezone": "Australia/Sydney", "blocks": [{ "days": [1,2,3,4,5], "start": "07:00", "end": "11:00", "start_date": null, "end_date": null }], "expected": true },
{ "description": "plain daytime window, outside after end (Syd Thu 12:00)", "utc_now": "2026-06-11T02:00:00Z", "timezone": "Australia/Sydney", "blocks": [{ "days": [1,2,3,4,5], "start": "07:00", "end": "11:00", "start_date": null, "end_date": null }], "expected": false },
{ "description": "time start is INCLUSIVE (Syd Fri 07:00 exactly)", "utc_now": "2026-06-11T21:00:00Z", "timezone": "Australia/Sydney", "blocks": [{ "days": [1,2,3,4,5], "start": "07:00", "end": "11:00", "start_date": null, "end_date": null }], "expected": true },
{ "description": "time end is EXCLUSIVE (Syd Fri 11:00 exactly -> out)", "utc_now": "2026-06-12T01:00:00Z", "timezone": "Australia/Sydney", "blocks": [{ "days": [1,2,3,4,5], "start": "07:00", "end": "11:00", "start_date": null, "end_date": null }], "expected": false },
{ "description": "overnight wrap, before midnight on the start day (Syd Fri 22:30, Fri 22:00-02:00)", "utc_now": "2026-06-12T12:30:00Z", "timezone": "Australia/Sydney", "blocks": [{ "days": [5], "start": "22:00", "end": "02:00", "start_date": null, "end_date": null }], "expected": true },
{ "description": "overnight wrap, after midnight anchored to start day (Syd Sat 01:30 belongs to Fri block)", "utc_now": "2026-06-12T15:30:00Z", "timezone": "Australia/Sydney", "blocks": [{ "days": [5], "start": "22:00", "end": "02:00", "start_date": null, "end_date": null }], "expected": true },
{ "description": "overnight wrap, after midnight but anchor day (yesterday=Sat) not active (Syd Sun 01:30, block Fri)", "utc_now": "2026-06-13T15:30:00Z", "timezone": "Australia/Sydney", "blocks": [{ "days": [5], "start": "22:00", "end": "02:00", "start_date": null, "end_date": null }], "expected": false },
{ "description": "overnight wrap, end is exclusive at 02:00 (Syd Sat 02:00 exactly -> out)", "utc_now": "2026-06-12T16:00:00Z", "timezone": "Australia/Sydney", "blocks": [{ "days": [5], "start": "22:00", "end": "02:00", "start_date": null, "end_date": null }], "expected": false },
{ "description": "overnight wrap, before midnight on a non-active day (Syd Thu 22:30, block Fri)", "utc_now": "2026-06-11T12:30:00Z", "timezone": "Australia/Sydney", "blocks": [{ "days": [5], "start": "22:00", "end": "02:00", "start_date": null, "end_date": null }], "expected": false },
{ "description": "date range: day before start_date (Syd local 06-11, range 06-12..06-14)", "utc_now": "2026-06-11T02:00:00Z", "timezone": "Australia/Sydney", "blocks": [{ "days": [0,1,2,3,4,5,6], "start": "00:00", "end": "24:00", "start_date": "2026-06-12", "end_date": "2026-06-14" }], "expected": false },
{ "description": "date range: on start_date inclusive (Syd local 06-12)", "utc_now": "2026-06-12T02:00:00Z", "timezone": "Australia/Sydney", "blocks": [{ "days": [0,1,2,3,4,5,6], "start": "00:00", "end": "24:00", "start_date": "2026-06-12", "end_date": "2026-06-14" }], "expected": true },
{ "description": "date range: on end_date inclusive (Syd local 06-14)", "utc_now": "2026-06-14T02:00:00Z", "timezone": "Australia/Sydney", "blocks": [{ "days": [0,1,2,3,4,5,6], "start": "00:00", "end": "24:00", "start_date": "2026-06-12", "end_date": "2026-06-14" }], "expected": true },
{ "description": "date range: day after end_date (Syd local 06-15)", "utc_now": "2026-06-15T02:00:00Z", "timezone": "Australia/Sydney", "blocks": [{ "days": [0,1,2,3,4,5,6], "start": "00:00", "end": "24:00", "start_date": "2026-06-12", "end_date": "2026-06-14" }], "expected": false },
{ "description": "null start_date = no lower bound (Syd local 2026-01-01, end 06-14)", "utc_now": "2026-01-01T01:00:00Z", "timezone": "Australia/Sydney", "blocks": [{ "days": [0,1,2,3,4,5,6], "start": "00:00", "end": "24:00", "start_date": null, "end_date": "2026-06-14" }], "expected": true },
{ "description": "null end_date = no upper bound (Syd local 2026-12-31, start 06-12)", "utc_now": "2026-12-31T01:00:00Z", "timezone": "Australia/Sydney", "blocks": [{ "days": [0,1,2,3,4,5,6], "start": "00:00", "end": "24:00", "start_date": "2026-06-12", "end_date": null }], "expected": true },
{ "description": "both dates null = unbounded (always, given day/time ok)", "utc_now": "2026-06-11T02:00:00Z", "timezone": "Australia/Sydney", "blocks": [{ "days": [0,1,2,3,4,5,6], "start": "00:00", "end": "24:00", "start_date": null, "end_date": null }], "expected": true },
{ "description": "date in range but wrong weekday (Syd Fri 06-12, block Mon only)", "utc_now": "2026-06-12T02:00:00Z", "timezone": "Australia/Sydney", "blocks": [{ "days": [1], "start": "00:00", "end": "24:00", "start_date": "2026-06-12", "end_date": "2026-06-14" }], "expected": false },
{ "description": "right weekday but date out of range (Syd Fri 06-19, block Fri but range 06-12..06-12)", "utc_now": "2026-06-19T02:00:00Z", "timezone": "Australia/Sydney", "blocks": [{ "days": [5], "start": "00:00", "end": "24:00", "start_date": "2026-06-12", "end_date": "2026-06-12" }], "expected": false },
{ "description": "multiple blocks OR: second block matches (Syd Sat 10:00)", "utc_now": "2026-06-13T00:00:00Z", "timezone": "Australia/Sydney", "blocks": [{ "days": [1,2,3,4,5], "start": "07:00", "end": "11:00", "start_date": null, "end_date": null }, { "days": [0,6], "start": "09:00", "end": "13:00", "start_date": null, "end_date": null }], "expected": true },
{ "description": "multiple blocks OR: none match (Syd Sat 14:00)", "utc_now": "2026-06-13T04:00:00Z", "timezone": "Australia/Sydney", "blocks": [{ "days": [1,2,3,4,5], "start": "07:00", "end": "11:00", "start_date": null, "end_date": null }, { "days": [0,6], "start": "09:00", "end": "13:00", "start_date": null, "end_date": null }], "expected": false },
{ "description": "zero blocks = always on", "utc_now": "2026-06-11T02:00:00Z", "timezone": "Australia/Sydney", "blocks": [], "expected": true },
{ "description": "tz correctness: same UTC, Sydney -> 09:00 in window", "utc_now": "2026-06-11T23:00:00Z", "timezone": "Australia/Sydney", "blocks": [{ "days": [0,1,2,3,4,5,6], "start": "09:00", "end": "17:00", "start_date": null, "end_date": null }], "expected": true },
{ "description": "tz correctness: same UTC, Berlin -> 01:00 out of window", "utc_now": "2026-06-11T23:00:00Z", "timezone": "Europe/Berlin", "blocks": [{ "days": [0,1,2,3,4,5,6], "start": "09:00", "end": "17:00", "start_date": null, "end_date": null }], "expected": false },
{ "description": "tz correctness: same UTC, Chicago -> 18:00 out of window", "utc_now": "2026-06-11T23:00:00Z", "timezone": "America/Chicago", "blocks": [{ "days": [0,1,2,3,4,5,6], "start": "09:00", "end": "17:00", "start_date": null, "end_date": null }], "expected": false },
{ "description": "tz correctness: different UTC, Chicago -> 11:00 in window", "utc_now": "2026-06-11T16:00:00Z", "timezone": "America/Chicago", "blocks": [{ "days": [0,1,2,3,4,5,6], "start": "09:00", "end": "17:00", "start_date": null, "end_date": null }], "expected": true },
{ "description": "tz correctness: different UTC, Berlin -> 18:00 out of window", "utc_now": "2026-06-11T16:00:00Z", "timezone": "Europe/Berlin", "blocks": [{ "days": [0,1,2,3,4,5,6], "start": "09:00", "end": "17:00", "start_date": null, "end_date": null }], "expected": false },
{ "description": "tz correctness: different UTC, Sydney -> 02:00 out of window", "utc_now": "2026-06-11T16:00:00Z", "timezone": "Australia/Sydney", "blocks": [{ "days": [0,1,2,3,4,5,6], "start": "09:00", "end": "17:00", "start_date": null, "end_date": null }], "expected": false },
{ "description": "DST US spring-forward: after the gap, local 03:00 CDT in 03:00-04:00", "utc_now": "2026-03-08T08:00:00Z", "timezone": "America/Chicago", "blocks": [{ "days": [0,1,2,3,4,5,6], "start": "03:00", "end": "04:00", "start_date": null, "end_date": null }], "expected": true },
{ "description": "DST US spring-forward: before the gap, local 01:30 CST out of 03:00-04:00", "utc_now": "2026-03-08T07:30:00Z", "timezone": "America/Chicago", "blocks": [{ "days": [0,1,2,3,4,5,6], "start": "03:00", "end": "04:00", "start_date": null, "end_date": null }], "expected": false },
{ "description": "DST US spring-forward: window inside the 02:00-03:00 hole is unreachable (local 03:00)", "utc_now": "2026-03-08T08:00:00Z", "timezone": "America/Chicago", "blocks": [{ "days": [0,1,2,3,4,5,6], "start": "02:15", "end": "02:45", "start_date": null, "end_date": null }], "expected": false },
{ "description": "DST US fall-back: first 01:30 (CDT) in 01:00-02:00", "utc_now": "2026-11-01T06:30:00Z", "timezone": "America/Chicago", "blocks": [{ "days": [0,1,2,3,4,5,6], "start": "01:00", "end": "02:00", "start_date": null, "end_date": null }], "expected": true },
{ "description": "DST US fall-back: repeated 01:30 (CST) still in 01:00-02:00", "utc_now": "2026-11-01T07:30:00Z", "timezone": "America/Chicago", "blocks": [{ "days": [0,1,2,3,4,5,6], "start": "01:00", "end": "02:00", "start_date": null, "end_date": null }], "expected": true },
{ "description": "DST US fall-back: after the repeated hour, local 02:30 out of 01:00-02:00", "utc_now": "2026-11-01T08:30:00Z", "timezone": "America/Chicago", "blocks": [{ "days": [0,1,2,3,4,5,6], "start": "01:00", "end": "02:00", "start_date": null, "end_date": null }], "expected": false },
{ "description": "DST AU spring-forward: after the gap, Sydney local 03:30 AEDT in 03:00-04:00", "utc_now": "2026-10-03T16:30:00Z", "timezone": "Australia/Sydney", "blocks": [{ "days": [0,1,2,3,4,5,6], "start": "03:00", "end": "04:00", "start_date": null, "end_date": null }], "expected": true },
{ "description": "DST AU spring-forward: before the gap, Sydney local 01:30 AEST out of 03:00-04:00", "utc_now": "2026-10-03T15:30:00Z", "timezone": "Australia/Sydney", "blocks": [{ "days": [0,1,2,3,4,5,6], "start": "03:00", "end": "04:00", "start_date": null, "end_date": null }], "expected": false },
{ "description": "DST AU fall-back: first 02:30 (AEDT) in 02:00-03:00", "utc_now": "2026-04-04T15:30:00Z", "timezone": "Australia/Sydney", "blocks": [{ "days": [0,1,2,3,4,5,6], "start": "02:00", "end": "03:00", "start_date": null, "end_date": null }], "expected": true },
{ "description": "DST AU fall-back: repeated 02:30 (AEST) still in 02:00-03:00", "utc_now": "2026-04-04T16:30:00Z", "timezone": "Australia/Sydney", "blocks": [{ "days": [0,1,2,3,4,5,6], "start": "02:00", "end": "03:00", "start_date": null, "end_date": null }], "expected": true },
{ "description": "device-local date AFTER utc date (UTC 06-11 15:00 = Syd 06-12 01:00); date range tests local date", "utc_now": "2026-06-11T15:00:00Z", "timezone": "Australia/Sydney", "blocks": [{ "days": [0,1,2,3,4,5,6], "start": "00:00", "end": "06:00", "start_date": "2026-06-12", "end_date": "2026-06-12" }], "expected": true },
{ "description": "device-local date BEFORE utc date (UTC 06-12 03:00 = Chicago 06-11 22:00); date range tests local date", "utc_now": "2026-06-12T03:00:00Z", "timezone": "America/Chicago", "blocks": [{ "days": [0,1,2,3,4,5,6], "start": "20:00", "end": "23:00", "start_date": "2026-06-11", "end_date": "2026-06-11" }], "expected": true }
]
}

View file

@ -14,6 +14,10 @@ FILES="config.xml index.html icon.png css js"
[ -d "$HOME/tizen-studio/tools/ide/bin" ] && export PATH="$HOME/tizen-studio/tools/ide/bin:$PATH"
rm -f "$OUT"
# #74/#75: refresh the bundled schedule evaluator from the single source so the
# .wgt always ships the canonical (byte-identical) copy, never a stale duplicate.
cp ../server/lib/schedule-eval.js js/schedule-eval.js
if command -v tizen >/dev/null 2>&1; then
PROFILE="${1:-ScreenTinker}"
echo "Tizen CLI found — signing with profile '$PROFILE'…"

View file

@ -39,6 +39,7 @@
<div id="toast" class="toast hidden"></div>
<script src="js/socket.io.min.js"></script>
<script src="js/schedule-eval.js"></script>
<script src="js/player.js"></script>
<script src="js/app.js"></script>
</body>

View file

@ -95,6 +95,9 @@
function telemetry() {
var t = { uptime_seconds: Math.floor(performance.now() / 1000) };
// #74/#75: OS timezone + UTC clock (effective-tz resolution + skew indicator)
try { t.timezone = Intl.DateTimeFormat().resolvedOptions().timeZone || null; } catch (e) { t.timezone = null; }
t.device_utc = Date.now();
try {
tizen.systeminfo.getPropertyValue('BATTERY', function (b) {
t.battery_level = Math.round((b.level || 0) * 100);
@ -227,6 +230,7 @@
// If we have content + we're paired, make sure we're on the stage.
if (elPairing.classList.contains('hidden') === false) show(elStage);
else if (elStage.classList.contains('hidden')) show(elStage);
player.setTimezone(payload.timezone || null); // #74/#75: effective tz for schedule eval
player.load(payload.assignments || []);
}

View file

@ -7,6 +7,17 @@
* widget -> iframe of {server}/api/widgets/{id}/render for duration_sec
* Content file URL: {server}/api/content/{content_id}/file (public)
*/
// Minimal i18n for the Tizen player (no shared i18n module here). Falls back to en.
var TIZEN_I18N = {
en: { nothing_scheduled: 'Nothing scheduled right now', no_content: 'No content assigned yet' },
es: { nothing_scheduled: 'No hay nada programado en este momento', no_content: 'Aún no hay contenido asignado' },
fr: { nothing_scheduled: 'Rien de programmé pour le moment', no_content: 'Aucun contenu attribué pour linstant' },
de: { nothing_scheduled: 'Derzeit ist nichts geplant', no_content: 'Noch kein Inhalt zugewiesen' },
pt: { nothing_scheduled: 'Nada programado no momento', no_content: 'Nenhum conteúdo atribuído ainda' }
};
var TZ_LANG = (function () { try { return (localStorage.getItem('rd_lang') || navigator.language || 'en').split('-')[0]; } catch (e) { return 'en'; } })();
function tzt(k) { return (TIZEN_I18N[TZ_LANG] && TIZEN_I18N[TZ_LANG][k]) || TIZEN_I18N.en[k] || k; }
function PlaylistPlayer(stageEl, getBase) {
this.stage = stageEl;
this.getBase = getBase;
@ -14,6 +25,7 @@ function PlaylistPlayer(stageEl, getBase) {
this.index = 0;
this.timer = null;
this.sig = '';
this.timezone = null; // #74/#75: device-effective IANA tz for schedule eval
this.DEFAULT_DURATION = 10;
this.MIN_DURATION = 3;
}
@ -26,14 +38,15 @@ PlaylistPlayer.prototype.load = function (assignments) {
items.sort(function (a, b) { return (a.sort_order || 0) - (b.sort_order || 0); });
var sig = JSON.stringify(items.map(function (a) {
return [a.content_id, a.widget_id, a.remote_url, a.duration_sec, a.mime_type];
// #74/#75: include schedules so a schedule edit (same content) re-renders.
return [a.content_id, a.widget_id, a.remote_url, a.duration_sec, a.mime_type, a.schedules || []];
}));
if (sig === this.sig && this.items.length) return; // unchanged, keep playing
this.sig = sig;
this.items = items;
this.index = 0;
this.playCurrent();
this.startPlayback();
};
PlaylistPlayer.prototype.stop = function () {
@ -52,7 +65,7 @@ PlaylistPlayer.prototype.idle = function () {
this.clearStage();
this.stage.innerHTML =
'<div class="card" style="position:relative"><h1>ScreenTinker</h1>' +
'<p class="sub">No content assigned yet</p></div>';
'<p class="sub">' + tzt('no_content') + '</p></div>';
};
PlaylistPlayer.prototype.durationMs = function (item) {
@ -69,7 +82,10 @@ PlaylistPlayer.prototype.contentUrl = function (item) {
PlaylistPlayer.prototype.advance = function () {
if (!this.items.length) return;
this.index = (this.index + 1) % this.items.length;
// #74/#75: advance to the next schedule-active item; idle if none.
var idx = this.nextActiveIndex(this.index);
if (idx < 0) { this.nothingScheduled(); return; }
this.index = idx;
this.playCurrent();
};
@ -79,12 +95,65 @@ PlaylistPlayer.prototype.schedule = function (ms) {
this.timer = setTimeout(function () { self.advance(); }, ms);
};
// #74/#75: per-item schedule gating (mirrors the web/Android players). No blocks =
// always on. Fails open: any evaluator error means the item plays.
PlaylistPlayer.prototype.setTimezone = function (tz) { this.timezone = tz || null; };
PlaylistPlayer.prototype.scheduleAllows = function (item) {
if (!item || !item.schedules || !item.schedules.length) return true;
try {
return (typeof ScheduleEval !== 'undefined')
? ScheduleEval.isItemActiveNow(item.schedules, Date.now(), this.timezone) : true;
} catch (e) { return true; }
};
PlaylistPlayer.prototype.anyScheduled = function () {
for (var i = 0; i < this.items.length; i++) {
if (this.items[i].schedules && this.items[i].schedules.length) return true;
}
return false;
};
PlaylistPlayer.prototype.firstActiveIndex = function () {
for (var i = 0; i < this.items.length; i++) if (this.scheduleAllows(this.items[i])) return i;
return -1;
};
PlaylistPlayer.prototype.nextActiveIndex = function (from) {
if (!this.items.length) return -1;
for (var i = 1; i <= this.items.length; i++) {
var idx = (from + i) % this.items.length;
if (this.scheduleAllows(this.items[idx])) return idx;
}
return -1;
};
PlaylistPlayer.prototype.startPlayback = function () {
if (!this.items.length) { this.idle(); return; }
var idx = this.firstActiveIndex();
if (idx < 0) { this.nothingScheduled(); return; }
this.index = idx;
this.playCurrent();
};
// Every item filtered out: idle and re-check shortly (a daypart may open).
PlaylistPlayer.prototype.nothingScheduled = function () {
if (this.timer) { clearTimeout(this.timer); this.timer = null; }
this.clearStage();
this.stage.innerHTML =
'<div class="card" style="position:relative"><h1>ScreenTinker</h1>' +
'<p class="sub">' + tzt('nothing_scheduled') + '</p></div>';
var self = this;
this.timer = setTimeout(function () { self.startPlayback(); }, 30000);
};
PlaylistPlayer.prototype.playCurrent = function () {
if (this.timer) { clearTimeout(this.timer); this.timer = null; }
if (!this.items.length) { this.idle(); return; }
var item = this.items[this.index];
var single = this.items.length === 1;
// Scheduled playlists cycle even with one active item so windows re-evaluate.
var single = this.items.length === 1 && !this.anyScheduled();
var mime = item.mime_type || '';
this.clearStage();

100
tizen/js/schedule-eval.js Normal file
View file

@ -0,0 +1,100 @@
// Canonical per-playlist-item schedule evaluator (#74 dayparting + #75 expiry).
//
// CONTRACT: shared/schedule-vectors.json. The JS server, the web player, and the
// Tizen player all consume this exact module; the Android (Kotlin) port must agree
// with the same vectors. If an implementation disagrees with a vector, the
// implementation is wrong.
//
// Time model: instants are UTC; schedule blocks are LOCAL wall-clock rules. We take
// utc_now, convert to device-local wall-clock via the device's IANA timezone (DST
// handled by Intl), then test the block(s). Blocks are never stored/transmitted in
// UTC - that would break across DST and zone changes.
//
// Block = { days:[0-6 (0=Sun)], start:"HH:MM", end:"HH:MM"|"24:00",
// start_date:"YYYY-MM-DD"|null, end_date:"YYYY-MM-DD"|null }
// - within a block: day AND date AND time must all pass
// - blocks OR together; >=1 match = active
// - zero blocks = always active (this is the "no schedule = always plays" fallback)
// - time window is [start, end): start inclusive, end exclusive ("24:00" = end of day)
// - start > end means the window crosses midnight; the day/date test anchors to the
// day the window STARTED (a Fri 22:00-02:00 block is active Sat 01:00).
//
// Dependency-free UMD: Node (require) + browser/Tizen (window.ScheduleEval).
(function (root, factory) {
if (typeof module === 'object' && module.exports) module.exports = factory();
else root.ScheduleEval = factory();
})(typeof self !== 'undefined' ? self : this, function () {
'use strict';
var DOW = { Sun: 0, Mon: 1, Tue: 2, Wed: 3, Thu: 4, Fri: 5, Sat: 6 };
// UTC instant -> device-local {y, mo(1-12), day, dow(0-6), min(0-1439)}.
// ianaTz falsy -> trust the runtime's own local clock as-is (the device's OS time).
function localParts(utcNow, ianaTz) {
var d = (utcNow instanceof Date) ? utcNow : new Date(utcNow);
if (!ianaTz) {
return { y: d.getFullYear(), mo: d.getMonth() + 1, day: d.getDate(), dow: d.getDay(), min: d.getHours() * 60 + d.getMinutes() };
}
var fmt = new Intl.DateTimeFormat('en-US', {
timeZone: ianaTz, year: 'numeric', month: '2-digit', day: '2-digit',
hour: '2-digit', minute: '2-digit', hourCycle: 'h23', weekday: 'short'
});
var p = {}, parts = fmt.formatToParts(d);
for (var i = 0; i < parts.length; i++) p[parts[i].type] = parts[i].value;
var hh = parseInt(p.hour, 10) % 24; // h23 yields 00-23; guard against env quirks
return { y: +p.year, mo: +p.month, day: +p.day, dow: DOW[p.weekday], min: hh * 60 + (+p.minute) };
}
function hm(s) { var a = String(s).split(':'); return (+a[0]) * 60 + (+a[1]); } // "24:00" -> 1440
function ymd(y, mo, day) { function p2(n) { return (n < 10 ? '0' : '') + n; } return y + '-' + p2(mo) + '-' + p2(day); }
// Pure calendar arithmetic (UTC Date used only for date math, never time/DST).
function addDays(y, mo, day, delta) {
var d = new Date(Date.UTC(y, mo - 1, day));
d.setUTCDate(d.getUTCDate() + delta);
return { y: d.getUTCFullYear(), mo: d.getUTCMonth() + 1, day: d.getUTCDate() };
}
function dayOk(dow, days) {
if (!days || !days.length) return false;
for (var i = 0; i < days.length; i++) if (days[i] === dow) return true;
return false;
}
function dateOk(dateStr, startDate, endDate) {
if (startDate && dateStr < startDate) return false; // ISO YYYY-MM-DD sorts lexicographically
if (endDate && dateStr > endDate) return false; // inclusive on both ends
return true;
}
function blockMatches(b, L) {
var s = hm(b.start), e = hm(b.end), now = L.min;
if (s <= e) {
// same-day window [s, e), anchored to today
if (now < s || now >= e) return false;
return dayOk(L.dow, b.days) && dateOk(ymd(L.y, L.mo, L.day), b.start_date, b.end_date);
}
// overnight wrap
if (now >= s) {
// before-midnight portion: anchor = today
return dayOk(L.dow, b.days) && dateOk(ymd(L.y, L.mo, L.day), b.start_date, b.end_date);
}
if (now < e) {
// after-midnight portion: anchor = the day it started = yesterday (device-local)
var y = addDays(L.y, L.mo, L.day, -1);
return dayOk((L.dow + 6) % 7, b.days) && dateOk(ymd(y.y, y.mo, y.day), b.start_date, b.end_date);
}
return false;
}
function isItemActiveNow(blocks, utcNow, ianaTz) {
if (!blocks || blocks.length === 0) return true; // no schedule = always on
var L = localParts(utcNow, ianaTz);
for (var i = 0; i < blocks.length; i++) if (blockMatches(blocks[i], L)) return true;
return false;
}
return { isItemActiveNow: isItemActiveNow, _localParts: localParts, _blockMatches: blockMatches };
});