feat(audio-rooms): scheduled rooms (T1 4b)

Shipped the deferred slice of Tier 1 Step 4: hosts can now
schedule a room for a future time instead of going live
immediately.

  Quartz:
    StatusTag.STATUS.PLANNED — new enum value alongside OPEN /
                               PRIVATE / CLOSED. Pre-Lite-03
                               clients that don't know the value
                               get null from the parser; the room
                               renderer's existing fallback handles
                               that. The amethyst feed renders
                               PLANNED with the OPEN badge in v1
                               (a "Scheduled — starts at HH:MM"
                               chip is a visual follow-up).
    StartsTag — `["starts", "<unix-seconds>"]` parser + assembler.
                Strict numeric — non-numeric values return null so
                a malformed event can't crash the room-list
                renderer. Negative values are rejected at assemble.
    MeetingSpaceEvent.starts() — accessor.
    TagArrayBuilderExt.starts(unixSeconds) — DSL helper.

  Amethyst:
    CreateAudioRoomViewModel —
      onScheduledToggle(scheduled) — flips PLANNED vs OPEN.
      onScheduledStartChange(unixSeconds) — picker callback.
      FormState.scheduled / .scheduledStartUnix — additive fields.
      canSubmit gates on a picked time when scheduled = true.
      publishAndBuildLaunchInfo() — emits status=PLANNED +
                                    `["starts", <unix>]` when
                                    scheduled; otherwise the
                                    existing OPEN path runs.

    CreateAudioRoomSheet —
      Schedule toggle (Material3 Switch) above the picker.
      ScheduleStartPicker — OutlinedButton that opens a Material3
                            DatePickerDialog. The selected date is
                            saved as 00:00 of that day in the
                            local time zone (TimePicker stitching
                            is a follow-up; nostrnests' web UI
                            does the same date-only flow).

    MeetingSpace.kt feed item — added the PLANNED branch to the
    exhaustive when so the build passes.

  Tests:
    StartsTagTest — numeric parse, malformed-rejected, missing /
    wrong-name rejection, assemble shape, negative rejected,
    STATUS.PLANNED parse round-trip.
This commit is contained in:
Claude
2026-04-26 23:35:51 +00:00
parent e28ff87e8e
commit f89ab8f1a2
9 changed files with 252 additions and 5 deletions
@@ -0,0 +1,64 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.quartz.nip53LiveActivities.meetingSpaces.tags
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFailsWith
import kotlin.test.assertNull
class StartsTagTest {
@Test
fun parsesNumericValue() {
assertEquals(1_780_000_000L, StartsTag.parse(arrayOf("starts", "1780000000")))
}
@Test
fun rejectsNonNumeric() {
// Malformed wire data must NOT crash the room-list renderer.
assertNull(StartsTag.parse(arrayOf("starts", "soon")))
}
@Test
fun rejectsMissingValue() {
assertNull(StartsTag.parse(arrayOf("starts")))
}
@Test
fun rejectsWrongName() {
assertNull(StartsTag.parse(arrayOf("ends", "1780000000")))
}
@Test
fun assembleProducesCanonicalShape() {
assertEquals(arrayOf("starts", "100").toList(), StartsTag.assemble(100L).toList())
}
@Test
fun assembleRejectsNegative() {
assertFailsWith<IllegalArgumentException> { StartsTag.assemble(-1) }
}
@Test
fun statusEnumIncludesPlanned() {
assertEquals(StatusTag.STATUS.PLANNED, StatusTag.STATUS.parse("planned"))
}
}