diff --git a/CHANGELOG.md b/CHANGELOG.md index 6c4de5d..c16f6ae 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,111 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [Unreleased] + +## [2.16.0] — 2026-07-24 + +### Added +- Choose how the month view is laid out. A new **Month view style** setting + (Settings → Views) offers three ways to read a month, each shown with a + preview of the layout it produces: + - **Pages** — what you have today: one month at a time, swiped sideways. + - **Continuous** — scroll up and down through the weeks without a break + between months. Because the weeks run on unbroken, no month is cut off and + no day appears twice, where paging repeats a boundary week at the end of one + month and the start of the next. The 1st of each month names itself so you + always know where you are, and the title bar keeps up as you scroll ([#38]). + - **Split** — a compact grid showing coloured dots for the days that have + something on them, with the day you tap listed in full underneath. Tap the + date above the list to open the whole day ([#53]). + + The Agenda view is untouched by this and stays available in all three styles — + the split layout lists a single day, while Agenda remains a rolling multi-day + window with its own range settings. +- Give an event its own time zone. A new **Time zone** field (under "more + fields" in the event form) pins an event to a specific zone, so a call set for + 8:00 AM in New York stays 8:00 AM in New York wherever you open it — and keeps + tracking that zone across daylight-saving changes instead of drifting an hour. + The form edits the event in its own zone and shows the local equivalent under + the times ("2:00 PM – 3:00 PM your time"); the event's details keep your local + time first and note the original beneath it, so both are always clear. Pick a + zone from a full-screen picker with your device zone and recent choices on top, + searching by city ("new york"), IANA id ("europe/berlin"), or abbreviation + ("CEST") to gather every matching zone at once. All-day events stay + date-anchored and carry no zone, as before ([#31]). +- Put the "jump to today" button in the toolbar. A new **Today button in + toolbar** setting (Settings → Appearance, off by default) swaps the floating + button that fades into the corner while you're away from today for a permanent + today icon in the top bar — always there, on today or not, matching the + familiar calendar-app pattern. Leave it off to keep the floating button as + before ([#60]). +- Choose what Calendula calls itself on your home screen. A new **App name** + setting (Settings → Appearance) switches the launcher label between + **Calendula** and **Calendar**, for launchers that can't rename apps + themselves. Pick from a full-screen chooser that previews both names as + launcher marks; the change applies at once ([#44]). +- Calendula now speaks **Arabic**, laid out right-to-left, and its French and + Italian translations have been brought up to date — thanks to the community + translators on Weblate. Pick a language under Settings → Language (or leave it + on the system default). + +### Changed +- Dates in the Month, Week and Day title bars now follow your language and + region instead of one hardcoded layout. Every date was rendered in a fixed + German-style order with a trailing dot on the day number, whatever your + settings: US English showed "Fri, 17. Jul 2026" where it should read + "Fri, Jul 17". The Agenda view already formatted correctly, so the two + disagreed about the same date. All four views now share one formatter, and the + day/month order, the separators and the ordinal all come from your locale — + so English-in-Germany reads "Fri, 17 Jul" and English-in-the-US "Fri, Jul 17", + each correct for where you are ([#60]). +- The title bar drops the year while you're in the current one — "July" rather + than "July 2026". The year reappears the moment you page out of the current + year, which is when it tells you something you didn't already know. +- The Week view's title now names the month instead of spelling out the day range. + "24. Jun – 31. Jun" restated the day numbers already printed in the column + headers right below it, in the widest string in the bar. A week that straddles + two months keeps the outgoing month until it is fully gone ([#60]). +- The custom recurrence picker has been redesigned and tightened up. As you + build a rule — "every 2 weeks on Mon & Wed, until a date" — the live summary + now describes exactly what will be saved rather than a near-copy that could + drift from it, the amount fields accept being left blank (reading as their + shown default instead of greying out OK), and the read-out no longer jumps + around as you tap weekdays ([#42]). + +### Fixed +- An all-day event no longer shows up again the day after it happened. In time + zones east of UTC, an all-day event — a birthday, say — set for one day also + appeared under the *next* day's heading in the Agenda (and the agenda widget), + because all-day events are anchored to UTC midnight and the following day's + window reached back across that boundary and pulled the event forward onto + "today". Each all-day event now lists only on the day it actually falls on + ([#65]). +- A multi-day event now shows under every day it spans in the Agenda, not just + its first day, so a trip or a multi-day booking appears on each day it covers. +- The "Upcoming" agenda widget now scales its text and rows to the size you give + it. Previously it was laid out once for the smallest size and simply stretched + when enlarged, so the text stayed small no matter how big you made the widget. + Now a bigger widget gets bigger, more readable type and roomier rows, while the + default size looks exactly as before — no new setting; it follows the size you + already chose ([#51]). +- Calendula now appears under other apps' "Add to calendar" / "Save to calendar" + actions. Some apps (e.g. DB Navigator) fire the widely-used "insert event" + intent with the singular `vnd.android.cursor.item/event` type, which Calendula + didn't advertise — so it was left out of the chooser, and if it was your only + calendar app the save silently did nothing. It now accepts that form, plus the + `INSERT_OR_EDIT` action, and opens the new event prefilled for review ([#74]). +- Opening a `.ics`/`.vcs` file now works even when another app hands it over + mislabelled as a generic download (`application/octet-stream`), as some mail + clients, browsers and file managers do — Calendula recognises it by its file + extension instead of relying on the declared type ([#74]). +- A recurrence end date no longer lands a day late. West of UTC, setting a rule + to end "until" a given day could save and show the day after the one picked; + the end date now reads back as chosen ([#42]). +- The status- and navigation-bar icons stay legible over full-screen pickers in + dark theme. They could render dark-on-dark — a near-invisible black clock + against the dark picker — instead of switching to light ([#70]). + ## [2.15.0] — 2026-07-15 ### Added @@ -984,6 +1089,7 @@ automatically, with zero telemetry and no internet permission. [#29]: https://codeberg.org/jlmakiola/calendula/issues/29 [#21]: https://codeberg.org/jlmakiola/calendula/issues/21 [#30]: https://codeberg.org/jlmakiola/calendula/issues/30 +[#31]: https://codeberg.org/jlmakiola/calendula/issues/31 [#32]: https://codeberg.org/jlmakiola/calendula/issues/32 [#33]: https://codeberg.org/jlmakiola/calendula/issues/33 [#34]: https://codeberg.org/jlmakiola/calendula/issues/34 @@ -996,4 +1102,13 @@ automatically, with zero telemetry and no internet permission. [#47]: https://codeberg.org/jlmakiola/calendula/issues/47 [#48]: https://codeberg.org/jlmakiola/calendula/issues/48 [#49]: https://codeberg.org/jlmakiola/calendula/issues/49 +[#51]: https://codeberg.org/jlmakiola/calendula/issues/51 [#52]: https://codeberg.org/jlmakiola/calendula/issues/52 +[#38]: https://codeberg.org/jlmakiola/calendula/issues/38 +[#53]: https://codeberg.org/jlmakiola/calendula/issues/53 +[#60]: https://codeberg.org/jlmakiola/calendula/issues/60 +[#65]: https://codeberg.org/jlmakiola/calendula/issues/65 +[#74]: https://codeberg.org/jlmakiola/calendula/issues/74 +[#42]: https://codeberg.org/jlmakiola/calendula/issues/42 +[#44]: https://codeberg.org/jlmakiola/calendula/issues/44 +[#70]: https://codeberg.org/jlmakiola/calendula/issues/70 diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 0dbf7cf..866683f 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -28,8 +28,8 @@ android { // which builds this version and then creates the matching vX.Y.Z tag + // release itself (versionCode is pinned to MAJOR*10000 + MINOR*100 + // PATCH from versionName, e.g. 2.7.2 -> 20702). See docs/RELEASING.md. - versionCode = 21500 - versionName = "2.15.0" + versionCode = 21600 + versionName = "2.16.0" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" } diff --git a/app/src/androidTest/java/de/jeanlucmakiola/calendula/data/calendar/CalendarRepositorySmokeTest.kt b/app/src/androidTest/java/de/jeanlucmakiola/calendula/data/calendar/CalendarRepositorySmokeTest.kt index fd76ebc..d6eea3f 100644 --- a/app/src/androidTest/java/de/jeanlucmakiola/calendula/data/calendar/CalendarRepositorySmokeTest.kt +++ b/app/src/androidTest/java/de/jeanlucmakiola/calendula/data/calendar/CalendarRepositorySmokeTest.kt @@ -9,6 +9,7 @@ import androidx.test.platform.app.InstrumentationRegistry import androidx.test.rule.GrantPermissionRule import com.google.common.truth.Truth.assertThat import de.jeanlucmakiola.calendula.data.prefs.CalendarPrefs +import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.first import kotlinx.coroutines.runBlocking @@ -31,7 +32,12 @@ class CalendarRepositorySmokeTest { val store: DataStore = PreferenceDataStoreFactory.create( produceFile = { context.cacheDir.resolve("smoke_test_prefs.preferences_pb") }, ) - return CalendarRepositoryImpl(dataSource, CalendarPrefs(store), Dispatchers.IO) + return CalendarRepositoryImpl( + dataSource, + CalendarPrefs(store), + SettingsPrefs(store), + Dispatchers.IO, + ) } @Test diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 930ab70..8e0df89 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -63,10 +63,9 @@ android:exported="true" android:launchMode="singleTop" android:windowSoftInputMode="adjustResize"> - - - - + + + + + + + + + + + + + @@ -124,11 +145,13 @@ + to the same prefilled create form. (The far more common *item*- + typed INSERT — the form the Android docs' example and apps like + DB Navigator use — is the item filter below, issue #74.) --> @@ -136,14 +159,31 @@ - + + + @@ -165,11 +205,47 @@ - + + + + + + + + - + + + + + + + + + Calendar Loading… @@ -99,6 +103,18 @@ Availability Visibility + + Device time zone + Follows wherever you are + Search time zones + Recent + All time zones + No time zone matches “%1$s” + + %1$s your time + Color Calendar color @@ -136,6 +152,9 @@ weeks months years + + Enter a number from 1 to 999 Ends Never On a date @@ -257,6 +276,12 @@ Tomorrow You\'re all caught up No more events today + + Starts %1$s + Ends %1$s + + + Go to today Search @@ -308,6 +333,10 @@ Automatic Week numbers Show calendar-week numbers in month view + Today button in toolbar + Show a jump-to-today button in the toolbar instead of a floating button + App name + Show Calendula as “Calendar” in your launcher. Only the launcher name changes; the icon may move to a new spot after switching. Time format Automatic 12-hour (2:00 PM) @@ -343,6 +372,20 @@ %d days Views + + Month view + Month view style + Pages + One month fills the screen. Swipe left or right to change month. + Scrolling months + Each month sits under its own heading, with a little space setting it apart from the next. + Seamless weeks + The weeks run on without a break, each month flowing straight into the next with no gap between them. + Split + A compact grid marks the days that have events, and the day you tap is listed underneath. + Nothing scheduled + Show the whole month + Show the day\'s events Quick-switch button Choose which views the top-right button cycles through, and drag to reorder them. Turned-off views stay reachable from the navigation menu. Navigation menu @@ -382,7 +425,7 @@ Add or improve a language on Weblate Theme, default view, week start - Quick-switch button and menu order + Month layout, quick-switch button, menu order Default fields for new events Event reminders Contact birthdays & anniversaries diff --git a/app/src/main/res/xml/locales_config.xml b/app/src/main/res/xml/locales_config.xml index 385bf1f..a6949ed 100644 --- a/app/src/main/res/xml/locales_config.xml +++ b/app/src/main/res/xml/locales_config.xml @@ -9,6 +9,7 @@ --> + diff --git a/app/src/test/java/de/jeanlucmakiola/calendula/data/appname/LauncherNameManagerTest.kt b/app/src/test/java/de/jeanlucmakiola/calendula/data/appname/LauncherNameManagerTest.kt new file mode 100644 index 0000000..c806dce --- /dev/null +++ b/app/src/test/java/de/jeanlucmakiola/calendula/data/appname/LauncherNameManagerTest.kt @@ -0,0 +1,51 @@ +package de.jeanlucmakiola.calendula.data.appname + +import android.content.pm.PackageManager +import com.google.common.truth.Truth.assertThat +import org.junit.jupiter.api.Test + +/** + * Pure decision logic behind the app-name toggle (issue #44). The framework seam + * ([LauncherNameManager.set]/[current], which call `PackageManager`) is covered + * on-device; these tests pin the read interpretation and the write ordering. + */ +class LauncherNameManagerTest { + + @Test + fun `enabled calendar alias reads as CALENDAR`() { + assertThat(launcherNameFor(PackageManager.COMPONENT_ENABLED_STATE_ENABLED)) + .isEqualTo(LauncherName.CALENDAR) + } + + @Test + fun `default and disabled calendar alias read as CALENDULA`() { + assertThat(launcherNameFor(PackageManager.COMPONENT_ENABLED_STATE_DEFAULT)) + .isEqualTo(LauncherName.CALENDULA) + assertThat(launcherNameFor(PackageManager.COMPONENT_ENABLED_STATE_DISABLED)) + .isEqualTo(LauncherName.CALENDULA) + } + + @Test + fun `write plan enables the target alias before disabling the other`() { + val toCalendar = aliasWritePlan(LauncherName.CALENDAR) + assertThat(toCalendar).containsExactly( + AliasStateChange(LauncherAlias.CALENDAR, enabled = true), + AliasStateChange(LauncherAlias.DEFAULT, enabled = false), + ).inOrder() + + val toCalendula = aliasWritePlan(LauncherName.CALENDULA) + assertThat(toCalendula).containsExactly( + AliasStateChange(LauncherAlias.DEFAULT, enabled = true), + AliasStateChange(LauncherAlias.CALENDAR, enabled = false), + ).inOrder() + } + + @Test + fun `write plan never disables both aliases in the same step`() { + // The first step is always an enable, so the launcher can never observe a + // zero-entry transient regardless of switch direction. + for (target in LauncherName.entries) { + assertThat(aliasWritePlan(target).first().enabled).isTrue() + } + } +} diff --git a/app/src/test/java/de/jeanlucmakiola/calendula/data/calendar/EventWriteMapperTest.kt b/app/src/test/java/de/jeanlucmakiola/calendula/data/calendar/EventWriteMapperTest.kt index 972b9d9..596d35b 100644 --- a/app/src/test/java/de/jeanlucmakiola/calendula/data/calendar/EventWriteMapperTest.kt +++ b/app/src/test/java/de/jeanlucmakiola/calendula/data/calendar/EventWriteMapperTest.kt @@ -19,7 +19,14 @@ class EventWriteMapperTest { isAllDay: Boolean = false, start: LocalDateTime = LocalDateTime(LocalDate(2026, 6, 11), LocalTime(10, 0)), end: LocalDateTime = LocalDateTime(LocalDate(2026, 6, 11), LocalTime(11, 30)), - ): EventForm = EventForm(calendarId = 1L, isAllDay = isAllDay, start = start, end = end) + timezone: String? = null, + ): EventForm = EventForm( + calendarId = 1L, + isAllDay = isAllDay, + start = start, + end = end, + timezone = timezone, + ) @Test fun `timed event resolves wall clock in the given zone`() { @@ -30,6 +37,26 @@ class EventWriteMapperTest { assertThat(times.timezone).isEqualTo("Europe/Berlin") } + @Test + fun `pinned zone wins over the device zone`() { + val times = form(timezone = "America/New_York").toWriteTimes(berlin) + // 10:00 in New York (EDT, UTC-4) == 14:00Z, not 08:00Z as Berlin would give. + assertThat(times.timezone).isEqualTo("America/New_York") + assertThat(times.dtStartMillis).isEqualTo(1_781_186_400_000L) + } + + @Test + fun `an unparseable pinned zone falls back to the device zone`() { + val times = form(timezone = "Mars/Olympus_Mons").toWriteTimes(berlin) + assertThat(times.timezone).isEqualTo("Europe/Berlin") + } + + @Test + fun `all-day ignores a pinned zone and stays UTC`() { + val times = form(isAllDay = true, timezone = "America/New_York").toWriteTimes(berlin) + assertThat(times.timezone).isEqualTo("UTC") + } + @Test fun `all-day event lives at UTC midnights with exclusive end`() { val times = form(isAllDay = true).toWriteTimes(berlin) @@ -96,8 +123,18 @@ class EventWriteMapperTest { private val seriesStart = 1_700_000_000_000L - private fun update(original: EventForm, updated: EventForm): Map = - buildEventUpdateValues(original, updated, seriesStart, berlin) + private fun update( + original: EventForm, + updated: EventForm, + series: Long = seriesStart, + ): Map = buildEventUpdateValues(original, updated, series, berlin) + + /** The instant [local] names in [zoneId], as the provider would store it. */ + private fun instantAt(local: String, zoneId: String): Long = + java.time.LocalDateTime.parse(local) + .atZone(java.time.ZoneId.of(zoneId)) + .toInstant() + .toEpochMilli() @Test fun `pristine form produces no values`() { @@ -105,6 +142,42 @@ class EventWriteMapperTest { assertThat(update(original, original.copy())).isEmpty() } + @Test + fun `editing the time of a pinned event keeps its zone`() { + // The regression this guards: the update used to stamp the device zone + // over the event's own, silently un-anchoring a foreign-zone event so it + // stopped tracking that zone across DST. + val original = form(timezone = "America/New_York") + val values = update(original, original.copy(title = "Standup", start = original.start)) + assertThat(values).doesNotContainKey(CalendarContract.Events.EVENT_TIMEZONE) + + val moved = original.copy( + start = LocalDateTime(LocalDate(2026, 6, 11), LocalTime(11, 0)), + end = LocalDateTime(LocalDate(2026, 6, 11), LocalTime(12, 30)), + ) + assertThat(update(original, moved)[CalendarContract.Events.EVENT_TIMEZONE]) + .isEqualTo("America/New_York") + } + + @Test + fun `changing only the zone still moves the event`() { + // Same wall-clock, different zone: the instant moves, so DTSTART must be + // rewritten even though start/end compare equal. + val original = form() + val values = update(original, original.copy(timezone = "America/New_York")) + assertThat(values[CalendarContract.Events.EVENT_TIMEZONE]).isEqualTo("America/New_York") + // 10:00 Berlin (08:00Z) -> 10:00 New York (14:00Z): six hours later. + assertThat(values[CalendarContract.Events.DTSTART]).isEqualTo(1_781_186_400_000L) + } + + @Test + fun `unpinning back to the device zone rewrites the times`() { + val original = form(timezone = "America/New_York") + val values = update(original, original.copy(timezone = null)) + assertThat(values[CalendarContract.Events.EVENT_TIMEZONE]).isEqualTo("Europe/Berlin") + assertThat(values[CalendarContract.Events.DTSTART]).isEqualTo(1_781_164_800_000L) + } + @Test fun `text-only edit writes just the changed columns`() { val original = form() @@ -156,6 +229,77 @@ class EventWriteMapperTest { assertThat(values[CalendarContract.Events.DURATION]).isEqualTo("P5400S") } + @Test + fun `pinning a recurring event to another zone keeps the series wall clock`() { + // The regression this guards: the series DTSTART used to move by a + // millisecond delta measured at the *edited occurrence*. Here the series + // anchor sits in January (Berlin CET, +1) and the edited occurrence in + // July (Berlin CEST, +2), so the July delta is an hour off for January — + // the whole series would have drifted to 10:00 Tokyo. + val series = instantAt("2026-01-07T09:00", "Europe/Berlin") + val original = form( + start = LocalDateTime(LocalDate(2026, 7, 15), LocalTime(9, 0)), + end = LocalDateTime(LocalDate(2026, 7, 15), LocalTime(10, 0)), + ).copy(rrule = "FREQ=WEEKLY") + + val values = update(original, original.copy(timezone = "Asia/Tokyo"), series) + + assertThat(values[CalendarContract.Events.EVENT_TIMEZONE]).isEqualTo("Asia/Tokyo") + // The anchor still reads 09:00 — now 09:00 in Tokyo, not 10:00. + assertThat(values[CalendarContract.Events.DTSTART]) + .isEqualTo(instantAt("2026-01-07T09:00", "Asia/Tokyo")) + } + + @Test + fun `a time edit moves the series anchor in wall clock across a DST boundary`() { + // Anchor in winter, edited occurrence in summer: pushing the occurrence + // one hour later must leave the anchor at 10:00 winter time, not at an + // instant that re-reads as 11:00 once the offset differs. + val series = instantAt("2026-01-07T09:00", "Europe/Berlin") + val original = form( + start = LocalDateTime(LocalDate(2026, 7, 15), LocalTime(9, 0)), + end = LocalDateTime(LocalDate(2026, 7, 15), LocalTime(10, 0)), + ).copy(rrule = "FREQ=WEEKLY") + val moved = original.copy( + start = LocalDateTime(LocalDate(2026, 7, 15), LocalTime(10, 0)), + end = LocalDateTime(LocalDate(2026, 7, 15), LocalTime(11, 0)), + ) + + assertThat(update(original, moved, series)[CalendarContract.Events.DTSTART]) + .isEqualTo(instantAt("2026-01-07T10:00", "Europe/Berlin")) + } + + @Test + fun `moving a recurring occurrence to another day shifts the anchor by whole days`() { + val series = instantAt("2026-01-07T09:00", "Europe/Berlin") + val original = form( + start = LocalDateTime(LocalDate(2026, 1, 7), LocalTime(9, 0)), + end = LocalDateTime(LocalDate(2026, 1, 7), LocalTime(10, 0)), + ).copy(rrule = "FREQ=WEEKLY") + val moved = original.copy( + start = LocalDateTime(LocalDate(2026, 1, 9), LocalTime(14, 30)), + end = LocalDateTime(LocalDate(2026, 1, 9), LocalTime(15, 30)), + ) + + assertThat(update(original, moved, series)[CalendarContract.Events.DTSTART]) + .isEqualTo(instantAt("2026-01-09T14:30", "Europe/Berlin")) + } + + @Test + fun `switching a recurring event to all-day anchors the series on a UTC midnight`() { + val series = instantAt("2026-01-07T09:00", "Europe/Berlin") + val original = form( + start = LocalDateTime(LocalDate(2026, 7, 15), LocalTime(9, 0)), + end = LocalDateTime(LocalDate(2026, 7, 15), LocalTime(10, 0)), + ).copy(rrule = "FREQ=WEEKLY") + + val values = update(original, original.copy(isAllDay = true), series) + + assertThat(values[CalendarContract.Events.EVENT_TIMEZONE]).isEqualTo("UTC") + val dtStart = values[CalendarContract.Events.DTSTART] as Long + assertThat(dtStart % (24L * 60 * 60 * 1000)).isEqualTo(0L) + } + @Test fun `adding a recurrence keeps the times and writes rule plus duration`() { val original = form() diff --git a/app/src/test/java/de/jeanlucmakiola/calendula/data/prefs/SettingsPrefsTest.kt b/app/src/test/java/de/jeanlucmakiola/calendula/data/prefs/SettingsPrefsTest.kt index 5b53234..7693801 100644 --- a/app/src/test/java/de/jeanlucmakiola/calendula/data/prefs/SettingsPrefsTest.kt +++ b/app/src/test/java/de/jeanlucmakiola/calendula/data/prefs/SettingsPrefsTest.kt @@ -13,6 +13,7 @@ import de.jeanlucmakiola.calendula.ui.agenda.AgendaRange import de.jeanlucmakiola.calendula.ui.common.CalendarView import de.jeanlucmakiola.calendula.ui.common.IMPLEMENTED_VIEWS import de.jeanlucmakiola.calendula.ui.common.QuickSwitchConfig +import de.jeanlucmakiola.calendula.ui.month.MonthViewStyle import kotlinx.coroutines.flow.first import kotlinx.coroutines.test.runTest import kotlinx.datetime.DayOfWeek @@ -125,6 +126,30 @@ class SettingsPrefsTest { assertThat(prefs.agendaWidgetRange.first()).isEqualTo(AgendaRange.Week) } + @Test + fun `month view style defaults to paged and round-trips`(@TempDir tempDir: Path) = runTest { + val prefs = SettingsPrefs(newDataStore(tempDir)) + assertThat(prefs.monthViewStyle.first()).isEqualTo(MonthViewStyle.Paged) + + prefs.setMonthViewStyle(MonthViewStyle.Split) + assertThat(prefs.monthViewStyle.first()).isEqualTo(MonthViewStyle.Split) + + prefs.setMonthViewStyle(MonthViewStyle.Continuous) + assertThat(prefs.monthViewStyle.first()).isEqualTo(MonthViewStyle.Continuous) + } + + @Test + fun `garbage stored month view style falls back to paged`(@TempDir tempDir: Path) = runTest { + val store = newDataStore(tempDir) + val prefs = SettingsPrefs(store) + store.updateData { p -> + val m = p.toMutablePreferences() + m[SettingsPrefs.MONTH_VIEW_STYLE_KEY] = "Carousel" + m + } + assertThat(prefs.monthViewStyle.first()).isEqualTo(MonthViewStyle.Paged) + } + @Test fun `garbage stored enum falls back to default`(@TempDir tempDir: Path) = runTest { val store = newDataStore(tempDir) diff --git a/app/src/test/java/de/jeanlucmakiola/calendula/domain/EventFormTest.kt b/app/src/test/java/de/jeanlucmakiola/calendula/domain/EventFormTest.kt index fc975c6..b2fb058 100644 --- a/app/src/test/java/de/jeanlucmakiola/calendula/domain/EventFormTest.kt +++ b/app/src/test/java/de/jeanlucmakiola/calendula/domain/EventFormTest.kt @@ -117,6 +117,7 @@ class EventFormTest { attendees: List = emptyList(), eventColor: Int? = null, eventColorKey: String? = null, + eventTimezone: String? = null, ): EventDetail = EventDetail( instance = EventInstance( instanceId = 1L, @@ -138,6 +139,7 @@ class EventFormTest { accessLevel = accessLevel, eventColor = eventColor, eventColorKey = eventColorKey, + eventTimezone = eventTimezone, ) @Test @@ -157,6 +159,117 @@ class EventFormTest { assertThat(prefilled.description).isEqualTo("Body") } + @Test + fun `toEditForm leaves an event in the device zone unpinned`() { + val prefilled = detail(eventTimezone = "Europe/Berlin").toEditForm( + beginMillis = 1_781_164_800_000L, + endMillis = 1_781_164_800_000L + 3_600_000L, + zone = berlin, + ) + // Same zone as the device: pinning it would only make the picker appear + // on every ordinary event. + assertThat(prefilled.timezone).isNull() + assertThat(prefilled.populatedFields()).doesNotContain(EventFormField.Timezone) + } + + @Test + fun `toEditForm pins a foreign zone and shows the times in it`() { + val prefilled = detail(eventTimezone = "America/New_York").toEditForm( + beginMillis = 1_781_164_800_000L, // 08:00Z + endMillis = 1_781_164_800_000L + 3_600_000L, + zone = berlin, + ) + assertThat(prefilled.timezone).isEqualTo("America/New_York") + // 08:00Z is 10:00 in Berlin but 04:00 in New York — the form shows the + // event's own wall-clock, which is what a later save re-anchors to. + assertThat(prefilled.start).isEqualTo(LocalDateTime(LocalDate(2026, 6, 11), LocalTime(4, 0))) + assertThat(prefilled.populatedFields()).contains(EventFormField.Timezone) + } + + @Test + fun `toEditForm never pins a zone on an all-day event`() { + // All-day rows carry a nominal "UTC" that is an anchor, not a location. + val prefilled = detail(isAllDay = true, eventTimezone = "UTC").toEditForm( + beginMillis = LocalDate(2026, 6, 11).toEpochDays() * 86_400_000L, + endMillis = LocalDate(2026, 6, 12).toEpochDays() * 86_400_000L, + zone = berlin, + ) + assertThat(prefilled.timezone).isNull() + } + + @Test + fun `toEditForm ignores an unparseable stored zone`() { + val prefilled = detail(eventTimezone = "Mars/Olympus_Mons").toEditForm( + beginMillis = 1_781_164_800_000L, + endMillis = 1_781_164_800_000L + 3_600_000L, + zone = berlin, + ) + // A malformed sync row must not be honoured, nor fail the open. + assertThat(prefilled.timezone).isNull() + assertThat(prefilled.start).isEqualTo(LocalDateTime(LocalDate(2026, 6, 11), LocalTime(10, 0))) + } + + @Test + fun `timesIn converts a pinned event into the target zone`() { + val form = EventForm( + calendarId = 1L, + start = LocalDateTime(LocalDate(2026, 7, 17), LocalTime(8, 0)), + end = LocalDateTime(LocalDate(2026, 7, 17), LocalTime(9, 0)), + timezone = "America/New_York", + ) + val (start, end) = form.timesIn(berlin)!! + // 08:00 New York (EDT, UTC-4) is 14:00 Berlin (CEST, UTC+2). + assertThat(start).isEqualTo(LocalDateTime(LocalDate(2026, 7, 17), LocalTime(14, 0))) + assertThat(end).isEqualTo(LocalDateTime(LocalDate(2026, 7, 17), LocalTime(15, 0))) + } + + @Test + fun `timesIn crosses the date line when the offset pushes past midnight`() { + val form = EventForm( + calendarId = 1L, + start = LocalDateTime(LocalDate(2026, 7, 17), LocalTime(20, 0)), + end = LocalDateTime(LocalDate(2026, 7, 17), LocalTime(21, 0)), + timezone = "America/New_York", + ) + val (start, _) = form.timesIn(berlin)!! + // 20:00 New York is 02:00 the NEXT day in Berlin — the date has to move + // with it, not just the clock. + assertThat(start).isEqualTo(LocalDateTime(LocalDate(2026, 7, 18), LocalTime(2, 0))) + } + + @Test + fun `timesIn tracks each zone's own DST rather than a fixed offset`() { + // In January both are on standard time: 08:00 EST is 14:00 CET — the + // same six hours as July, but only because both shifted. Late March, + // when the US has sprung forward and Europe hasn't, the gap is five. + val march = EventForm( + calendarId = 1L, + start = LocalDateTime(LocalDate(2026, 3, 20), LocalTime(8, 0)), + end = LocalDateTime(LocalDate(2026, 3, 20), LocalTime(9, 0)), + timezone = "America/New_York", + ) + assertThat(march.timesIn(berlin)!!.first) + .isEqualTo(LocalDateTime(LocalDate(2026, 3, 20), LocalTime(13, 0))) + } + + @Test + fun `timesIn returns null when there is nothing to disambiguate`() { + val base = EventForm( + calendarId = 1L, + start = LocalDateTime(LocalDate(2026, 7, 17), LocalTime(8, 0)), + end = LocalDateTime(LocalDate(2026, 7, 17), LocalTime(9, 0)), + ) + // Unpinned: the form's times already are the local times. + assertThat(base.timesIn(berlin)).isNull() + // Pinned to the target itself: same thing. + assertThat(base.copy(timezone = "Europe/Berlin").timesIn(berlin)).isNull() + // All-day: date-anchored, so there's no zone conversion to show. + assertThat(base.copy(isAllDay = true, timezone = "America/New_York").timesIn(berlin)) + .isNull() + // Unparseable: can't convert, mustn't throw. + assertThat(base.copy(timezone = "Mars/Olympus_Mons").timesIn(berlin)).isNull() + } + @Test fun `toEditForm turns the exclusive all-day end into the last covered day`() { // 11th..13th = UTC midnights of the 11th and the (exclusive) 14th. diff --git a/app/src/test/java/de/jeanlucmakiola/calendula/domain/TimeZoneCatalogTest.kt b/app/src/test/java/de/jeanlucmakiola/calendula/domain/TimeZoneCatalogTest.kt new file mode 100644 index 0000000..56f6e8b --- /dev/null +++ b/app/src/test/java/de/jeanlucmakiola/calendula/domain/TimeZoneCatalogTest.kt @@ -0,0 +1,159 @@ +package de.jeanlucmakiola.calendula.domain + +import com.google.common.truth.Truth.assertThat +import org.junit.jupiter.api.Test +import java.time.Instant +import java.util.Locale + +class TimeZoneCatalogTest { + + // A fixed instant so DST-dependent offsets can't drift with the wall clock: + // 2026-06-11 is northern summer, i.e. Berlin on CEST and New York on EDT. + private val summer: Instant = Instant.parse("2026-06-11T12:00:00Z") + private val zones = timeZoneOptions(Locale.ENGLISH, summer) + + private fun filter(query: String) = filterTimeZones(zones, query) + + @Test + fun `catalogue holds the real zones and drops the legacy aliases`() { + assertThat(zones.map { it.id }).containsAtLeast("Europe/Berlin", "America/New_York") + // Bare aliases the tz database keeps for compatibility would double up + // the real zones in the picker. + assertThat(zones.map { it.id }).containsNoneOf("EST", "CST6CDT", "UTC") + assertThat(zones.none { it.id.startsWith("SystemV/") }).isTrue() + } + + @Test + fun `option exposes city and region split from the id`() { + val ny = zones.first { it.id == "America/New_York" } + assertThat(ny.city).isEqualTo("New York") + assertThat(ny.region).isEqualTo("America") + } + + @Test + fun `label is the id with underscores undone`() { + assertThat(zones.first { it.id == "America/New_York" }.label) + .isEqualTo("America/New York") + assertThat(zones.first { it.id == "Europe/Berlin" }.label).isEqualTo("Europe/Berlin") + } + + @Test + fun `resolved abbreviation is compact, not the long name`() { + // Whatever CLDR gives ("CET"/"CEST" or a "GMT+.." fallback), it must be + // short and space-free — that's the whole point of showing it instead of + // "Central European Time". + val berlin = zones.first { it.id == "Europe/Berlin" } + assertThat(berlin.shortName).doesNotContain(" ") + assertThat(berlin.shortName.length).isLessThan(berlin.displayName.length) + } + + @Test + fun `descriptor pairs a named abbreviation with the offset`() { + val option = TimeZoneOption( + id = "Europe/Berlin", + displayName = "Central European Time", + shortName = "CET", + offsetMinutes = 60, + ) + assertThat(zoneDescriptor(option)).isEqualTo("CET · GMT+01:00") + } + + @Test + fun `descriptor drops the offset when the abbreviation already is one`() { + // Repeating the offset after an offset-shaped abbreviation would just say + // the same thing twice. + fun descriptorFor(shortName: String, offset: Int) = zoneDescriptor( + TimeZoneOption(id = "X/Y", displayName = "", shortName = shortName, offsetMinutes = offset), + ) + assertThat(descriptorFor("UTC", 0)).isEqualTo("UTC") + assertThat(descriptorFor("GMT+05:30", 330)).isEqualTo("GMT+05:30") + } + + @Test + fun `offset is resolved at the given instant, not the current one`() { + val berlin = zones.first { it.id == "Europe/Berlin" } + // CEST in June, so +02:00 — a fixed +01:00 would mean we ignored DST. + assertThat(berlin.offsetMinutes).isEqualTo(120) + + val winter = timeZoneOptions(Locale.ENGLISH, Instant.parse("2026-01-11T12:00:00Z")) + assertThat(winter.first { it.id == "Europe/Berlin" }.offsetMinutes).isEqualTo(60) + } + + @Test + fun `blank query returns everything unchanged`() { + assertThat(filter("")).isEqualTo(zones) + assertThat(filter(" ")).isEqualTo(zones) + } + + @Test + fun `query matches the city, ignoring case and underscores`() { + assertThat(filter("new york").map { it.id }).contains("America/New_York") + assertThat(filter("NEW YORK").map { it.id }).contains("America/New_York") + assertThat(filter("new_york").map { it.id }).contains("America/New_York") + } + + @Test + fun `query matches accented cities typed plainly`() { + // Sao_Paulo has no accent in the id, but its localized name does — the + // point is that a user typing plain ASCII still finds it. + assertThat(filter("sao paulo").map { it.id }).contains("America/Sao_Paulo") + assertThat(filter("zurich").map { it.id }).contains("Europe/Zurich") + } + + @Test + fun `query matches the full IANA id`() { + assertThat(filter("europe/berlin").map { it.id }).contains("Europe/Berlin") + } + + @Test + fun `query matches the abbreviation, case-insensitively`() { + // "cest" isn't a city, id, or substring of "Central European Summer + // Time", so the only way these match is via the abbreviation. + val hits = filter("cest") + assertThat(hits).isNotEmpty() + assertThat(hits.map { it.id }).contains("Europe/Berlin") + // Every hit genuinely carries that abbreviation — nothing bled in. + assertThat(hits.all { it.shortName.equals("CEST", ignoreCase = true) }).isTrue() + // Case doesn't matter. + assertThat(filter("CEST").map { it.id }).isEqualTo(hits.map { it.id }) + } + + @Test + fun `a city starting with the query outranks one merely containing it`() { + val ids = filter("york").map { it.id } + // "New York" contains "york"; nothing starts with it, so it should still + // surface rather than being buried. + assertThat(ids).contains("America/New_York") + + // "col" starts Colombo but only appears mid-string elsewhere. + val col = filter("col").map { it.id } + assertThat(col.first()).isEqualTo("Asia/Colombo") + } + + @Test + fun `no match yields an empty list rather than everything`() { + assertThat(filter("zzzznotazone")).isEmpty() + } + + @Test + fun `single zone resolves the same way the catalogue does`() { + val fromCatalogue = zones.first { it.id == "Europe/Berlin" } + assertThat(timeZoneOptionOf("Europe/Berlin", Locale.ENGLISH, summer)) + .isEqualTo(fromCatalogue) + } + + @Test + fun `an unknown zone id resolves to null`() { + assertThat(timeZoneOptionOf("Mars/Olympus_Mons")).isNull() + } + + @Test + fun `gmt offsets format with a sign and padding`() { + assertThat(formatGmtOffset(0)).isEqualTo("GMT") + assertThat(formatGmtOffset(120)).isEqualTo("GMT+02:00") + assertThat(formatGmtOffset(-300)).isEqualTo("GMT-05:00") + // India is +05:30 — a whole-hour assumption would render this wrong. + assertThat(formatGmtOffset(330)).isEqualTo("GMT+05:30") + assertThat(formatGmtOffset(-210)).isEqualTo("GMT-03:30") + } +} diff --git a/app/src/test/java/de/jeanlucmakiola/calendula/ui/agenda/AgendaTimeLabelTest.kt b/app/src/test/java/de/jeanlucmakiola/calendula/ui/agenda/AgendaTimeLabelTest.kt new file mode 100644 index 0000000..25c71ed --- /dev/null +++ b/app/src/test/java/de/jeanlucmakiola/calendula/ui/agenda/AgendaTimeLabelTest.kt @@ -0,0 +1,76 @@ +package de.jeanlucmakiola.calendula.ui.agenda + +import com.google.common.truth.Truth.assertThat +import de.jeanlucmakiola.calendula.domain.EventInstance +import kotlinx.datetime.LocalDate +import kotlinx.datetime.LocalDateTime +import kotlinx.datetime.TimeZone +import kotlinx.datetime.toInstant +import kotlin.time.Instant +import org.junit.jupiter.api.Test + +class AgendaTimeLabelTest { + + private val zone = TimeZone.UTC + + private fun at(y: Int, mo: Int, d: Int, h: Int = 0, min: Int = 0): Instant = + LocalDateTime(y, mo, d, h, min).toInstant(zone) + + private fun event(start: Instant, end: Instant, isAllDay: Boolean = false) = EventInstance( + instanceId = 1, + eventId = 1, + calendarId = 1, + title = "e", + start = start, + end = end, + isAllDay = isAllDay, + color = 0, + location = null, + ) + + private fun labelOn(y: Int, mo: Int, d: Int, event: EventInstance) = + agendaTimeLabel(event, LocalDate(y, mo, d), zone) + + @Test + fun `a single-day timed event is a start-end range`() { + val e = event(at(2026, 7, 2, 12, 0), at(2026, 7, 2, 13, 0)) + + assertThat(labelOn(2026, 7, 2, e)) + .isEqualTo(AgendaTimeLabel.Range(e.start, e.end)) + } + + @Test + fun `a single-day all-day event is all-day`() { + val e = event(at(2026, 7, 2), at(2026, 7, 3), isAllDay = true) + + assertThat(labelOn(2026, 7, 2, e)).isEqualTo(AgendaTimeLabel.AllDay) + } + + @Test + fun `a multi-day timed event names the start, middle, and end days`() { + val e = event(at(2026, 7, 1, 14, 0), at(2026, 7, 4, 10, 0)) + + assertThat(labelOn(2026, 7, 1, e)).isEqualTo(AgendaTimeLabel.Starts(e.start)) + assertThat(labelOn(2026, 7, 2, e)).isEqualTo(AgendaTimeLabel.AllDay) + assertThat(labelOn(2026, 7, 3, e)).isEqualTo(AgendaTimeLabel.AllDay) + assertThat(labelOn(2026, 7, 4, e)).isEqualTo(AgendaTimeLabel.Ends(e.end)) + } + + @Test + fun `a multi-day all-day event is all-day on every day`() { + val e = event(at(2026, 7, 2), at(2026, 7, 5), isAllDay = true) + + assertThat(labelOn(2026, 7, 2, e)).isEqualTo(AgendaTimeLabel.AllDay) + assertThat(labelOn(2026, 7, 3, e)).isEqualTo(AgendaTimeLabel.AllDay) + assertThat(labelOn(2026, 7, 4, e)).isEqualTo(AgendaTimeLabel.AllDay) + } + + @Test + fun `an event begun before the shown day is not labelled as starting`() { + // Runs 29 Jun 09:00 → 2 Jul 09:00; on 1 Jul it is mid-span, on 2 Jul it ends. + val e = event(at(2026, 6, 29, 9, 0), at(2026, 7, 2, 9, 0)) + + assertThat(labelOn(2026, 7, 1, e)).isEqualTo(AgendaTimeLabel.AllDay) + assertThat(labelOn(2026, 7, 2, e)).isEqualTo(AgendaTimeLabel.Ends(e.end)) + } +} diff --git a/app/src/test/java/de/jeanlucmakiola/calendula/ui/agenda/GroupAgendaDaysTest.kt b/app/src/test/java/de/jeanlucmakiola/calendula/ui/agenda/GroupAgendaDaysTest.kt new file mode 100644 index 0000000..179d93f --- /dev/null +++ b/app/src/test/java/de/jeanlucmakiola/calendula/ui/agenda/GroupAgendaDaysTest.kt @@ -0,0 +1,175 @@ +package de.jeanlucmakiola.calendula.ui.agenda + +import com.google.common.truth.Truth.assertThat +import de.jeanlucmakiola.calendula.domain.EventInstance +import kotlinx.datetime.LocalDate +import kotlinx.datetime.LocalDateTime +import kotlinx.datetime.TimeZone +import kotlinx.datetime.toInstant +import kotlin.time.Instant +import org.junit.jupiter.api.Test + +class GroupAgendaDaysTest { + + private val zone = TimeZone.UTC + private val anchor = LocalDate(2026, 7, 1) + // A wide window so clamping to the window end is out of the way unless tested. + private val windowEnd = LocalDate(2026, 7, 31) + + private fun at(y: Int, mo: Int, d: Int, h: Int = 0, min: Int = 0): Instant = + LocalDateTime(y, mo, d, h, min).toInstant(zone) + + private fun event( + id: Long, + title: String, + start: Instant, + end: Instant, + isAllDay: Boolean = false, + ) = EventInstance( + instanceId = id, + eventId = id, + calendarId = 1, + title = title, + start = start, + end = end, + isAllDay = isAllDay, + color = 0, + location = null, + ) + + private fun days(instances: List) = + groupAgendaDays(anchor, windowEnd, instances, zone) + + @Test + fun `a timed multi-day event lists on every day it spans`() { + val e = event(1, "trip", at(2026, 7, 1, 14, 0), at(2026, 7, 3, 10, 0)) + + val result = days(listOf(e)) + + assertThat(result.map { it.date }).containsExactly( + LocalDate(2026, 7, 1), + LocalDate(2026, 7, 2), + LocalDate(2026, 7, 3), + ).inOrder() + result.forEach { assertThat(it.events).containsExactly(e) } + } + + @Test + fun `an all-day multi-day event stops before its exclusive next-midnight end`() { + // 1–3 July inclusive: end is the exclusive midnight opening 4 July. + val e = event(1, "holiday", at(2026, 7, 1), at(2026, 7, 4), isAllDay = true) + + val result = days(listOf(e)) + + assertThat(result.map { it.date }).containsExactly( + LocalDate(2026, 7, 1), + LocalDate(2026, 7, 2), + LocalDate(2026, 7, 3), + ).inOrder() + } + + @Test + fun `a single-day event lists exactly once`() { + val e = event(1, "lunch", at(2026, 7, 2, 12, 0), at(2026, 7, 2, 13, 0)) + + assertThat(days(listOf(e)).map { it.date }) + .containsExactly(LocalDate(2026, 7, 2)) + } + + @Test + fun `an event ending exactly at midnight does not reach the next day`() { + val e = event(1, "late", at(2026, 7, 1, 22, 0), at(2026, 7, 2, 0, 0)) + + assertThat(days(listOf(e)).map { it.date }) + .containsExactly(LocalDate(2026, 7, 1)) + } + + @Test + fun `an event begun before the anchor is clamped to the anchor day`() { + val e = event(1, "ongoing", at(2026, 6, 29, 9, 0), at(2026, 7, 2, 9, 0)) + + assertThat(days(listOf(e)).map { it.date }).containsExactly( + LocalDate(2026, 7, 1), + LocalDate(2026, 7, 2), + ).inOrder() + } + + @Test + fun `an event running past the window is clamped to the last visible day`() { + val narrowEnd = LocalDate(2026, 7, 2) + val e = event(1, "long", at(2026, 7, 1, 9, 0), at(2026, 7, 5, 9, 0)) + + val result = groupAgendaDays(anchor, narrowEnd, listOf(e), zone) + + assertThat(result.map { it.date }).containsExactly( + LocalDate(2026, 7, 1), + LocalDate(2026, 7, 2), + ).inOrder() + } + + // All-day events are stored at UTC midnights; resolving them in a device + // zone east of UTC would leak them onto the following day (see spanLastDay). + private val berlin = TimeZone.of("Europe/Berlin") + + private fun utcMidnight(y: Int, mo: Int, d: Int): Instant = + LocalDateTime(y, mo, d, 0, 0).toInstant(TimeZone.UTC) + + @Test + fun `a single-day all-day event does not leak onto the next day in an eastern zone`() { + val e = event(1, "birthday", utcMidnight(2026, 7, 2), utcMidnight(2026, 7, 3), isAllDay = true) + + val result = groupAgendaDays(anchor, windowEnd, listOf(e), berlin) + + assertThat(result.map { it.date }).containsExactly(LocalDate(2026, 7, 2)) + assertThat(e.spansMultipleDays(berlin)).isFalse() + } + + @Test + fun `an all-day multi-day event spans its true days in an eastern zone`() { + // 2–4 July inclusive: exclusive end is the UTC midnight opening 5 July. + val e = event(1, "holiday", utcMidnight(2026, 7, 2), utcMidnight(2026, 7, 5), isAllDay = true) + + val result = groupAgendaDays(anchor, windowEnd, listOf(e), berlin) + + assertThat(result.map { it.date }).containsExactly( + LocalDate(2026, 7, 2), + LocalDate(2026, 7, 3), + LocalDate(2026, 7, 4), + ).inOrder() + } + + @Test + fun `yesterday's all-day event does not surface under the anchor day in an eastern zone`() { + // The agenda window starts at the anchor's local midnight, which in Berlin is + // 22:00 UTC the day before — so an all-day event that ended yesterday (whose + // exclusive UTC-midnight end sits at that instant) is still returned by the + // provider. It must not be clamped onto the anchor's "today" section (#65). + val jul19 = LocalDate(2026, 7, 19) + val e = event(1, "birthday", utcMidnight(2026, 7, 18), utcMidnight(2026, 7, 19), isAllDay = true) + + val result = groupAgendaDays(jul19, LocalDate(2026, 8, 18), listOf(e), berlin) + + assertThat(result).isEmpty() + } + + @Test + fun `an all-day event that falls entirely past the window end is dropped`() { + // The same guard, at the far edge: an instance whose true days are all beyond + // windowEnd occupies no visible day and must not be clamped onto the last one. + val e = event(1, "birthday", utcMidnight(2026, 7, 3), utcMidnight(2026, 7, 4), isAllDay = true) + + val result = groupAgendaDays(anchor, LocalDate(2026, 7, 2), listOf(e), berlin) + + assertThat(result).isEmpty() + } + + @Test + fun `within a day all-day events sort before timed ones`() { + val allDay = event(1, "birthday", at(2026, 7, 1), at(2026, 7, 2), isAllDay = true) + val timed = event(2, "call", at(2026, 7, 1, 9, 0), at(2026, 7, 1, 10, 0)) + + val dayOne = days(listOf(timed, allDay)).first { it.date == anchor } + + assertThat(dayOne.events).containsExactly(allDay, timed).inOrder() + } +} diff --git a/app/src/test/java/de/jeanlucmakiola/calendula/ui/common/RecurrenceTextTest.kt b/app/src/test/java/de/jeanlucmakiola/calendula/ui/common/RecurrenceTextTest.kt new file mode 100644 index 0000000..213d31d --- /dev/null +++ b/app/src/test/java/de/jeanlucmakiola/calendula/ui/common/RecurrenceTextTest.kt @@ -0,0 +1,69 @@ +package de.jeanlucmakiola.calendula.ui.common + +import com.google.common.truth.Truth.assertThat +import java.time.LocalDate +import java.time.ZoneId +import org.junit.jupiter.api.Test + +/** + * The read side of the UNTIL round-trip. `SimpleRecurrence.toRRule` deliberately + * writes the end of the chosen *local* day expressed in UTC (see its docs — the + * provider applies UNTIL coarsely), so displaying it means converting back into + * the device zone first. Reading the leading digits raw showed the wrong day for + * zones behind UTC. + */ +class RecurrenceTextTest { + + private val berlin = ZoneId.of("Europe/Berlin") + private val losAngeles = ZoneId.of("America/Los_Angeles") + private val utc = ZoneId.of("UTC") + + @Test + fun `UTC form converts back to the picked day west of UTC`() { + // toRRule("2026-12-31", America/Los_Angeles) → 23:59:59 local = 07:59:59Z + // on 1 Jan. Reading the digits raw would show 1 Jan 2027. + assertThat(untilLocalDate("20270101T075959Z", losAngeles)) + .isEqualTo(LocalDate.of(2026, 12, 31)) + } + + @Test + fun `UTC form converts back to the picked day east of UTC`() { + // Berlin in December is UTC+1: 23:59:59 local = 22:59:59Z the same day. + assertThat(untilLocalDate("20261231T225959Z", berlin)) + .isEqualTo(LocalDate.of(2026, 12, 31)) + } + + @Test + fun `UTC form is unchanged at UTC itself`() { + assertThat(untilLocalDate("20261231T235959Z", utc)) + .isEqualTo(LocalDate.of(2026, 12, 31)) + } + + @Test + fun `summer offset is honoured, not a fixed one`() { + // Berlin in July is UTC+2, so the same local end-of-day lands at 21:59:59Z. + assertThat(untilLocalDate("20260801T215959Z", berlin)) + .isEqualTo(LocalDate.of(2026, 8, 1)) + } + + @Test + fun `date-only form is already local and passes through`() { + assertThat(untilLocalDate("20261231", losAngeles)) + .isEqualTo(LocalDate.of(2026, 12, 31)) + } + + @Test + fun `floating date-time form uses its date as-is`() { + // No trailing Z, so it isn't an instant — no conversion may be applied. + assertThat(untilLocalDate("20261231T235959", losAngeles)) + .isEqualTo(LocalDate.of(2026, 12, 31)) + } + + @Test + fun `garbage returns null rather than throwing`() { + assertThat(untilLocalDate("", berlin)).isNull() + assertThat(untilLocalDate("nonsense", berlin)).isNull() + assertThat(untilLocalDate("2026", berlin)).isNull() + assertThat(untilLocalDate("20261340", berlin)).isNull() + } +} diff --git a/app/src/test/java/de/jeanlucmakiola/calendula/ui/month/ClipWeekToMonthTest.kt b/app/src/test/java/de/jeanlucmakiola/calendula/ui/month/ClipWeekToMonthTest.kt new file mode 100644 index 0000000..3193ded --- /dev/null +++ b/app/src/test/java/de/jeanlucmakiola/calendula/ui/month/ClipWeekToMonthTest.kt @@ -0,0 +1,120 @@ +package de.jeanlucmakiola.calendula.ui.month + +import com.google.common.truth.Truth.assertThat +import de.jeanlucmakiola.calendula.domain.EventInstance +import kotlinx.datetime.DateTimeUnit +import kotlinx.datetime.DayOfWeek +import kotlinx.datetime.LocalDate +import kotlinx.datetime.Month +import kotlinx.datetime.TimeZone +import kotlinx.datetime.YearMonth +import kotlinx.datetime.atTime +import kotlinx.datetime.plus +import kotlinx.datetime.toInstant +import org.junit.jupiter.api.Test + +/** + * What makes the continuous style's month blocks self-contained: a boundary week + * keeps its seven columns but carries only the block's own month. + */ +class ClipWeekToMonthTest { + + private val zone = TimeZone.UTC + private val jul26 = YearMonth(2026, Month.JULY) + + private fun allDay(from: LocalDate, toInclusive: LocalDate, id: Long = 1L) = EventInstance( + instanceId = id, + eventId = id, + calendarId = 1L, + title = "A", + start = from.atTime(0, 0).toInstant(TimeZone.UTC), + end = toInclusive.plus(1, DateTimeUnit.DAY).atTime(0, 0).toInstant(TimeZone.UTC), + isAllDay = true, + color = 0xFF445566.toInt(), + location = null, + ) + + private fun timed(date: LocalDate, hour: Int, id: Long = 2L) = EventInstance( + instanceId = id, + eventId = id, + calendarId = 1L, + title = "T", + start = date.atTime(hour, 0).toInstant(zone), + end = date.atTime(hour + 1, 0).toInstant(zone), + isAllDay = false, + color = 0xFF112233.toInt(), + location = null, + ) + + /** July 2026 starts on a Wednesday, so its first Monday-anchored row is Jun 29 – Jul 5. */ + private fun firstRowOfJuly(events: List) = + clipWeekToMonth( + layoutMonthWeeks(jul26, DayOfWeek.MONDAY, events, zone).first(), + jul26, + ) + + @Test + fun `the row keeps all seven days so the columns don't shift`() { + val week = firstRowOfJuly(emptyList()) + assertThat(week.days).hasSize(7) + assertThat(week.days.first()).isEqualTo(LocalDate(2026, 6, 29)) + } + + @Test + fun `the neighbour month's events are dropped`() { + val june = timed(LocalDate(2026, 6, 30), 9) + val july = timed(LocalDate(2026, 7, 1), 9, id = 3L) + val week = firstRowOfJuly(listOf(june, july)) + + assertThat(week.timedByDay.keys).doesNotContain(LocalDate(2026, 6, 30)) + assertThat(week.timedByDay[LocalDate(2026, 7, 1)]).containsExactly(july) + assertThat(week.countByDay[LocalDate(2026, 6, 30)]).isNull() + assertThat(week.countByDay[LocalDate(2026, 7, 1)]).isEqualTo(1) + } + + @Test + fun `a bar reaching in from the previous month is cut back to the 1st`() { + // Jun 29 – Jul 2: columns 0..3 unclipped, 2..3 once July owns the row. + val week = firstRowOfJuly(listOf(allDay(LocalDate(2026, 6, 29), LocalDate(2026, 7, 2)))) + + val span = week.spans.single() + assertThat(span.startCol).isEqualTo(2) // Wednesday the 1st + assertThat(span.endCol).isEqualTo(3) + // Flat cap on the cut side: the event continues out of this block. + assertThat(span.continuesLeft).isTrue() + assertThat(span.continuesRight).isFalse() + } + + @Test + fun `a bar living entirely in the neighbour month disappears`() { + val week = firstRowOfJuly(listOf(allDay(LocalDate(2026, 6, 29), LocalDate(2026, 6, 30)))) + assertThat(week.spans).isEmpty() + } + + @Test + fun `a bar reaching out into the next month is cut at the last day`() { + // Jul 29 – Aug 2 sits in July's final row (Jul 27 – Aug 2). + val weeks = layoutMonthWeeks( + jul26, + DayOfWeek.MONDAY, + listOf(allDay(LocalDate(2026, 7, 29), LocalDate(2026, 8, 2))), + zone, + ) + val span = clipWeekToMonth(weeks.last(), jul26).spans.single() + assertThat(span.startCol).isEqualTo(2) // Wednesday the 29th + assertThat(span.endCol).isEqualTo(4) // Friday the 31st + assertThat(span.continuesRight).isTrue() + assertThat(span.continuesLeft).isFalse() + } + + @Test + fun `a row wholly inside the month is untouched`() { + val mid = layoutMonthWeeks( + jul26, + DayOfWeek.MONDAY, + listOf(timed(LocalDate(2026, 7, 8), 9), allDay(LocalDate(2026, 7, 7), LocalDate(2026, 7, 9))), + zone, + )[1] + assertThat(clipWeekToMonth(mid, jul26)).isEqualTo(mid) + } +} diff --git a/app/src/test/java/de/jeanlucmakiola/calendula/ui/month/ContinuousMonthIndexTest.kt b/app/src/test/java/de/jeanlucmakiola/calendula/ui/month/ContinuousMonthIndexTest.kt new file mode 100644 index 0000000..ef2c56b --- /dev/null +++ b/app/src/test/java/de/jeanlucmakiola/calendula/ui/month/ContinuousMonthIndexTest.kt @@ -0,0 +1,238 @@ +package de.jeanlucmakiola.calendula.ui.month + +import com.google.common.truth.Truth.assertThat +import kotlinx.datetime.DateTimeUnit +import kotlinx.datetime.DayOfWeek +import kotlinx.datetime.LocalDate +import kotlinx.datetime.Month +import kotlinx.datetime.TimeZone +import kotlinx.datetime.YearMonth +import kotlinx.datetime.minus +import kotlinx.datetime.plus +import org.junit.jupiter.api.Test + +/** + * The continuous grid's coordinate space. Every block's identity — and the + * LazyColumn items it maps to — hangs off this arithmetic, so it gets its own + * tests rather than being exercised only through the UI. + */ +class ContinuousMonthIndexTest { + + private val jun26 = YearMonth(2026, Month.JUNE) + + @Test + fun `consecutive months are consecutive indices`() { + val jun = monthIndexOf(jun26) + assertThat(monthIndexOf(YearMonth(2026, Month.JULY))).isEqualTo(jun + 1) + assertThat(monthIndexOf(YearMonth(2026, Month.MAY))).isEqualTo(jun - 1) + // And across the year boundary. + assertThat(monthIndexOf(YearMonth(2027, Month.JANUARY))) + .isEqualTo(monthIndexOf(YearMonth(2026, Month.DECEMBER)) + 1) + } + + @Test + fun `index round-trips back to its month`() { + listOf( + YearMonth(1900, Month.JANUARY), + jun26, + YearMonth(2026, Month.DECEMBER), + YearMonth(2100, Month.DECEMBER), + ).forEach { ym -> + assertThat(yearMonthForIndex(monthIndexOf(ym))).isEqualTo(ym) + } + } + + @Test + fun `indices are non-negative and span 1900 through 2100`() { + assertThat(monthIndexOf(YearMonth(1900, Month.JANUARY))).isEqualTo(0) + assertThat(monthIndexOf(jun26)).isGreaterThan(0) + assertThat(monthIndexOf(YearMonth(2100, Month.DECEMBER))) + .isEqualTo(continuousMonthCount() - 1) + assertThat(continuousMonthCount()).isEqualTo(201 * 12) + } + + @Test + fun `a month maps to its header item and back`() { + val index = monthIndexOf(jun26) + val item = itemIndexForMonth(index) + // Both of a month's items — its sticky header and the block under it — + // resolve back to the same month, so a title read off the first visible + // item is right wherever the viewport sits inside the block. + assertThat(monthIndexForItem(item)).isEqualTo(index) + assertThat(monthIndexForItem(item + 1)).isEqualTo(index) + assertThat(monthIndexForItem(item + 2)).isEqualTo(index + 1) + } + + @Test + fun `week rows follow the month's shape and its week start`() { + // June 2026 starts on a Monday: 30 days → 5 rows either way. + assertThat(weekRowsInMonth(jun26, DayOfWeek.MONDAY)).isEqualTo(5) + assertThat(weekRowsInMonth(jun26, DayOfWeek.SUNDAY)).isEqualTo(5) + // February 2026 starts on a Sunday: a Sunday-anchored grid fits it in 4 + // rows, a Monday-anchored one needs 5 — the block's height moves with the + // preference even though its position in the list doesn't. + val feb26 = YearMonth(2026, Month.FEBRUARY) + assertThat(weekRowsInMonth(feb26, DayOfWeek.SUNDAY)).isEqualTo(4) + assertThat(weekRowsInMonth(feb26, DayOfWeek.MONDAY)).isEqualTo(5) + // February 2021 starts on a Monday and is 28 days → exactly 4. + assertThat(weekRowsInMonth(YearMonth(2021, Month.FEBRUARY), DayOfWeek.MONDAY)) + .isEqualTo(4) + } + + @Test + fun `the placeholder block is the size the loaded one will be`() { + // Otherwise scrolling across an unloaded month jumps when its data lands. + (0 until 24).forEach { offset -> + val ym = yearMonthForIndex(monthIndexOf(jun26) + offset) + DayOfWeek.entries.forEach { ws -> + assertThat(weekRowsInMonth(ym, ws)) + .isEqualTo(layoutMonthWeeks(ym, ws, emptyList(), TimeZone.UTC).size) + } + } + } + + @Test + fun `a window is clamped to the months that exist`() { + // The regression this guards: an empty visible-items list reported as + // "month 0", whose padded window ran to -4 — and yearMonthForIndex has + // no month for that, so the whole grid failed with "couldn't read". + assertThat(clampMonthWindow(-4..4)).isEqualTo(0..4) + val last = continuousMonthCount() - 1 + assertThat(clampMonthWindow((last - 2)..(last + 4))).isEqualTo((last - 2)..last) + assertThat(clampMonthWindow(10..20)).isEqualTo(10..20) + } + + @Test + fun `every index a clamped window can hold resolves to a month`() { + listOf(clampMonthWindow(-4..4), clampMonthWindow((continuousMonthCount() - 1)..(continuousMonthCount() + 3))) + .forEach { window -> + window.forEach { index -> yearMonthForIndex(index) } + } + } + + @Test + fun `the dense week window covers every week its months touch`() { + // One month-window load has to lay out every Dense row those months + // appear in, boundary weeks included, or rows would flicker as + // placeholders while the data for them is already in hand. + DayOfWeek.entries.forEach { ws -> + val window = monthIndexOf(jun26)..monthIndexOf(YearMonth(2026, Month.AUGUST)) + val weeks = weekWindowFor(window, ws) + window.forEach { index -> + val ym = yearMonthForIndex(index) + val first = weekIndexOf(firstOfMonth(ym), ws) + val last = weekIndexOf(firstOfMonth(yearMonthForIndex(index + 1)).minus(1, DateTimeUnit.DAY), ws) + assertThat(weeks.contains(first)).isTrue() + assertThat(weeks.contains(last)).isTrue() + } + } + } + + @Test + fun `a dense row reports the month that owns most of it`() { + // June 29 – July 5, 2026 (Monday-anchored): four of its days are July's, + // so the window follows July rather than snapping back to June. + val boundary = weekIndexOf(LocalDate(2026, 6, 29), DayOfWeek.MONDAY) + assertThat(monthIndexForWeek(boundary, DayOfWeek.MONDAY)) + .isEqualTo(monthIndexOf(YearMonth(2026, Month.JULY))) + // A row wholly inside June reports June. + assertThat(monthIndexForWeek(weekIndexOf(LocalDate(2026, 6, 15), DayOfWeek.MONDAY), DayOfWeek.MONDAY)) + .isEqualTo(monthIndexOf(jun26)) + } + + @Test + fun `every day of a dense week shares one index`() { + val mon = LocalDate(2026, 6, 8) + val indices = (0..6).map { weekIndexOf(mon.plus(it, DateTimeUnit.DAY), DayOfWeek.MONDAY) } + assertThat(indices.toSet()).hasSize(1) + } + + @Test + fun `dense week indices round-trip and stay inside the list`() { + val wed = LocalDate(2026, 6, 10) + DayOfWeek.entries.forEach { ws -> + val index = weekIndexOf(wed, ws) + assertThat(weekStartForIndex(index, ws)).isEqualTo(wed.startOfGridWeek(ws)) + assertThat(weekIndexOf(LocalDate(1900, 1, 1), ws)).isAtLeast(0) + assertThat(index).isLessThan(continuousWeekCount(ws)) + assertThat(weekIndexOf(LocalDate(2100, 12, 31), ws)) + .isLessThan(continuousWeekCount(ws)) + } + } + + @Test + fun `firstOfMonth is the 1st`() { + assertThat(firstOfMonth(jun26)).isEqualTo(LocalDate(2026, 6, 1)) + } + + @Test + fun `a window comfortably around the visible range is kept`() { + assertThat(nextLoadWindow(loaded = 0..100, firstVisible = 40, lastVisible = 45)).isNull() + } + + @Test + fun `nearing a loaded edge widens the window around the visible range`() { + // Within two months of the top edge → reload, padded on both sides. + assertThat(nextLoadWindow(loaded = 0..100, firstVisible = 1, lastVisible = 2)) + .isEqualTo(-4..7) + + assertThat(nextLoadWindow(loaded = 0..100, firstVisible = 98, lastVisible = 100)) + .isEqualTo(93..105) + } + + @Test + fun `a jump far outside the window reloads around the destination`() { + assertThat(nextLoadWindow(loaded = 0..100, firstVisible = 500, lastVisible = 501)) + .isEqualTo(495..506) + } + + @Test + fun `the reloaded window always clears the trigger it just crossed`() { + // Otherwise every scroll frame would re-trigger a query — and with the + // window growing from a pad of 1, this has to hold at every rung of the + // ladder, not just the widest one. + (1..8).forEach { pad -> + val loaded = (10 - pad)..(10 + pad) + val past = 10 + pad + 1 + val widened = nextLoadWindow(loaded, past, past, pad)!! + assertThat(nextLoadWindow(widened, past, past, pad)).isNull() + } + } + + @Test + fun `the reload trigger stays inside the pad`() { + // A trigger at or beyond the pad would fire again the instant its own + // reload landed, and the window would query forever. + (1..8).forEach { pad -> assertThat(edgeForPad(pad)).isLessThan(pad) } + // A one-month window has no room for hysteresis: reload only on contact. + assertThat(edgeForPad(1)).isEqualTo(0) + assertThat(edgeForPad(0)).isEqualTo(0) + } + + @Test + fun `the smallest window reloads on crossing rather than nearing its edge`() { + // The first frame loads one month either side of today, which leaves no + // room to reload *before* the edge: at pad 1 the trigger is contact. + // Only momentary — the first completed load widens the pad to 3, which + // buys the usual head start back. + val initial = 10..12 + assertThat(nextLoadWindow(initial, firstVisible = 12, lastVisible = 12, pad = 1)).isNull() + assertThat(nextLoadWindow(initial, firstVisible = 13, lastVisible = 13, pad = 1)) + .isEqualTo(12..14) + } + + @Test + fun `the split selection follows the month, landing on today when it's there`() { + val today = LocalDate(2026, 6, 10) + assertThat(selectionForMonth(jun26, today)).isEqualTo(today) + } + + @Test + fun `the split selection falls to the 1st of any other month`() { + val today = LocalDate(2026, 6, 10) + assertThat(selectionForMonth(YearMonth(2026, Month.JULY), today)) + .isEqualTo(LocalDate(2026, 7, 1)) + assertThat(selectionForMonth(YearMonth(2025, Month.JUNE), today)) + .isEqualTo(LocalDate(2025, 6, 1)) + } +} diff --git a/app/src/test/java/de/jeanlucmakiola/calendula/ui/month/DayIndexTest.kt b/app/src/test/java/de/jeanlucmakiola/calendula/ui/month/DayIndexTest.kt new file mode 100644 index 0000000..d71eb19 --- /dev/null +++ b/app/src/test/java/de/jeanlucmakiola/calendula/ui/month/DayIndexTest.kt @@ -0,0 +1,121 @@ +package de.jeanlucmakiola.calendula.ui.month + +import com.google.common.truth.Truth.assertThat +import de.jeanlucmakiola.calendula.domain.EventInstance +import kotlinx.datetime.DateTimeUnit +import kotlinx.datetime.DayOfWeek +import kotlinx.datetime.LocalDate +import kotlinx.datetime.Month +import kotlinx.datetime.TimeZone +import kotlinx.datetime.YearMonth +import kotlinx.datetime.atTime +import kotlinx.datetime.minus +import kotlinx.datetime.plus +import kotlinx.datetime.toInstant +import org.junit.jupiter.api.Test + +/** + * [DayIndex] exists purely to make the scrolling styles cheap, so what it owes + * is that cheapness changes *nothing*: a row laid out from the index must be the + * row that would have come from scanning every instance. + * + * Run in a zone east of UTC, where the all-day anchoring and the day boundaries + * disagree — the case that has bitten this codebase before (#65). + */ +class DayIndexTest { + + private val zone = TimeZone.of("Europe/Berlin") + private val mon = LocalDate(2026, 6, 8) + private val week = (0..6).map { mon.plus(it, DateTimeUnit.DAY) } + + private var nextId = 0L + + private fun timed(from: LocalDate, startHour: Int, to: LocalDate, endHour: Int): EventInstance { + val id = ++nextId + return EventInstance( + instanceId = id, + eventId = id, + calendarId = 1L, + title = "T$id", + start = from.atTime(startHour, 0).toInstant(zone), + end = to.atTime(endHour, 0).toInstant(zone), + isAllDay = false, + color = 0xFF112233.toInt(), + location = null, + ) + } + + private fun allDay(from: LocalDate, toInclusive: LocalDate): EventInstance { + val id = ++nextId + return EventInstance( + instanceId = id, + eventId = id, + calendarId = 1L, + title = "A$id", + start = from.atTime(0, 0).toInstant(TimeZone.UTC), + end = toInclusive.plus(1, DateTimeUnit.DAY).atTime(0, 0).toInstant(TimeZone.UTC), + isAllDay = true, + color = 0xFF445566.toInt(), + location = null, + ) + } + + /** Every shape the layout distinguishes, plus the boundary cases around them. */ + private fun awkwardEvents(): List = listOf( + timed(mon, 9, mon, 10), + // Crosses midnight into the next day. + timed(mon.plus(1, DateTimeUnit.DAY), 23, mon.plus(2, DateTimeUnit.DAY), 1), + // Ends exactly at midnight: covers its own day, not the next. + timed(mon.plus(3, DateTimeUnit.DAY), 22, mon.plus(4, DateTimeUnit.DAY), 0), + // Zero length, at midnight: covers nothing at all. + timed(mon.plus(4, DateTimeUnit.DAY), 0, mon.plus(4, DateTimeUnit.DAY), 0), + allDay(mon.plus(2, DateTimeUnit.DAY), mon.plus(2, DateTimeUnit.DAY)), + // Runs in from before the row and out past its end. + allDay(mon.minus(3, DateTimeUnit.DAY), mon.plus(9, DateTimeUnit.DAY)), + // Wholly outside the row, both sides. + timed(mon.minus(10, DateTimeUnit.DAY), 9, mon.minus(10, DateTimeUnit.DAY), 10), + allDay(mon.plus(20, DateTimeUnit.DAY), mon.plus(21, DateTimeUnit.DAY)), + // Two on one day, to pin the ordering the index hands back. + timed(mon.plus(5, DateTimeUnit.DAY), 8, mon.plus(5, DateTimeUnit.DAY), 9), + timed(mon.plus(5, DateTimeUnit.DAY), 14, mon.plus(5, DateTimeUnit.DAY), 15), + ) + + @Test + fun `a row from the index is the row from a full scan`() { + val events = awkwardEvents() + val scanned = layoutCalendarWeek(week, events, zone) + val indexed = layoutCalendarWeek(week, DayIndex(events, zone).eventsOn(week), zone) + assertThat(indexed).isEqualTo(scanned) + } + + @Test + fun `a whole month agrees, row for row`() { + val events = awkwardEvents() + val jun = YearMonth(2026, Month.JUNE) + DayOfWeek.entries.forEach { ws -> + assertThat(layoutMonthWeeks(jun, ws, DayIndex(events, zone), zone)) + .isEqualTo(layoutMonthWeeks(jun, ws, events, zone)) + } + } + + @Test + fun `an event covering several of a row's days is listed once`() { + // It is bucketed on each date it covers, so the row would otherwise see + // it repeatedly and lay out a lane per copy. + val long = allDay(mon, mon.plus(4, DateTimeUnit.DAY)) + val events = DayIndex(listOf(long), zone).eventsOn(week) + assertThat(events).containsExactly(long) + } + + @Test + fun `days no event touches come back empty`() { + val index = DayIndex(listOf(timed(mon, 9, mon, 10)), zone) + assertThat(index.eventsOn(listOf(mon.plus(1, DateTimeUnit.DAY)))).isEmpty() + assertThat(index.eventsOn(emptyList())).isEmpty() + } + + @Test + fun `an empty calendar indexes to nothing`() { + assertThat(DayIndex(emptyList(), zone).eventsOn(week)).isEmpty() + } +} diff --git a/app/src/test/java/de/jeanlucmakiola/calendula/ui/month/LaneEventsTest.kt b/app/src/test/java/de/jeanlucmakiola/calendula/ui/month/LaneEventsTest.kt new file mode 100644 index 0000000..0374ab3 --- /dev/null +++ b/app/src/test/java/de/jeanlucmakiola/calendula/ui/month/LaneEventsTest.kt @@ -0,0 +1,129 @@ +package de.jeanlucmakiola.calendula.ui.month + +import com.google.common.truth.Truth.assertThat +import de.jeanlucmakiola.calendula.domain.EventInstance +import kotlinx.datetime.DateTimeUnit +import kotlinx.datetime.DayOfWeek +import kotlinx.datetime.LocalDate +import kotlinx.datetime.Month +import kotlinx.datetime.TimeZone +import kotlinx.datetime.YearMonth +import kotlinx.datetime.atTime +import kotlinx.datetime.plus +import kotlinx.datetime.toInstant +import org.junit.jupiter.api.Test + +/** + * What lets the split style's dots morph into the paged style's bars (#53): both + * read a day off the *same* lane seating, so dot _i_ and lane _i_ are one event. + */ +class LaneEventsTest { + + private val zone = TimeZone.UTC + private val jul26 = YearMonth(2026, Month.JULY) + + /** July 2026 starts on a Wednesday, so this row — Jul 6–12 — sits wholly inside it. */ + private fun rowOfJuly6(events: List) = + layoutMonthWeeks(jul26, DayOfWeek.MONDAY, events, zone)[1] + + private fun allDay(from: LocalDate, toInclusive: LocalDate, id: Long, color: Int = BLUE) = + EventInstance( + instanceId = id, + eventId = id, + calendarId = 1L, + title = "A$id", + start = from.atTime(0, 0).toInstant(zone), + end = toInclusive.plus(1, DateTimeUnit.DAY).atTime(0, 0).toInstant(zone), + isAllDay = true, + color = color, + location = null, + ) + + private fun timed(date: LocalDate, hour: Int, id: Long, color: Int = RED) = EventInstance( + instanceId = id, + eventId = id, + calendarId = 1L, + title = "T$id", + start = date.atTime(hour, 0).toInstant(zone), + end = date.atTime(hour + 1, 0).toInstant(zone), + isAllDay = false, + color = color, + location = null, + ) + + @Test + fun `a bar keeps its lane and the day's timed events fill what's left`() { + val bar = allDay(LocalDate(2026, 7, 7), LocalDate(2026, 7, 9), id = 1L) + val meeting = timed(LocalDate(2026, 7, 7), hour = 9, id = 2L) + val week = rowOfJuly6(listOf(bar, meeting)) + + // Jul 7 is column 1 of a Monday-anchored row starting Jul 6. + assertThat(week.laneEvents(col = 1, day = LocalDate(2026, 7, 7), laneCap = 3)) + .containsExactly(bar, meeting) + .inOrder() + } + + @Test + fun `a day the bar misses seats its own events from lane zero`() { + val bar = allDay(LocalDate(2026, 7, 7), LocalDate(2026, 7, 9), id = 1L) + val monday = timed(LocalDate(2026, 7, 6), hour = 9, id = 2L) + val week = rowOfJuly6(listOf(bar, monday)) + + assertThat(week.laneEvents(col = 0, day = LocalDate(2026, 7, 6), laneCap = 3)) + .containsExactly(monday) + } + + @Test + fun `a multi-day bar is seated on every day it covers`() { + val bar = allDay(LocalDate(2026, 7, 7), LocalDate(2026, 7, 9), id = 1L) + val week = rowOfJuly6(listOf(bar)) + + (1..3).forEach { col -> + val day = LocalDate(2026, 7, 6 + col) + assertThat(week.laneEvents(col, day, laneCap = 3)).containsExactly(bar) + } + assertThat(week.laneEvents(col = 4, day = LocalDate(2026, 7, 10), laneCap = 3)).isEmpty() + } + + /** The bug the colour-gathered dots had: one dot for two events on one calendar. */ + @Test + fun `events sharing a colour each keep their own lane`() { + val first = timed(LocalDate(2026, 7, 6), hour = 9, id = 1L, color = RED) + val second = timed(LocalDate(2026, 7, 6), hour = 14, id = 2L, color = RED) + val week = rowOfJuly6(listOf(first, second)) + + assertThat(week.laneEvents(col = 0, day = LocalDate(2026, 7, 6), laneCap = 3)) + .containsExactly(first, second) + .inOrder() + } + + @Test + fun `seating stops at the cap and leaves the rest to the overflow count`() { + val events = (0 until 5).map { timed(LocalDate(2026, 7, 6), hour = 8 + it, id = it + 1L) } + val week = rowOfJuly6(events) + + val seated = week.laneEvents(col = 0, day = LocalDate(2026, 7, 6), laneCap = 3) + assertThat(seated).hasSize(3) + assertThat(seated).containsExactlyElementsIn(events.take(3)).inOrder() + assertThat(week.countByDay[LocalDate(2026, 7, 6)]!! - seated.size).isEqualTo(2) + } + + /** A bar parked below the cap is out of view, so it takes no dot with it. */ + @Test + fun `a bar beyond the cap is left out`() { + val bars = (0 until 4).map { + allDay(LocalDate(2026, 7, 6), LocalDate(2026, 7, 8), id = it + 1L) + } + val week = rowOfJuly6(bars) + + val seated = week.laneEvents(col = 0, day = LocalDate(2026, 7, 6), laneCap = 3) + assertThat(seated).hasSize(3) + assertThat(week.spans.filter { it.lane >= 3 }.map { it.event }) + .containsNoneIn(seated) + } + + private companion object { + const val BLUE = 0xFF3366CC.toInt() + const val RED = 0xFFCC3333.toInt() + } +} diff --git a/app/src/test/java/de/jeanlucmakiola/calendula/ui/month/MonthLayoutTest.kt b/app/src/test/java/de/jeanlucmakiola/calendula/ui/month/MonthLayoutTest.kt new file mode 100644 index 0000000..52e43f8 --- /dev/null +++ b/app/src/test/java/de/jeanlucmakiola/calendula/ui/month/MonthLayoutTest.kt @@ -0,0 +1,220 @@ +package de.jeanlucmakiola.calendula.ui.month + +import com.google.common.truth.Truth.assertThat +import de.jeanlucmakiola.calendula.domain.EventInstance +import kotlinx.datetime.DateTimeUnit +import kotlinx.datetime.DayOfWeek +import kotlinx.datetime.LocalDate +import kotlinx.datetime.TimeZone +import kotlinx.datetime.YearMonth +import kotlinx.datetime.atTime +import kotlinx.datetime.plus +import kotlinx.datetime.toInstant +import kotlin.time.Instant +import org.junit.jupiter.api.Test + +class MonthLayoutTest { + + private val zone = TimeZone.UTC + + // 2026-06-01 is a Monday, so a Monday-anchored June grid starts on the 1st. + private val jun = YearMonth(2026, kotlinx.datetime.Month.JUNE) + private val jun1 = LocalDate(2026, 6, 1) + private val jun8 = LocalDate(2026, 6, 8) + private val weekOf8th = (0..6).map { jun8.plus(it, DateTimeUnit.DAY) } + + private fun at(date: LocalDate, h: Int, m: Int = 0): Instant = + date.atTime(h, m).toInstant(zone) + + private fun timed( + date: LocalDate, + startHour: Int, + endHour: Int, + id: Long = 1L, + title: String = "E", + ) = EventInstance( + instanceId = id, + eventId = id, + calendarId = 1L, + title = title, + start = at(date, startHour), + end = at(date, endHour), + isAllDay = false, + color = 0xFF112233.toInt(), + location = null, + ) + + /** All-day events live at UTC midnights with an *exclusive* end. */ + private fun allDay( + from: LocalDate, + toInclusive: LocalDate = from, + id: Long = 100L, + title: String = "A", + ) = EventInstance( + instanceId = id, + eventId = id, + calendarId = 1L, + title = title, + start = from.atTime(0, 0).toInstant(TimeZone.UTC), + end = toInclusive.plus(1, DateTimeUnit.DAY).atTime(0, 0).toInstant(TimeZone.UTC), + isAllDay = true, + color = 0xFF445566.toInt(), + location = null, + ) + + @Test + fun `startOfGridWeek snaps back to the configured week start`() { + val wed = LocalDate(2026, 6, 10) + assertThat(wed.startOfGridWeek(DayOfWeek.MONDAY)).isEqualTo(jun8) + assertThat(jun8.startOfGridWeek(DayOfWeek.MONDAY)).isEqualTo(jun8) + // A Sunday-anchored week containing the 10th starts on the 7th. + assertThat(wed.startOfGridWeek(DayOfWeek.SUNDAY)).isEqualTo(LocalDate(2026, 6, 7)) + } + + @Test + fun `monthGridRange always covers 42 days from the grid start`() { + val range = monthGridRange(jun, DayOfWeek.MONDAY, zone) + assertThat(range.start).isEqualTo(at(jun1, 0)) + // 42 days inclusive → the last second of 2026-07-12. + assertThat(range.endInclusive) + .isEqualTo(LocalDate(2026, 7, 12).atTime(23, 59, 59).toInstant(zone)) + } + + @Test + fun `week count follows the month's shape rather than a fixed six rows`() { + // June 2026: starts Monday, 30 days → 5 rows. + assertThat(layoutMonthWeeks(jun, DayOfWeek.MONDAY, emptyList(), zone)).hasSize(5) + // August 2026: starts Saturday, 31 days → spills to 6 rows. + val aug = YearMonth(2026, kotlinx.datetime.Month.AUGUST) + assertThat(layoutMonthWeeks(aug, DayOfWeek.MONDAY, emptyList(), zone)).hasSize(6) + // February 2021: starts Monday, 28 days → exactly 4 rows. + val feb = YearMonth(2021, kotlinx.datetime.Month.FEBRUARY) + assertThat(layoutMonthWeeks(feb, DayOfWeek.MONDAY, emptyList(), zone)).hasSize(4) + } + + @Test + fun `layoutMonthWeeks rows are contiguous and seven days wide`() { + val weeks = layoutMonthWeeks(jun, DayOfWeek.MONDAY, emptyList(), zone) + assertThat(weeks.first().days.first()).isEqualTo(jun1) + weeks.forEach { assertThat(it.days).hasSize(7) } + val allDays = weeks.flatMap { it.days } + allDays.zipWithNext { a, b -> assertThat(b).isEqualTo(a.plus(1, DateTimeUnit.DAY)) } + } + + @Test + fun `a multi-day event becomes one span across its columns`() { + val ev = allDay(LocalDate(2026, 6, 10), LocalDate(2026, 6, 12)) + val week = layoutCalendarWeek(weekOf8th, listOf(ev), zone) + + assertThat(week.spans).hasSize(1) + val span = week.spans.single() + assertThat(span.startCol).isEqualTo(2) // Wednesday the 10th + assertThat(span.endCol).isEqualTo(4) // Friday the 12th + assertThat(span.lane).isEqualTo(0) + assertThat(span.continuesLeft).isFalse() + assertThat(span.continuesRight).isFalse() + } + + @Test + fun `a span running past the row end is flagged as continuing`() { + // Saturday the 13th through Tuesday the 16th straddles the row boundary. + val ev = allDay(LocalDate(2026, 6, 13), LocalDate(2026, 6, 16)) + val week = layoutCalendarWeek(weekOf8th, listOf(ev), zone) + + val span = week.spans.single() + assertThat(span.startCol).isEqualTo(5) + assertThat(span.endCol).isEqualTo(6) + assertThat(span.continuesLeft).isFalse() + assertThat(span.continuesRight).isTrue() + + // The following row picks it up with the flags mirrored. + val nextRow = layoutCalendarWeek( + weekOf8th.map { it.plus(7, DateTimeUnit.DAY) }, + listOf(ev), + zone, + ) + val tail = nextRow.spans.single() + assertThat(tail.startCol).isEqualTo(0) + assertThat(tail.endCol).isEqualTo(1) + assertThat(tail.continuesLeft).isTrue() + assertThat(tail.continuesRight).isFalse() + } + + @Test + fun `overlapping spans are stacked on separate lanes`() { + val a = allDay(LocalDate(2026, 6, 9), LocalDate(2026, 6, 11), id = 1L) + val b = allDay(LocalDate(2026, 6, 10), LocalDate(2026, 6, 12), id = 2L) + val week = layoutCalendarWeek(weekOf8th, listOf(a, b), zone) + + assertThat(week.spans.map { it.lane }).containsExactly(0, 1) + } + + @Test + fun `single-day timed events stay pills and sort by start`() { + val late = timed(LocalDate(2026, 6, 10), 14, 15, id = 1L) + val early = timed(LocalDate(2026, 6, 10), 9, 10, id = 2L) + val week = layoutCalendarWeek(weekOf8th, listOf(late, early), zone) + + assertThat(week.spans).isEmpty() + assertThat(week.timedByDay[LocalDate(2026, 6, 10)]?.map { it.instanceId }) + .containsExactly(2L, 1L) + .inOrder() + assertThat(week.timedByDay[LocalDate(2026, 6, 11)]).isEmpty() + } + + @Test + fun `countByDay totals bars and pills on each date`() { + val span = allDay(LocalDate(2026, 6, 10), LocalDate(2026, 6, 12), id = 1L) + val pill = timed(LocalDate(2026, 6, 10), 9, 10, id = 2L) + val week = layoutCalendarWeek(weekOf8th, listOf(span, pill), zone) + + assertThat(week.countByDay[LocalDate(2026, 6, 10)]).isEqualTo(2) + assertThat(week.countByDay[LocalDate(2026, 6, 11)]).isEqualTo(1) + assertThat(week.countByDay[LocalDate(2026, 6, 13)]).isEqualTo(0) + } + + @Test + fun `layoutMonthWeeks agrees with laying each row out on its own`() { + val events = listOf( + allDay(LocalDate(2026, 6, 10), LocalDate(2026, 6, 12), id = 1L), + timed(LocalDate(2026, 6, 18), 9, 10, id = 2L), + ) + val weeks = layoutMonthWeeks(jun, DayOfWeek.MONDAY, events, zone) + + weeks.forEach { row -> + assertThat(row).isEqualTo(layoutCalendarWeek(row.days, events, zone)) + } + } + + @Test + fun `instancesByDay puts all-day events first then orders by start`() { + val allDayEv = allDay(LocalDate(2026, 6, 10), id = 1L) + val late = timed(LocalDate(2026, 6, 10), 14, 15, id = 2L) + val early = timed(LocalDate(2026, 6, 10), 9, 10, id = 3L) + + val byDay = instancesByDay(weekOf8th, listOf(late, early, allDayEv), zone) + + assertThat(byDay.getValue(LocalDate(2026, 6, 10)).map { it.instanceId }) + .containsExactly(1L, 3L, 2L) + .inOrder() + } + + @Test + fun `instancesByDay repeats a multi-day event on every date it covers`() { + val ev = allDay(LocalDate(2026, 6, 10), LocalDate(2026, 6, 12)) + val byDay = instancesByDay(weekOf8th, listOf(ev), zone) + + assertThat(byDay.getValue(LocalDate(2026, 6, 9))).isEmpty() + assertThat(byDay.getValue(LocalDate(2026, 6, 10))).hasSize(1) + assertThat(byDay.getValue(LocalDate(2026, 6, 11))).hasSize(1) + assertThat(byDay.getValue(LocalDate(2026, 6, 12))).hasSize(1) + assertThat(byDay.getValue(LocalDate(2026, 6, 13))).isEmpty() + } + + @Test + fun `instancesByDay covers every date in the grid, empty ones included`() { + val byDay = instancesByDay(weekOf8th, emptyList(), zone) + assertThat(byDay.keys).containsExactlyElementsIn(weekOf8th) + assertThat(byDay.values.flatten()).isEmpty() + } +} diff --git a/app/src/test/java/de/jeanlucmakiola/calendula/widget/WidgetScaleTest.kt b/app/src/test/java/de/jeanlucmakiola/calendula/widget/WidgetScaleTest.kt new file mode 100644 index 0000000..287400c --- /dev/null +++ b/app/src/test/java/de/jeanlucmakiola/calendula/widget/WidgetScaleTest.kt @@ -0,0 +1,89 @@ +package de.jeanlucmakiola.calendula.widget + +import androidx.compose.ui.unit.DpSize +import androidx.compose.ui.unit.dp +import com.google.common.truth.Truth.assertThat +import org.junit.jupiter.api.Test + +class WidgetScaleTest { + + @Test + fun `the on-device calibration points map to their tiers`() { + // The two sizes measured on-device: the compact widget stays COMPACT (the + // baseline, unchanged), the full-width one steps up to LARGE — not XLARGE, + // which read as too big on a phone (#51). + assertThat(scaleFor(DpSize(222.dp, 270.dp))).isEqualTo(WidgetScale.COMPACT) + assertThat(scaleFor(DpSize(378.dp, 672.dp))).isEqualTo(WidgetScale.LARGE) + } + + @Test + fun `a full-width widget at ordinary height still scales up`() { + // The regression the height cap used to cause: widening the widget without + // also making it unusually tall is *the* resize #51 reports, and it must + // reach the tier its width earned. Three cells tall is about 270dp. + assertThat(scaleFor(DpSize(378.dp, 270.dp))).isEqualTo(WidgetScale.LARGE) + assertThat(scaleFor(DpSize(300.dp, 270.dp))).isEqualTo(WidgetScale.REGULAR) + assertThat(scaleFor(DpSize(460.dp, 300.dp))).isEqualTo(WidgetScale.XLARGE) + } + + @Test + fun `width buckets into the four tiers`() { + // Tall enough that the height cap never binds, isolating the width rule. + val h = 500.dp + assertThat(scaleFor(DpSize(180.dp, h))).isEqualTo(WidgetScale.COMPACT) + assertThat(scaleFor(DpSize(259.dp, h))).isEqualTo(WidgetScale.COMPACT) + assertThat(scaleFor(DpSize(260.dp, h))).isEqualTo(WidgetScale.REGULAR) + assertThat(scaleFor(DpSize(329.dp, h))).isEqualTo(WidgetScale.REGULAR) + assertThat(scaleFor(DpSize(330.dp, h))).isEqualTo(WidgetScale.LARGE) + assertThat(scaleFor(DpSize(419.dp, h))).isEqualTo(WidgetScale.LARGE) + assertThat(scaleFor(DpSize(420.dp, h))).isEqualTo(WidgetScale.XLARGE) + assertThat(scaleFor(DpSize(900.dp, h))).isEqualTo(WidgetScale.XLARGE) + } + + @Test + fun `extra height never raises the tier`() { + // Height decides how many rows are visible, not how big they are: a tall, + // narrow widget wants more events, not bigger text. + assertThat(scaleFor(DpSize(222.dp, 200.dp))).isEqualTo(WidgetScale.COMPACT) + assertThat(scaleFor(DpSize(222.dp, 900.dp))).isEqualTo(WidgetScale.COMPACT) + assertThat(scaleFor(DpSize(300.dp, 900.dp))).isEqualTo(WidgetScale.REGULAR) + } + + @Test + fun `only a genuinely squashed widget is stepped back down`() { + // Same (wide) width, shrinking height. The cap exists to stop oversized type + // surviving in a one- or two-row sliver — it must not fire at normal heights. + // Heights are gross; the cap works on height minus 60dp of chrome, so the + // LARGE floor is 190dp (130dp usable) and the REGULAR floor 130dp (70dp). + val wide = 378.dp + assertThat(scaleFor(DpSize(wide, 400.dp))).isEqualTo(WidgetScale.LARGE) + assertThat(scaleFor(DpSize(wide, 260.dp))).isEqualTo(WidgetScale.LARGE) + assertThat(scaleFor(DpSize(wide, 190.dp))).isEqualTo(WidgetScale.LARGE) + assertThat(scaleFor(DpSize(wide, 189.dp))).isEqualTo(WidgetScale.REGULAR) + assertThat(scaleFor(DpSize(wide, 130.dp))).isEqualTo(WidgetScale.REGULAR) + assertThat(scaleFor(DpSize(wide, 129.dp))).isEqualTo(WidgetScale.COMPACT) + // The provider's declared floor (minResizeWidth/Height = 110dp) is COMPACT. + assertThat(scaleFor(DpSize(110.dp, 110.dp))).isEqualTo(WidgetScale.COMPACT) + } + + @Test + fun `the tier is monotonic in both axes`() { + // Growing a widget must never make its type smaller. Sweeps the whole + // plausible range rather than spot-checking, so a future threshold edit + // can't accidentally invert a step. + val widths = (110..900 step 7).map { it.dp } + val heights = (110..900 step 7).map { it.dp } + widths.forEach { w -> + heights.zipWithNext { shorter, taller -> + assertThat(scaleFor(DpSize(w, taller))) + .isAtLeast(scaleFor(DpSize(w, shorter))) + } + } + heights.forEach { h -> + widths.zipWithNext { narrower, wider -> + assertThat(scaleFor(DpSize(wider, h))) + .isAtLeast(scaleFor(DpSize(narrower, h))) + } + } + } +} diff --git a/app/src/test/java/de/jeanlucmakiola/calendula/widget/agenda/AgendaScaleTest.kt b/app/src/test/java/de/jeanlucmakiola/calendula/widget/agenda/AgendaScaleTest.kt new file mode 100644 index 0000000..edc84c4 --- /dev/null +++ b/app/src/test/java/de/jeanlucmakiola/calendula/widget/agenda/AgendaScaleTest.kt @@ -0,0 +1,121 @@ +package de.jeanlucmakiola.calendula.widget.agenda + +import androidx.compose.ui.unit.DpSize +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.google.common.truth.Truth.assertThat +import de.jeanlucmakiola.calendula.widget.WidgetScale +import de.jeanlucmakiola.calendula.widget.scaleFor +import org.junit.jupiter.api.Test + +class AgendaScaleTest { + + // --- the "default size is unchanged" regression guard --------------------- + + @Test + fun `every default placement width stays COMPACT`() { + // Ties the guarantee to the provider XML's declared 3-cell default rather + // than to one measured launcher: the whole band a default placement can + // land in must bucket to COMPACT, or a freshly placed widget silently + // changes appearance (#51). See AGENDA_DEFAULT_WIDTH_BAND. + val band = AGENDA_DEFAULT_WIDTH_BAND + var w = band.start + while (w <= band.endInclusive) { + assertThat(scaleFor(DpSize(w, 270.dp))).isEqualTo(WidgetScale.COMPACT) + w += 1.dp + } + } + + @Test + fun `COMPACT metrics equal the widget's original constants`() { + // If this fails, a default-sized agenda widget no longer looks as it did. + val m = metricsFor(WidgetScale.COMPACT) + assertThat(m.title).isEqualTo(16.sp) + assertThat(m.dayHeader).isEqualTo(13.sp) + assertThat(m.eventTitle).isEqualTo(14.sp) + assertThat(m.eventTime).isEqualTo(12.sp) + assertThat(m.placeholder).isEqualTo(14.sp) + assertThat(m.message).isEqualTo(14.sp) + assertThat(m.stripeH).isEqualTo(36.dp) + assertThat(m.iconImage).isEqualTo(22.dp) + assertThat(m.iconBox).isEqualTo(40.dp) + assertThat(m.rowVPad).isEqualTo(4.dp) + assertThat(m.dayHeaderTopPad).isEqualTo(10.dp) + } + + @Test + fun `the derived text indent matches the original hardcoded inset`() { + // TEXT_INDENT replaced a hardcoded 19dp; it must still resolve to 19dp or + // day headers and the placeholder line stop aligning with event titles. + assertThat(TEXT_INDENT).isEqualTo(19.dp) + assertThat(TEXT_INDENT).isEqualTo(ROW_H_PAD + STRIPE_WIDTH + STRIPE_GAP) + } + + // --- the ramp ------------------------------------------------------------ + + @Test + fun `sizes are non-decreasing across the tiers`() { + val tiers = WidgetScale.entries.map(::metricsFor) + + tiers.zipWithNext { small, big -> + assertThat(big.title.value).isAtLeast(small.title.value) + assertThat(big.dayHeader.value).isAtLeast(small.dayHeader.value) + assertThat(big.eventTitle.value).isAtLeast(small.eventTitle.value) + assertThat(big.eventTime.value).isAtLeast(small.eventTime.value) + assertThat(big.placeholder.value).isAtLeast(small.placeholder.value) + assertThat(big.message.value).isAtLeast(small.message.value) + assertThat(big.stripeH.value).isAtLeast(small.stripeH.value) + assertThat(big.iconImage.value).isAtLeast(small.iconImage.value) + assertThat(big.iconBox.value).isAtLeast(small.iconBox.value) + assertThat(big.rowVPad.value).isAtLeast(small.rowVPad.value) + assertThat(big.dayHeaderTopPad.value).isAtLeast(small.dayHeaderTopPad.value) + } + } + + @Test + fun `the event title keeps its lead over the time line`() { + // The secondary line steps more slowly on purpose; if it ever caught up the + // row would lose its hierarchy. + WidgetScale.entries.map(::metricsFor).forEach { m -> + assertThat(m.eventTitle.value).isGreaterThan(m.eventTime.value) + } + } + + @Test + fun `no tier grows type more than half again over the baseline`() { + // Guards against a future edit turning "more readable" into "absurd". + val base = metricsFor(WidgetScale.COMPACT) + val top = metricsFor(WidgetScale.XLARGE) + assertThat(top.title.value / base.title.value).isLessThan(1.5f) + assertThat(top.eventTitle.value / base.eventTitle.value).isLessThan(1.5f) + } + + // --- font scale ---------------------------------------------------------- + + @Test + fun `the stripe tracks the system font scale`() { + // The stripe is Dp, the text beside it is sp: without this the two diverge + // at large accessibility font settings and the stripe under-runs the row. + val m = metricsFor(WidgetScale.COMPACT) + assertThat(m.scaledForFont(1f).stripeH).isEqualTo(36.dp) + assertThat(m.scaledForFont(1.3f).stripeH.value).isWithin(0.01f).of(46.8f) + assertThat(m.scaledForFont(0.85f).stripeH.value).isWithin(0.01f).of(30.6f) + } + + @Test + fun `scaling for the default font scale changes nothing`() { + val m = metricsFor(WidgetScale.LARGE) + assertThat(m.scaledForFont(1f)).isSameInstanceAs(m) + } + + @Test + fun `font scaling leaves the sp sizes alone`() { + // Glance already applies the font scale to sp; scaling them here too would + // double-count it. + val m = metricsFor(WidgetScale.REGULAR) + val scaled = m.scaledForFont(1.3f) + assertThat(scaled.title).isEqualTo(m.title) + assertThat(scaled.eventTitle).isEqualTo(m.eventTitle) + assertThat(scaled.rowVPad).isEqualTo(m.rowVPad) + } +} diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 21d1f42..a8c3600 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -99,6 +99,30 @@ on-device — see plan 03): UTC, so zones ahead of UTC can't leak an extra occurrence. - All-day events are normalised to UTC midnights with an exclusive end. +### Event time zones + +`EventForm.timezone` is the zone its wall-clock times mean, and **null means +"the device zone at save time"** — not "no zone". The data layer resolves it in +`toWriteTimes` and always stamps a concrete `EVENT_TIMEZONE`, so an ordinary +event behaves exactly as it did before the field existed. + +- A non-null value **pins** the event: it keeps tracking that zone's offset + across DST no matter where the device is. `toEditForm` only pins when the + stored zone differs from the device's, so the optional Time-zone field stays + hidden on ordinary events and reveals itself (via `populatedFields`) on + foreign-zone ones. +- A pinned event is prefilled **in its own zone**, so the form shows the + wall-clock the event means rather than the device's rendering of it. +- A zone change counts as a **time change** even with the wall-clock untouched + (same 09:00 elsewhere is a different instant), so `buildEventUpdateValues` + includes it in `timesChanged` and rewrites `DTSTART`. +- **All-day events never carry a zone.** They're date-anchored — the UTC + midnights above are an anchor, not a location — so the field is withheld from + the form entirely and `toWriteTimes` forces `"UTC"` regardless. + +Still device-zone-relative, and knowingly so: `RRULE`'s `UNTIL` rendering and +`AllDayReminderEncoding`'s offset (see its KDoc). + ## Save conflicts No locking. `openForEdit` keeps an `EditSnapshot` — the prefilled form diff --git a/fastlane/metadata/android/en-US/changelogs/21600.txt b/fastlane/metadata/android/en-US/changelogs/21600.txt new file mode 100644 index 0000000..72f1f11 --- /dev/null +++ b/fastlane/metadata/android/en-US/changelogs/21600.txt @@ -0,0 +1,101 @@ +### Added +- Choose how the month view is laid out. A new **Month view style** setting + (Settings → Views) offers three ways to read a month, each shown with a + preview of the layout it produces: + - **Pages** — what you have today: one month at a time, swiped sideways. + - **Continuous** — scroll up and down through the weeks without a break + between months. Because the weeks run on unbroken, no month is cut off and + no day appears twice, where paging repeats a boundary week at the end of one + month and the start of the next. The 1st of each month names itself so you + always know where you are, and the title bar keeps up as you scroll ([#38]). + - **Split** — a compact grid showing coloured dots for the days that have + something on them, with the day you tap listed in full underneath. Tap the + date above the list to open the whole day ([#53]). + + The Agenda view is untouched by this and stays available in all three styles — + the split layout lists a single day, while Agenda remains a rolling multi-day + window with its own range settings. +- Give an event its own time zone. A new **Time zone** field (under "more + fields" in the event form) pins an event to a specific zone, so a call set for + 8:00 AM in New York stays 8:00 AM in New York wherever you open it — and keeps + tracking that zone across daylight-saving changes instead of drifting an hour. + The form edits the event in its own zone and shows the local equivalent under + the times ("2:00 PM – 3:00 PM your time"); the event's details keep your local + time first and note the original beneath it, so both are always clear. Pick a + zone from a full-screen picker with your device zone and recent choices on top, + searching by city ("new york"), IANA id ("europe/berlin"), or abbreviation + ("CEST") to gather every matching zone at once. All-day events stay + date-anchored and carry no zone, as before ([#31]). +- Put the "jump to today" button in the toolbar. A new **Today button in + toolbar** setting (Settings → Appearance, off by default) swaps the floating + button that fades into the corner while you're away from today for a permanent + today icon in the top bar — always there, on today or not, matching the + familiar calendar-app pattern. Leave it off to keep the floating button as + before ([#60]). +- Choose what Calendula calls itself on your home screen. A new **App name** + setting (Settings → Appearance) switches the launcher label between + **Calendula** and **Calendar**, for launchers that can't rename apps + themselves. Pick from a full-screen chooser that previews both names as + launcher marks; the change applies at once ([#44]). +- Calendula now speaks **Arabic**, laid out right-to-left, and its French and + Italian translations have been brought up to date — thanks to the community + translators on Weblate. Pick a language under Settings → Language (or leave it + on the system default). + +### Changed +- Dates in the Month, Week and Day title bars now follow your language and + region instead of one hardcoded layout. Every date was rendered in a fixed + German-style order with a trailing dot on the day number, whatever your + settings: US English showed "Fri, 17. Jul 2026" where it should read + "Fri, Jul 17". The Agenda view already formatted correctly, so the two + disagreed about the same date. All four views now share one formatter, and the + day/month order, the separators and the ordinal all come from your locale — + so English-in-Germany reads "Fri, 17 Jul" and English-in-the-US "Fri, Jul 17", + each correct for where you are ([#60]). +- The title bar drops the year while you're in the current one — "July" rather + than "July 2026". The year reappears the moment you page out of the current + year, which is when it tells you something you didn't already know. +- The Week view's title now names the month instead of spelling out the day range. + "24. Jun – 31. Jun" restated the day numbers already printed in the column + headers right below it, in the widest string in the bar. A week that straddles + two months keeps the outgoing month until it is fully gone ([#60]). +- The custom recurrence picker has been redesigned and tightened up. As you + build a rule — "every 2 weeks on Mon & Wed, until a date" — the live summary + now describes exactly what will be saved rather than a near-copy that could + drift from it, the amount fields accept being left blank (reading as their + shown default instead of greying out OK), and the read-out no longer jumps + around as you tap weekdays ([#42]). + +### Fixed +- An all-day event no longer shows up again the day after it happened. In time + zones east of UTC, an all-day event — a birthday, say — set for one day also + appeared under the *next* day's heading in the Agenda (and the agenda widget), + because all-day events are anchored to UTC midnight and the following day's + window reached back across that boundary and pulled the event forward onto + "today". Each all-day event now lists only on the day it actually falls on + ([#65]). +- A multi-day event now shows under every day it spans in the Agenda, not just + its first day, so a trip or a multi-day booking appears on each day it covers. +- The "Upcoming" agenda widget now scales its text and rows to the size you give + it. Previously it was laid out once for the smallest size and simply stretched + when enlarged, so the text stayed small no matter how big you made the widget. + Now a bigger widget gets bigger, more readable type and roomier rows, while the + default size looks exactly as before — no new setting; it follows the size you + already chose ([#51]). +- Calendula now appears under other apps' "Add to calendar" / "Save to calendar" + actions. Some apps (e.g. DB Navigator) fire the widely-used "insert event" + intent with the singular `vnd.android.cursor.item/event` type, which Calendula + didn't advertise — so it was left out of the chooser, and if it was your only + calendar app the save silently did nothing. It now accepts that form, plus the + `INSERT_OR_EDIT` action, and opens the new event prefilled for review ([#74]). +- Opening a `.ics`/`.vcs` file now works even when another app hands it over + mislabelled as a generic download (`application/octet-stream`), as some mail + clients, browsers and file managers do — Calendula recognises it by its file + extension instead of relying on the declared type ([#74]). +- A recurrence end date no longer lands a day late. West of UTC, setting a rule + to end "until" a given day could save and show the day after the one picked; + the end date now reads back as chosen ([#42]). +- The status- and navigation-bar icons stay legible over full-screen pickers in + dark theme. They could render dark-on-dark — a near-invisible black clock + against the dark picker — instead of switching to light ([#70]). + diff --git a/floret-kit b/floret-kit index 2124227..b4d3ead 160000 --- a/floret-kit +++ b/floret-kit @@ -1 +1 @@ -Subproject commit 2124227a7f462b46b5833250bc26d2ae6c7e2cd0 +Subproject commit b4d3ead8f0fe1e042c1cd7f350ff7132f22189b3