diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index 2907953..2247f05 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -412,6 +412,23 @@
android:resource="@xml/appwidget_info_today" />
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
index 335accf..3072061 100644
--- a/app/src/main/res/values/strings.xml
+++ b/app/src/main/res/values/strings.xml
@@ -447,6 +447,51 @@
No upcoming events
Calendula today
Nothing left today
+ Calendula countdown
+ Now
+ Ending now
+ Tomorrow
+ Ends tomorrow
+
+ Until %1$s
+
+ - in %d min
+ - in %d min
+
+
+ - in %d h
+ - in %d h
+
+
+ - in %d day
+ - in %d days
+
+
+ - ends in %d min
+ - ends in %d min
+
+
+ - ends in %d h
+ - ends in %d h
+
+
+ - ends in %d day
+ - ends in %d days
+
+ Tap to choose an event to count down to
+
+ %1$s is over
+ Count down to
+ Next event
+ Chosen event
+ The next timed event in this widget\'s calendars, or the one running now. All-day and declined events are skipped.
+ Event
+ None chosen yet
+ Search events
+ No events in the coming year match.
+ Couldn\'t load your events.
+
+ %1$s · repeats
Refresh
New event
Open Calendula to grant calendar access
diff --git a/app/src/main/res/xml/appwidget_info_countdown.xml b/app/src/main/res/xml/appwidget_info_countdown.xml
new file mode 100644
index 0000000..e1a1071
--- /dev/null
+++ b/app/src/main/res/xml/appwidget_info_countdown.xml
@@ -0,0 +1,16 @@
+
+
diff --git a/app/src/test/java/de/jeanlucmakiola/calendula/widget/countdown/CountdownTest.kt b/app/src/test/java/de/jeanlucmakiola/calendula/widget/countdown/CountdownTest.kt
new file mode 100644
index 0000000..285e9ed
--- /dev/null
+++ b/app/src/test/java/de/jeanlucmakiola/calendula/widget/countdown/CountdownTest.kt
@@ -0,0 +1,89 @@
+package de.jeanlucmakiola.calendula.widget.countdown
+
+import com.google.common.truth.Truth.assertThat
+import de.jeanlucmakiola.calendula.domain.EventInstance
+import de.jeanlucmakiola.calendula.domain.EventResponse
+import kotlinx.datetime.TimeZone
+import org.junit.jupiter.api.Test
+import kotlin.time.Duration.Companion.days
+import kotlin.time.Duration.Companion.hours
+import kotlin.time.Duration.Companion.minutes
+import kotlin.time.Duration.Companion.seconds
+import kotlin.time.Instant
+
+class CountdownTest {
+
+ private val zone = TimeZone.UTC
+ private val now = Instant.parse("2026-10-21T12:00:00Z")
+
+ private fun event(
+ start: Instant,
+ end: Instant = start + 1.hours,
+ id: Long = 1,
+ allDay: Boolean = false,
+ response: EventResponse = EventResponse.Going,
+ ) = EventInstance(
+ instanceId = id, eventId = id, calendarId = 1, title = "e$id",
+ start = start, end = end, isAllDay = allDay, color = 0, location = null, response = response,
+ )
+
+ @Test
+ fun `labels step from days to hours to minutes to now`() {
+ assertThat(countdownLabel(event(now + 30.seconds), now, zone)).isEqualTo(CountdownLabel(false, CountdownUnit.NOW, 0))
+ assertThat(countdownLabel(event(now + 24.minutes + 10.seconds), now, zone)).isEqualTo(CountdownLabel(false, CountdownUnit.MINUTES, 25))
+ assertThat(countdownLabel(event(now + 2.hours + 40.minutes), now, zone)).isEqualTo(CountdownLabel(false, CountdownUnit.HOURS, 3))
+ assertThat(countdownLabel(event(now + 1.hours + 10.minutes), now, zone)).isEqualTo(CountdownLabel(false, CountdownUnit.HOURS, 1))
+ // Tomorrow 23:00 is 35 h away but one calendar day.
+ assertThat(countdownLabel(event(now + 35.hours), now, zone)).isEqualTo(CountdownLabel(false, CountdownUnit.DAYS, 1))
+ assertThat(countdownLabel(event(now + 12.days), now, zone)).isEqualTo(CountdownLabel(false, CountdownUnit.DAYS, 12))
+ }
+
+ @Test
+ fun `a running event counts to its end`() {
+ val running = event(start = now - 20.minutes, end = now + 10.minutes)
+ assertThat(countdownLabel(running, now, zone)).isEqualTo(CountdownLabel(true, CountdownUnit.MINUTES, 10))
+ }
+
+ @Test
+ fun `the next redraw lands where the label changes`() {
+ // "in 25 min" turns into "in 24 min" a minute later.
+ assertThat(nextLabelChange(event(now + 25.minutes), now, zone)).isEqualTo(now + 1.minutes)
+ // "in 3 h" (2 h 40 min) holds until 2 h 30 min are left.
+ assertThat(nextLabelChange(event(now + 2.hours + 40.minutes), now, zone)).isEqualTo(now + 10.minutes)
+ // "in 1 h" holds until the last hour, then counts minutes.
+ assertThat(nextLabelChange(event(now + 1.hours + 10.minutes), now, zone)).isEqualTo(now + 10.minutes)
+ // Days change at midnight.
+ assertThat(nextLabelChange(event(now + 5.days), now, zone)).isEqualTo(Instant.parse("2026-10-22T00:00:00Z"))
+ }
+
+ @Test
+ fun `next mode skips all-day, declined and finished events`() {
+ val allDay = event(now + 1.hours, id = 1, allDay = true)
+ val declined = event(now + 1.hours, id = 2, response = EventResponse.Declined)
+ val over = event(now - 2.hours, now - 1.hours, id = 3)
+ val later = event(now + 3.hours, id = 4)
+ val running = event(now - 1.hours, now + 2.hours, id = 5)
+ assertThat(pickNextEvent(listOf(allDay, declined, over, later), now)?.eventId).isEqualTo(4)
+ // A running event ending before the next one starts comes first.
+ assertThat(pickNextEvent(listOf(later, running), now)?.eventId).isEqualTo(5)
+ }
+
+ @Test
+ fun `a pinned series follows its next occurrence`() {
+ val past = event(now - 7.days)
+ val next = event(now + 7.days)
+ assertThat(pickPinnedOccurrence(listOf(past, next), now, zone)).isEqualTo(next)
+ assertThat(pickPinnedOccurrence(listOf(past), now, zone)).isNull()
+ }
+
+ @Test
+ fun `an all-day event counts in local days west of UTC`() {
+ val newYork = TimeZone.of("America/New_York")
+ // 21 Oct, 20:00 in New York; the vacation is all day on the 23rd.
+ val evening = Instant.parse("2026-10-22T00:00:00Z")
+ val vacation = event(Instant.parse("2026-10-23T00:00:00Z"), Instant.parse("2026-10-24T00:00:00Z"), allDay = true)
+ val counted = vacation.counted(newYork)
+ assertThat(counted.start).isEqualTo(Instant.parse("2026-10-23T04:00:00Z"))
+ assertThat(countdownLabel(counted, evening, newYork)).isEqualTo(CountdownLabel(false, CountdownUnit.DAYS, 2))
+ }
+}