Skip to content

feat: display Claude usage reset times in local timezone#64

Merged
matej21 merged 6 commits intocontember:mainfrom
JanTvrdik:feat/local-timezone-usage
Mar 27, 2026
Merged

feat: display Claude usage reset times in local timezone#64
matej21 merged 6 commits intocontember:mainfrom
JanTvrdik:feat/local-timezone-usage

Conversation

@JanTvrdik
Copy link
Copy Markdown
Contributor

@JanTvrdik JanTvrdik commented Mar 27, 2026

Summary

  • Convert the Claude Usage popover and incident/outage timestamps from hardcoded UTC to the user's local timezone with the correct abbreviation (e.g. "resets 16:00 CET" instead of "resets 15:00 UTC")
  • Uses jiff crate for timezone handling — correct DST resolution, proper abbreviations on all platforms, no unsafe code
  • Falls back to UTC display if system timezone is unavailable

Test plan

  • cargo test -p okena-ext-claude — 12 unit tests pass
  • Manual: verify the popover shows local timezone abbreviation (e.g. CET, EST, PST) instead of UTC
  • Manual: verify "today"/"tomorrow" labels are correct for the local timezone (edge case: near midnight when UTC date differs from local date)

Co-Authored-By: Claude Code

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
Copilot AI review requested due to automatic review settings March 27, 2026 09:39
Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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, Windows localtime_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.

Comment thread crates/okena-ext-claude/src/usage.rs Outdated
#[test]
fn test_local_today_returns_valid_date() {
let (y, m, d) = local_today().unwrap();
assert!(y >= 2025);
Copy link

Copilot AI Mar 27, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
assert!(y >= 2025);
assert!(y >= 1970);

Copilot uses AI. Check for mistakes.
Comment thread crates/okena-ext-claude/src/usage.rs Outdated
Comment on lines +976 to +982
// 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");
Copy link

Copilot AI Mar 27, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
// 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
);
}

Copilot uses AI. Check for mistakes.
Comment thread crates/okena-ext-claude/src/usage.rs Outdated
// 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 };
Copy link

Copilot AI Mar 27, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
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();

Copilot uses AI. Check for mistakes.
Comment thread crates/okena-ext-claude/src/usage.rs Outdated
Comment on lines +265 to +270
// Use DaylightName if in daylight time (result == 2), else StandardName
let name = if result == 2 {
&tzi.DaylightName
} else {
&tzi.StandardName
};
Copy link

Copilot AI Mar 27, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
Comment thread crates/okena-ext-claude/src/usage.rs Outdated
Comment on lines +257 to +266
// 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 {
Copy link

Copilot AI Mar 27, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
// 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 {

Copilot uses AI. Check for mistakes.
Comment thread crates/okena-ext-claude/src/usage.rs Outdated
Comment on lines +255 to +273
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]))
}
Copy link

Copilot AI Mar 27, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
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
@matej21
Copy link
Copy Markdown
Member

matej21 commented Mar 27, 2026

Some suggestions by Claude:

  1. Windows timezone abbreviation bug: GetTimeZoneInformation returns the full timezone name (e.g. "Central European Standard Time") rather than the abbreviation ("CET"), so the output on Windows would look like "14:00 Central European Standard Time".

  2. Windows DST mismatch: windows_tz_abbr() checks the DST state at the time of the call, not at the time of the timestamp being formatted. So formatting a summer timestamp during winter would show the wrong timezone name (StandardName instead of DaylightName).

  3. Have you considered using a crate for this instead of the manual libc implementation? For example, jiff (by the author of ripgrep) handles this in ~3 lines:

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 unsafe blocks, platform-specific Unix/Windows branches, the custom LocalTime struct, and the libc/windows-sys dependencies. chrono would work similarly.

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
Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread crates/okena-ext-claude/src/usage.rs Outdated
}

format!("{}:{} UTC", hm[0], hm[1])
// Fallback: return as-is if we can't parse at all
Copy link

Copilot AI Mar 27, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
// 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

Copilot uses AI. Check for mistakes.
Comment thread crates/okena-ext-claude/src/usage.rs Outdated
Comment on lines 152 to 156
/// 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)
}
Copy link

Copilot AI Mar 27, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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).

Copilot uses AI. Check for mistakes.
Comment thread crates/okena-ext-claude/src/usage.rs Outdated

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> {
Copy link

Copilot AI Mar 27, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
pub(crate) fn parse_iso8601_to_epoch(ts: &str) -> Option<f64> {
fn parse_iso8601_to_epoch(ts: &str) -> Option<f64> {

Copilot uses AI. Check for mistakes.
Comment on lines +158 to 163
/// 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()))
}
Copy link

Copilot AI Mar 27, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
Comment on lines +3 to +12
/// 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)
}
Copy link

Copilot AI Mar 27, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
- 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
@matej21 matej21 merged commit 3794f99 into contember:main Mar 27, 2026
4 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants