feat(calendars): "Add to phone calendar" intent + multi-day bars in month grid

- New IconButton in the detail screen opens the system event composer
  (Google Calendar / Samsung / iCloud) pre-filled with title, range,
  location, and description via Intent.ACTION_INSERT on CalendarContract.
  Falls back to the .ics share path if no calendar app is installed.
- Replaced the per-cell event-dot row with horizontal bars that span the
  cells a multi-day event covers. Bars are laid out by a greedy
  lowest-lane assignment so overlapping events stack rather than collide;
  cells removed their horizontal padding so adjacent bars merge into one
  uninterrupted line.
- Bars round their ends only at the event's actual start/end (or at week
  boundaries), so a 3-day conference reads as one continuous pill across
  the row.
- Added MonthGridBarsTest (7 tests) covering single/multi-day, lane
  collision, longer-event tiebreak, and the empty/ghost edge cases.
This commit is contained in:
Claude
2026-05-19 19:51:01 +00:00
parent 971e9e4846
commit ab5d884b34
7 changed files with 431 additions and 33 deletions
@@ -0,0 +1,103 @@
/*
* 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.amethyst.ui.screen.loggedIn.calendars
import android.content.ActivityNotFoundException
import android.content.Context
import android.content.Intent
import android.provider.CalendarContract
import com.vitorpamplona.amethyst.ui.screen.loggedIn.calendars.dal.parseIsoDateToUnixSeconds
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip52Calendar.appt.day.CalendarDateSlotEvent
import com.vitorpamplona.quartz.nip52Calendar.appt.time.CalendarTimeSlotEvent
/**
* Opens the system "New Event" composer (Google Calendar / Samsung / iCloud / etc.) pre-populated
* with this appointment's title, time, location, and description. The user sees their normal
* calendar UI with one tap to "Save" — strictly nicer than the .ics-via-share-sheet path because
* it doesn't go through a file and surfaces the user's preferred calendar app directly.
*
* Returns true if a calendar app handled the intent, false if no handler was found — the caller
* can decide whether to fall back to the .ics share path.
*/
fun addToPhoneCalendar(
context: Context,
event: Event,
): Boolean {
val (title, location, summary) =
when (event) {
is CalendarTimeSlotEvent -> Triple(event.title(), event.location(), event.summary())
is CalendarDateSlotEvent -> Triple(event.title(), event.location(), event.summary())
else -> return false
}
val (beginMs, endMs, allDay) = computeRangeMs(event) ?: return false
val description =
buildString {
summary?.let { append(it) }
if (event.content.isNotBlank()) {
if (isNotEmpty()) append("\n\n")
append(event.content)
}
}
val intent =
Intent(Intent.ACTION_INSERT).apply {
data = CalendarContract.Events.CONTENT_URI
putExtra(CalendarContract.Events.TITLE, title.orEmpty())
location?.let { putExtra(CalendarContract.Events.EVENT_LOCATION, it) }
if (description.isNotEmpty()) {
putExtra(CalendarContract.Events.DESCRIPTION, description)
}
putExtra(CalendarContract.EXTRA_EVENT_BEGIN_TIME, beginMs)
putExtra(CalendarContract.EXTRA_EVENT_END_TIME, endMs)
putExtra(CalendarContract.EXTRA_EVENT_ALL_DAY, allDay)
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
}
return try {
context.startActivity(intent)
true
} catch (_: ActivityNotFoundException) {
false
}
}
/**
* Computes the (beginMillis, endMillis, isAllDay) triple from a calendar event. NIP-52 date-slot
* uses ISO dates that we anchor at local midnight; time-slot uses unix seconds. End defaults to
* begin + 1 hour for time-slot events without an end and to begin + 1 day for all-day events
* without an end (calendar providers expect end > start for any visible event).
*/
private fun computeRangeMs(event: Event): Triple<Long, Long, Boolean>? =
when (event) {
is CalendarTimeSlotEvent -> {
val start = event.start() ?: return null
val end = event.end() ?: (start + 3600L)
Triple(start * 1000L, end * 1000L, false)
}
is CalendarDateSlotEvent -> {
val startSec = parseIsoDateToUnixSeconds(event.start()) ?: return null
val endSec = parseIsoDateToUnixSeconds(event.end()) ?: (startSec + 86400L)
Triple(startSec * 1000L, endSec * 1000L, true)
}
else -> null
}
@@ -32,10 +32,8 @@ import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
@@ -48,7 +46,6 @@ import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
@@ -56,9 +53,11 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.ui.feeds.FeedContentState
import com.vitorpamplona.amethyst.commons.ui.feeds.FeedState
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.calendars.dal.MONTH_GRID_MAX_LANES
import com.vitorpamplona.amethyst.ui.screen.loggedIn.calendars.dal.MonthGridBarSegment
import com.vitorpamplona.amethyst.ui.screen.loggedIn.calendars.dal.computeMonthGridBars
import com.vitorpamplona.amethyst.ui.screen.loggedIn.calendars.dal.groupByDayKeyExpanded
import com.vitorpamplona.amethyst.ui.stringRes
import java.time.LocalDate
@@ -94,6 +93,7 @@ fun CalendarMonthView(
}
val eventsByDay by remember(notes) { derivedStateOf { groupByDayKeyExpanded(notes) } }
val barsByDay by remember(notes) { derivedStateOf { computeMonthGridBars(notes) } }
var selectedDayKey by rememberSaveable { mutableStateOf<Long?>(null) }
@@ -136,7 +136,7 @@ fun CalendarMonthView(
MonthGrid(
visibleMonth = visibleMonth,
today = today,
eventsByDay = eventsByDay,
barsByDay = barsByDay,
selectedDayKey = selectedDayKey,
onDayClick = { dayKey ->
selectedDayKey = if (selectedDayKey == dayKey) null else dayKey
@@ -176,7 +176,7 @@ private fun WeekdayHeader() {
private fun MonthGrid(
visibleMonth: YearMonth,
today: LocalDate,
eventsByDay: Map<Long, List<Note>>,
barsByDay: Map<Long, List<MonthGridBarSegment>>,
selectedDayKey: Long?,
onDayClick: (Long) -> Unit,
) {
@@ -196,16 +196,22 @@ private fun MonthGrid(
if (dayNumber in 1..daysInMonth) {
val date = visibleMonth.atDay(dayNumber)
val dayKey = date.toEpochDay()
val cellBars = barsByDay[dayKey].orEmpty()
DayCell(
modifier = Modifier.weight(1f),
dayNumber = dayNumber,
isToday = isCurrentMonth && date == today,
isSelected = selectedDayKey == dayKey,
eventCount = eventsByDay[dayKey]?.size ?: 0,
bars = cellBars,
// Anything past the visible lane cap collapses into a "+N" tail —
// keeps each cell readable when a day has more than three events.
extraEventCount = cellBars.count { it.lane >= MONTH_GRID_MAX_LANES },
isWeekStart = c == 0,
isWeekEnd = c == 6,
onClick = { onDayClick(dayKey) },
)
} else {
Box(modifier = Modifier.weight(1f).height(56.dp))
Box(modifier = Modifier.weight(1f).height(MONTH_CELL_HEIGHT))
}
}
}
@@ -213,13 +219,18 @@ private fun MonthGrid(
}
}
private val MONTH_CELL_HEIGHT = 72.dp
@Composable
private fun DayCell(
modifier: Modifier,
dayNumber: Int,
isToday: Boolean,
isSelected: Boolean,
eventCount: Int,
bars: List<MonthGridBarSegment>,
extraEventCount: Int,
isWeekStart: Boolean,
isWeekEnd: Boolean,
onClick: () -> Unit,
) {
val bg =
@@ -232,8 +243,11 @@ private fun DayCell(
Box(
modifier =
modifier
.height(56.dp)
.padding(2.dp)
.height(MONTH_CELL_HEIGHT)
// Vertical-only padding so adjacent cells in a row touch horizontally — a
// multi-day bar that extends from the right edge of one cell to the left edge of
// the next visually merges into a single uninterrupted line.
.padding(vertical = 2.dp)
.background(bg, RoundedCornerShape(8.dp))
.border(
width = if (isToday) 1.5.dp else 0.5.dp,
@@ -242,9 +256,8 @@ private fun DayCell(
).clickable(onClick = onClick),
) {
Column(
modifier = Modifier.fillMaxSize().padding(4.dp),
modifier = Modifier.fillMaxSize().padding(horizontal = 2.dp, vertical = 4.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.SpaceBetween,
) {
Text(
text = dayNumber.toString(),
@@ -257,35 +270,69 @@ private fun DayCell(
MaterialTheme.colorScheme.onSurface
},
)
EventDotRow(eventCount)
Spacer(modifier = Modifier.height(2.dp))
EventBarLanes(
bars = bars,
extraEventCount = extraEventCount,
isWeekStart = isWeekStart,
isWeekEnd = isWeekEnd,
)
}
}
}
/**
* Renders up to [MONTH_GRID_MAX_LANES] horizontal bars stacked vertically. Each lane occupies a
* fixed height across every cell so a multi-day event sits on the same y-row in every column it
* covers — the visual continuity that makes "spans 3 days" readable at a glance.
*
* The bar is rounded only at the event's start (`isLeftEnd`) and end (`isRightEnd`). On week
* boundaries we also round so each row of the grid looks self-contained instead of bleeding into
* an unaligned next row.
*/
@Composable
private fun EventDotRow(eventCount: Int) {
if (eventCount <= 0) {
Spacer(modifier = Modifier.height(6.dp))
return
}
Row(
horizontalArrangement = Arrangement.spacedBy(2.dp),
modifier = Modifier.padding(bottom = 1.dp),
private fun EventBarLanes(
bars: List<MonthGridBarSegment>,
extraEventCount: Int,
isWeekStart: Boolean,
isWeekEnd: Boolean,
) {
repeat(eventCount.coerceAtMost(3)) {
val barColor = MaterialTheme.colorScheme.primary
Column(
modifier = Modifier.fillMaxWidth(),
verticalArrangement = Arrangement.spacedBy(1.dp),
) {
for (i in 0 until MONTH_GRID_MAX_LANES) {
val seg = bars.firstOrNull { it.lane == i }
if (seg != null) {
val roundLeft = seg.isLeftEnd || isWeekStart
val roundRight = seg.isRightEnd || isWeekEnd
Box(
modifier =
Modifier
.size(5.dp)
.background(MaterialTheme.colorScheme.primary, CircleShape),
.fillMaxWidth()
.height(5.dp)
.background(
color = barColor,
shape =
RoundedCornerShape(
topStart = if (roundLeft) 2.dp else 0.dp,
bottomStart = if (roundLeft) 2.dp else 0.dp,
topEnd = if (roundRight) 2.dp else 0.dp,
bottomEnd = if (roundRight) 2.dp else 0.dp,
),
),
)
} else {
Spacer(modifier = Modifier.fillMaxWidth().height(5.dp))
}
if (eventCount > 3) {
}
if (extraEventCount > 0) {
Text(
text = "+",
text = "+$extraEventCount",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.primary,
modifier = Modifier.graphicsLayer { translationY = -3f },
color = barColor,
fontWeight = FontWeight.SemiBold,
)
}
}
@@ -0,0 +1,98 @@
/*
* 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.amethyst.ui.screen.loggedIn.calendars.dal
import androidx.compose.runtime.Immutable
import com.vitorpamplona.amethyst.model.Note
/**
* One bar drawn into one day cell, with `lane` controlling its vertical position so two
* overlapping multi-day events stack rather than collide. `isLeftEnd` / `isRightEnd` control
* which corners of the bar are rounded — a continuation day in the middle of a 3-day event gets
* neither end rounded, so adjacent cells visually merge into one bar.
*
* Note: the underlying [Note] is exposed so the UI can colour or label bars per event. Equality
* is on idHex so a row of cells holding the same bar share segment identity for keys.
*/
@Immutable
data class MonthGridBarSegment(
val note: Note,
val lane: Int,
val isLeftEnd: Boolean,
val isRightEnd: Boolean,
)
/**
* Maximum lanes we render before collapsing the remainder into a "+N" overflow label. Three
* matches the previous dot-row capacity and keeps each 56dp cell readable on mid-range phones.
*/
const val MONTH_GRID_MAX_LANES = 3
/**
* Greedy lane-assignment for the month grid: sort events earliest-start-first (longer events
* wins ties so they take the top lane), then for each event pick the lowest lane index whose
* full day-range is unoccupied. Returns a per-day-key map so each cell can render its own
* segments without re-running the layout.
*
* Single-day events participate in the same layout — they get bars too, just short ones,
* which keeps the visual language consistent.
*/
fun computeMonthGridBars(notes: List<Note>): Map<Long, List<MonthGridBarSegment>> {
val ranges =
notes
.distinctBy { it.idHex }
.mapNotNull { n -> n.calendarLocalDayKeyRange()?.let { n to it } }
.sortedWith(
compareBy(
{ it.second.first },
{ -(it.second.last - it.second.first) },
{ it.first.idHex },
),
)
// day-key → set of lanes already claimed for that day
val occupied = mutableMapOf<Long, MutableSet<Int>>()
val perDay = mutableMapOf<Long, MutableList<MonthGridBarSegment>>()
for ((note, range) in ranges) {
// Find the lowest lane index whose full range is free. Bounded at 32 so a pathological
// input can't loop forever; overflow events still render as "+N" via the cap downstream.
var lane = 0
while (lane < 32) {
val clash = (range).any { occupied[it]?.contains(lane) == true }
if (!clash) break
lane++
}
for (day in range) {
occupied.getOrPut(day) { mutableSetOf() }.add(lane)
perDay.getOrPut(day) { mutableListOf() }.add(
MonthGridBarSegment(
note = note,
lane = lane,
isLeftEnd = day == range.first,
isRightEnd = day == range.last,
),
)
}
}
// Within each cell, sort by lane so the rendering doesn't have to.
return perDay.mapValues { (_, list) -> list.sortedBy { it.lane } }
}
@@ -72,6 +72,7 @@ import com.vitorpamplona.amethyst.ui.note.ReactionsRow
import com.vitorpamplona.amethyst.ui.note.UsernameDisplay
import com.vitorpamplona.amethyst.ui.note.types.CalendarRsvpRow
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.calendars.addToPhoneCalendar
import com.vitorpamplona.amethyst.ui.screen.loggedIn.calendars.dal.IcsExport
import com.vitorpamplona.amethyst.ui.screen.loggedIn.calendars.dal.appointmentView
import com.vitorpamplona.amethyst.ui.screen.loggedIn.calendars.datasource.CalendarsFilterAssemblerSubscription
@@ -153,6 +154,24 @@ fun CalendarEventDetailScreen(
// in posts, in other clients). A single button with a chooser would be
// cleaner, but two icons keep both actions one tap away.
if (event != null) {
// Direct "Add to phone calendar" — opens the system event composer with
// every field pre-filled. Falls back to the .ics share path if the device
// has no calendar app registered for ACTION_INSERT (rare; Wear OS, some
// GrapheneOS profiles).
IconButton(onClick = {
if (!addToPhoneCalendar(context, event)) {
val ics = IcsExport.appointmentToIcs(event, targetAddress, TimeUtils.now())
val filename = IcsExport.appointmentFilename(event, targetAddress)
shareIcs(context, filename, ics)
}
}) {
Icon(
symbol = MaterialSymbols.EventAvailable,
contentDescription = stringRes(R.string.calendar_add_to_phone_calendar),
modifier = Modifier.size(20.dp),
tint = MaterialTheme.colorScheme.onSurface,
)
}
IconButton(onClick = {
val ics = IcsExport.appointmentToIcs(event, targetAddress, TimeUtils.now())
val filename = IcsExport.appointmentFilename(event, targetAddress)
+1
View File
@@ -1968,6 +1968,7 @@
<string name="calendar_no_events">No events</string>
<string name="calendar_continues">Continues</string>
<string name="calendar_day_of_total">Day %1$d of %2$d</string>
<string name="calendar_add_to_phone_calendar">Add to phone calendar</string>
<string name="calendar_untitled">(untitled)</string>
<string name="calendar_all_day">All-day</string>
<string name="calendar_rsvp_going_prefixed">✓ Going</string>
@@ -0,0 +1,129 @@
/*
* 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.amethyst.calendar
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.ui.screen.loggedIn.calendars.dal.computeMonthGridBars
import com.vitorpamplona.quartz.nip52Calendar.appt.day.CalendarDateSlotEvent
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
import java.time.LocalDate
class MonthGridBarsTest {
@Test
fun singleDayEvent_oneSegment_bothEndsRounded() {
val note = dateSlot("a", "2025-01-15")
val key = LocalDate.of(2025, 1, 15).toEpochDay()
val byDay = computeMonthGridBars(listOf(note))
val seg = byDay[key]?.single()
assertNotNull(seg)
assertTrue("single-day event should round both ends", seg!!.isLeftEnd && seg.isRightEnd)
assertEquals(0, seg.lane)
}
@Test
fun threeDayEvent_segmentPerDay_endsOnlyOnBoundaries() {
val note = dateSlot("a", "2025-01-15", end = "2025-01-17")
val byDay = computeMonthGridBars(listOf(note))
val k15 = LocalDate.of(2025, 1, 15).toEpochDay()
val k16 = LocalDate.of(2025, 1, 16).toEpochDay()
val k17 = LocalDate.of(2025, 1, 17).toEpochDay()
assertEquals(true to false, byDay[k15]!!.single().run { isLeftEnd to isRightEnd })
assertEquals(false to false, byDay[k16]!!.single().run { isLeftEnd to isRightEnd })
assertEquals(false to true, byDay[k17]!!.single().run { isLeftEnd to isRightEnd })
}
@Test
fun overlappingEvents_assignedToDistinctLanes() {
// A: Jan 1517. B: Jan 1618. They overlap on 16 and 17 so must land in different lanes.
val a = dateSlot("a", "2025-01-15", end = "2025-01-17")
val b = dateSlot("b", "2025-01-16", end = "2025-01-18")
val byDay = computeMonthGridBars(listOf(a, b))
val k16 = LocalDate.of(2025, 1, 16).toEpochDay()
val lanes = byDay[k16]!!.map { it.lane }.toSet()
assertEquals("expected two distinct lanes on the overlap day", 2, lanes.size)
}
@Test
fun longerEventTakesLowerLane_amongTies() {
// Earliest-start ties broken by length-descending: the longer event sits on lane 0 so it
// visually anchors the top, with the shorter one tucked under it.
val long3 = dateSlot("L", "2025-01-15", end = "2025-01-17")
val short1 = dateSlot("S", "2025-01-15")
val byDay = computeMonthGridBars(listOf(short1, long3))
val k15 = byDay[LocalDate.of(2025, 1, 15).toEpochDay()]!!
val longLane = k15.first { it.note === long3 }.lane
val shortLane = k15.first { it.note === short1 }.lane
assertTrue("longer event should be in a lower lane", longLane < shortLane)
}
@Test
fun nonOverlappingEvents_reuseLowestLane() {
// A: Jan 15. B: Jan 16. C: Jan 17. No overlaps → all on lane 0.
val a = dateSlot("a", "2025-01-15")
val b = dateSlot("b", "2025-01-16")
val c = dateSlot("c", "2025-01-17")
val byDay = computeMonthGridBars(listOf(a, b, c))
for (note in listOf(a, b, c)) {
val key =
note.event!!
.tags
.first { it[0] == "start" }[1]
.let(LocalDate::parse)
.toEpochDay()
assertEquals(0, byDay[key]!!.single().lane)
}
}
@Test
fun noEvents_emptyMap() {
val byDay = computeMonthGridBars(emptyList())
assertTrue(byDay.isEmpty())
}
@Test
fun noteWithoutStart_dropped() {
val ghost = Note("ghost") // no event
val real = dateSlot("a", "2025-01-15")
val byDay = computeMonthGridBars(listOf(ghost, real))
assertEquals(1, byDay.size)
assertNull(byDay[0L])
}
private fun dateSlot(
id: String,
start: String,
end: String? = null,
): Note {
val tags =
buildList {
add(arrayOf("d", "$id-d"))
add(arrayOf("title", "T"))
add(arrayOf("start", start))
end?.let { add(arrayOf("end", it)) }
}.toTypedArray()
val e = CalendarDateSlotEvent(id, "pub", 0L, tags, "", "sig")
return Note(id).apply { event = e }
}
}
@@ -97,6 +97,7 @@ object MaterialSymbols {
val EmojiEmotions = MaterialSymbol("\uEA22")
val Error = MaterialSymbol("\uF8B6")
val ErrorOutline = MaterialSymbol("\uF8B6")
val EventAvailable = MaterialSymbol("\uE614")
val ExpandLess = MaterialSymbol("\uE5CE")
val ExpandMore = MaterialSymbol("\uE5CF")
val Explore = MaterialSymbol("\uE87A")