// Timezone lookup. package timezone import "core:fmt" import "core:slice" import "core:time" import "core:time/datetime" region_load :: proc(reg: string, allocator := context.allocator) -> (out_reg: ^datetime.TZ_Region, ok: bool) { return _region_load(reg, allocator) } region_load_from_buffer :: proc(buffer: []u8, reg: string, allocator := context.allocator) -> (out_reg: ^datetime.TZ_Region, ok: bool) { return parse_tzif(buffer, reg, allocator) } rrule_destroy :: proc(rrule: datetime.TZ_RRule, allocator := context.allocator) { delete(rrule.std_name, allocator) delete(rrule.dst_name, allocator) } region_destroy :: proc(region: ^datetime.TZ_Region, allocator := context.allocator) { if region == nil { return } for name in region.shortnames { delete(name, allocator) } delete(region.shortnames, allocator) delete(region.records, allocator) delete(region.name, allocator) rrule_destroy(region.rrule, allocator) free(region, allocator) } @private region_get_nearest :: proc(region: ^datetime.TZ_Region, tm: time.Time) -> (out: datetime.TZ_Record, success: bool) { if len(region.records) == 0 { return process_rrule(region.rrule, tm) } n := len(region.records) left, right := 0, n tm_sec := time.to_unix_seconds(tm) last_time := region.records[len(region.records)-1].time if tm_sec > last_time { return process_rrule(region.rrule, tm) } for left < right { mid := int(uint(left+right) >> 1) if region.records[mid].time < tm_sec { left = mid + 1 } else { right = mid } } idx := max(0, left-1) return region.records[idx], true } @private month_to_seconds :: proc(month: int, is_leap: bool) -> i64 { month_seconds := []i64{ 0, 31 * 86_400, 59 * 86_400, 90 * 86_400, 120 * 86_400, 151 * 86_400, 181 * 86_400, 212 * 86_400, 243 * 86_400, 273 * 86_400, 304 * 86_400, 334 * 86_400, } t := month_seconds[month] if is_leap && month >= 2 { t += 86_400 } return t } @private trans_date_to_seconds :: proc(year: i64, td: datetime.TZ_Transition_Date) -> (secs: i64, ok: bool) { is_leap := datetime.is_leap_year(year) DAY_SEC :: 86_400 year_start := datetime.DateTime{{year, 1, 1}, {0, 0, 0, 0}, nil} year_start_time := time.datetime_to_time(year_start) or_return t := i64(time.to_unix_seconds(year_start_time)) switch td.type { case .Month_Week_Day: if td.month < 1 { return } t += month_to_seconds(int(td.month) - 1, is_leap) weekday := ((t + (4 * DAY_SEC)) %% (7 * DAY_SEC)) / DAY_SEC days := i64(td.day) - weekday if days < 0 { days += 7 } month_daycount, err := datetime.last_day_of_month(year, td.month) if err != nil { return } week := td.week if week == 5 && days + 28 >= i64(month_daycount) { week = 4 } t += DAY_SEC * (days + (7 * i64(week - 1))) t += td.time return t, true // Both of these should result in 0 -> 365 days (in seconds) case .No_Leap: day := i64(td.day) // if before Feb 29th || not a leap year if day < 60 || !is_leap { day -= 1 } t += DAY_SEC * day return t, true case .Leap: t += DAY_SEC * i64(td.day) return t, true case: return } return } @private process_rrule :: proc(rrule: datetime.TZ_RRule, tm: time.Time) -> (out: datetime.TZ_Record, success: bool) { if !rrule.has_dst { return datetime.TZ_Record{ time = time.to_unix_seconds(tm), utc_offset = rrule.std_offset, shortname = rrule.std_name, dst = false, }, true } y, _, _ := time.date(tm) std_secs := trans_date_to_seconds(i64(y), rrule.std_date) or_return dst_secs := trans_date_to_seconds(i64(y), rrule.dst_date) or_return records := []datetime.TZ_Record{ { time = std_secs, utc_offset = rrule.std_offset, shortname = rrule.std_name, dst = false, }, { time = dst_secs, utc_offset = rrule.dst_offset, shortname = rrule.dst_name, dst = true, }, } record_sort_proc :: proc(i, j: datetime.TZ_Record) -> bool { return i.time < j.time } slice.sort_by(records, record_sort_proc) tm_sec := time.to_unix_seconds(tm) for record in records { if tm_sec < record.time { return record, true } } return records[0], true } datetime_to_utc :: proc(dt: datetime.DateTime) -> (out: datetime.DateTime, success: bool) #optional_ok { if dt.tz == nil { return dt, true } tm := time.datetime_to_time(dt) or_return record := region_get_nearest(dt.tz, tm) or_return secs := time.time_to_unix(tm) adj_time := time.unix(secs - record.utc_offset, 0) adj_dt := time.time_to_datetime(adj_time) or_return return adj_dt, true } /* Converts a datetime on one timezone to another timezone Inputs: - dt: The input datetime - tz: The timezone to convert to NOTE: tz will be referenced in the result datetime, so it must stay alive/allocated as long as it is used Returns: - out: The converted datetime - success: `false` if the datetime was invalid */ datetime_to_tz :: proc(dt: datetime.DateTime, tz: ^datetime.TZ_Region) -> (out: datetime.DateTime, success: bool) #optional_ok { dt := dt if dt.tz == tz { return dt, true } if dt.tz != nil { dt = datetime_to_utc(dt) } if tz == nil { return dt, true } tm := time.datetime_to_time(dt) or_return record := region_get_nearest(tz, tm) or_return secs := time.time_to_unix(tm) adj_time := time.unix(secs + record.utc_offset, i64(dt.nano)) adj_dt := time.time_to_datetime(adj_time) or_return adj_dt.tz = tz return adj_dt, true } /* Gets the timezone abbreviation/shortname for a given date. (ex: "PDT") Inputs: - dt: The datetime containing the date, time, and timezone pointer for the lookup NOTE: The lifetime of name matches the timezone it was pulled from. Returns: - name: The timezone abbreviation - success: returns `false` if the passed datetime is invalid */ shortname :: proc(dt: datetime.DateTime) -> (name: string, success: bool) #optional_ok { tm := time.datetime_to_time(dt) or_return if dt.tz == nil { return "UTC", true } record := region_get_nearest(dt.tz, tm) or_return return record.shortname, true } /* Gets the timezone abbreviation/shortname for a given date. (ex: "PDT") WARNING: This is unsafe because it doesn't check if your datetime is valid or if your region contains a valid record. Inputs: - dt: The input datetime NOTE: The lifetime of name matches the timezone it was pulled from. Returns: - name: The timezone abbreviation */ shortname_unsafe :: proc(dt: datetime.DateTime) -> string { if dt.tz == nil { return "UTC" } tm, _ := time.datetime_to_time(dt) record, _ := region_get_nearest(dt.tz, tm) return record.shortname } /* Checks DST for a given date. Inputs: - dt: The input datetime Returns: - is_dst: returns `true` if dt is in daylight savings time, `false` if not - success: returns `false` if the passed datetime is invalid */ dst :: proc(dt: datetime.DateTime) -> (is_dst: bool, success: bool) #optional_ok { tm := time.datetime_to_time(dt) or_return if dt.tz == nil { return false, true } record := region_get_nearest(dt.tz, tm) or_return return record.dst, true } /* Checks DST for a given date. WARNING: This is unsafe because it doesn't check if your datetime is valid or if your region contains a valid record. Inputs: - dt: The input datetime Returns: - is_dst: returns `true` if dt is in daylight savings time, `false` if not */ dst_unsafe :: proc(dt: datetime.DateTime) -> bool { if dt.tz == nil { return false } tm, _ := time.datetime_to_time(dt) record, _ := region_get_nearest(dt.tz, tm) return record.dst } datetime_to_str :: proc(dt: datetime.DateTime, allocator := context.allocator) -> string { if dt.tz == nil { _, ok := time.datetime_to_time(dt) if !ok { return "" } return fmt.aprintf("%02d-%02d-%04d @ %02d:%02d:%02d UTC", dt.month, dt.day, dt.year, dt.hour, dt.minute, dt.second, allocator = allocator) } else { tm, ok := time.datetime_to_time(dt) if !ok { return "" } record, ok2 := region_get_nearest(dt.tz, tm) if !ok2 { return "" } hour := dt.hour am_pm_str := "AM" if hour > 12 { am_pm_str = "PM" hour -= 12 } return fmt.aprintf("%02d-%02d-%04d @ %02d:%02d:%02d %s %s", dt.month, dt.day, dt.year, hour, dt.minute, dt.second, am_pm_str, record.shortname, allocator = allocator) } }