Kalendar

Changelog#

All notable changes to Kalendar will be documented in this file. The format is based on Keep a Changelog and this project adheres to Semantic Versioning.

This page mirrors CHANGELOG.md at the repository root, which is the canonical copy.


[2.0.0]#

2.0.0 is a ground-up redesign. There is no compatibility guarantee with 1.x, and none with any 2.0.0 release candidate. Every public type has moved, most have been rewritten, and the 1.x entry point no longer exists. This is not a version you upgrade into by bumping a number — read the migration table below, then Migration from 1.x.

Two things define the release:

  1. kalendar-foundation is a public, documented headless calendar engine. The date arithmetic, paging arithmetic, grid layout, overlap packing, drag maths and selection reduction that were sealed inside one flat internal package are now a supported API — enough to build a calendar Kalendar did not design, without depending on Kalendar's UI. Its public surface went from 42 lines of API dump to 334. See The headless engine.
  2. The package com.himanshoe.kalendar.view no longer exists. "View" is a Swing-and-Android-View word in a library whose entire selling point is Compose, and one flat 27-file package gave an IDE user nothing to navigate. The nine calendar composables sit at the package root, with state, theme, slot and component beside them.

Artifact coordinates at the time of writing: com.himanshoe:kalendar:2.0.0-RC3, com.himanshoe:kalendar-foundation:2.0.0, com.himanshoe:kalendar-sync:1.0.0.

Migration: every public symbol, old name to new#

Derived from the two modules' binary-compatibility dumps — kalendar/api/*/kalendar.api and kalendar-foundation/api/*/kalendar-foundation.api — before and after the move, so nothing public is missing from it. Fifty-one top-level entities moved. Not one was renamed and not one was removed: every row below is a package change, so in most codebases the migration is a find-and-replace on import lines.

Members did not move independently of their owners — KalendarViewState.visibleDate is still visibleDate — so the table lists top-level entities only. Every member of every one of them is documented in the API reference.

Calendar composables, their config, and their *Defaults — now at the package root, the way material3 puts Button at its root.

1.x / RC2.0.0
com.himanshoe.kalendar.view.KalendarWeekcom.himanshoe.kalendar.KalendarWeek
com.himanshoe.kalendar.view.KalendarMonthcom.himanshoe.kalendar.KalendarMonth
com.himanshoe.kalendar.view.KalendarYearcom.himanshoe.kalendar.KalendarYear
com.himanshoe.kalendar.view.KalendarTimelinecom.himanshoe.kalendar.KalendarTimeline
com.himanshoe.kalendar.view.KalendarSchedulecom.himanshoe.kalendar.KalendarSchedule
com.himanshoe.kalendar.view.KalendarScheduleWeekcom.himanshoe.kalendar.KalendarScheduleWeek
com.himanshoe.kalendar.view.KalendarResourceViewcom.himanshoe.kalendar.KalendarResourceView
com.himanshoe.kalendar.view.KalendarAgendacom.himanshoe.kalendar.KalendarAgenda
com.himanshoe.kalendar.view.KalendarDatePickercom.himanshoe.kalendar.KalendarDatePicker
com.himanshoe.kalendar.view.KalendarViewConfigcom.himanshoe.kalendar.KalendarViewConfig
com.himanshoe.kalendar.view.KalendarAgendaDefaultscom.himanshoe.kalendar.KalendarAgendaDefaults
com.himanshoe.kalendar.view.KalendarScheduleDefaultscom.himanshoe.kalendar.KalendarScheduleDefaults
com.himanshoe.kalendar.view.KalendarResourceDefaultscom.himanshoe.kalendar.KalendarResourceDefaults
com.himanshoe.kalendar.view.KalendarDatePickerDefaultscom.himanshoe.kalendar.KalendarDatePickerDefaults
com.himanshoe.kalendar.view.KalendarDatePickerDayScopecom.himanshoe.kalendar.KalendarDatePickerDayScope
com.himanshoe.kalendar.view.KalendarDatePickerActionScopecom.himanshoe.kalendar.KalendarDatePickerActionScope

Navigation, selection and clock state → com.himanshoe.kalendar.state.

1.x / RC2.0.0
com.himanshoe.kalendar.view.KalendarViewStatecom.himanshoe.kalendar.state.KalendarViewState
com.himanshoe.kalendar.view.KalendarTimelineStatecom.himanshoe.kalendar.state.KalendarTimelineState
com.himanshoe.kalendar.view.KalendarSelectionStatecom.himanshoe.kalendar.state.KalendarSelectionState
com.himanshoe.kalendar.view.rememberKalendarWeekStatecom.himanshoe.kalendar.state.rememberKalendarWeekState
com.himanshoe.kalendar.view.rememberKalendarMonthStatecom.himanshoe.kalendar.state.rememberKalendarMonthState
com.himanshoe.kalendar.view.rememberKalendarYearStatecom.himanshoe.kalendar.state.rememberKalendarYearState
com.himanshoe.kalendar.view.rememberKalendarScheduleStatecom.himanshoe.kalendar.state.rememberKalendarScheduleState
com.himanshoe.kalendar.view.rememberKalendarTimelineStatecom.himanshoe.kalendar.state.rememberKalendarTimelineState
com.himanshoe.kalendar.view.rememberKalendarSelectionStatecom.himanshoe.kalendar.state.rememberKalendarSelectionState
com.himanshoe.kalendar.view.ProvideKalendarTimeSourcecom.himanshoe.kalendar.state.ProvideKalendarTimeSource
com.himanshoe.kalendar.view.rememberKalendarTimeSourcecom.himanshoe.kalendar.state.rememberKalendarTimeSource
com.himanshoe.kalendar.view.rememberKalendarTodaycom.himanshoe.kalendar.state.rememberKalendarToday
com.himanshoe.kalendar.view.rememberKalendarNowcom.himanshoe.kalendar.state.rememberKalendarNow

The theme and its six token sets → com.himanshoe.kalendar.theme.

1.x / RC2.0.0
com.himanshoe.kalendar.view.KalendarThemecom.himanshoe.kalendar.theme.KalendarTheme
com.himanshoe.kalendar.view.KalendarThemeDefaultscom.himanshoe.kalendar.theme.KalendarThemeDefaults
com.himanshoe.kalendar.view.KalendarThemeTokenscom.himanshoe.kalendar.theme.KalendarThemeTokens
com.himanshoe.kalendar.view.KalendarColorscom.himanshoe.kalendar.theme.KalendarColors
com.himanshoe.kalendar.view.KalendarTypographycom.himanshoe.kalendar.theme.KalendarTypography
com.himanshoe.kalendar.view.KalendarShapescom.himanshoe.kalendar.theme.KalendarShapes
com.himanshoe.kalendar.view.KalendarDimensionscom.himanshoe.kalendar.theme.KalendarDimensions
com.himanshoe.kalendar.view.KalendarAnimationscom.himanshoe.kalendar.theme.KalendarAnimations
com.himanshoe.kalendar.view.KalendarStringscom.himanshoe.kalendar.theme.KalendarStrings

Content-slot scope types → com.himanshoe.kalendar.slot.

1.x / RC2.0.0
com.himanshoe.kalendar.view.KalendarDayScopecom.himanshoe.kalendar.slot.KalendarDayScope
com.himanshoe.kalendar.view.KalendarDayOverflowScopecom.himanshoe.kalendar.slot.KalendarDayOverflowScope
com.himanshoe.kalendar.view.KalendarHeaderScopecom.himanshoe.kalendar.slot.KalendarHeaderScope
com.himanshoe.kalendar.view.KalendarScheduleEventScopecom.himanshoe.kalendar.slot.KalendarScheduleEventScope
com.himanshoe.kalendar.view.KalendarResourceEventScopecom.himanshoe.kalendar.slot.KalendarResourceEventScope

Default renderings and the day-cell modifier → com.himanshoe.kalendar.component.

1.x / RC2.0.0
com.himanshoe.kalendar.view.KalendarDayCellDefaultscom.himanshoe.kalendar.component.KalendarDayCellDefaults
com.himanshoe.kalendar.view.KalendarHeaderDefaultscom.himanshoe.kalendar.component.KalendarHeaderDefaults
com.himanshoe.kalendar.view.kalendarDaySemanticscom.himanshoe.kalendar.component.kalendarDaySemantics

Types that turned out to be pure logic → kalendar-foundation. These moved module, not just package. kalendar re-exports the engine as an api dependency, so a single-dependency consumer needs no build change.

1.x / RC2.0.0
com.himanshoe.kalendar.view.KalendarTimeSourcecom.himanshoe.kalendar.foundation.datetime.KalendarTimeSource
com.himanshoe.kalendar.view.datesUntilcom.himanshoe.kalendar.foundation.datetime.datesUntil
com.himanshoe.kalendar.view.KalendarSelectionModecom.himanshoe.kalendar.foundation.selection.KalendarSelectionMode
com.himanshoe.kalendar.view.KalendarHourWindowcom.himanshoe.kalendar.foundation.schedule.KalendarHourWindow
com.himanshoe.kalendar.view.KalendarResourcecom.himanshoe.kalendar.foundation.schedule.KalendarResource

Newly public, with no 1.x equivalent: the whole of com.himanshoe.kalendar.foundation.datetime (period starts, whole-period differences), .grid (weekDates, monthGridDates, monthDates, visibleDateOnOrAfter, visibleDateStep, daysOfWeekStartingAt, ALL_DAYS_OF_WEEK), .paging (KalendarPager, KalendarPageUnit), .format (KalendarFormatters, monthTitle), .selection (KalendarSelection), most of .schedule (KalendarScheduleBlock, scheduleBlocks, KalendarBlockPlacement, blockPlacement, the drag and create maths, the resource-lane functions, and the four minute/hour constants), and byDateExpandingSpans on .event. None of these are migrations — they were internal, and are now supported.

Breaking changes#

  • The calendar views are generic in their event type: KalendarMonth<E : KalendarEvent> and so on for KalendarWeek, KalendarYear, KalendarTimeline, KalendarAgenda, KalendarSchedule, KalendarScheduleWeek and KalendarResourceView. events is now List<E> rather than KalendarEvents, and every callback and slot hands E straight back — onDateClick receives List<Booking>, onEventClick receives Booking, resourceIdOf is given a Booking. The cast a consumer used to write to get its own type back out of a callback is gone; there is nothing left to cast. E is inferred from events, so a call that already passes events needs no change at all:

    kotlin
    // before: dayEvents was List<KalendarEvent>, and reading roomId meant a cast
    KalendarResourceView(
        resources = rooms,
        events = bookings,
        resourceIdOf = { event -> (event as Booking).roomId },
        onEventClick = { event -> openDetails(event as Booking) },
    )
    
    // after: E is Booking, inferred from `events`
    KalendarResourceView(
        resources = rooms,
        events = bookings,
        resourceIdOf = { booking -> booking.roomId },
        onEventClick = { booking -> openDetails(booking) },
    )

    Migration. Every row below was checked by compiling it, not by reasoning about it.

    Call shapeChange
    Passes events, callbacks take inferred parametersnone
    Passes events, and a callback declares KalendarEvent explicitly (onDateClick = { _, events: List<KalendarEvent> -> … }), or passes a stored (LocalDate, List<KalendarEvent>) -> Unit or a function reference of that shapenone — a lambda parameter may be annotated with a supertype of the expected one, and a function type is contravariant in its parameters, so E still infers to your own type and the widened annotation is accepted
    Passes events, and a dayContent slot is written against KalendarDayScope<KalendarEvent>none — KalendarDayScope is covariant, so one cell renderer still serves a calendar of any event type
    Passes no events and no event-only parameternone — an event-free overload takes over, see below
    Passes no events but does pass an event-only parameteronDayEventClick, onEventDrop, onEventClick, onEventTimeChange, resourceIdOf, eventContent, allDayEventContentbreaks: Cannot infer type for type parameter 'E'. Add events = emptyList(), or name the type (KalendarSchedule<KalendarEvent>(onEventClick = …)), or drop the parameter — on an event-free calendar it could never have fired
    Declares a slot type in a helper (fun MyCell(scope: KalendarDayScope))add a type argument: KalendarDayScope<*> to read any calendar's events, KalendarDayScope<Booking> to read yours

    The fourth row is the one that costs a compile. Omitting events is what selects the event-free overload, and that overload does not have these parameters at all — so passing one leaves the generic overload as the only candidate, with nothing to fix E to. It is reached by ordinary 1.x code, because events used to default to emptyList(): KalendarSchedule(onEventClick = …) and KalendarResourceView(resources = rooms, resourceIdOf = …) both compiled and both now do not. events = emptyList() is the mechanical fix and infers E = KalendarEvent, matching 1.x exactly.

    The same rule catches the new parameters, which is worth knowing even on a fresh call site: KalendarMonth(selectedDate = today, loadingContent = { … }) does not compile on its own, because loadingContent exists only on the generic overload. Pass an eventLoader — which supplies E itself — or one of the two remedies above.

  • Every event-carrying slot scope is generic too, so a dayContent sees the same type its onDateClick reports rather than the erased one: KalendarDayScope<out E : KalendarEvent>, KalendarDayOverflowScope<E>, KalendarScheduleEventScope<out E> and KalendarResourceEventScope<out E> : KalendarScheduleEventScope<E>. The three covariant ones only ever produce an event, so a cell or block renderer written once against …<KalendarEvent> still serves a calendar of any event type — a slot consumes the scope, and consumers are contravariant in it.

    KalendarDayOverflowScope is the exception, and is invariant, because onEventClick(event: E) consumes an E. Covariance there was unsound rather than merely permissive: a popover holding the scope as KalendarDayOverflowScope<KalendarEvent> could be handed a KalendarDayOverflowScope<Booking> and pass it any KalendarEvent, which the cell's own (Booking) -> Unit received and threw ClassCastException on.

    KalendarDayCellDefaults.Cell and KalendarMonthGridCell gained the same parameter; kalendarDaySemantics, KalendarScheduleDefaults.EventBlock and KalendarResourceDefaults.EventBlock read their interface only and take a star projection. KalendarDayCellDefaults.OverflowPopup is instead generic in E — a star projection could not call onEventClick at all.

    Migration. A shared overflowPopup renderer must become generic rather than monomorphic:

    overflowPopup written asChange
    A lambda at the call site (overflowPopup = { … })none — E is inferred from the calendar
    A read-only popover over KalendarDayOverflowScope<*>none — reading events is still allowed
    A stored @Composable (KalendarDayOverflowScope<KalendarEvent>) -> Unit, reused across calendarsbreaks: make it a generic composable, @Composable fun <E : KalendarEvent> MyPopup(scope: KalendarDayOverflowScope<E>), and pass overflowPopup = { MyPopup(scope = it) }

    This is a source-level break only; both forms erase to the same JVM signature, so it is not an ABI break.

  • Each view whose events is optional gained an event-free overloadKalendarMonth, KalendarWeek, KalendarYear, KalendarTimeline, KalendarSchedule, KalendarScheduleWeek, KalendarResourceView. KalendarMonth(selectedDate = today) still compiles and still means what it did; it now resolves to the overload that has no events parameter. This is not a convenience: a Kotlin default argument is never a source of type inference, so with events omitted there is nothing to fix E to and a single generic function would not compile at that call site at all. Distinguishing the two by the absence of events is also what keeps the overloads unambiguous — supply events and only the generic one is a candidate, supply none and only the event-free one is. Each event-free overload drops the parameters a calendar with no events could never reach (onDayEventClick, onEventDrop, onEventClick, onEventTimeChange, resourceIdOf, eventContent, allDayEventContent), and keeps everything that still draws.

  • byDateExpandingSpans() is generic: fun <E : KalendarEvent> List<E>.byDateExpandingSpans(): Map<LocalDate, List<E>>. It is the index the views build before handing a date's events to onDateClick, so widening it there would have widened every callback downstream. Existing calls on a KalendarEvents infer E = KalendarEvent and are unchanged.

  • KalendarEvents survives, unparameterised, and is now documented as what it always was: the widest event list there is, List<KalendarEvent>. It is not becoming KalendarEvents<E> — that would be the same length as List<E>, one more name to learn, and would force a type argument onto every place that genuinely does not care which event type it holds. Every List<E> already is a KalendarEvents, because List is covariant.

  • Where the generic stops. kalendar-foundation's block engine — scheduleBlocks, resourceBlocks, KalendarScheduleBlock — stays typed on KalendarEvent, and the Schedule and Resource views re-attach E on the way back out. The engine is geometry: it packs minute ranges and carries each event along untouched, so nothing it computes could be more precise than KalendarEvent, and parameterising it would put a type argument on every internal signature for no expressiveness at all. The price is one unchecked cast per view, at the single boundary where the event comes back, documented and covered by KalendarDomainEventTypeUiTest — one cast inside the library instead of one in every consumer's callback.

  • Kalendar(type = ...) and KalendarType are gone. The last remaining type, Agenda, is now the top-level com.himanshoe.kalendar.KalendarAgenda composable, so every view in the library is called directly. There is no dispatch entry point left; replace Kalendar(type = KalendarType.Agenda, events = events, config = KalendarConfig(...)) with KalendarAgenda(events = events, config = KalendarViewConfig(...)).

  • KalendarAgenda rewritten as a first-class modern view. It is public, lives in com.himanshoe.kalendar, takes KalendarViewConfig, and draws entirely with Compose Foundation — no Material widgets, no hardcoded colours, shapes or text sizes. Every visual comes from KalendarTheme, so it restyles with the other views. New slots dateHeader, eventContent and emptyState default to KalendarAgendaDefaults.DateHeader / .EventRow / .EmptyState. Its date headers now go through KalendarViewConfig.monthNameFormatter (they used the raw English Month enum name and could not be localized), and its empty-state text comes from the new KalendarStrings.agendaEmptyState.

  • kalendar-foundation is now 2.0.0 (was 1.1.0 stable), aligning it with kalendar on one major. The following had zero read sites anywhere in the project and were deleted: KalendarConfig, KalendarDayConfig, KalendarDayLabelConfig, KalendarHeaderConfig, OnDaySelectionAction, LocalDate.onDayClick, KalendarSelectedDayRange, and the KalendarColor sealed class with its Color.asSolidColor() / List<Color>.asGradientColor() extensions. KalendarConfig in particular published 16 fields of which the Agenda view read exactly two; the rest were inert. What remains in kalendar-foundation is the event model: KalendarEvent, BasicKalendarEvent, KalendarEvents. Configure views with KalendarViewConfig and style them with KalendarTheme.

  • Removed KalendarType.Oceanic, Firey, Solaris, Aerial, and Yearly (and the kalendar-foundation internals they depended on) entirely. Replace them with the standalone com.himanshoe.kalendar composables below — there is no KalendarType-based drop-in replacement; call KalendarWeek/KalendarMonth/KalendarYear/KalendarTimeline directly.

  • KalendarColors, KalendarTypography and KalendarShapes each gained a required constructor parameter (agendaCard; agendaEventTitle/agendaEventSubtitle; agendaCard). Code that copy()s a defaults instance is unaffected; code that constructs one from scratch must supply them.

Added#

  • Events can be loaded per visible range instead of all at once, and the UI says when they have not arrived. Every view took its whole event list up front, so a calendar backed by a database or a network had two options: load the world before showing anything, or reload it on every swipe. rememberKalendarEventLoader(state, source) sits beside the eager list rather than replacing it — a screen with events already in memory still passes events and pays nothing — and asks a KalendarEventSource for the range each page is about to draw. Four properties are the point of it, and are what a hand-rolled version usually misses: a range in the window is requested once, so sliding the window by a page asks only for the page that entered it; the window is the visible page plus prefetchPages either side, so an ordinary swipe lands on a page that is already there, and a programmatic jump extends the window to its destination when the animation starts; a range the user has swiped past is cancelled, so a slow answer for a page nobody is looking at is neither waited for nor shown; and memory is bounded by the window, not by how far the user has scrolled. The range a page reports is the grid it draws rather than the period it is named after — March 2026 opens on a Sunday, so its page asks for 23 February through 5 April — because those leading and trailing cells show events like any other. A source is Flow-shaped so a Room query keeps the calendar current on its own, with KalendarEventSource.suspending { } for a one-shot network load. Events reaching the views are de-duplicated by KalendarEvent.id, since two adjacent ranges both legitimately return a multi-day event that straddles their boundary.

  • A month with no events yet no longer looks like a month with no events. Once events can be absent-because-pending, the two have to be told apart. Each view gained a loadingContent slot, handed a KalendarLoadingScope carrying the range, its KalendarLoadStatus and — when it failed — the failure and a retry. It defaults to KalendarLoadingDefaults.Placeholder, a wash drawn from KalendarColors.dayContent so it follows the theme in both light and dark. The placeholder is a match-parent-size overlay, which is a layout guarantee rather than a coincidence: it is measured after the page has decided its own size, so events landing changes what is drawn and never where — pinned by a test that compares a day cell's bounds before and after. There is deliberately no shimmer: an indefinitely repeating animation never lets a Compose test go idle, so a shimmering default would hang the first UI test a consumer wrote around a loading calendar. KalendarAgenda, having no pages, uses the slot for the one case it does have — an empty list that is still filling.

  • A calendar can be pinned to a time zone rather than the device's. KalendarTimeSource gained a timeZone, defaulting to TimeZone.currentSystemDefault(), and it reaches everything that turns an instant into a position: what "today" is, where the now-indicator sits, where a day boundary falls when a span is expanded, and which date a timed event belongs to. That last one is the reason it matters — an event at 23:00 UTC is today in London and tomorrow in Tokyo, so a booking tool showing an office's calendar has to ask the office's zone, not the viewer's phone. ProvideKalendarTimeSource sets it for a subtree.

    Four zone-aware functions in kalendar-foundation are public with it, because the engine has to answer the same questions without the views: Instant.dateIn(timeZone), LocalDateTime.sameInstantIn(source, target), LocalDate.dayLengthIn(timeZone) and datesOfSpanIn(start, end, timeZone), plus the MAX_SPAN_DAYS cap that keeps one malformed span from expanding without bound. dayLengthIn exists because a day is not always 24 hours: it is 23 or 25 across a daylight-saving step, and neither whole nor round in the zones whose step is half an hour, so anything measuring a day by adding 24 hours to its midnight is wrong twice a year in most of the world. The hour grids deliberately do not divide their height by it — they are ruled in wall clock, which reaches 24:00 on every date there is.

  • Year and Timeline are keyboard navigable, matching what Month and Week already did rather than inventing a second idiom for them. All four use the roving-tabindex pattern every native date picker uses: the grid is one tab stop, arrows move a focus ring between days without leaving it, and Tab again leaves — reaching 42 cells by pressing Tab 42 times is a tab trap with extra steps, not navigation. Home/End go to the first and last day of the period rather than the grid, so Home in a month reaches the 1st and not the leading padding day; PageUp/PageDown step by the view's own unit, a year for KalendarYear and a month for KalendarTimeline. Arrows leaving the visible period scroll the period holding the target into view, so a boundary is invisible to the user. Disabled dates are focusable but not selectable — a keyboard user is told the date exists and is unavailable rather than having it silently skipped and a hole appear in the grid. Shift+arrow extends a range on the two views that have a range-drag gesture to mirror (Month, Week); without it onDateRangeSelect would be reachable by pointer only, which is a WCAG 2.1.1 failure.

  • A call site can override one theme token without constructing a theme. KalendarTheme(tokens: KalendarThemeTokens?, content) takes a prepared bundle, so restyling one calendar is KalendarTheme(tokens = KalendarTheme.tokens.copy(dimensions = …)) rather than spreading six token sets back over the six-parameter overload. tokens = null inherits the surrounding theme untouched, passing the ambient bundle through by identity, which makes it a usable default for a theme parameter on a consumer's own composable — not overriding costs nothing and changes nothing. The bundle is provided unconditionally even for null: wrapping conditionally would give the content a different group shape in the two cases, so a caller toggling an override on and off would discard the calendar's remembered state with it.

  • Recurring events can be edited and deleted at one occurrence, at this and following, or across the series. KalendarRecurrenceScope is the three-way choice every calendar app offers, and KalendarSyncEvent.editOccurrence(occurrenceDate, edited, scope) / deleteOccurrence(occurrenceDate, scope) turn the answer into a KalendarSeriesChange — at most one write to the stored series row, plus the rows that have to be inserted. Both are pure functions; nothing touches a device until the change is applied.

    What each scope does to the data is the part a backend has to reason about. THIS_OCCURRENCE keeps the rule and adds an EXDATE, and an edit additionally produces a detached one-off event that must not inherit the rule — a copy that still carried it would resurrect the whole series under a second id. THIS_AND_FOLLOWING terminates the stored row with UNTIL = occurrenceDate - 1 day, since RFC 5545's UNTIL is inclusive and the occurrence at the split has to be lost while the one before it is kept, and starts a new series at the split. A COUNT is divided rather than copied — carried across, both halves would run the full count — and is counted with exception dates cleared, because EXDATE removes occurrences from a determined series rather than shortening it, so an excluded date still consumed one. A split at the series' own first occurrence rewrites in place instead of leaving an UNTIL before its own DTSTART. ENTIRE_SERIES rewrites the row in place under its original id, shifting the anchor, the span and every exception date by the same number of days when the occurrence moved. A change never both deletes the series and creates events, so applying the series write before the inserts cannot leave an occurrence existing twice.

  • Multi-day events draw as one continuous bar, not as loose dots. A three-day conference used to render as three dots on three days, which is exactly what three unrelated single-day events look like — the month grid had no way to say "this is one thing that lasts". An event with an endDate now draws a bar running edge to edge across every day it covers, breaking at the week boundary the way a calendar has to, so a Thursday-to-Tuesday event is two bars. The event's real start and end are inset and rounded; an end that is only the row running out stays square and flush, which is what distinguishes a conference that finished on Saturday from one that continues into Sunday. Overlapping spans pack into lanes by the same greedy interval sweep the schedule grid already uses for overlapping blocks, so a bar holds one height for its whole run instead of climbing a staircase. Five dimension tokens control it: KalendarDimensions.spanBarHeight (4.dp), spanBarSpacing (2.dp), spanBarCornerRadius (2.dp), spanBarEndInset (3.dp) and spanBarMaxLanes (2). A span past the last lane is not hidden — it falls back to the indicator dot it drew before and still reaches the +N overflow, so the cap costs continuity, not the event. spanBarMaxLanes = 0 turns bars off entirely and restores the old rendering. The property this could not break, and did not: every day number in a grid sits on one baseline whatever its day holds — measured across the week of the 9th at 221, 222, 221, 221, 222, 222.

  • API invariants are enforced against the checked-in ABI dumps, not against memory. KalendarApiInvariantsTest asserts four rules that were previously obeyed by discipline alone and are exactly the kind quietly broken by a one-line change that looks harmless. (1) No data class reaches the public API — a data class compiles one componentN per property plus a copy whose name carries a signature hash, so adding a property breaks every consumer at link time; KalendarEvent has gained two properties since 1.0, so this is live rather than hypothetical, and it is why eight public types hand-write copy/equals/hashCode/toString. (2) No composable takes a State<T> or MutableState<T> parameter — it makes a plain value impossible to pass and leaks a storage decision into a signature, and a () -> T buys the same deferral without the coupling. (3) kalendar-foundation's ABI references nothing from the UI module, so the engine stays reachable without it. (4) No compiler-generated ComposableSingletons$* lambda holders appear in a dump — their accessors carry a hash of the lambda's source position and re-hash whenever an unrelated edit moves a lambda, which is how a real ABI change once went through review unnoticed; they are excluded via ignoredClasses, and this notices when a new one appears. Because apiCheck already guarantees the dumps match the source, asserting against the dumps is the same as asserting against the API, and costs nothing at runtime.

  • KalendarEvent.id — an optional stable identifier (null by default, so existing implementations are unaffected). Views that keep per-item state use it as their list key, and it gives consumers a way to look an event back up instead of matching on name and date. BasicKalendarEvent gained a matching trailing id parameter.

  • Agenda theme tokensKalendarColors.agendaCard, KalendarShapes.agendaCard, KalendarTypography.agendaEventTitle/agendaEventSubtitle, KalendarDimensions.agendaRowPadding/agendaRowSpacing/agendaRowGap/agendaDotSize, and KalendarStrings.agendaEmptyState, so KalendarAgenda restyles and localizes like every other view.

  • Recurring events write to device calendarsinsertEvent/updateEvent now persist recurrenceRule on Android (RRULE + RFC 2445 DURATION — recurring rows must not set DTEND — plus EXDATE) and iOS (EKRecurrenceRule mapping, with EKSpanFutureEvents on rule updates so EventKit applies them); fetch round-trips the rule on both platforms. iOS cannot write exceptionDates (no EventKit EXDATE API) — documented.

  • Drag to move/resize Schedule blocks — press-and-hold a block to drag it to a new time (bottom-edge drag resizes; horizontal drag on the week grid moves across days), snapped to a configurable step, with live preview and overlap re-packing; onEventTimeChange(event, newStart, newEnd) fires on release.

  • Month/year jump picker — tapping the header title opens a year-stepper + month-grid popup that scrolls straight to the chosen month, on every header-bearing view.

  • Configurability passKalendarViewConfig gains showJumpPicker, hourLabelFormatter (12h/24h gutter labels), scheduleInitialScrollHour, scheduleDragSnapMinutes, and eventIndicatorCap; doc/Config.md rewritten around the real KalendarViewConfig (it previously documented a deleted 1.x API).

  • Phone-framed demo site — the wasm demo now renders the sample app inside a phone mockup on a gradient landing page (pure Compose: hero title, GitHub/Docs link chips, punch-hole bezel), falling back to full-bleed on narrow viewports.

  • KalendarTheme / KalendarColors — every view colour now resolves through an ambient token set (selection, today/day/label content, event accent, now-indicator, grid lines, drag ghost). Material apps need no change (tokens derive from the ambient colour scheme); any app can restyle by wrapping views in KalendarTheme(colors = ...).

  • KalendarScheduleWeek — 7-day time grid paged by week: day columns share one hour gutter, overlap packing per column, day-header row with today emphasised, all-day chips prefixed with day-of-month, now-indicator on today's column.

  • Saveable selectionrememberKalendarSelectionState now uses rememberSaveable, so selection (including an in-progress range) survives rotation and process death.

  • Golden-image tests — desktop screenshot tests lock in the month grid (default and events/overflow) and the schedule day view; regenerate intentionally changed visuals with ./gradlew :kalendar:desktopTest -PupdateGoldens=true.

  • Live demo deployment — a Deploy Demo GitHub Actions workflow publishes the sample's wasm build to GitHub Pages on every push to main; README links the demo.

  • Calendar picker in kalendar-syncKalendarSyncProvider.listCalendars() returns the device's calendars (KalendarSyncCalendar), and insertEvent takes an optional calendarId to write into a specific calendar instead of the platform default.

  • Reminders in kalendar-syncKalendarSyncEvent.reminders (KalendarReminder, minutes before start) written/read on Android (CalendarContract.Reminders), iOS (EKAlarm), and the Desktop .ics provider; encoded as RFC 5545 VALARM blocks by the iCal codec.

  • Attendees in kalendar-syncKalendarSyncEvent.attendees (KalendarAttendee) on Android (CalendarContract.Attendees) and via ATTENDEE lines in the iCal codec. On iOS attendees are read-only (EventKit cannot add them programmatically): populated on fetch, ignored on write.

  • KalendarSchedule — hourly time-grid day view paged by day: timed events as duration-sized blocks with side-by-side packing for overlaps, all-day chips above the grid, a minute-refreshed now-indicator on today, and an initial scroll to early morning.

  • Drag-to-reschedule on KalendarMonth — press-and-hold a date with events and drag to another date; onEventDrop receives the events and the drop date, with a ghost highlight following the pointer. Composes with drag-to-select (event-less dates still range-select).

  • Per-event indicator dots — day cells show one dot per event coloured by KalendarEvent.eventColor (theme-primary fallback), capped at three with a +N overflow label.

  • KalendarWeek, KalendarMonth, KalendarYear — standalone composables backed by a shared KalendarViewState, navigable by both arrow-click and swipe (HorizontalPager) through the same state so the two input methods never desync.

  • KalendarTimeline — a continuous, virtualized vertical scroll through months (Google Calendar-list style), optionally bounded by minDate/maxDate on rememberKalendarTimelineState, unbounded by default.

  • KalendarViewConfig — shared visual/behavioural config (disabledDates, showNavigationArrows, showAdjacentMonthDates, showTodayButton, background) for all four views above.

  • Today buttonKalendarWeek/Month/Year's header, and KalendarTimeline's new sticky header, show a button that animates back to today's date. Enabled by default (KalendarViewConfig.showTodayButton = true).

  • Sticky header on KalendarTimeline — the topmost visible month's title and the today button now stay pinned above the scrolling content instead of scrolling away with it.

  • Sliding selection indicator on KalendarWeek/KalendarMonth — when exactly one date is selected and no custom dayContent is supplied, the highlight animates smoothly from the previous cell to the newly tapped one instead of each cell fading independently. Snaps instantly (no fly-in) the first time a grid page appears, and falls back to independent per-cell fades for multi-select/range.

  • KalendarSelectionMode + rememberKalendarSelectionState — a hoisted-state helper implementing single/multiple/range tap behaviour for selectedDates/onDateClick, so common selection modes don't need to be hand-rolled.

  • Drag-to-select a range on KalendarWeek/KalendarMonth — press and hold a date, then drag, to select a range live via the new onDateRangeSelect/onDateRangeSelectEnd parameters (paired with KalendarSelectionState.onRangeDrag/onRangeDragEnd for the common case). Requires a long-press before the drag starts, so a quick tap is unaffected.

  • Locale-aware formattersKalendarViewConfig.monthNameFormatter/dayOfWeekLabelFormatter let you plug in a platform locale API (java.time, NSDateFormatter, Intl, ...) for month names and day-of-week labels; kotlinx-datetime has no locale-aware formatting of its own, so both default to plain English.

  • KalendarSyncEvent.exceptionDates — the RFC 5545 EXDATE equivalent for kalendar-sync: skips specific dates when a recurring event is expanded via expandOccurrences, and round-trips through exportToIcs/importFromIcs as an EXDATE line.

  • Accessibility semantics on day cells — every day cell now exposes a screen-reader contentDescription (e.g. "August 12, 2026, today, has events") and a selected semantic state, and uses Role.Button so assistive tech announces it correctly.

  • KalendarEvent.endDate — multi-day event spans. A day cell shows an event on every date from date through endDate inclusive; expansion is capped at 366 days to guard against runaway spans.

  • Selection, press, and event-indicator animations (animateColorAsState, press-scale, AnimatedVisibility) and a title crossfade in the shared navigation header.

  • kalendar-sync module — reads/writes the real device calendar with no third-party account: CalendarContract on Android, EventKit on iOS, a file-backed .ics provider on Desktop. Includes RFC 5545 recurrence (KalendarRule, KalendarRecurrenceExpander) and pure in-memory iCal encode/decode (exportToIcs/importFromIcs) available on every platform including wasmJS. KalendarSyncEvent.endDate carries multi-day spans through insert/update/fetch and iCal round-trips on every platform provider.

  • Sample app now has a view switcher covering every view type (Week, Month, Year, Timeline, Schedule, Week grid, Agenda).

  • explicitApi() mode enabled across all three published modules — every public declaration now requires an explicit visibility modifier and return type, guarding against accidental API leaks.

  • kotlinx-binary-compatibility-validator wired into all three published modules and CI (apiCheck), so an accidental breaking change to the public API surface now fails the build.

  • Root .editorconfig documenting this project's formatting conventions (4-space indent, 140-char line length, no forced trailing commas) for editors and the existing detekt-formatting (ktlint-based) ruleset to read.

