Capstone Toolkit

Date and Time in Practice

Chapter 10 ยท Capstone Toolkit

This final chapter puts the course together. You will build a small scheduling toolkit with four parts: one function that turns "local date, wall time and zone" into a correct moment, a generator for weekly and monthly recurring events, a search for the next occurrence after a given moment, and a finder for working hours that several zones share. Then you will test it against the traps from earlier chapters. Every snippet was run with Python 3.14; the comments show what was printed.

The Design in Four Rules

  1. A recurring event is a rule, not a list of instants. "Mondays at 09:00 in London" is stored as weekdays, a wall time and a zone name (Chapter 9, future local appointments).
  2. Generate each occurrence from the local date. Step through calendar dates in the rule's zone, attach the wall time, then convert. Never add timedelta(days=7) to one UTC instant (Chapter 5).
  3. Return aware values only, and reject naive input at the boundary (Chapter 3).
  4. Decide every edge case once, in one place: a wall time that does not exist, a wall time that happens twice, and a day number that a month does not have.

Step 1: Resolving a Local Time

The core of the toolkit is a function that turns a date, a wall time and a zone name into one definite moment. Its policy for the two awkward cases is deliberately simple: a time that falls in a spring gap moves forward by the length of the gap, and a time that happens twice takes its first occurrence (fold=0).

from datetime import datetime, date, time, timedelta, timezone from zoneinfo import ZoneInfo import calendar UTC = timezone.utc def resolve(d, t, zone): """Local date + wall time + zone -> aware datetime. A time inside a gap moves forward.""" z = ZoneInfo(zone) dt = datetime.combine(d, t, tzinfo=z) return dt.astimezone(UTC).astimezone(z) print(resolve(date(2027, 3, 28), time(1, 30), "Europe/London")) # 2027-03-28 02:30:00+01:00 r = resolve(date(2026, 10, 25), time(1, 30), "Europe/London") print(r, r.fold) # 2026-10-25 01:30:00+01:00 0

The round trip through UTC is what repairs the gap: zoneinfo gives the non-existent 01:30 the offset from before the change, so converting to UTC and back lands on 02:30 BST. For an ambiguous time the same call returns the first of the two, offset +01:00. If your product needs a different policy (skip the occurrence, or use the second one), this is the one function to change.

Step 2: Recurrence

A weekly rule walks forward one calendar day at a time and keeps the days whose weekday is in the set. A monthly rule fixes a day number and clamps it to the last day of shorter months (Chapter 6), so "the 31st" becomes the 28th in February instead of failing.

def weekly(start, t, zone, weekdays, count): """First `count` occurrences on the given weekdays (0=Mon) from start, at wall time t.""" out, d = [], start while len(out) < count: if d.weekday() in weekdays: out.append(resolve(d, t, zone)) d += timedelta(days=1) return out def monthly(year, month, t, zone, day, count): """Same day-of-month each month, clamped to the month's last day.""" out = [] for _ in range(count): last = calendar.monthrange(year, month)[1] out.append(resolve(date(year, month, min(day, last)), t, zone)) month += 1 if month == 13: year, month = year + 1, 1 return out for o in weekly(date(2027, 3, 22), time(9), "Europe/London", {0, 3}, 4): print(o, o.astimezone(UTC).strftime("%H:%M")) # 2027-03-22 09:00:00+00:00 09:00 # 2027-03-25 09:00:00+00:00 09:00 # 2027-03-29 09:00:00+01:00 08:00 # 2027-04-01 09:00:00+01:00 08:00 print([str(x.date()) for x in monthly(2027, 1, time(12), "UTC", 31, 3)]) # ['2027-01-31', '2027-02-28', '2027-03-31']

The weekly output is the whole lesson of the course in four lines: the wall time stays at 09:00 while the UTC time moves from 09:00 to 08:00 when British Summer Time begins on 28 March. A calendar app that stored the first occurrence as a UTC instant and repeated it every 7 days would show 10:00 after the change.

Step 3: The Next Occurrence

Most real code does not need the whole series, only "when is the next one after now?". Start from today's date in the rule's zone, which may differ from the UTC date, and take the first candidate that is strictly later than now. Looking ahead 8 days is enough to see every weekday at least once.

def next_after(now, t, zone, weekdays): if now.tzinfo is None: raise ValueError("now must be aware") d = now.astimezone(ZoneInfo(zone)).date() for _ in range(8): if d.weekday() in weekdays: cand = resolve(d, t, zone).astimezone(UTC) if cand > now: return cand d += timedelta(days=1) raise ValueError("no weekdays given") now = datetime(2026, 11, 2, 23, 30, tzinfo=UTC) # Monday night in UTC, already Tuesday in Tokyo print(next_after(now, time(9), "Asia/Tokyo", {1})) # 2026-11-03 00:00:00+00:00

Step 4: Finding a Shared Window

To schedule one meeting for people in several zones, check each UTC hour of the day and keep the hours that fall inside everyone's working hours. Because it works from the actual date, daylight saving is accounted for without any special code:

