Drive-In event ideas, phase 2: Approve, Defer and Dismiss
pauljd/davidson-ci: Davidson CI / quality (push) Failing after 2m35s
pauljd/davidson-ci: Davidson CI / ai-review (push) Failing after 1m33s

Approved by Paul on 5 November 2026. The idea pop-up in the Director's
Chair now offers Approve, Defer and Dismiss. Approve keeps each film's
identity (title, year, IMDb ID, library item) in the history and turns the
idea into a Drive-In event booked exactly like the ten curated ones: only
once the Drive-In's plan reaches its evening, only appending, and only if
every film passes the box's checks again then. Defer brings an idea back
after four weeks; Dismiss drops it unless its films change. Approved events
count for editorial competition and evenings like the curated ones.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-11-05 19:00:27 +00:00
co-authored by Claude Opus 5.5
parent d878419fb4
commit 995f2e2bdb
6 changed files with 124 additions and 15 deletions
@@ -140,7 +140,8 @@ internal class ChannelScheduler(private val store: ScheduleStore, private val cl
val studios = EventStudios.associateWith { catalogue.filmsByStudio(it.first) }
val read = catalogue.ideaFilms(lines.keys).map(::enrich)
val held = seasonalFilms().map { it.first.itemId } + ApprovedDebuts.map { it.film.itemId }
val eventFilms = DriveInEvents.flatMap { e -> e.films.map { it.itemId } }.toSet() + held
ApprovedDriveInEvents.refresh(historyFile)
val eventFilms = allDriveInEvents().flatMap { e -> e.films.map { it.itemId } }.toSet() + held
val seeds = ideaSeeds(collections, studios, read, eventFilms)
var films = catalogue.ideaFilms(seeds.flatMap { it.members }).associateBy { it.id }.mapValues { enrich(it.value) }
// Evidence for the candidates that have none yet, read only and bounded: a theme can't be judged without its ratings.
@@ -188,7 +189,9 @@ internal class ChannelScheduler(private val store: ScheduleStore, private val cl
val failed = store.loadFailed(userId)
fun films(numbers: List<Int>) = numbers.flatMap { number -> withoutFailed(store.loadPool(userId, number)?.series.orEmpty().filter { it.isFilm }, failed) }.distinctBy { seriesKey(it.title) }
val avoid = clashesWith(SmartChannels.mapNotNull { store.loadPlan(userId, it.number) })
val events = DriveInEvents.filter { !it.evening.isBefore(java.time.Instant.ofEpochMilli(now).atZone(java.time.ZoneId.systemDefault()).toLocalDate().minusDays(1)) }
// The curated events and the ideas you've approved (Drive-In event ideas, phase 2), booked alike.
ApprovedDriveInEvents.refresh(store.eventIdeaHistoryFile())
val events = allDriveInEvents().filter { !it.evening.isBefore(java.time.Instant.ofEpochMilli(now).atZone(java.time.ZoneId.systemDefault()).toLocalDate().minusDays(1)) }
val eventFilms = withoutFailed(eventFilms(userId, session, events, now), failed).associateBy { it.id }
val plan = planDriveIn(existing, films(DriveInMarqueeChannels), films(DriveInLateChannels), films(DriveInBillChannels), now, avoid = avoid,
events = events, eventFilms = eventFilms, note = { Log.i("OrbitSchedule", "channel=$DRIVE_IN_NUMBER $it") }, alsoHeld = seasonalHeld()) ?: return existing
@@ -53,24 +53,28 @@ internal class DeskAsk(val title: String, val first: String, val second: String,
val options: List<DeskOption> = emptyList(), val line: String = "", val firstRun: DeskOption? = null)
/** A Drive-In event idea as the Chair shows it (phase 1, read only): the occasion, its evening, its films and why. */
internal class DeskEventIdea(val theme: String, val format: String, val evening: String, val films: List<String>, val reasons: List<String>) {
internal class DeskEventIdea(val theme: String, val format: String, val evening: String, val films: List<String>, val reasons: List<String>,
val signature: String = "", val decision: String = "proposed", val filmIds: List<String> = emptyList()) {
fun toJson(): JSONObject = JSONObject().put("t", theme).put("f", format).put("e", evening).put("m", JSONArray(films)).put("r", JSONArray(reasons))
.put("s", signature).put("d", decision).put("i", JSONArray(filmIds))
companion object {
fun of(o: JSONObject): DeskEventIdea {
fun strings(key: String) = o.optJSONArray(key)?.let { a -> (0 until a.length()).map { a.getString(it) } }.orEmpty()
return DeskEventIdea(o.optString("t"), o.optString("f"), o.optString("e"), strings("m"), strings("r"))
return DeskEventIdea(o.optString("t"), o.optString("f"), o.optString("e"), strings("m"), strings("r"), o.optString("s"), o.optString("d", "proposed"), strings("i"))
}
/** From the ideas file the box keeps: this week's ideas, with each film's year and viewer rating. */
fun fromFile(file: File): List<DeskEventIdea> = runCatching {
val ideas = JSONObject(file.readText()).getJSONArray("ideas")
(0 until ideas.length()).map { ideas.getJSONObject(it) }.map { o ->
// Deferred and dismissed ideas leave the Chair; approved ones stay, marked, until their evening has passed.
(0 until ideas.length()).map { ideas.getJSONObject(it) }.filter { it.optString("decision") in setOf("proposed", "approved") }.map { o ->
val details = o.optJSONArray("details")?.let { a -> (0 until a.length()).map { a.getJSONObject(it) } }.orEmpty()
val evening = o.optString("evening").takeIf { it.isNotEmpty() && it != "null" }?.let { java.time.LocalDateTime.parse(it) }
DeskEventIdea(o.optString("theme"), IdeaFormat.valueOf(o.optString("format")).label,
evening?.let { "%s %d %s, %02d:%02d".format(it.dayOfWeek.getDisplayName(java.time.format.TextStyle.FULL, java.util.Locale.UK), it.dayOfMonth,
it.month.getDisplayName(java.time.format.TextStyle.FULL, java.util.Locale.UK), it.hour, it.minute) } ?: "no free evening yet",
details.map { d -> d.optString("title") + (if (d.isNull("year")) "" else " (${d.optInt("year")})") + (if (d.isNull("community")) "" else ", rated %.1f".format(d.optDouble("community"))) },
o.optJSONArray("reasons")?.let { a -> (0 until a.length()).map { a.getString(it) } }.orEmpty())
o.optJSONArray("reasons")?.let { a -> (0 until a.length()).map { a.getString(it) } }.orEmpty(), o.optString("signature"), o.optString("decision"),
o.optJSONArray("films")?.let { a -> (0 until a.length()).map { a.getString(it) } }.orEmpty())
}
}.getOrDefault(emptyList())
}
@@ -389,7 +393,9 @@ internal fun DirectorChair(load: (DirectorMode) -> DeskReport?, first: FocusRequ
/** Your learning switch: saved, then the report is worked out again. */
setLearning: suspend (Boolean) -> Unit = {},
/** Confirms (true) or dismisses (false) a proposed lesson by its key. */
decideLesson: suspend (String, Boolean) -> Unit = { _, _ -> }) {
decideLesson: suspend (String, Boolean) -> Unit = { _, _ -> },
/** Your decision on a Drive-In event idea: approved, deferred or dismissed. Returns what to tell you. */
decideIdea: suspend (DeskEventIdea, String) -> String = { _, _ -> "" }) {
var mode by remember { mutableStateOf(DirectorMode.Balanced) }
var version by remember { mutableIntStateOf(0) }
var report by remember { mutableStateOf<DeskReport?>(null) }
@@ -452,7 +458,7 @@ internal fun DirectorChair(load: (DirectorMode) -> DeskReport?, first: FocusRequ
}
// Drive-In event ideas (phase 1, read only): at most two a week, none when nothing is worthwhile.
r.eventIdeas.forEach { idea ->
ChairCard("DRIVE-IN EVENT IDEA", idea.theme, idea.format.uppercase(java.util.Locale.UK), idea.evening, onClick = { eventOpen = idea })
ChairCard(if (idea.decision == "approved") "DRIVE-IN EVENT · APPROVED" else "DRIVE-IN EVENT IDEA", idea.theme, idea.format.uppercase(java.util.Locale.UK), idea.evening, onClick = { eventOpen = idea })
}
if (r.premiereApproved.isNotEmpty()) Text("Approved for Premiere, waiting for booking: ${r.premiereApproved.joinToString(", ")}", color = ChairMint, fontSize = 10.sp)
if (r.premiereQueued > 0) Text("${r.premiereQueued} recent major films still being read for Premiere", color = Muted, fontSize = 10.sp)
@@ -493,9 +499,18 @@ internal fun DirectorChair(load: (DirectorMode) -> DeskReport?, first: FocusRequ
Text("${idea.format} · ${idea.evening}", color = ChairSky, fontSize = 13.sp)
idea.films.forEachIndexed { i, f -> Text("${i + 1}. $f", color = Color.White, fontSize = 13.sp) }
idea.reasons.forEach { Text("· $it", color = Muted, fontSize = 11.sp) }
Text("Checked on the box: every film is the right library item with a copy this box can play, rested, not booked elsewhere around the evening, and nothing competes for the night. Read only for now: nothing is booked.",
Text(if (idea.decision == "approved") "Approved: it books like the Drive-In's other events once the Drive-In plans that evening (about a week ahead), after every film is checked on the box again. Nothing already booked changes."
else "Checked on the box: every film is the right library item with a copy this box can play, rested, not booked elsewhere around the evening, and nothing competes for the night. Approve books it like the Drive-In's other events; Defer brings it back in four weeks; Dismiss drops it.",
color = Muted, fontSize = 10.sp)
OrbitButton("Close", Modifier.focusRequester(first), onClick = { eventOpen = null })
Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) {
fun decide(decision: String) { eventOpen = null; scope.launch { said = decideIdea(idea, decision); version++ } }
if (idea.decision != "approved") {
OrbitButton("Approve", Modifier.focusRequester(first), onClick = { decide("approved") })
OrbitButton("Defer", onClick = { decide("deferred") })
OrbitButton("Dismiss", onClick = { decide("dismissed") })
}
OrbitButton("Close", if (idea.decision == "approved") Modifier.focusRequester(first) else Modifier, onClick = { eventOpen = null })
}
}
}
}
@@ -161,7 +161,7 @@ internal fun premiereHeld(channel: Int, zone: ZoneId = ZoneId.systemDefault()):
internal fun competingEvent(startMs: Long, endMs: Long, zone: ZoneId = ZoneId.systemDefault(), except: String? = null): String? {
fun ms(t: java.time.LocalDateTime) = t.atZone(zone).toInstant().toEpochMilli()
val hour = 60 * 60_000L
val events = DriveInEvents.map { e -> Triple("the Drive-In's ${e.name}", ms(e.evening.atTime(e.hour, e.minute)), ms(e.evening.atTime(e.hour, e.minute)) + e.films.size * 2 * hour) } +
val events = allDriveInEvents().map { e -> Triple("the Drive-In's ${e.name}", ms(e.evening.atTime(e.hour, e.minute)), ms(e.evening.atTime(e.hour, e.minute)) + e.films.size * 2 * hour) } +
AdventDoors.map { d -> Triple("Advent's Door ${d.door}", ms(d.at), ms(d.at) + 2 * hour) } +
NaughtyList.map { n -> Triple("The Naughty List's ${n.film.title}", ms(n.at), ms(n.at) + 2 * hour) } +
ApprovedDebuts.filter { it.film.itemId != except }.map { d -> Triple("the premiere of ${d.film.title}", d.startMs(zone), d.startMs(zone) + 3 * hour) }
@@ -527,7 +527,16 @@ internal fun EmbyConnectionScreen(modifier: Modifier = Modifier, visible: Boolea
else "${choice.title} premieres on Orbit Premiere ${java.time.Instant.ofEpochMilli(debut).atZone(java.time.ZoneId.systemDefault()).let { "%s %d %s at %02d:%02d".format(it.dayOfWeek.getDisplayName(java.time.format.TextStyle.FULL, java.util.Locale.UK), it.dayOfMonth, it.month.getDisplayName(java.time.format.TextStyle.FULL, java.util.Locale.UK), it.hour, it.minute) }}, then joins ${home.name}."
} },
setLearning = { on -> withContext(Dispatchers.IO) { ChairLearning.set(scheduleStore.learningFile(), on) }; android.util.Log.i("OrbitSchedule", "director learning=$on") },
decideLesson = { key, confirm -> withContext(Dispatchers.IO) { ChairLearning.decide(scheduleStore.learningFile(), key, confirm) }; android.util.Log.i("OrbitSchedule", "director lesson confirmed=$confirm") }) }, isOn = { scheduledChannels.containsKey(it) }, onSwitch = { programming, on -> setSmartSchedule(programming, on) }, onSuggestions = { openSuggestions(it) },
decideLesson = { key, confirm -> withContext(Dispatchers.IO) { ChairLearning.decide(scheduleStore.learningFile(), key, confirm) }; android.util.Log.i("OrbitSchedule", "director lesson confirmed=$confirm") },
decideIdea = { idea, decision -> withContext(Dispatchers.IO) {
val films = runCatching { catalogue.ideaFilms(idea.filmIds) }.getOrDefault(emptyList())
val ok = EventIdeaHistory.decide(scheduleStore.eventIdeaHistoryFile(), scheduleStore.eventIdeasFile(), idea.signature, decision, films, System.currentTimeMillis())
android.util.Log.i("OrbitSchedule", "event idea decision=$decision saved=$ok")
when { !ok -> "That decision couldn't be saved. Try again."
decision == "approved" -> "${idea.theme} approved for ${idea.evening}. It books once the Drive-In plans that evening, after its films are checked again."
decision == "deferred" -> "${idea.theme} deferred: it can come back in four weeks."
else -> "${idea.theme} dismissed: it won't be proposed again unless its films change." }
} }) }, isOn = { scheduledChannels.containsKey(it) }, onSwitch = { programming, on -> setSmartSchedule(programming, on) }, onSuggestions = { openSuggestions(it) },
onSignOut = { job?.cancel(); closeSession(forget = true) }, windingDown = windingDown, onWindDown = { on ->
val tots = scheduledChannels[TOTS_NUMBER]
val active = session
@@ -224,7 +224,7 @@ internal fun weeklyEventIdeas(seeds: List<IdeaSeed>, films: Map<String, IdeaFilm
internal fun ideaEvening(format: IdeaFormat, from: LocalDate, planEndMs: Long, taken: Set<LocalDateTime>, filmsMs: Long, zone: ZoneId = ZoneId.systemDefault()): LocalDateTime? {
var day = from
repeat(42) {
if (day.dayOfWeek in format.days && DriveInEvents.none { it.evening == day }) {
if (day.dayOfWeek in format.days && allDriveInEvents().none { it.evening == day }) {
val at = day.atTime(format.hour, 0); val ms = at.atZone(zone).toInstant().toEpochMilli()
if (ms > planEndMs && at !in taken && competingEvent(ms, ms + filmsMs, zone) == null) return at
}
@@ -233,10 +233,63 @@ internal fun ideaEvening(format: IdeaFormat, from: LocalDate, planEndMs: Long, t
return null
}
/** The proposal history: every idea shown, when, and (from phase 2) what you decided. Kept for explainable learning. */
/**
* Phase 2 (approved by Paul on 5 November 2026): an idea you approve becomes a Drive-In event, booked like the ten curated ones
* ([DriveInEvents]): only once the Drive-In's booking reaches its evening (it only ever appends), and only if every film is
* verified on the box again then. Deferred ideas wait [EVENT_IDEA_DEFER_MS] before they can be proposed again; dismissed ones
* don't return unless their films change.
*/
internal const val EVENT_IDEA_DEFER_MS = 28 * 24 * 60 * 60_000L
/** Ideas you've approved, as Drive-In events. Read from the history whenever it changes or the Drive-In books. */
internal object ApprovedDriveInEvents {
@Volatile var list: List<DriveInEvent> = emptyList()
fun refresh(historyFile: File) { list = EventIdeaHistory.approvedEvents(historyFile) }
}
/** The curated events and the ideas you've approved: every Drive-In event. */
internal fun allDriveInEvents(): List<DriveInEvent> = DriveInEvents + ApprovedDriveInEvents.list
/** The proposal history: every idea shown, when, and what you decided. Kept for explainable learning. */
internal object EventIdeaHistory {
fun load(file: File): List<JSONObject> = runCatching { JSONArray(file.readText()).let { a -> (0 until a.length()).map { a.getJSONObject(it) } } }.getOrDefault(emptyList())
fun dismissed(file: File): Set<String> = load(file).filter { it.optString("decision") == "dismissed" }.map { it.optString("signature") }.toSet()
/** Not to be proposed: dismissed, deferred and still waiting, or approved already. */
fun dismissed(file: File, nowMs: Long = System.currentTimeMillis()): Set<String> = load(file).filter {
val d = it.optString("decision")
d == "dismissed" || d == "approved" || (d == "deferred" && nowMs - it.optLong("decidedMs") < EVENT_IDEA_DEFER_MS)
}.map { it.optString("signature") }.toSet()
/**
* Records your decision on an idea ([decision]: approved, deferred or dismissed), in the history and in this week's ideas.
* An approval keeps each film's identity (title, year, IMDb ID, library item) so the event can be verified and booked.
*/
fun decide(historyFile: File, ideasFile: File, signature: String, decision: String, films: List<IdeaFilm>, nowMs: Long): Boolean = runCatching {
require(decision in setOf("approved", "deferred", "dismissed"))
fun update(o: JSONObject): JSONObject {
if (o.optString("signature") != signature) return o
val n = JSONObject(o.toString()).put("decision", decision).put("decidedMs", nowMs)
if (decision == "approved") n.put("filmData", JSONArray(films.map { f -> JSONObject().put("id", f.id).put("title", f.title).put("year", f.year ?: 0).put("imdb", f.imdb ?: "") }))
return n
}
val all = load(historyFile)
if (all.none { it.optString("signature") == signature }) return@runCatching false
historyFile.writeText(JSONArray(all.map(::update)).toString())
runCatching { JSONObject(ideasFile.readText()).let { root ->
val ideas = root.getJSONArray("ideas")
ideasFile.writeText(root.put("ideas", JSONArray((0 until ideas.length()).map { update(ideas.getJSONObject(it)) })).toString())
} }
ApprovedDriveInEvents.refresh(historyFile)
true
}.getOrDefault(false)
/** Your approved ideas as Drive-In events, each with its films pinned by library item and IMDb ID. */
fun approvedEvents(file: File): List<DriveInEvent> = load(file).filter { it.optString("decision") == "approved" }.mapNotNull { o ->
val evening = o.optString("evening").takeIf { it.isNotEmpty() && it != "null" }?.let { runCatching { LocalDateTime.parse(it) }.getOrNull() } ?: return@mapNotNull null
val films = o.optJSONArray("filmData")?.let { a -> (0 until a.length()).map { a.getJSONObject(it) } }.orEmpty()
.map { EventFilm(it.optString("title"), it.optInt("year"), it.optString("imdb"), it.optString("id")) }.filter { it.imdb.isNotBlank() && EmbyClient.validId(it.itemId) }
if (films.size < 2) return@mapNotNull null
DriveInEvent("idea-" + Integer.toHexString(o.optString("signature").hashCode()), o.optString("theme"), evening.toLocalDate(), evening.hour, evening.minute, films)
}
/** Ideas shown under older rules and never decided are withdrawn, kept in the history with the reason. */
fun withdrawOld(file: File) = runCatching {
val all = load(file)
@@ -95,4 +95,33 @@ class EventIdeasTest {
assertEquals("proposed", EventIdeaHistory.load(file).single().optString("decision"))
assertEquals(2L, EventIdeaHistory.load(file).single().optLong("shownMs"))
}
@Test fun anApprovedIdeaBecomesADriveInEventAndBooksLikeOne() {
val history = java.io.File.createTempFile("history", ".json").apply { deleteOnExit(); writeText("[]") }
val ideasFile = java.io.File.createTempFile("ideas", ".json").apply { deleteOnExit() }
val a = film("5000001", "Comedy One", setOf("Comedy"), "U", 8.0, 1949, 81, "tt0041546"); val b = film("5000002", "Comedy Two", setOf("Comedy"), "U", 7.5, 1951, 78, "tt0044829")
val idea = EventIdea("studio:Ealing Studios|FridayDouble", "Ealing Comedy Night", "studio", IdeaFormat.FridayDouble, java.time.LocalDateTime.of(2026, 11, 20, 20, 0), listOf(a, b), listOf("why"), 8.0)
EventIdeaHistory.record(history, listOf(idea), 1)
ideasFile.writeText(org.json.JSONObject().put("ideas", org.json.JSONArray(EventIdeaHistory.load(history))).toString())
assertTrue(EventIdeaHistory.decide(history, ideasFile, idea.signature, "approved", listOf(a, b), 2))
val event = EventIdeaHistory.approvedEvents(history).single()
assertEquals(LocalDate.of(2026, 11, 20), event.evening); assertEquals(20, event.hour); assertEquals(listOf("5000001", "5000002"), event.films.map { it.itemId })
assertEquals("tt0041546", event.films.first().imdb)
assertTrue(allDriveInEvents().any { it.name == "Ealing Comedy Night" }) // the Drive-In books it like its own
assertEquals("approved", org.json.JSONObject(ideasFile.readText()).getJSONArray("ideas").getJSONObject(0).optString("decision"))
assertTrue(idea.signature in EventIdeaHistory.dismissed(history, 3)) // never proposed again
// It books on its evening, from the verified films, as the curated events do.
val pool = (1..40).map { n -> PoolSeries("p$n", "Film $n", listOf(PoolEpisode("p$n", "s$n", EpisodeMark(1, 1), "Film $n", 100 * 60_000L))) }
val verified = listOf(a, b).associate { f -> f.id to PoolSeries(f.id, f.title, listOf(PoolEpisode(f.id, "src", EpisodeMark(1, 1), f.title, f.runtimeMs!!))) }
var plan: ChannelPlan? = null; var now = java.time.ZonedDateTime.of(2026, 11, 12, 12, 0, 0, 0, london).toInstant().toEpochMilli()
repeat(9) { plan = planDriveIn(plan, pool, pool, pool, now, london, events = listOf(event), eventFilms = verified); now += 24 * 60 * 60_000L }
val booked = plan!!.entries.filter { it.seriesId in verified.keys }
assertEquals(listOf("5000001", "5000002"), booked.map { it.seriesId })
assertTrue(booked.first().why.startsWith(DRIVE_IN_EVENT_WHY))
// Deferred: back after four weeks; dismissed: not again.
val h2 = java.io.File.createTempFile("history", ".json").apply { deleteOnExit(); writeText("[]") }
EventIdeaHistory.record(h2, listOf(idea), 1); EventIdeaHistory.decide(h2, ideasFile, idea.signature, "deferred", emptyList(), 10)
assertTrue(idea.signature in EventIdeaHistory.dismissed(h2, 10 + EVENT_IDEA_DEFER_MS - 1)); assertFalse(idea.signature in EventIdeaHistory.dismissed(h2, 10 + EVENT_IDEA_DEFER_MS))
ApprovedDriveInEvents.list = emptyList()
}
}