Fixed#

  • KalendarGrid no longer writes snapshot state from the layout phase. It tracked where each day cell landed in a mutableStateMapOf written from onGloballyPositioned and read from its own composable body to position the selection indicator — a cross-phase back-write that cost one whole extra composition of the grid per page composed. The map is a plain remember { mutableMapOf() } now, and no reader is in composition: the gesture code reads it from a pointer callback, the overlays from their layout lambdas, and the indicator's target is computed inside a snapshotFlow in the effect that drives its animator. The one thing a plain map gives up — telling a reader the cells have moved — comes back as a single Int revision bumped only on a placement that actually changed; bumping it on every callback would invalidate the placement that produced the callback and the grid would lay itself out forever. Measured on a March 2026 month: snapshot-map writes on a first frame 37 → 0, grid-body compositions 2 → 1, grid content recompositions during a six-move drag 7 → 0. layingOutAMonthGridWritesNoSnapshotMap guards it by counting the cause rather than the symptom, since a lazy grid memoizes item content by key and the second composition walked no cells to observe. Two earlier claims about this were wrong and are corrected here: the writes did not recur on every layout pass (SnapshotStateMap.put of an equal value is a no-op, so the real cost was one extra whole composition per page, not per-frame churn), and there was never a "404 writes for a year" — KalendarYear composes through KalendarStaticGrid, which never tracked coordinates at all; 404 was the cell count of the old eager year column. The naive version of this fix moves the reads to the layout phase and silently stops the indicator animating, because the LaunchedEffect key is itself a composition read — the first-paint-flash and post-offset test-tag tests are what keep that honest.
  • KalendarAgenda no longer collides an event id with an item index. Items were keyed "event-$day-${event.id ?: index}", which put the id space and the index space in one space: an event carrying the id "1" beside an id-less event sitting at index 1 produced identical keys, and a LazyColumn throws on a duplicate rather than degrading, so the whole agenda crashed. The mix that triggers it is ordinary rather than exotic — events synced from a device arrive with ids and locally drafted ones do not, so any consumer doing both on one day hit it. The two fallbacks are namespaced apart now (id-$id against at-$index), which is sufficient because neither prefix can be produced by the other. Found by an adversarial review probe rather than by any existing test; the probe is kept as a regression test, with a second case one step along.
  • KalendarAgenda no longer crashes on duplicate events. Its list keyed items on "${date}_${eventName}_${startTime}", so two all-day events with the same name on one date produced identical keys and SaveableStateHolder threw IllegalArgumentException — reliably, since the stable sort placed the colliding pair adjacent. Items are now keyed on the new KalendarEvent.id (falling back to the event's position within its date) plus the date it is rendered under.
  • On iOS 17+ the sync provider requested calendar access with the deprecated pre-17 API, which can grant only write access and break event fetching; it now calls requestFullAccessToEventsWithCompletion when available (runtime-gated), falling back to the legacy request on older iOS.
  • Drag gesture handlers on the Schedule views and the month grid captured their first composition's state forever (a pointerInput coroutine launches once and never sees later lambdas): after the events list changed, the next drag moved/dropped events using stale blocks and stale callbacks. All gesture callbacks now read through rememberUpdatedState, and a regression UI test drags the same block twice across an events update.
  • On KalendarScheduleWeek, dragging a block horizontally computed the day shift from the block's own width instead of the day column's width, so drags across days landed on the wrong day whenever events overlapped (blocks share a column side by side). Day shift is also now clamped to ±6 days.
  • Dragging a block to the bottom of the day reported an end time of 23:59 on the same day instead of 00:00 on the next day, silently shortening the event by a minute.
  • Drag snapping rounded twice (pixels → minutes → snap step), drifting up to half a step for snap steps that don't divide 60; it now rounds once, directly to the snap step.
  • KalendarTimeline crashed (IndexOutOfBoundsException) or wrapped to a negative index when scrolled to the edge of a minDate/maxDate-bounded range, and when only one bound was set the index math could overflow Int. Index arithmetic is now done in Long and clamped.
  • The header's previous-arrow disabled itself one page too early when minDate fell mid-period (e.g. a minDate of the 15th made its own month unreachable), and animateScrollTo with an out-of-bounds date scrolled past the configured minDate/maxDate instead of clamping to it.
  • KalendarRecurrenceExpander with a count-limited rule invented occurrences when the query window started after the series began: the count was only consumed inside the window, so a DAILY COUNT=3 series from January 1st still produced events in February. The count is now consumed from the series start.
  • Recurring rules that combine interval with MONTHLY byDay, or YEARLY byMonth, never produced any occurrences (the expander stepped by whole months/years and missed the filter days). YEARLY byMonth also correctly pins the day-of-month from the series start per RFC 5545.
  • All-day events written to Android used local-midnight timestamps instead of the UTC midnights CalendarContract requires, shifting them a day in negative-offset timezones; reads now convert back through UTC as well.
  • Timed recurrence exceptions (exceptionDates) written to Android used wall-clock time with a Z suffix, so the platform matched no instance and the exclusions silently failed; EXDATE values now convert through the device timezone to real UTC instants (and parse back the same way).
  • Fetching events on iOS excluded the final day of the requested range (predicate ended at the range end's start-of-day instead of the day after).
  • The iCal codec stamped Z onto wall-clock times on export, silently shifting every timed event for consumers in non-UTC timezones; it now emits RFC 5545 floating times (legacy Z input still decodes).
  • Expanded recurrence occurrences dropped their template's exceptionDates; DURATION values using weeks (P1W) failed to parse; day-based VALARM triggers (-P1D) were ignored; Android fetch hid recurring series that started before the query window; iOS all-day fetches reported an end date one day off.
  • kalendar-sync did not actually compile for iOS or wasmJS — commonMain used JVM-only String.format/SecurityException, and the iOS EventKit integration referenced EKAuthorizationStatusAuthorized (deprecated in iOS 17, replaced by EKAuthorizationStatusFullAccess) and an NSDate constructor signature that doesn't exist in the generated Kotlin/Native bindings.
  • Multi-day events (endDate) were silently dropped when a recurring event was expanded into occurrences, and when the Desktop/Android/iOS sync providers inserted or updated an event.
  • KalendarRule with a multi-value byMonthDay (e.g. the 1st and 15th of every month, as shown in its own documentation) only ever produced one occurrence per month instead of both.
  • KalendarRule.interval was silently ignored when combined with WEEKLY byDay or MONTHLY byMonthDay filters — an every-other-week rule fired every week. Interval now composes with the BY-filters, and the previously documented limitation is removed.
  • The month/week grid's internal LazyVerticalGrid scrollable consumed vertical drags before the long-press drag gestures (drag-to-select, drag-to-reschedule) could see them, cancelling the gesture; internal grid scrolling is now disabled (the grid never needed it — pages are fixed height).
  • Nested-lazy-layout crash on Android/iOS in KalendarTimeline/KalendarYear (a LazyVerticalGrid inside an already-scrollable parent) — replaced with the eager KalendarStaticGrid in both.
  • Grid cells stretched to fill the row on trailing partial weeks (fewer than 7 days).

[2.0.0-RC1] – 2026#

Breaking changes from 1.x#

  • The library has been fully migrated to Kotlin Multiplatform (Android, JVM/Desktop, iOS, wasmJS).
  • kalendar now depends on the new kalendar-foundation module which must be added separately if you need direct access to foundation types.
  • KalendarType is now a sealed interface instead of an enum. Replace KalendarType.OCEANIC style usages with KalendarType.Oceanic data-object references.
  • Day-click callbacks now receive List<KalendarEvent> (events on that day) in addition to the tapped LocalDate.
  • Selection behaviour is now configured through OnDaySelectionAction instead of individual boolean flags.

Added#

  • Four calendar variants via KalendarType:
    • Oceanic — full month grid with previous/next arrow navigation.
    • Firey — single week row with previous/next arrow navigation.
    • Solaris — swipeable full month grid (infinite HorizontalPager).
    • Aerial — swipeable week row (infinite HorizontalPager).
  • OnDaySelectionAction sealed class — Single, Multiple, and Range selection modes.
  • KalendarSelectedDayRange — typed value for range selection with compile-time start/end fields.
  • KalendarEvents / KalendarEvent / BasicKalendarEvent — typed event model with per-day event indicators (up to 3 dots).
  • KalendarKonfig / KalendarDayKonfig / KalendarHeaderKonfig / KalendarDayLabelKonfig — composable configuration objects for colours, sizes, and text styles.
  • KalendarColor — sealed class for Solid and Gradient colour values used throughout the configuration API.
  • startDayOfWeek parameter — configure the first column of the week grid.
  • restrictToCurrentWeekOrMonth flag — prevent back-navigation past the current period.
  • wasmJS target support.

Fixed#

  • isCurrentMonth navigation guard now correctly handles cross-year navigation (e.g. viewing January 2026 from December 2026 no longer disables back navigation).

[1.0.0]#

Initial release supporting Android-only Jetpack Compose with basic month and week calendar views.