A simple clock with a non-trivial time model

A countdown looks like subtraction: take the Tết date, subtract the current time, and format the result. That model breaks as soon as the product reaches devices in several time zones or crosses daylight-saving boundaries. For Sắp Tết, the real question is not “how many milliseconds remain?” It is “how long until Vietnamese New Year as Vietnamese users understand it?” Product language must become an explicit time rule before it becomes code.

Tết is anchored to a place. Someone in Hà Nội and someone visiting California may still want to count toward the same Vietnamese midnight. If the target is constructed in the device zone, those people count toward different instants. Time zone is therefore part of the domain model, not a formatting option hidden at the edge.

Anchor the event deliberately

Construct the target in Asia/Ho_Chi_Minh, then convert it to an absolute instant. This separates local meaning from elapsed-time arithmetic and avoids parsing an incomplete date string. A value such as 2027-02-06T00:00:00 has no offset; two runtimes are free to interpret it as two different instants.

targetLocal = 2027-02-06 00:00:00
zone = "Asia/Ho_Chi_Minh"
targetInstant = toInstant(targetLocal, zone)
remaining = max(0, targetInstant - clock.now())

Make the clock replaceable. Tests that depend on the wall clock are slow, flaky, and almost impossible to position precisely around midnight. A fixed clock can replay the second before, the exact boundary, and the second after it with deterministic results.

Calendar dates are not durations

A day can mean a fixed duration of 86,400 seconds or a transition between calendar dates. Those ideas often match in Vietnam, which does not currently observe daylight saving time, but a viewer in another zone can experience a 23- or 25-hour day. If the promise is an instant in Vietnam, absolute duration is consistent. If the promise is calendar days remaining, calculation must happen in the chosen zone.

The interface should expose that decision. A small “Vietnam time” label removes more ambiguity than invisible cleverness. Far from the event, days are the useful unit. During the final day, hours, minutes, and seconds become more intuitive. This is a presentation policy layered on top of one stable instant.

Update without accumulating drift

A one-second timer does not run precisely every thousand milliseconds. The operating system delays callbacks when the app is backgrounded, power saving is active, or the main thread is busy. Decrementing a counter on every callback gradually drifts. Recalculate from `clock.now()` on each tick instead. The timer schedules rendering; it never becomes the source of truth.

  • Derive remaining time from the target instant on every tick.
  • Refresh immediately when the app returns to the foreground.
  • Stop scheduling after zero and enter a completed state.
  • Do not contact a server every second; calibrate only when the product truly needs it.

Widgets live on a different schedule

Home-screen widgets have a different update budget. Per-second refreshes waste power and are rarely guaranteed by the platform. Use a resolution that matches distance: days while Tết is far away, hours when it approaches, and the full app for the live final countdown. A widget timeline should contain meaningful transitions, not thousands of nearly identical entries.

Define the post-midnight state as well. Negative values are implementation details leaking into the interface. The completed state might show a greeting, the new year, or a path to another seasonal utility. A small state machine — upcoming, imminent, complete — is easier to reason about than scattered conditional expressions.

A boundary-focused test matrix

Time bugs collect at boundaries. Useful tests cover one second before and after midnight, a device-zone change while the app is open, suspension across the target, a manually adjusted system clock, 12- and 24-hour locales, and delayed widget refreshes. Include a daylight-saving zone even though the target is in Vietnam, because presentation still happens in the viewer’s environment.

given target = TetInstant
for now in [target-1s, target, target+1s]:
  state = countdown(now, target)
  assert state never exposes a negative duration

Treat the annual change as a release process

A seasonal countdown also has a long operational life. The target date changes every year, content evolves, and old app versions remain installed. Keep the event definition outside presentation code, validate it during release, and make the next supported year explicit. An emergency remote correction can be useful, but it should be signed, cached, and bounded so a broken response cannot erase the local target. Monitor how many clients still use an old year without collecting personal countdown behavior. Before each Tết cycle, run the boundary suite against the production configuration, review store copy, and verify that widgets migrate to the new event. Test the first launch after the prior event too, because a stale completed state can survive an upgrade and confuse returning users. Calendar correctness is not a one-time algorithm; it is a small, repeatable release discipline.

Reliability is the feature users feel

Nobody installs Sắp Tết for its time-zone architecture. People install it to feel the celebration approaching, and notice the engineering only when it fails. An explicit target zone, a testable clock, drift-free updates, and a realistic widget cadence make the surface feel effortless on every supported platform and locale. That is the useful kind of simplicity: complexity handled deliberately so the user is left with anticipation, not arithmetic.