mirror of
https://github.com/screentinker/screentinker.git
synced 2026-06-21 05:32:34 -06:00
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:
parent
c8e664e66c
commit
2ccf3264a9
17
.github/workflows/ci.yml
vendored
17
.github/workflows/ci.yml
vendored
|
|
@ -33,6 +33,23 @@ jobs:
|
||||||
- run: npm ci
|
- run: npm ci
|
||||||
- run: npm test
|
- 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:
|
smoke:
|
||||||
name: Boot smoke + version check
|
name: Boot smoke + version check
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
|
|
||||||
50
CHANGELOG.md
Normal file
50
CHANGELOG.md
Normal 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:00–02: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.
|
||||||
|
|
@ -75,4 +75,15 @@ dependencies {
|
||||||
|
|
||||||
// Coroutines
|
// Coroutines
|
||||||
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.3")
|
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)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -137,8 +137,10 @@ class MainActivity : AppCompatActivity() {
|
||||||
// Setup playlist controller
|
// Setup playlist controller
|
||||||
playlistController = PlaylistController(
|
playlistController = PlaylistController(
|
||||||
onItemChanged = { item -> item?.let { playItem(it) } },
|
onItemChanged = { item -> item?.let { playItem(it) } },
|
||||||
onPlaylistEmpty = { showStatus("Waiting for content...") },
|
// #74/#75: clear the last frame when going idle (else a now-filtered item lingers on screen)
|
||||||
onRequestRefresh = { wsService?.requestPlaylistRefresh() }
|
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
|
// Setup media player
|
||||||
|
|
@ -166,6 +168,8 @@ class MainActivity : AppCompatActivity() {
|
||||||
val assignments = cached.getJSONArray("assignments")
|
val assignments = cached.getJSONArray("assignments")
|
||||||
if (assignments.length() > 0) {
|
if (assignments.length() > 0) {
|
||||||
Log.i("MainActivity", "Restoring cached playlist: ${assignments.length()} items")
|
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.updatePlaylist(assignments)
|
||||||
playlistController.startIfNeeded()
|
playlistController.startIfNeeded()
|
||||||
}
|
}
|
||||||
|
|
@ -245,6 +249,11 @@ class MainActivity : AppCompatActivity() {
|
||||||
|
|
||||||
val assignments = data.getJSONArray("assignments")
|
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
|
// Cache playlist JSON for offline cold-start
|
||||||
config.cachedPlaylist = data.toString()
|
config.cachedPlaylist = data.toString()
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -19,7 +19,8 @@ data class PlaylistItem(
|
||||||
val remoteUrl: String? = null,
|
val remoteUrl: String? = null,
|
||||||
val muted: Boolean = false,
|
val muted: Boolean = false,
|
||||||
val widgetId: String? = null,
|
val widgetId: String? = null,
|
||||||
val widgetType: String? = null
|
val widgetType: String? = null,
|
||||||
|
val schedules: List<ScheduleEval.Block> = emptyList()
|
||||||
) {
|
) {
|
||||||
val isRemote: Boolean get() = !remoteUrl.isNullOrEmpty()
|
val isRemote: Boolean get() = !remoteUrl.isNullOrEmpty()
|
||||||
// Widget assignments have a widget_id and no downloadable content file.
|
// Widget assignments have a widget_id and no downloadable content file.
|
||||||
|
|
@ -29,16 +30,23 @@ data class PlaylistItem(
|
||||||
class PlaylistController(
|
class PlaylistController(
|
||||||
private val onItemChanged: (PlaylistItem?) -> Unit,
|
private val onItemChanged: (PlaylistItem?) -> Unit,
|
||||||
private val onPlaylistEmpty: () -> 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 val items = mutableListOf<PlaylistItem>()
|
||||||
private var currentIndex = -1
|
private var currentIndex = -1
|
||||||
private val handler = Handler(Looper.getMainLooper())
|
private val handler = Handler(Looper.getMainLooper())
|
||||||
private var advanceRunnable: Runnable? = null
|
private var advanceRunnable: Runnable? = null
|
||||||
private var isRunning = false
|
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
|
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?
|
val currentItem: PlaylistItem?
|
||||||
get() = if (currentIndex in items.indices) items[currentIndex] else null
|
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 },
|
remoteUrl = if (obj.isNull("remote_url")) null else obj.optString("remote_url", "").ifEmpty { null },
|
||||||
muted = obj.optInt("muted", 0) == 1,
|
muted = obj.optInt("muted", 0) == 1,
|
||||||
widgetId = if (obj.isNull("widget_id")) null else obj.optString("widget_id", "").ifEmpty { null },
|
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
|
// Check if playlist actually changed (key on content OR widget id, since
|
||||||
// widget items share an empty contentId).
|
// widget items share an empty contentId).
|
||||||
val oldContentIds = items.map { it.contentId + "|" + (it.widgetId ?: "") }
|
// #74/#75: a schedule edit changes playback even when content is identical, so
|
||||||
val newContentIds = newItems.map { it.contentId + "|" + (it.widgetId ?: "") }
|
// 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
|
val playlistChanged = oldContentIds != newContentIds
|
||||||
|
|
||||||
if (!playlistChanged && items.isNotEmpty()) {
|
if (!playlistChanged && items.isNotEmpty()) {
|
||||||
|
|
@ -106,9 +121,10 @@ class PlaylistController(
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Current item was removed or nothing was playing - start from beginning
|
// Current item was removed or nothing was playing - start from the first
|
||||||
currentIndex = 0
|
// schedule-active item; idle if none are active right now.
|
||||||
playCurrentItem()
|
val idx = firstActiveIndex()
|
||||||
|
if (idx >= 0) { currentIndex = idx; playCurrentItem() } else showNothingScheduled()
|
||||||
} else {
|
} else {
|
||||||
currentIndex = 0
|
currentIndex = 0
|
||||||
}
|
}
|
||||||
|
|
@ -130,12 +146,12 @@ class PlaylistController(
|
||||||
|
|
||||||
fun start() {
|
fun start() {
|
||||||
isRunning = true
|
isRunning = true
|
||||||
if (items.isNotEmpty()) {
|
if (items.isEmpty()) { onPlaylistEmpty(); return }
|
||||||
if (currentIndex < 0) currentIndex = 0
|
// #74/#75: begin on the first schedule-active item; idle if none.
|
||||||
playCurrentItem()
|
val idx = firstActiveIndex()
|
||||||
} else {
|
if (idx < 0) { showNothingScheduled(); return }
|
||||||
onPlaylistEmpty()
|
currentIndex = idx
|
||||||
}
|
playCurrentItem()
|
||||||
}
|
}
|
||||||
|
|
||||||
fun startIfNeeded() {
|
fun startIfNeeded() {
|
||||||
|
|
@ -156,13 +172,17 @@ class PlaylistController(
|
||||||
fun stop() {
|
fun stop() {
|
||||||
isRunning = false
|
isRunning = false
|
||||||
cancelAdvance()
|
cancelAdvance()
|
||||||
|
cancelRetry()
|
||||||
}
|
}
|
||||||
|
|
||||||
fun next() {
|
fun next() {
|
||||||
if (items.isEmpty()) return
|
if (items.isEmpty()) return
|
||||||
currentIndex = (currentIndex + 1) % items.size
|
|
||||||
// Request a playlist refresh between plays so new content gets picked up
|
// Request a playlist refresh between plays so new content gets picked up
|
||||||
onRequestRefresh?.invoke()
|
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()
|
playCurrentItem()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -173,6 +193,7 @@ class PlaylistController(
|
||||||
|
|
||||||
private fun playCurrentItem() {
|
private fun playCurrentItem() {
|
||||||
cancelAdvance()
|
cancelAdvance()
|
||||||
|
cancelRetry()
|
||||||
val item = currentItem ?: return
|
val item = currentItem ?: return
|
||||||
Log.i("PlaylistController", "Playing: ${item.filename} (index $currentIndex)")
|
Log.i("PlaylistController", "Playing: ${item.filename} (index $currentIndex)")
|
||||||
onItemChanged(item)
|
onItemChanged(item)
|
||||||
|
|
@ -194,4 +215,64 @@ class PlaylistController(
|
||||||
advanceRunnable?.let { handler.removeCallbacks(it) }
|
advanceRunnable?.let { handler.removeCallbacks(it) }
|
||||||
advanceRunnable = null
|
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
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -51,6 +51,10 @@ class ZoneManager(
|
||||||
private set
|
private set
|
||||||
var lastAssignmentSig: String? = null
|
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 hasZones(): Boolean = zones.isNotEmpty()
|
||||||
|
|
||||||
fun setupZones(zonesJson: JSONArray, layoutId: String? = null) {
|
fun setupZones(zonesJson: JSONArray, layoutId: String? = null) {
|
||||||
|
|
@ -120,6 +124,35 @@ class ZoneManager(
|
||||||
Log.i(TAG, "Rendered ${zoneViews.size} zone views")
|
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
|
// 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
|
// 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).
|
// 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) }
|
zoneViews.remove(zone.id)?.let { container.removeView(it) }
|
||||||
zoneExoPlayers.remove(zone.id)?.release()
|
zoneExoPlayers.remove(zone.id)?.release()
|
||||||
|
|
||||||
val a = assignments[index % assignments.size]
|
// #74/#75: skip items whose schedule excludes them now; blank-idle the zone
|
||||||
val multi = assignments.size > 1
|
// and re-check shortly (a daypart may open) if none are active.
|
||||||
val advance: () -> Unit = { showZoneItem(zone, assignments, index + 1, params) }
|
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 mimeType = a.optString("mime_type", "")
|
||||||
val remoteUrl = if (a.isNull("remote_url")) null else a.optString("remote_url", null)
|
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
|
// 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.
|
// 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" } }
|
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 {
|
when {
|
||||||
// Widget - render in WebView
|
// Widget - render in WebView
|
||||||
|
|
|
||||||
|
|
@ -30,6 +30,9 @@ class DeviceInfo(private val context: Context) {
|
||||||
put("wifi_ssid", getWifiSSID())
|
put("wifi_ssid", getWifiSSID())
|
||||||
put("wifi_rssi", getWifiRSSI())
|
put("wifi_rssi", getWifiRSSI())
|
||||||
put("uptime_seconds", getUptimeSeconds())
|
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())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -2,4 +2,6 @@
|
||||||
<resources>
|
<resources>
|
||||||
<string name="app_name">RemoteDisplay</string>
|
<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="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>
|
</resources>
|
||||||
|
|
|
||||||
|
|
@ -2,4 +2,6 @@
|
||||||
<resources>
|
<resources>
|
||||||
<string name="app_name">RemoteDisplay</string>
|
<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="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>
|
</resources>
|
||||||
|
|
|
||||||
|
|
@ -2,4 +2,6 @@
|
||||||
<resources>
|
<resources>
|
||||||
<string name="app_name">RemoteDisplay</string>
|
<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="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>
|
</resources>
|
||||||
|
|
|
||||||
|
|
@ -2,4 +2,6 @@
|
||||||
<resources>
|
<resources>
|
||||||
<string name="app_name">RemoteDisplay</string>
|
<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="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>
|
</resources>
|
||||||
|
|
|
||||||
|
|
@ -2,4 +2,6 @@
|
||||||
<resources>
|
<resources>
|
||||||
<string name="app_name">RemoteDisplay</string>
|
<string name="app_name">RemoteDisplay</string>
|
||||||
<string name="accessibility_description">RemoteDisplay uses accessibility to enable remote power controls and system navigation.</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>
|
</resources>
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -146,6 +146,9 @@ export const api = {
|
||||||
updatePlaylistItem: (id, itemId, data) => request(`/playlists/${id}/items/${itemId}`, { method: 'PUT', body: JSON.stringify(data) }),
|
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' }),
|
deletePlaylistItem: (id, itemId) => request(`/playlists/${id}/items/${itemId}`, { method: 'DELETE' }),
|
||||||
reorderPlaylistItems: (id, order) => request(`/playlists/${id}/items/reorder`, { method: 'POST', body: JSON.stringify({ order }) }),
|
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 }) }),
|
assignPlaylistToDevice: (playlistId, device_id) => request(`/playlists/${playlistId}/assign`, { method: 'POST', body: JSON.stringify({ device_id }) }),
|
||||||
publishPlaylist: (id) => request(`/playlists/${id}/publish`, { method: 'POST' }),
|
publishPlaylist: (id) => request(`/playlists/${id}/publish`, { method: 'POST' }),
|
||||||
discardPlaylistDraft: (id) => request(`/playlists/${id}/discard`, { method: 'POST' }),
|
discardPlaylistDraft: (id) => request(`/playlists/${id}/discard`, { method: 'POST' }),
|
||||||
|
|
|
||||||
|
|
@ -691,6 +691,36 @@ export default {
|
||||||
'playlist.adding': 'Wird hinzugefügt...',
|
'playlist.adding': 'Wird hinzugefügt...',
|
||||||
'playlist.added': '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
|
||||||
'onboarding.back': 'Zurück',
|
'onboarding.back': 'Zurück',
|
||||||
'onboarding.next': 'Weiter',
|
'onboarding.next': 'Weiter',
|
||||||
|
|
|
||||||
|
|
@ -775,6 +775,36 @@ export default {
|
||||||
'playlist.adding': 'Adding...',
|
'playlist.adding': 'Adding...',
|
||||||
'playlist.added': 'Added',
|
'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
|
||||||
'onboarding.back': 'Back',
|
'onboarding.back': 'Back',
|
||||||
'onboarding.next': 'Next',
|
'onboarding.next': 'Next',
|
||||||
|
|
|
||||||
|
|
@ -690,6 +690,36 @@ export default {
|
||||||
'playlist.adding': 'Agregando...',
|
'playlist.adding': 'Agregando...',
|
||||||
'playlist.added': 'Agregado',
|
'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
|
||||||
'onboarding.back': 'Atrás',
|
'onboarding.back': 'Atrás',
|
||||||
'onboarding.next': 'Siguiente',
|
'onboarding.next': 'Siguiente',
|
||||||
|
|
|
||||||
|
|
@ -691,6 +691,36 @@ export default {
|
||||||
'playlist.adding': 'Ajout...',
|
'playlist.adding': 'Ajout...',
|
||||||
'playlist.added': '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 l’heure 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 l’envoyer 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': 'L’heure de début doit être HH:MM',
|
||||||
|
'itemsched.err.end': 'L’heure 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 l’appareil',
|
||||||
|
'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
|
||||||
'onboarding.back': 'Retour',
|
'onboarding.back': 'Retour',
|
||||||
'onboarding.next': 'Suivant',
|
'onboarding.next': 'Suivant',
|
||||||
|
|
|
||||||
|
|
@ -691,6 +691,36 @@ export default {
|
||||||
'playlist.adding': 'Adicionando...',
|
'playlist.adding': 'Adicionando...',
|
||||||
'playlist.added': 'Adicionado',
|
'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
|
||||||
'onboarding.back': 'Voltar',
|
'onboarding.back': 'Voltar',
|
||||||
'onboarding.next': 'Próximo',
|
'onboarding.next': 'Próximo',
|
||||||
|
|
|
||||||
|
|
@ -28,6 +28,24 @@ function formatUptime(seconds) {
|
||||||
return `${m}m`;
|
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) {
|
export function render(container, deviceId) {
|
||||||
container.innerHTML = `
|
container.innerHTML = `
|
||||||
<div class="device-detail">
|
<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-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 class="info-card-value small">${device.screen_width && device.screen_height ? device.screen_width + 'x' + device.screen_height : '--'}</div>
|
||||||
</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/') ? `
|
${device.android_version && !device.android_version.startsWith('Web/') ? `
|
||||||
<div class="info-card">
|
<div class="info-card">
|
||||||
<div class="info-card-label">${t('device.info.ram')}</div>
|
<div class="info-card-label">${t('device.info.ram')}</div>
|
||||||
|
|
|
||||||
|
|
@ -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>';
|
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;
|
let currentPlaylistId = null;
|
||||||
|
|
||||||
export function render(container) {
|
export function render(container) {
|
||||||
|
|
@ -296,7 +329,10 @@ function renderItems(items) {
|
||||||
</div>
|
</div>
|
||||||
<div style="flex:1;min-width:0">
|
<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: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>
|
||||||
<div style="display:flex;align-items:center;gap:8px;flex-shrink:0">
|
<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>
|
<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>
|
<span style="font-size:12px;color:var(--text-muted)">${t('playlist.sec')}</span>
|
||||||
</div>
|
</div>
|
||||||
<div style="display:flex;align-items:center;gap:4px;flex-shrink:0">
|
<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' : ''}">
|
<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>
|
<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>
|
</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 => {
|
itemsEl.querySelectorAll('.item-move').forEach(btn => {
|
||||||
btn.addEventListener('click', async (e) => {
|
btn.addEventListener('click', async (e) => {
|
||||||
if (btn.disabled) return;
|
if (btn.disabled) return;
|
||||||
|
|
@ -597,3 +644,110 @@ async function showAddItemModal(playlistId) {
|
||||||
|
|
||||||
renderTab();
|
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();
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -73,6 +73,12 @@ const migrations = [
|
||||||
// Layout & zone support on devices and assignments
|
// Layout & zone support on devices and assignments
|
||||||
'ALTER TABLE devices ADD COLUMN layout_id TEXT',
|
'ALTER TABLE devices ADD COLUMN layout_id TEXT',
|
||||||
'ALTER TABLE devices ADD COLUMN timezone TEXT DEFAULT \'UTC\'',
|
'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 wall_id TEXT',
|
||||||
'ALTER TABLE devices ADD COLUMN team_id TEXT',
|
'ALTER TABLE devices ADD COLUMN team_id TEXT',
|
||||||
'ALTER TABLE assignments ADD COLUMN zone_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)`);
|
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)
|
// Fix assignments table: make content_id nullable (SQLite requires table rebuild)
|
||||||
try {
|
try {
|
||||||
const colInfo = db.prepare("PRAGMA table_info(assignments)").all();
|
const colInfo = db.prepare("PRAGMA table_info(assignments)").all();
|
||||||
|
|
|
||||||
|
|
@ -354,6 +354,26 @@ CREATE TABLE IF NOT EXISTS playlist_items (
|
||||||
updated_at INTEGER NOT NULL DEFAULT (strftime('%s','now'))
|
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 =====================
|
-- ===================== ACTIVITY LOG =====================
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS activity_log (
|
CREATE TABLE IF NOT EXISTS activity_log (
|
||||||
|
|
|
||||||
100
server/lib/schedule-eval.js
Normal file
100
server/lib/schedule-eval.js
Normal 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 };
|
||||||
|
});
|
||||||
|
|
@ -218,6 +218,7 @@
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script src="/socket.io/socket.io.js"></script>
|
<script src="/socket.io/socket.io.js"></script>
|
||||||
|
<script src="/player/schedule-eval.js"></script>
|
||||||
<script>
|
<script>
|
||||||
// ==================== i18n ====================
|
// ==================== i18n ====================
|
||||||
// Lightweight inline i18n for the player. The player is a standalone page
|
// Lightweight inline i18n for the player. The player is a standalone page
|
||||||
|
|
@ -251,18 +252,19 @@
|
||||||
info_nothing: 'Nothing',
|
info_nothing: 'Nothing',
|
||||||
info_na: 'N/A',
|
info_na: 'N/A',
|
||||||
info_sw: 'Service Worker',
|
info_sw: 'Service Worker',
|
||||||
|
nothing_scheduled: 'Nothing scheduled right now',
|
||||||
},
|
},
|
||||||
es: {
|
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: {
|
fr: {
|
||||||
web_player: 'Lecteur web', server_url: 'URL du serveur', server_url_placeholder: 'https://signage.votredomaine.com', connect: 'Connecter', pairing_code: 'Code d’appairage', 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 l’appareil', info_device_name: 'Nom de l’appareil', 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 d’appairage', 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 l’appareil', info_device_name: 'Nom de l’appareil', 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: {
|
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: {
|
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 = (() => {
|
const PLAYER_LANG = (() => {
|
||||||
|
|
@ -326,6 +328,8 @@
|
||||||
let playlist = [];
|
let playlist = [];
|
||||||
let currentIndex = -1;
|
let currentIndex = -1;
|
||||||
let isPlaying = false;
|
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 heartbeatTimer = null;
|
||||||
let refreshTimer = null;
|
let refreshTimer = null;
|
||||||
let remoteStreaming = false;
|
let remoteStreaming = false;
|
||||||
|
|
@ -530,10 +534,8 @@
|
||||||
console.log('Restored cached playlist:', cachedPlaylist.length, 'items');
|
console.log('Restored cached playlist:', cachedPlaylist.length, 'items');
|
||||||
playlist = cachedPlaylist;
|
playlist = cachedPlaylist;
|
||||||
layout = loadLayoutCache();
|
layout = loadLayoutCache();
|
||||||
currentIndex = 0;
|
|
||||||
isPlaying = true;
|
|
||||||
document.getElementById('setupScreen').style.display = 'none';
|
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
|
// Always show the tap overlay on cold load. Browser autoplay policy is
|
||||||
|
|
@ -876,6 +878,9 @@
|
||||||
wifi_ssid: 'Web Player',
|
wifi_ssid: 'Web Player',
|
||||||
wifi_rssi: null,
|
wifi_rssi: null,
|
||||||
uptime_seconds: Math.floor(performance.now() / 1000),
|
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);
|
}, HEARTBEAT_INTERVAL);
|
||||||
|
|
@ -1040,8 +1045,9 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
const newItems = data.assignments || [];
|
const newItems = data.assignments || [];
|
||||||
// Build fingerprint from id + url + filename to detect any content change
|
// 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(',');
|
// #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 newFp = fingerprint(newItems);
|
||||||
const oldFp = fingerprint(playlist);
|
const oldFp = fingerprint(playlist);
|
||||||
|
|
||||||
|
|
@ -1075,6 +1081,7 @@
|
||||||
|
|
||||||
layout = data.layout || null;
|
layout = data.layout || null;
|
||||||
saveLayoutCache(layout);
|
saveLayoutCache(layout);
|
||||||
|
playerTimezone = data.timezone || null; // #74/#75: effective tz for schedule eval
|
||||||
|
|
||||||
if (newFp === oldFp && playlist.length > 0 && !wallChanged) {
|
if (newFp === oldFp && playlist.length > 0 && !wallChanged) {
|
||||||
console.log('Playlist unchanged');
|
console.log('Playlist unchanged');
|
||||||
|
|
@ -1127,9 +1134,44 @@
|
||||||
}
|
}
|
||||||
if (nextIdx === -1) nextIdx = 0;
|
if (nextIdx === -1) nextIdx = 0;
|
||||||
|
|
||||||
currentIndex = nextIdx;
|
startPlaybackAt(nextIdx);
|
||||||
isPlaying = true;
|
}
|
||||||
playCurrentItem();
|
|
||||||
|
// #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() {
|
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();
|
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) {
|
function renderZones(container, defaultItem) {
|
||||||
clearZoneTimers();
|
clearZoneTimers();
|
||||||
// Group assignments by zone, ordered by sort_order so each zone rotates its
|
// Group assignments by zone, ordered by sort_order so each zone rotates its
|
||||||
|
|
@ -1513,8 +1573,14 @@
|
||||||
// loop / don't advance.
|
// loop / don't advance.
|
||||||
function showZoneItem(zone, div, items, index) {
|
function showZoneItem(zone, div, items, index) {
|
||||||
if (zoneTimers[zone.id]) { clearTimeout(zoneTimers[zone.id]); delete zoneTimers[zone.id]; }
|
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 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);
|
const advance = () => showZoneItem(zone, div, items, index + 1);
|
||||||
// Tear down any prior media in this zone before swapping.
|
// 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) {} });
|
div.querySelectorAll('video').forEach(v => { try { v.onended = null; v.pause(); v.removeAttribute('src'); v.load(); } catch (e) {} });
|
||||||
|
|
|
||||||
|
|
@ -65,8 +65,8 @@ function requirePlaylistWrite(req, res, next) {
|
||||||
|
|
||||||
// Build the snapshot item list for a playlist (denormalized for device payload)
|
// Build the snapshot item list for a playlist (denormalized for device payload)
|
||||||
function buildSnapshotItems(playlistId) {
|
function buildSnapshotItems(playlistId) {
|
||||||
return db.prepare(`
|
const items = db.prepare(`
|
||||||
SELECT pi.content_id, pi.widget_id, pi.zone_id, pi.sort_order, pi.duration_sec,
|
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,
|
COALESCE(c.filename, w.name) as filename, c.mime_type, c.filepath, c.file_size,
|
||||||
c.duration_sec as content_duration, c.remote_url,
|
c.duration_sec as content_duration, c.remote_url,
|
||||||
w.name as widget_name, w.widget_type, w.config as widget_config
|
w.name as widget_name, w.widget_type, w.config as widget_config
|
||||||
|
|
@ -76,6 +76,29 @@ function buildSnapshotItems(playlistId) {
|
||||||
WHERE pi.playlist_id = ?
|
WHERE pi.playlist_id = ?
|
||||||
ORDER BY pi.sort_order ASC
|
ORDER BY pi.sort_order ASC
|
||||||
`).all(playlistId);
|
`).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)
|
// 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 = ?
|
WHERE pi.playlist_id = ?
|
||||||
ORDER BY pi.sort_order ASC
|
ORDER BY pi.sort_order ASC
|
||||||
`).all(req.params.id);
|
`).all(req.params.id);
|
||||||
|
for (const it of items) it.schedules = schedulesForItem(it.id); // #74/#75: editor needs the blocks
|
||||||
res.json(items);
|
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:
|
// Phase 2.2k: add item closes 2 pre-existing cross-tenant leaks:
|
||||||
// 1. Content gate: today checks content.user_id == caller. A workspace_admin
|
// 1. Content gate: today checks content.user_id == caller. A workspace_admin
|
||||||
// who owns content in another workspace could push it into a playlist
|
// who owns content in another workspace could push it into a playlist
|
||||||
|
|
|
||||||
55
server/scripts/demo-schedule.js
Normal file
55
server/scripts/demo-schedule.js
Normal 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');
|
||||||
|
|
@ -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
|
// Serve web player at /player (same no-cache for JS/HTML). The index.html
|
||||||
// route above intercepts the HTML requests; everything else still falls
|
// route above intercepts the HTML requests; everything else still falls
|
||||||
// through to this static handler (debug-overlay.js, sw.js, manifest, etc).
|
// through to this static handler (debug-overlay.js, sw.js, manifest, etc).
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
const { db } = require('../db/database');
|
const { db } = require('../db/database');
|
||||||
|
const { _localParts } = require('../lib/schedule-eval');
|
||||||
|
|
||||||
let io = null;
|
let io = null;
|
||||||
|
|
||||||
|
|
@ -36,7 +37,7 @@ function evaluateSchedules() {
|
||||||
s.created_at ASC
|
s.created_at ASC
|
||||||
`).all(device.id, device.id);
|
`).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);
|
const override = activeOverrides.get(device.id);
|
||||||
let changed = false;
|
let changed = false;
|
||||||
|
|
||||||
|
|
@ -65,28 +66,42 @@ function evaluateSchedules() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function isScheduleActiveNow(schedule, now) {
|
// #74/#75 Part B: device-level schedules are evaluated in the DEVICE's effective
|
||||||
const start = new Date(schedule.start_time);
|
// timezone, not the server's. We reuse the canonical UTC->local conversion
|
||||||
const end = new Date(schedule.end_time);
|
// (_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) {
|
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);
|
const rule = parseSimpleRRule(schedule.recurrence);
|
||||||
if (!rule) return now >= start && now <= end;
|
if (!rule) return nowStamp >= startStamp && nowStamp <= endStamp;
|
||||||
|
|
||||||
// Check day of week
|
// Day-of-week in the device's local zone.
|
||||||
if (rule.byDay && !rule.byDay.includes(now.getDay())) return false;
|
if (rule.byDay && !rule.byDay.includes(L.dow)) return false;
|
||||||
|
|
||||||
// Check time of day
|
// Time-of-day window in the device's local zone (HH:MM string compare).
|
||||||
const nowMinutes = now.getHours() * 60 + now.getMinutes();
|
const nowHM = nowStamp.slice(11), startHM = startStamp.slice(11), endHM = endStamp.slice(11);
|
||||||
const startMinutes = start.getHours() * 60 + start.getMinutes();
|
return nowHM >= startHM && nowHM <= endHM;
|
||||||
const endMinutes = end.getHours() * 60 + end.getMinutes();
|
|
||||||
|
|
||||||
return nowMinutes >= startMinutes && nowMinutes <= endMinutes;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function parseSimpleRRule(rrule) {
|
function parseSimpleRRule(rrule) {
|
||||||
|
|
|
||||||
23
server/test/schedule-eval.test.js
Normal file
23
server/test/schedule-eval.test.js
Normal 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`);
|
||||||
|
});
|
||||||
26
server/test/tizen-eval-drift.test.js
Normal file
26
server/test/tizen-eval-drift.test.js
Normal 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`);
|
||||||
|
});
|
||||||
|
|
@ -75,7 +75,7 @@ function logDeviceStatus(deviceId, status) {
|
||||||
// Build playlist payload with layout and zones
|
// Build playlist payload with layout and zones
|
||||||
// Reads from published_snapshot (Phase 3) so draft edits don't affect live devices
|
// Reads from published_snapshot (Phase 3) so draft edits don't affect live devices
|
||||||
function buildPlaylistPayload(deviceId) {
|
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 = [];
|
let assignments = [];
|
||||||
if (device?.playlist_id) {
|
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));
|
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
|
// Check if a device should show trial expired screen
|
||||||
|
|
@ -485,6 +490,13 @@ module.exports = function setupDeviceSocket(io) {
|
||||||
);
|
);
|
||||||
pruneTelemetry(device_id);
|
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', {
|
emitToDeviceWorkspace(dashboardNs, device_id, 'dashboard:device-status', {
|
||||||
device_id,
|
device_id,
|
||||||
status: 'online',
|
status: 'online',
|
||||||
|
|
|
||||||
52
shared/schedule-vectors.json
Normal file
52
shared/schedule-vectors.json
Normal 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 }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
@ -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"
|
[ -d "$HOME/tizen-studio/tools/ide/bin" ] && export PATH="$HOME/tizen-studio/tools/ide/bin:$PATH"
|
||||||
rm -f "$OUT"
|
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
|
if command -v tizen >/dev/null 2>&1; then
|
||||||
PROFILE="${1:-ScreenTinker}"
|
PROFILE="${1:-ScreenTinker}"
|
||||||
echo "Tizen CLI found — signing with profile '$PROFILE'…"
|
echo "Tizen CLI found — signing with profile '$PROFILE'…"
|
||||||
|
|
|
||||||
|
|
@ -39,6 +39,7 @@
|
||||||
<div id="toast" class="toast hidden"></div>
|
<div id="toast" class="toast hidden"></div>
|
||||||
|
|
||||||
<script src="js/socket.io.min.js"></script>
|
<script src="js/socket.io.min.js"></script>
|
||||||
|
<script src="js/schedule-eval.js"></script>
|
||||||
<script src="js/player.js"></script>
|
<script src="js/player.js"></script>
|
||||||
<script src="js/app.js"></script>
|
<script src="js/app.js"></script>
|
||||||
</body>
|
</body>
|
||||||
|
|
|
||||||
|
|
@ -95,6 +95,9 @@
|
||||||
|
|
||||||
function telemetry() {
|
function telemetry() {
|
||||||
var t = { uptime_seconds: Math.floor(performance.now() / 1000) };
|
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 {
|
try {
|
||||||
tizen.systeminfo.getPropertyValue('BATTERY', function (b) {
|
tizen.systeminfo.getPropertyValue('BATTERY', function (b) {
|
||||||
t.battery_level = Math.round((b.level || 0) * 100);
|
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 we have content + we're paired, make sure we're on the stage.
|
||||||
if (elPairing.classList.contains('hidden') === false) show(elStage);
|
if (elPairing.classList.contains('hidden') === false) show(elStage);
|
||||||
else if (elStage.classList.contains('hidden')) 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 || []);
|
player.load(payload.assignments || []);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,17 @@
|
||||||
* widget -> iframe of {server}/api/widgets/{id}/render for duration_sec
|
* widget -> iframe of {server}/api/widgets/{id}/render for duration_sec
|
||||||
* Content file URL: {server}/api/content/{content_id}/file (public)
|
* 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 l’instant' },
|
||||||
|
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) {
|
function PlaylistPlayer(stageEl, getBase) {
|
||||||
this.stage = stageEl;
|
this.stage = stageEl;
|
||||||
this.getBase = getBase;
|
this.getBase = getBase;
|
||||||
|
|
@ -14,6 +25,7 @@ function PlaylistPlayer(stageEl, getBase) {
|
||||||
this.index = 0;
|
this.index = 0;
|
||||||
this.timer = null;
|
this.timer = null;
|
||||||
this.sig = '';
|
this.sig = '';
|
||||||
|
this.timezone = null; // #74/#75: device-effective IANA tz for schedule eval
|
||||||
this.DEFAULT_DURATION = 10;
|
this.DEFAULT_DURATION = 10;
|
||||||
this.MIN_DURATION = 3;
|
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); });
|
items.sort(function (a, b) { return (a.sort_order || 0) - (b.sort_order || 0); });
|
||||||
|
|
||||||
var sig = JSON.stringify(items.map(function (a) {
|
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
|
if (sig === this.sig && this.items.length) return; // unchanged, keep playing
|
||||||
|
|
||||||
this.sig = sig;
|
this.sig = sig;
|
||||||
this.items = items;
|
this.items = items;
|
||||||
this.index = 0;
|
this.index = 0;
|
||||||
this.playCurrent();
|
this.startPlayback();
|
||||||
};
|
};
|
||||||
|
|
||||||
PlaylistPlayer.prototype.stop = function () {
|
PlaylistPlayer.prototype.stop = function () {
|
||||||
|
|
@ -52,7 +65,7 @@ PlaylistPlayer.prototype.idle = function () {
|
||||||
this.clearStage();
|
this.clearStage();
|
||||||
this.stage.innerHTML =
|
this.stage.innerHTML =
|
||||||
'<div class="card" style="position:relative"><h1>ScreenTinker</h1>' +
|
'<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) {
|
PlaylistPlayer.prototype.durationMs = function (item) {
|
||||||
|
|
@ -69,7 +82,10 @@ PlaylistPlayer.prototype.contentUrl = function (item) {
|
||||||
|
|
||||||
PlaylistPlayer.prototype.advance = function () {
|
PlaylistPlayer.prototype.advance = function () {
|
||||||
if (!this.items.length) return;
|
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();
|
this.playCurrent();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -79,12 +95,65 @@ PlaylistPlayer.prototype.schedule = function (ms) {
|
||||||
this.timer = setTimeout(function () { self.advance(); }, 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 () {
|
PlaylistPlayer.prototype.playCurrent = function () {
|
||||||
if (this.timer) { clearTimeout(this.timer); this.timer = null; }
|
if (this.timer) { clearTimeout(this.timer); this.timer = null; }
|
||||||
if (!this.items.length) { this.idle(); return; }
|
if (!this.items.length) { this.idle(); return; }
|
||||||
|
|
||||||
var item = this.items[this.index];
|
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 || '';
|
var mime = item.mime_type || '';
|
||||||
this.clearStage();
|
this.clearStage();
|
||||||
|
|
||||||
|
|
|
||||||
100
tizen/js/schedule-eval.js
Normal file
100
tizen/js/schedule-eval.js
Normal 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 };
|
||||||
|
});
|
||||||
Loading…
Reference in a new issue