ical-recurrence: ignore a generated leap second whatever DTSTART is `FREQ=MINUTELY;BYSECOND=60;COUNT=3` produced a different recurrence set depending only on `DTSTART`'s value type: | DTSTART | occurrences | | --- | --- | | floating | `12:00:00`, `12:00:60`, `12:01:60` — all three kept | | zoned `+01:00` | `2020-01-01 12:00:00`, **`2020-01-02 00:59:60`** — a different day | ## Why RFC 5545 §3.3.10 makes the input legal: > The BYSECOND rule part specifies a COMMA-separated list of seconds > within a minute. Valid values are 0 to 60. and `bySecondExpand` puts the 60 straight into a `TimeOfDay` without normalising, so a rule really can generate a local time no wall clock ever shows. The same section says what to do with one: > Recurrence rules may generate recurrence instances with an invalid > date (e.g., February 30) or nonexistent local time (e.g., 1:30 AM > on a day where the local time is moved forward by an hour at 1:00 > AM). Such recurrence instances MUST be ignored and MUST NOT be > counted as part of the recurrence set. `localTimeExists` is the predicate that implements that, but it answered `const True` for anything that is not a zoned DATE-TIME, because the resolve/unresolve round trip it uses needs a time zone. So a floating or UTC `DTSTART` never had its instances checked at all. The zoned column is odder than it looks. Every leap second is dropped *except* the one landing on the UTC leap-second slot — in a `+01:00` zone that is `00:59:60` the next day, the exact analogue of `23:59:60` at a zero offset. That one survives the round trip, so `localTimeExists` calls it existent. ## The fix A leap second needs no time zone to rule out, so it is now ruled out in every branch: ```haskell isLeapSecond :: Time.LocalTime -> Bool isLeapSecond = (>= 60) . Time.todSec . Time.localTimeOfDay ``` Honouring second 60 was never coherent. Whether such a local time survives resolving and unresolving depends on the time of day *and* the offset, so the recurrence set came to depend on facts about neither the rule nor the calendar. ## The tradeoff, worth a look **`BYSECOND=60` now generates no instance at all, ever.** A spec-legal rule part becomes inert. I think that is the honest outcome: real leap seconds are announced by IERS for particular dates, and nothing here knows which, so any instance we did generate would be fiction. The alternative is a fixable error at parse time, so the rule part is refused loudly rather than ignored silently — that is a larger change in `ical` and I did not take it. Easy to add later if you would rather it were loud. ## Two commits 1. **Red.** Both cases fail, and the floating one prints `12:00:60` and `12:01:60` surviving. The tests assert **local** starts rather than resolved instants, because resolving is exactly what mangles a leap second and would hide what is under test. 2. **Green.** ## One incidental cleanup `ICal.Recurrence.TimeZoneSpec` had its own copy of this predicate as a let-binding, added in #31. It now uses the exported one, so the concept has a single definition. That does not make that property tautological: `isLeapSecond` is a syntactic check on the local time, not something derived from the round trip, and the three concrete cases in that file still pin what actually happens to a leap second independently. ## Checks `ical-recurrence-gen` 299 → 301 passing, 0 failing, 1 pending (the deliberate one). `ical-gen` unchanged at 1077. `nix flake check` passes. Independent of #33 — disjoint files, so no rebase needed either way.