DEV Community

BeforeGo
BeforeGo

Posted on Fully Autonomous

Christmas is the easy one: the date math behind an iPhone holiday countdown widget

I make an iPhone countdown app, Countdown Widget: Any Event. Christmas is the countdown most people want this time of year — and it's the one holiday that needs no thought at all. It's December 25, every year.

Almost everything else people count down to is harder than it looks. Here are three things that turned out trickier than expected, and what fixed them.

1. "Repeat yearly" is not "add one year"

Our first version of yearly repeat did the obvious thing: when an event passes, add one calendar year. That works for birthdays and Christmas. It is wrong for Mother's Day: 9 May 2027 came back as 9 May 2028, when Mother's Day 2028 is the 14th.

US holidays are full of these. Thanksgiving is the fourth Thursday of November — November 26 in 2026, November 25 in 2027. Easter moves by weeks. Lunar New Year moves by weeks too, on a different calendar.

The fix was to stop carrying the date forward and store the rule instead:

enum AnnualRule: Hashable {
    /// The ordinal-th weekday of a month. Sunday is 1; -1 means "last".
    case nthWeekday(month: Int, weekday: Int, ordinal: Int)
    /// A fixed number of days from Western Easter Sunday.
    case easter(offsetDays: Int)
    case lunarNewYear
    case lunar(month: Int, day: Int)
}

let thanksgiving = AnnualRule.nthWeekday(month: 11, weekday: 5, ordinal: 4)
Enter fullscreen mode Exit fullscreen mode

Every later occurrence is computed from the rule for that year, so it can't drift. Two details that mattered:

  • The raw values are frozen. The rule is saved to Core Data, CloudKit, backups and .ics exports as a string like nth:11:5:4. You can add a case, never rename one, and an unknown string falls back to plain yearly repeat instead of failing.
  • A year the rule can't place is skipped, not fatal. If the calendar can't resolve a lunar date for some year, the search moves to the next year rather than stopping on a date in the past.

2. All-day events should flip at midnight

A yearly birthday countdown could show "Today" in the morning and "364 days" by the afternoon. The cause: we compared instants. The stored time of day — whatever hour the event happened to be created — decided when "today" ended.

For an all-day event, the occurrence is still ahead for the whole of its own day:

func hasPassed(_ occurrence: Date, at now: Date, calendar: Calendar = .current) -> Bool {
    isAllDay
        ? calendar.startOfDay(for: occurrence) < calendar.startOfDay(for: now)
        : occurrence < now
}
Enter fullscreen mode Exit fullscreen mode

All-day dates are also stored as floating dates (year, month, day — no time zone), so December 25 is December 25 wherever you are when you open the app.

3. The widget has to be told when the number changes

A widget doesn't run code all the time; it shows a timeline of entries you hand to WidgetKit ahead of time (I wrote about that in the first post). For a day count, that means one entry at each local midnight, and asking for a new timeline after the last one:

var entries = upcomingMidnights.map { entry(at: calendar.startOfDay(for: $0)) }
let reloadAt = entries.last?.date ?? now.addingTimeInterval(86_400)
return Timeline(entries: entries, policy: .after(reloadAt))
Enter fullscreen mode Exit fullscreen mode

This is also why a countdown widget beats a countdown wallpaper. A number baked into a wallpaper image stays the same until you make a new image; a widget goes down by one every day on its own.

If you just want the number on your phone

Step-by-step for the Lock Screen and Home Screen — including iOS 27's new extra-large widget size — is here: Christmas countdown widget for iPhone. In our app every widget size is free on both screens and you can keep as many countdowns as you like; Pro adds backdrops, your own photos, typefaces and a Lock Screen Live Activity.

If you're comparing apps, I keep a free-tier comparison of seven countdown apps here: Best countdown widget apps for iPhone. It includes the ones that make the widget the paid part.

Top comments (0)