|
| 1 | +use chrono::{Datelike, Timelike}; |
| 2 | +use chrono_tz::Tz; |
| 3 | +use serde::Deserialize; |
| 4 | +use std::collections::HashMap; |
| 5 | +use std::fs; |
| 6 | +use std::path::PathBuf; |
| 7 | + |
| 8 | +#[derive(Deserialize, Clone)] |
| 9 | +pub struct Config { |
| 10 | + pub clocks: Vec<ClockEntry>, |
| 11 | +} |
| 12 | + |
| 13 | +#[derive(Deserialize, Clone)] |
| 14 | +pub struct ClockEntry { |
| 15 | + pub name: String, |
| 16 | + pub city: String, |
| 17 | + pub timezone: String, |
| 18 | + pub flag: Option<String>, |
| 19 | + pub status: Option<StatusSchedule>, |
| 20 | +} |
| 21 | + |
| 22 | +#[derive(Deserialize, Clone)] |
| 23 | +pub struct StatusSchedule { |
| 24 | + pub blocks: HashMap<String, StatusBlock>, |
| 25 | + pub months: HashMap<String, String>, |
| 26 | +} |
| 27 | + |
| 28 | +#[derive(Deserialize, Clone)] |
| 29 | +pub struct StatusBlock { |
| 30 | + pub label: String, |
| 31 | + pub start: String, |
| 32 | + pub end: String, |
| 33 | +} |
| 34 | + |
| 35 | +pub enum Availability { |
| 36 | + Busy(String), |
| 37 | + Available, |
| 38 | + DayOff, |
| 39 | +} |
| 40 | + |
| 41 | +pub fn config_path() -> PathBuf { |
| 42 | + let home = dirs::home_dir().expect("could not find home directory"); |
| 43 | + home.join(".config/fuso/clocks.json") |
| 44 | +} |
| 45 | + |
| 46 | +pub fn load_config() -> Config { |
| 47 | + let path = config_path(); |
| 48 | + |
| 49 | + if !path.exists() { |
| 50 | + if let Some(parent) = path.parent() { |
| 51 | + fs::create_dir_all(parent).ok(); |
| 52 | + } |
| 53 | + let json = serde_json::to_string_pretty(&serde_json::json!({ |
| 54 | + "clocks": [{ |
| 55 | + "name": "Me", |
| 56 | + "city": "New York", |
| 57 | + "timezone": "America/New_York" |
| 58 | + }] |
| 59 | + })) |
| 60 | + .unwrap(); |
| 61 | + fs::write(&path, json).ok(); |
| 62 | + return Config { |
| 63 | + clocks: vec![ClockEntry { |
| 64 | + name: "Me".into(), |
| 65 | + city: "New York".into(), |
| 66 | + timezone: "America/New_York".into(), |
| 67 | + flag: None, |
| 68 | + status: None, |
| 69 | + }], |
| 70 | + }; |
| 71 | + } |
| 72 | + |
| 73 | + let data = fs::read_to_string(&path).expect("could not read config file"); |
| 74 | + serde_json::from_str(&data).expect("invalid config format") |
| 75 | +} |
| 76 | + |
| 77 | +pub fn timezone_to_flag(tz: &str) -> &'static str { |
| 78 | + match tz { |
| 79 | + s if s.starts_with("America/New_York") |
| 80 | + | s.starts_with("America/Chicago") |
| 81 | + | s.starts_with("America/Denver") |
| 82 | + | s.starts_with("America/Los_Angeles") |
| 83 | + | s.starts_with("America/Phoenix") |
| 84 | + | s.starts_with("America/Anchorage") |
| 85 | + | s.starts_with("Pacific/Honolulu") => |
| 86 | + { |
| 87 | + "\u{1f1fa}\u{1f1f8}" |
| 88 | + } |
| 89 | + s if s.starts_with("America/Sao_Paulo") |
| 90 | + | s.starts_with("America/Fortaleza") |
| 91 | + | s.starts_with("America/Manaus") |
| 92 | + | s.starts_with("America/Bahia") |
| 93 | + | s.starts_with("America/Belem") |
| 94 | + | s.starts_with("America/Recife") |
| 95 | + | s.starts_with("America/Cuiaba") |
| 96 | + | s.starts_with("America/Campo_Grande") |
| 97 | + | s.starts_with("America/Rio_Branco") |
| 98 | + | s.starts_with("America/Porto_Velho") |
| 99 | + | s.starts_with("America/Maceio") |
| 100 | + | s.starts_with("America/Araguaina") => |
| 101 | + { |
| 102 | + "\u{1f1e7}\u{1f1f7}" |
| 103 | + } |
| 104 | + "Asia/Tokyo" => "\u{1f1ef}\u{1f1f5}", |
| 105 | + "Europe/London" | "Europe/Dublin" => "\u{1f1ec}\u{1f1e7}", |
| 106 | + "Europe/Paris" => "\u{1f1eb}\u{1f1f7}", |
| 107 | + "Europe/Berlin" => "\u{1f1e9}\u{1f1ea}", |
| 108 | + "Europe/Rome" => "\u{1f1ee}\u{1f1f9}", |
| 109 | + "Europe/Madrid" => "\u{1f1ea}\u{1f1f8}", |
| 110 | + "Europe/Lisbon" => "\u{1f1f5}\u{1f1f9}", |
| 111 | + "Europe/Amsterdam" => "\u{1f1f3}\u{1f1f1}", |
| 112 | + "Europe/Zurich" => "\u{1f1e8}\u{1f1ed}", |
| 113 | + "Europe/Vienna" => "\u{1f1e6}\u{1f1f9}", |
| 114 | + "Europe/Prague" => "\u{1f1e8}\u{1f1ff}", |
| 115 | + "Europe/Warsaw" => "\u{1f1f5}\u{1f1f1}", |
| 116 | + "Europe/Stockholm" => "\u{1f1f8}\u{1f1ea}", |
| 117 | + "Europe/Oslo" => "\u{1f1f3}\u{1f1f4}", |
| 118 | + "Europe/Copenhagen" => "\u{1f1e9}\u{1f1f0}", |
| 119 | + "Europe/Helsinki" => "\u{1f1eb}\u{1f1ee}", |
| 120 | + "Europe/Moscow" => "\u{1f1f7}\u{1f1fa}", |
| 121 | + "Europe/Istanbul" => "\u{1f1f9}\u{1f1f7}", |
| 122 | + "Asia/Shanghai" => "\u{1f1e8}\u{1f1f3}", |
| 123 | + "Asia/Hong_Kong" => "\u{1f1ed}\u{1f1f0}", |
| 124 | + "Asia/Seoul" => "\u{1f1f0}\u{1f1f7}", |
| 125 | + "Asia/Singapore" => "\u{1f1f8}\u{1f1ec}", |
| 126 | + "Asia/Kolkata" => "\u{1f1ee}\u{1f1f3}", |
| 127 | + "Asia/Dubai" => "\u{1f1e6}\u{1f1ea}", |
| 128 | + "Asia/Bangkok" => "\u{1f1f9}\u{1f1ed}", |
| 129 | + "Asia/Jakarta" => "\u{1f1ee}\u{1f1e9}", |
| 130 | + "Asia/Taipei" => "\u{1f1f9}\u{1f1fc}", |
| 131 | + "Asia/Riyadh" => "\u{1f1f8}\u{1f1e6}", |
| 132 | + "Asia/Jerusalem" => "\u{1f1ee}\u{1f1f1}", |
| 133 | + "Australia/Sydney" | "Australia/Melbourne" | "Australia/Perth" | "Australia/Brisbane" => { |
| 134 | + "\u{1f1e6}\u{1f1fa}" |
| 135 | + } |
| 136 | + "Pacific/Auckland" => "\u{1f1f3}\u{1f1ff}", |
| 137 | + "America/Toronto" | "America/Vancouver" | "America/Edmonton" => "\u{1f1e8}\u{1f1e6}", |
| 138 | + "America/Mexico_City" => "\u{1f1f2}\u{1f1fd}", |
| 139 | + "America/Argentina/Buenos_Aires" => "\u{1f1e6}\u{1f1f7}", |
| 140 | + "America/Santiago" => "\u{1f1e8}\u{1f1f1}", |
| 141 | + "America/Bogota" => "\u{1f1e8}\u{1f1f4}", |
| 142 | + "America/Lima" => "\u{1f1f5}\u{1f1ea}", |
| 143 | + "Africa/Johannesburg" => "\u{1f1ff}\u{1f1e6}", |
| 144 | + "Africa/Lagos" => "\u{1f1f3}\u{1f1ec}", |
| 145 | + "Africa/Cairo" => "\u{1f1ea}\u{1f1ec}", |
| 146 | + "Africa/Nairobi" => "\u{1f1f0}\u{1f1ea}", |
| 147 | + _ => "\u{1f30d}", |
| 148 | + } |
| 149 | +} |
| 150 | + |
| 151 | +fn parse_time(t: &str) -> u32 { |
| 152 | + let parts: Vec<u32> = t.split(':').filter_map(|p| p.parse().ok()).collect(); |
| 153 | + if parts.len() == 2 { |
| 154 | + parts[0] * 60 + parts[1] |
| 155 | + } else { |
| 156 | + 0 |
| 157 | + } |
| 158 | +} |
| 159 | + |
| 160 | +pub fn current_availability(entry: &ClockEntry, now: chrono::DateTime<Tz>) -> Option<Availability> { |
| 161 | + let schedule = entry.status.as_ref()?; |
| 162 | + |
| 163 | + let month_key = format!("{}-{:02}", now.year(), now.month()); |
| 164 | + let month_str = schedule.months.get(&month_key)?; |
| 165 | + let day = now.day() as usize; |
| 166 | + |
| 167 | + if day < 1 || day > month_str.len() { |
| 168 | + return None; |
| 169 | + } |
| 170 | + |
| 171 | + let block_id = &month_str[day - 1..day]; |
| 172 | + let now_minutes = now.hour() * 60 + now.minute(); |
| 173 | + |
| 174 | + if block_id != "0" { |
| 175 | + if let Some(block) = schedule.blocks.get(block_id) { |
| 176 | + let start = parse_time(&block.start); |
| 177 | + let end = parse_time(&block.end); |
| 178 | + |
| 179 | + if end > start { |
| 180 | + if now_minutes >= start && now_minutes < end { |
| 181 | + return Some(Availability::Busy(block.label.clone())); |
| 182 | + } |
| 183 | + } else if end < start && now_minutes >= start { |
| 184 | + return Some(Availability::Busy(block.label.clone())); |
| 185 | + } |
| 186 | + } |
| 187 | + } |
| 188 | + |
| 189 | + let yesterday = now - chrono::Duration::days(1); |
| 190 | + let y_month_key = format!("{}-{:02}", yesterday.year(), yesterday.month()); |
| 191 | + if let Some(y_month_str) = schedule.months.get(&y_month_key) { |
| 192 | + let y_day = yesterday.day() as usize; |
| 193 | + if y_day >= 1 && y_day <= y_month_str.len() { |
| 194 | + let y_block_id = &y_month_str[y_day - 1..y_day]; |
| 195 | + if y_block_id != "0" { |
| 196 | + if let Some(y_block) = schedule.blocks.get(y_block_id) { |
| 197 | + let y_start = parse_time(&y_block.start); |
| 198 | + let y_end = parse_time(&y_block.end); |
| 199 | + if y_end < y_start && now_minutes < y_end { |
| 200 | + return Some(Availability::Busy(y_block.label.clone())); |
| 201 | + } |
| 202 | + } |
| 203 | + } |
| 204 | + } |
| 205 | + } |
| 206 | + |
| 207 | + if block_id == "0" { |
| 208 | + Some(Availability::DayOff) |
| 209 | + } else { |
| 210 | + Some(Availability::Available) |
| 211 | + } |
| 212 | +} |
| 213 | + |
| 214 | +pub fn relative_offset(local_tz: Tz, remote_tz: Tz, now: chrono::DateTime<chrono::Utc>) -> String { |
| 215 | + use chrono::Offset; |
| 216 | + let local_offset = now.with_timezone(&local_tz).offset().fix().local_minus_utc(); |
| 217 | + let remote_offset = now.with_timezone(&remote_tz).offset().fix().local_minus_utc(); |
| 218 | + let diff = remote_offset - local_offset; |
| 219 | + let hours = diff / 3600; |
| 220 | + let minutes = (diff.abs() % 3600) / 60; |
| 221 | + |
| 222 | + if hours == 0 && minutes == 0 { |
| 223 | + return "local".into(); |
| 224 | + } |
| 225 | + if minutes > 0 { |
| 226 | + format!("{:+}:{:02}h", hours, minutes) |
| 227 | + } else { |
| 228 | + format!("{:+}h", hours) |
| 229 | + } |
| 230 | +} |
| 231 | + |
| 232 | +pub fn local_tz() -> Tz { |
| 233 | + iana_time_zone::get_timezone() |
| 234 | + .ok() |
| 235 | + .and_then(|s| s.parse().ok()) |
| 236 | + .unwrap_or(chrono_tz::UTC) |
| 237 | +} |
0 commit comments