def overlap(zones, start_h, end_h, d): """UTC hours on date d at which every zone is inside [start_h, end_h) local time.""" ok = [] base = datetime(d.year, d.month, d.day, tzinfo=UTC) for h in range(24): u = base + timedelta(hours=h) if all(start_h <= u.astimezone(ZoneInfo(z)).hour < end_h for z in zones): ok.append(u) return ok pair = ["Europe/London", "America/New_York"] print([x.strftime("%H:%M") for x in overlap(pair, 9, 17, date(2026, 11, 2))]) # ['14:00', '15:00', '16:00'] print([x.strftime("%H:%M") for x in overlap(pair, 9, 17, date(2026, 3, 16))]) # ['13:00', '14:00', '15:00', '16:00'] print([x.strftime("%H:%M") for x in overlap(pair + ["Asia/Tokyo"], 9, 17, date(2026, 11, 2))]) # []

The same two cities have a three-hour shared window on 2 November and a four-hour window on 16 March, because on 16 March New York is already on daylight time and London is not, so the gap between them is four hours instead of five. Add Tokyo and there is no shared hour at all. An empty result is a legitimate answer, so the caller must handle it (for example by widening the hours or rotating the unfriendly time between the zones).

Testing the Toolkit

Date code is easy to get almost right, so the tests are not an extra: they are how you know it works. Choose cases at the boundaries, state the expected value by hand, and use fixed dates rather than the current time so the tests give the same result every day:

import unittest class T(unittest.TestCase): def test_wall_time_stable_utc_moves(self): occ = weekly(date(2027, 3, 22), time(9), "Europe/London", {0}, 2) self.assertEqual([o.hour for o in occ], [9, 9]) self.assertEqual([o.astimezone(UTC).hour for o in occ], [9, 8]) def test_gap(self): r = resolve(date(2027, 3, 28), time(1, 30), "Europe/London") self.assertEqual((r.hour, r.minute, r.utcoffset()), (2, 30, timedelta(hours=1))) def test_fold(self): r = resolve(date(2026, 10, 25), time(1, 30), "Europe/London") self.assertEqual((r.fold, r.utcoffset()), (0, timedelta(hours=1))) def test_month_clamp(self): m = monthly(2027, 1, time(12), "UTC", 31, 3) self.assertEqual([x.day for x in m], [31, 28, 31]) def test_overlap(self): o = overlap(pair, 9, 17, date(2026, 11, 2)) self.assertEqual([x.hour for x in o], [14, 15, 16]) def test_overlap_dst_mismatch(self): o = overlap(pair, 9, 17, date(2026, 3, 16)) self.assertEqual([x.hour for x in o], [13, 14, 15, 16]) unittest.main(argv=["x"], verbosity=1) # ...... # Ran 6 tests in 0.000s # OK

Writing the last test taught a useful lesson: the author's first guess for the expected value was three hours, and the test failed with four. The test was right and the guess was wrong, because the gap between the cities that week was four hours, not five. That is exactly why you work out the expected value from first principles and then let the code argue with you.

Course Checklist

  • Types and text (1-2): use ISO 8601 for text; never rely on locale-dependent or day/month-ambiguous formats.
  • Naive vs aware (3): a naive value is not a moment; attach a zone or reject it.
  • Zones (4): IANA names, UTC for storage and comparison, local time for display.
  • Daylight saving (5): a day is not always 24 hours; handle the gap and the repeated hour on purpose.
  • Calendar (6-7): clamp month ends, mind leap years, use half-open intervals.
  • Timestamps (8): know the unit, and the limits of the type that holds them.
  • Storage (9): instants in UTC, calendar dates as dates, future local events as wall time plus zone name.
  • Always: test the boundaries, with fixed dates, and work out the expected answer by hand first.

Challenges

Exercise 1

A team stand-up happens every Tuesday and Thursday at 10:00 New York time, starting on 2026-10-27. Using the toolkit's resolve and weekly, list the first six occurrences, and for each print the New York time with its zone abbreviation, the UTC time, and the London time with its abbreviation. Predict, before running, which rows change their UTC hour and why London's time is the same as UTC for every row.

๐Ÿ“„ View solution
Exercise 2

Use resolve to schedule a daily 01:30 London job for 27 to 30 March 2027, printing the local time with its abbreviation and the UTC time for each day. Then build 01:30 on 25 October 2026 in London with fold=0 and fold=1 and print both in local and UTC time. Predict which day's 01:30 is moved and to what, and how far apart the two folds are. Finish by comparing the two fold values with == and explain the result.

๐Ÿ“„ View solution
Exercise 3

Write next_after(now, t, zone, weekdays), which returns the first occurrence strictly after an aware now as a UTC datetime and rejects a naive now. Then write a unittest suite with five tests: a later time on the same day, a now that is exactly on the occurrence, a search that crosses a clock change, a zone whose date is ahead of the UTC date, and a naive input. Work out all expected values by hand before running the suite.

๐Ÿ“„ View solution

Quick Reference

  • Recurring event: store weekdays or day number + wall time + IANA zone name, never a list of UTC instants
  • Resolve: datetime.combine(d, t, tzinfo=z).astimezone(UTC).astimezone(z) repairs a gap and picks the first of a repeated hour
  • Generate: step through local calendar dates, attach the wall time, then convert
  • Month ends: min(day, calendar.monthrange(y, m)[1])
  • Next occurrence: start from today's date in the rule's zone and require the result to be strictly after now
  • Shared window: test each UTC hour against every zone's local hours; an empty list is a valid result
  • Compare: convert to UTC first; same-zone aware values compare by wall clock and ignore fold
  • Test: fixed dates, boundary cases, expected values worked out by hand
Course complete
That is the end of Date and Time in Practice. You can now read, write, convert, store and schedule dates and times without the usual surprises. The best next step is to take a date function from one of your own projects and run it through the boundary tests above.