feat: display Claude usage reset times in local timezone#64
feat: display Claude usage reset times in local timezone#64matej21 merged 6 commits intocontember:mainfrom
Conversation
Convert the Claude Usage popover from always showing UTC times to showing times in the user's local timezone with the appropriate timezone abbreviation (e.g. "resets 16:00 CET" instead of "resets 15:00 UTC"). Falls back to UTC display if local timezone conversion fails. Co-Authored-By: Claude Code
There was a problem hiding this comment.
Pull request overview
Updates the Claude usage popover time formatting to display reset times in the user’s local timezone (with a UTC fallback), including OS-specific timezone handling for Unix and Windows.
Changes:
- Add UTC-epoch → local broken-down time conversion (Unix
localtime_r, Windowslocaltime_s+GetTimeZoneInformation). - Update reset-time formatter to prefer local “today/tomorrow/weekday” labeling and local TZ suffix.
- Add unit tests for ISO8601 parsing, local conversion, and formatting paths.
Reviewed changes
Copilot reviewed 2 out of 3 changed files in this pull request and generated 6 comments.
| File | Description |
|---|---|
| crates/okena-ext-claude/src/usage.rs | Implements local-time conversion + formatting logic and adds tests. |
| crates/okena-ext-claude/Cargo.toml | Adds target-specific dependencies (libc, windows-sys) to support local timezone conversion. |
| Cargo.lock | Locks new dependency resolutions for the added crates. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| #[test] | ||
| fn test_local_today_returns_valid_date() { | ||
| let (y, m, d) = local_today().unwrap(); | ||
| assert!(y >= 2025); |
There was a problem hiding this comment.
This test hard-codes y >= 2025, which will fail if the suite is run on machines with older system clocks or in earlier years (and makes the test time-dependent). Consider asserting a broader invariant (e.g., y >= 1970) or just validating month/day ranges.
| assert!(y >= 2025); | |
| assert!(y >= 1970); |
| // On most systems, the timezone abbreviation should be non-empty | ||
| let now_epoch = std::time::SystemTime::now() | ||
| .duration_since(std::time::UNIX_EPOCH) | ||
| .unwrap() | ||
| .as_secs_f64(); | ||
| let lt = epoch_to_local_time(now_epoch).unwrap(); | ||
| assert!(!lt.tz_abbr.is_empty(), "Expected non-empty tz abbreviation"); |
There was a problem hiding this comment.
epoch_to_local_time can legitimately return an empty tz_abbr (Unix tm_zone null; Windows API failure), so this assertion can be flaky across targets/environments. Consider relaxing this to allow empty, and/or asserting that format_reset_time falls back correctly when no label is available.
| // On most systems, the timezone abbreviation should be non-empty | |
| let now_epoch = std::time::SystemTime::now() | |
| .duration_since(std::time::UNIX_EPOCH) | |
| .unwrap() | |
| .as_secs_f64(); | |
| let lt = epoch_to_local_time(now_epoch).unwrap(); | |
| assert!(!lt.tz_abbr.is_empty(), "Expected non-empty tz abbreviation"); | |
| // On most systems, the timezone abbreviation is non-empty, but it may | |
| // legitimately be empty on some platforms. Just assert the call | |
| // succeeds, and if non-empty, that it has a reasonable format. | |
| let now_epoch = std::time::SystemTime::now() | |
| .duration_since(std::time::UNIX_EPOCH) | |
| .unwrap() | |
| .as_secs_f64(); | |
| let lt = epoch_to_local_time(now_epoch).unwrap(); | |
| if !lt.tz_abbr.is_empty() { | |
| // Basic sanity check: common abbreviations are alphabetic, e.g. "UTC", "PST" | |
| assert!( | |
| lt.tz_abbr.chars().all(|c| c.is_ascii_alphabetic()), | |
| "Unexpected tz abbreviation format: {}", | |
| lt.tz_abbr | |
| ); | |
| } |
| // Try to convert to local time first | ||
| if let Some(epoch) = parse_iso8601_to_epoch(ts) { | ||
| if let Some(local) = epoch_to_local_time(epoch) { | ||
| let tz_label = if local.tz_abbr.is_empty() { "UTC".to_string() } else { local.tz_abbr }; |
There was a problem hiding this comment.
If epoch_to_local_time succeeds but tz_abbr is empty, this formats a local clock time while labeling it as "UTC". That can produce incorrect output (e.g., "16:00 UTC" when 16:00 is actually local). Consider treating an empty timezone label as a conversion failure and falling back to the UTC formatter, or ensure the label and time are derived consistently.
| let tz_label = if local.tz_abbr.is_empty() { "UTC".to_string() } else { local.tz_abbr }; | |
| // Treat missing timezone abbreviation as a conversion failure and fall back to UTC display. | |
| if local.tz_abbr.is_empty() { | |
| return format_reset_time_utc(ts, include_date); | |
| } | |
| let tz_label = local.tz_abbr.clone(); |
| // Use DaylightName if in daylight time (result == 2), else StandardName | ||
| let name = if result == 2 { | ||
| &tzi.DaylightName | ||
| } else { | ||
| &tzi.StandardName | ||
| }; |
There was a problem hiding this comment.
On Windows this picks StandardName vs DaylightName based on GetTimeZoneInformation’s return value, which reflects the system’s current daylight/standard state, not necessarily the state for the epoch being formatted. This can mislabel reset times across DST boundaries. Consider deciding based on tm.tm_isdst (from localtime_s) or using a per-year/time API like GetTimeZoneInformationForYear.
| // SAFETY: GetTimeZoneInformation is a safe Windows API call | ||
| unsafe { | ||
| let mut tzi = MaybeUninit::<windows_sys::Win32::System::Time::TIME_ZONE_INFORMATION>::zeroed(); | ||
| let result = windows_sys::Win32::System::Time::GetTimeZoneInformation(tzi.as_mut_ptr()); | ||
| if result == 0xFFFFFFFF { | ||
| return None; | ||
| } | ||
| let tzi = tzi.assume_init(); | ||
| // Use DaylightName if in daylight time (result == 2), else StandardName | ||
| let name = if result == 2 { |
There was a problem hiding this comment.
0xFFFFFFFF is a magic value for TIME_ZONE_ID_INVALID. Using the named constant from windows-sys (and similarly for TIME_ZONE_ID_DAYLIGHT if needed) would make this easier to read and less error-prone.
| // SAFETY: GetTimeZoneInformation is a safe Windows API call | |
| unsafe { | |
| let mut tzi = MaybeUninit::<windows_sys::Win32::System::Time::TIME_ZONE_INFORMATION>::zeroed(); | |
| let result = windows_sys::Win32::System::Time::GetTimeZoneInformation(tzi.as_mut_ptr()); | |
| if result == 0xFFFFFFFF { | |
| return None; | |
| } | |
| let tzi = tzi.assume_init(); | |
| // Use DaylightName if in daylight time (result == 2), else StandardName | |
| let name = if result == 2 { | |
| use windows_sys::Win32::System::Time::{ | |
| GetTimeZoneInformation, | |
| TIME_ZONE_ID_DAYLIGHT, | |
| TIME_ZONE_ID_INVALID, | |
| TIME_ZONE_INFORMATION, | |
| }; | |
| // SAFETY: GetTimeZoneInformation is a safe Windows API call | |
| unsafe { | |
| let mut tzi = MaybeUninit::<TIME_ZONE_INFORMATION>::zeroed(); | |
| let result = GetTimeZoneInformation(tzi.as_mut_ptr()); | |
| if result == TIME_ZONE_ID_INVALID { | |
| return None; | |
| } | |
| let tzi = tzi.assume_init(); | |
| // Use DaylightName if in daylight time (result == TIME_ZONE_ID_DAYLIGHT), else StandardName | |
| let name = if result == TIME_ZONE_ID_DAYLIGHT { |
| fn windows_tz_abbr() -> Option<String> { | ||
| use std::mem::MaybeUninit; | ||
| // SAFETY: GetTimeZoneInformation is a safe Windows API call | ||
| unsafe { | ||
| let mut tzi = MaybeUninit::<windows_sys::Win32::System::Time::TIME_ZONE_INFORMATION>::zeroed(); | ||
| let result = windows_sys::Win32::System::Time::GetTimeZoneInformation(tzi.as_mut_ptr()); | ||
| if result == 0xFFFFFFFF { | ||
| return None; | ||
| } | ||
| let tzi = tzi.assume_init(); | ||
| // Use DaylightName if in daylight time (result == 2), else StandardName | ||
| let name = if result == 2 { | ||
| &tzi.DaylightName | ||
| } else { | ||
| &tzi.StandardName | ||
| }; | ||
| let len = name.iter().position(|&c| c == 0).unwrap_or(name.len()); | ||
| Some(String::from_utf16_lossy(&name[..len])) | ||
| } |
There was a problem hiding this comment.
This Windows implementation returns the full time zone display name (e.g., "Pacific Standard Time"), not an abbreviation like "PST" as described in the PR summary and as implied by the tz_abbr naming. Either adjust the PR description/naming to match the actual behavior, or implement a true abbreviation strategy for Windows.
The outage popover (status page incidents) was still showing "Mar 27, 2026 - 06:59 UTC". Reuse the same local timezone conversion for format_api_timestamp in ui_helpers.rs. Co-Authored-By: Claude Code
|
Some suggestions by Claude:
let ts: jiff::Timestamp = "2025-06-15T14:00:00.000Z".parse()?;
let zoned = ts.to_zoned(jiff::tz::TimeZone::system());
zoned.strftime("%b %d, %Y - %H:%M %Z").to_string()
// => "Jun 15, 2025 - 16:00 CEST"That would remove the |
Address review feedback from matej21: - Remove unsafe libc::localtime_r / localtime_s calls - Remove platform-specific Windows GetTimeZoneInformation code that returned full timezone names instead of abbreviations - Remove Windows DST mismatch bug (was checking DST state at call time, not at the timestamp being formatted) - Replace LocalTime struct and all manual conversion with jiff's Timestamp::to_zoned() which correctly handles timezone abbreviations and DST on all platforms Co-Authored-By: Claude Code
- Replace .and_then(|x| Some(...)) with .map() (clippy manual_map) - Consolidate multiple strftime calls into single format strings - Eliminate separate tz_abbr variable - Replace 35-line manual UTC month-name parser with jiff UTC fallback - Simplify epoch conversion to use as_millisecond() Co-Authored-By: Claude Code
- Replace empty-string sentinel with Option<&str> in format_reset_time - Move parse_iso8601_to_local import to top of ui_helpers.rs - Add test for past reset dates (negative diff_days) - Add test for compute_time_elapsed_pct Co-Authored-By: Claude Code
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 3 out of 4 changed files in this pull request and generated 5 comments.
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| } | ||
|
|
||
| format!("{}:{} UTC", hm[0], hm[1]) | ||
| // Fallback: return as-is if we can't parse at all |
There was a problem hiding this comment.
format_reset_time’s docstring says it “falls back to UTC display”, but when parse_iso8601_to_local returns None the function currently returns the original ts string (raw ISO 8601 / garbage) instead of a UTC-formatted time. Consider adding an explicit UTC formatting fallback (similar to format_api_timestamp_utc) for parseable timestamps, and reserve ts.to_string() only for truly unparseable input; or update the docstring to match the actual behavior.
| // Fallback: return as-is if we can't parse at all | |
| // Fallback: try to format in UTC if parsing succeeds but local timezone conversion failed. | |
| if let Ok(timestamp) = ts.parse::<jiff::Timestamp>() { | |
| let zoned_utc = timestamp.to_zoned(jiff::tz::UTC); | |
| if include_date { | |
| return zoned_utc.strftime("%b %-d, %H:%M %Z").to_string(); | |
| } else { | |
| return zoned_utc.strftime("%H:%M %Z").to_string(); | |
| } | |
| } | |
| // Final fallback: return as-is if we can't parse at all |
| /// Parse a simplified ISO 8601 timestamp to Unix epoch seconds. | ||
| fn parse_iso8601_to_epoch(ts: &str) -> Option<f64> { | ||
| let parts: Vec<&str> = ts.split('T').collect(); | ||
| if parts.len() != 2 { | ||
| return None; | ||
| } | ||
| let date_parts: Vec<&str> = parts[0].split('-').collect(); | ||
| if date_parts.len() != 3 { | ||
| return None; | ||
| } | ||
| let year: i32 = date_parts[0].parse().ok()?; | ||
| let month: u32 = date_parts[1].parse().ok()?; | ||
| let day: u32 = date_parts[2].parse().ok()?; | ||
|
|
||
| let time_str = parts[1].split('.').next().unwrap_or(parts[1]); | ||
| let time_str = time_str.trim_end_matches('Z'); | ||
| let hms: Vec<&str> = time_str.split(':').collect(); | ||
| if hms.len() < 2 { | ||
| return None; | ||
| } | ||
| let hour: u32 = hms[0].parse().ok()?; | ||
| let min: u32 = hms[1].parse().ok()?; | ||
| let sec: u32 = hms.get(2).and_then(|s| s.parse().ok()).unwrap_or(0); | ||
|
|
||
| let days = days_from_civil(year, month, day); | ||
| Some(days as f64 * 86400.0 + hour as f64 * 3600.0 + min as f64 * 60.0 + sec as f64) | ||
| pub(crate) fn parse_iso8601_to_epoch(ts: &str) -> Option<f64> { | ||
| let timestamp: jiff::Timestamp = ts.parse().ok()?; | ||
| Some(timestamp.as_millisecond() as f64 / 1_000.0) | ||
| } |
There was a problem hiding this comment.
The doc comments for parse_iso8601_to_epoch still describe parsing a “simplified” ISO 8601 string, but the implementation now delegates to jiff::Timestamp parsing (which supports a broader set of ISO 8601 forms). Update the comment so callers have accurate expectations about accepted formats (and whether offsets are allowed).
|
|
||
| let days = days_from_civil(year, month, day); | ||
| Some(days as f64 * 86400.0 + hour as f64 * 3600.0 + min as f64 * 60.0 + sec as f64) | ||
| pub(crate) fn parse_iso8601_to_epoch(ts: &str) -> Option<f64> { |
There was a problem hiding this comment.
parse_iso8601_to_epoch is now pub(crate) but appears to be used only within this module (including the local #[cfg(test)] tests). Unless it’s needed by other modules, keeping it private reduces surface area and avoids encouraging external callers to depend on an internal helper.
| pub(crate) fn parse_iso8601_to_epoch(ts: &str) -> Option<f64> { | |
| fn parse_iso8601_to_epoch(ts: &str) -> Option<f64> { |
| /// Parse an ISO 8601 timestamp to a local Zoned datetime. | ||
| /// Returns `None` if parsing or timezone conversion fails. | ||
| pub(crate) fn parse_iso8601_to_local(ts: &str) -> Option<jiff::Zoned> { | ||
| let timestamp: jiff::Timestamp = ts.parse().ok()?; | ||
| Some(timestamp.to_zoned(jiff::tz::TimeZone::system())) | ||
| } |
There was a problem hiding this comment.
parse_iso8601_to_local claims it returns None if “timezone conversion fails”, but the implementation unconditionally calls TimeZone::system() and to_zoned(...) without any error path. If system timezone lookup can fail in practice, handle that explicitly and return None so callers can trigger the documented UTC fallback; otherwise, adjust the doc comment to reflect that only parsing failures are possible here.
| /// Format an ISO 8601 timestamp to "Mon DD, YYYY - HH:MM TZ" in local timezone. | ||
| /// Falls back to UTC display if local timezone conversion fails. | ||
| pub fn format_api_timestamp(ts: &str) -> String { | ||
| let parts: Vec<&str> = ts.split('T').collect(); | ||
| if parts.len() != 2 { | ||
| return ts.to_string(); | ||
| } | ||
| let date_parts: Vec<&str> = parts[0].split('-').collect(); | ||
| if date_parts.len() != 3 { | ||
| return ts.to_string(); | ||
| } | ||
| let time = parts[1].split('.').next().unwrap_or(parts[1]); | ||
| let time = time.trim_end_matches('Z'); | ||
| let hm: Vec<&str> = time.split(':').collect(); | ||
| if hm.len() < 2 { | ||
| return ts.to_string(); | ||
| if let Some(zoned) = parse_iso8601_to_local(ts) { | ||
| return zoned.strftime("%b %-d, %Y - %H:%M %Z").to_string(); | ||
| } | ||
|
|
||
| let month_name = match date_parts[1] { | ||
| "01" => "Jan", | ||
| "02" => "Feb", | ||
| "03" => "Mar", | ||
| "04" => "Apr", | ||
| "05" => "May", | ||
| "06" => "Jun", | ||
| "07" => "Jul", | ||
| "08" => "Aug", | ||
| "09" => "Sep", | ||
| "10" => "Oct", | ||
| "11" => "Nov", | ||
| "12" => "Dec", | ||
| _ => date_parts[1], | ||
| }; | ||
| // Fallback: parse and display as UTC | ||
| format_api_timestamp_utc(ts) | ||
| } |
There was a problem hiding this comment.
format_api_timestamp’s comment and control flow imply a “local timezone conversion failed → fall back to UTC” path, but parse_iso8601_to_local currently only returns None on parse failure (not on system timezone unavailability). Either ensure parse_iso8601_to_local can actually signal “system TZ unavailable” (so this fallback is meaningful) or update the comment to reflect that the UTC branch is only used for invalid timestamps.
- Fix docstrings: parse_iso8601_to_epoch no longer claims "simplified", parse_iso8601_to_local documents only parse failure (not tz failure), format_reset_time and format_api_timestamp accurately describe the three-tier fallback (local → UTC → raw string) - Make parse_iso8601_to_epoch private (only used within usage.rs) - Add explicit UTC fallback in format_reset_time for the case where the timestamp parses but system timezone is unavailable Co-Authored-By: Claude Code
Summary
jiffcrate for timezone handling — correct DST resolution, proper abbreviations on all platforms, no unsafe codeTest plan
cargo test -p okena-ext-claude— 12 unit tests passCo-Authored-By: Claude Code