1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
|
//! IANA time-zone helpers: Matter TimeZone/DSTOffset structure generation
//! and offset introspection for status output.
//!
//! jiff exposes the real tzdb transition table, so upcoming DST changes are
//! read directly instead of probed for (the TypeScript implementation had to
//! binary-search ICU offsets; here `TimeZone::following` is exact by
//! construction).
use jiff::Timestamp;
use jiff::tz::TimeZone;
use crate::time::MatterMicros;
/// Matter TimeZoneStruct: the zone's standard offset, DST carried separately.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct MatterTimeZoneEntry {
/// Standard (non-DST) UTC offset in seconds.
pub offset_seconds: i32,
/// Matter-epoch microseconds at which the entry takes effect; 0 = always.
pub valid_at: MatterMicros,
pub name: String,
}
/// Matter DSTOffsetStruct: one DST period, added on top of the TimeZone offset.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct MatterDstOffsetEntry {
pub offset_seconds: i32,
pub valid_starting: MatterMicros,
/// None = valid until further notice (last entry only).
pub valid_until: Option<MatterMicros>,
}
/// The next instant the zone's UTC offset changes, with the offsets on both
/// sides. None for fixed-offset zones.
pub fn next_offset_transition(tz: &TimeZone, from: Timestamp) -> Option<(Timestamp, i32, i32)> {
let before = tz.to_offset(from).seconds();
tz.following(from)
.map(|transition| (transition.timestamp(), transition.offset().seconds()))
.find(|&(_, offset)| offset != before)
.map(|(at, after)| (at, before, after))
}
/// The zone's standard (non-DST) UTC offset in seconds: the smaller of the
/// mid-January and mid-July offsets of the year. DST increases the offset in
/// every zone as consumed here (including Europe/Dublin, which the tzdb
/// models with a negative SAVE but presents as +00:00 winter / +01:00 summer).
pub fn standard_offset_seconds(tz: &TimeZone, at: Timestamp) -> i32 {
let year = at.to_zoned(TimeZone::UTC).year();
let probe = |month: i8| -> i32 {
let date = jiff::civil::date(year, month, 15).at(0, 0, 0, 0);
let ts = date
.to_zoned(TimeZone::UTC)
.expect("UTC has no gaps")
.timestamp();
tz.to_offset(ts).seconds()
};
probe(1).min(probe(7))
}
/// The TimeZone list for SetTimeZone: a single entry carrying the zone's
/// standard offset and IANA name, valid from the beginning of time.
pub fn build_time_zone_list(tz: &TimeZone, name: &str, at: Timestamp) -> Vec<MatterTimeZoneEntry> {
vec![MatterTimeZoneEntry {
offset_seconds: standard_offset_seconds(tz, at),
valid_at: MatterMicros(0),
name: name.chars().take(64).collect(),
}]
}
/// The DSTOffset list for SetDstOffset: the DST state in effect at `from`
/// followed by upcoming transitions, at most `max_entries` entries (the
/// device's DSTOffsetListMaxSize; spec minimum 1). Entries carry concrete
/// valid-until bounds where a next transition is known, so an unrefreshed
/// device falls back to standard time rather than trusting stale DST; the
/// periodic sync refreshes the list long before it expires. Zones without
/// transitions yield a single open-ended entry.
pub fn build_dst_offset_list(
tz: &TimeZone,
max_entries: usize,
from: Timestamp,
) -> Vec<MatterDstOffsetEntry> {
let limit = max_entries.max(1);
let standard = standard_offset_seconds(tz, from);
let mut entries = Vec::with_capacity(limit);
let mut current_offset = tz.to_offset(from).seconds();
let mut valid_starting = MatterMicros(0);
let mut transitions = tz
.following(from)
.map(|transition| (transition.timestamp(), transition.offset().seconds()));
while entries.len() < limit {
// Skip tzdb transitions that don't change the offset (abbreviation or
// rule bookkeeping only); they are not DST boundaries.
let next = transitions.find(|&(_, offset)| offset != current_offset);
match next {
Some((at, offset_after)) => {
let until = MatterMicros::from_timestamp(at);
entries.push(MatterDstOffsetEntry {
offset_seconds: current_offset - standard,
valid_starting,
valid_until: Some(until),
});
valid_starting = until;
current_offset = offset_after;
}
None => {
entries.push(MatterDstOffsetEntry {
offset_seconds: current_offset - standard,
valid_starting,
valid_until: None,
});
break;
}
}
}
entries
}
/// "UTC-05:00" style rendering for logs and status output.
pub fn format_utc_offset(offset_seconds: i32) -> String {
let sign = if offset_seconds < 0 { '-' } else { '+' };
let magnitude = offset_seconds.unsigned_abs();
format!(
"UTC{sign}{:02}:{:02}",
magnitude / 3600,
(magnitude % 3600) / 60
)
}
#[cfg(test)]
mod tests {
use super::*;
fn tz(name: &str) -> TimeZone {
TimeZone::get(name).unwrap()
}
fn ts(s: &str) -> Timestamp {
s.parse().unwrap()
}
fn matter(s: &str) -> MatterMicros {
MatterMicros::from_timestamp(ts(s))
}
// The two Chicago transitions after 2026-07-26.
fn fall_2026() -> MatterMicros {
matter("2026-11-01T07:00:00Z")
}
fn spring_2027() -> MatterMicros {
matter("2027-03-14T08:00:00Z")
}
#[test]
fn standard_offset_ignores_season_and_hemisphere() {
let chicago = tz("America/Chicago");
assert_eq!(
standard_offset_seconds(&chicago, ts("2026-07-15T00:00:00Z")),
-6 * 3600
);
assert_eq!(
standard_offset_seconds(&chicago, ts("2026-01-15T00:00:00Z")),
-6 * 3600
);
// Sydney: AEST UTC+10 standard, AEDT UTC+11 in southern summer.
assert_eq!(
standard_offset_seconds(&tz("Australia/Sydney"), ts("2026-01-15T00:00:00Z")),
10 * 3600
);
assert_eq!(
standard_offset_seconds(&tz("UTC"), ts("2026-07-15T00:00:00Z")),
0
);
assert_eq!(
standard_offset_seconds(&tz("Asia/Kolkata"), ts("2026-07-15T00:00:00Z")),
(5.5 * 3600.0) as i32
);
}
#[test]
fn finds_exact_chicago_transitions() {
let chicago = tz("America/Chicago");
let (at, before, after) =
next_offset_transition(&chicago, ts("2026-07-15T00:00:00Z")).unwrap();
// 2026-11-01 02:00 CDT (UTC-5) -> 01:00 CST (UTC-6): 07:00:00 UTC.
assert_eq!(at, ts("2026-11-01T07:00:00Z"));
assert_eq!((before, after), (-5 * 3600, -6 * 3600));
assert!(next_offset_transition(&tz("UTC"), ts("2026-01-15T00:00:00Z")).is_none());
}
#[test]
fn time_zone_list_is_single_standard_entry() {
let list = build_time_zone_list(
&tz("America/Chicago"),
"America/Chicago",
ts("2026-07-26T00:00:00Z"),
);
assert_eq!(
list,
vec![MatterTimeZoneEntry {
offset_seconds: -6 * 3600,
valid_at: MatterMicros(0),
name: "America/Chicago".into(),
}]
);
}
#[test]
fn dst_list_covers_active_period_plus_next() {
let list = build_dst_offset_list(&tz("America/Chicago"), 2, ts("2026-07-26T00:00:00Z"));
assert_eq!(
list,
vec![
MatterDstOffsetEntry {
offset_seconds: 3600,
valid_starting: MatterMicros(0),
valid_until: Some(fall_2026()),
},
MatterDstOffsetEntry {
offset_seconds: 0,
valid_starting: fall_2026(),
valid_until: Some(spring_2027()),
},
]
);
}
#[test]
fn dst_list_respects_device_capacity() {
let list = build_dst_offset_list(&tz("America/Chicago"), 1, ts("2026-07-26T00:00:00Z"));
assert_eq!(list.len(), 1);
assert_eq!(list[0].valid_until, Some(fall_2026()));
}
#[test]
fn dst_list_is_single_open_ended_zero_for_fixed_zones() {
for name in ["UTC", "Asia/Kolkata"] {
let list = build_dst_offset_list(&tz(name), 2, ts("2026-07-26T00:00:00Z"));
assert_eq!(
list,
vec![MatterDstOffsetEntry {
offset_seconds: 0,
valid_starting: MatterMicros(0),
valid_until: None,
}],
"zone {name}"
);
}
}
#[test]
fn dst_list_starts_from_standard_time_in_winter() {
let list = build_dst_offset_list(&tz("America/Chicago"), 2, ts("2026-01-15T00:00:00Z"));
let spring_2026 = matter("2026-03-08T08:00:00Z");
assert_eq!(list[0].offset_seconds, 0);
assert_eq!(list[0].valid_until, Some(spring_2026));
assert_eq!(list[1].offset_seconds, 3600);
assert_eq!(list[1].valid_starting, spring_2026);
}
#[test]
fn formats_utc_offsets() {
assert_eq!(format_utc_offset(-6 * 3600), "UTC-06:00");
assert_eq!(format_utc_offset(19_800), "UTC+05:30");
assert_eq!(format_utc_offset(0), "UTC+00:00");
}
}
|