Times are the person's: a brain on a server takes the phone's time zone instead of its own UTC
Both cloud brains had 「Etc/UTC」 as the time zone (the server's, set when they were made), so pages said 「今天 01:03」 for 09:03 and a routine for 9 in the morning would run at 5 in the afternoon. The phone now says where the person is; a brain still on a server's zone takes it (a zone the person chose stays). Page times use the person's zone, not the computer's. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Uirha2RVfSYmrhqNnqx9Z4
This commit is contained in:
parent
39cd7e85d7
commit
ce566b97f0
|
|
@ -552,13 +552,15 @@ async function pickHost() {
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------- live state ----------
|
// ---------- live state ----------
|
||||||
|
// The person's time zone, for a brain on a server that only knows UTC.
|
||||||
|
const TZ = (() => { try { return Intl.DateTimeFormat().resolvedOptions().timeZone || ""; } catch (_) { return ""; } })();
|
||||||
async function start() {
|
async function start() {
|
||||||
if (S.open) openChat(S.open); else show("list");
|
if (S.open) openChat(S.open); else show("list");
|
||||||
if (S.polling) return;
|
if (S.polling) return;
|
||||||
S.polling = true;
|
S.polling = true;
|
||||||
while (S.token) {
|
while (S.token) {
|
||||||
try {
|
try {
|
||||||
const v = await api(base() + "api/state?since=" + S.version);
|
const v = await api(base() + "api/state?since=" + S.version + "&tz=" + encodeURIComponent(TZ));
|
||||||
if (v.version !== S.version) { S.version = v.version; apply(v); }
|
if (v.version !== S.version) { S.version = v.version; apply(v); }
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (!S.token) break;
|
if (!S.token) break;
|
||||||
|
|
|
||||||
|
|
@ -219,6 +219,7 @@ impl App {
|
||||||
// ANYBOT_SHOT_REAL=1: a screenshot of the real data (and connections), not the demo.
|
// ANYBOT_SHOT_REAL=1: a screenshot of the real data (and connections), not the demo.
|
||||||
let demo = screenshot.is_some() && std::env::var("ANYBOT_SHOT_REAL").is_err();
|
let demo = screenshot.is_some() && std::env::var("ANYBOT_SHOT_REAL").is_err();
|
||||||
let state = if demo { State::demo() } else { State::load() };
|
let state = if demo { State::demo() } else { State::load() };
|
||||||
|
crate::routines::set_person_timezone(&state.timezone);
|
||||||
let (tx, rx) = channel();
|
let (tx, rx) = channel();
|
||||||
let mut app = App {
|
let mut app = App {
|
||||||
state,
|
state,
|
||||||
|
|
|
||||||
|
|
@ -207,6 +207,7 @@ impl App {
|
||||||
fn remote_signature(&self) -> u64 {
|
fn remote_signature(&self) -> u64 {
|
||||||
let mut h = std::collections::hash_map::DefaultHasher::new();
|
let mut h = std::collections::hash_map::DefaultHasher::new();
|
||||||
self.state.remote.host_name.hash(&mut h);
|
self.state.remote.host_name.hash(&mut h);
|
||||||
|
self.state.timezone.hash(&mut h);
|
||||||
self.scr.cfg_hash.hash(&mut h);
|
self.scr.cfg_hash.hash(&mut h);
|
||||||
for b in &self.state.bots {
|
for b in &self.state.bots {
|
||||||
(b.id, &b.name, &b.color, b.unread, b.messages.len()).hash(&mut h);
|
(b.id, &b.name, &b.color, b.unread, b.messages.len()).hash(&mut h);
|
||||||
|
|
@ -243,6 +244,7 @@ impl App {
|
||||||
}
|
}
|
||||||
self.rm.sig = sig;
|
self.rm.sig = sig;
|
||||||
self.rm.last_publish = Some(Instant::now());
|
self.rm.last_publish = Some(Instant::now());
|
||||||
|
crate::routines::set_person_timezone(&self.state.timezone);
|
||||||
let names: HashMap<u64, String> = self.state.bots.iter().map(|b| (b.id, b.name.clone())).collect();
|
let names: HashMap<u64, String> = self.state.bots.iter().map(|b| (b.id, b.name.clone())).collect();
|
||||||
let mut bots: Vec<&Bot> = self.state.bots.iter().collect();
|
let mut bots: Vec<&Bot> = self.state.bots.iter().collect();
|
||||||
// Pinned first, then the most recently active.
|
// Pinned first, then the most recently active.
|
||||||
|
|
@ -349,6 +351,14 @@ impl App {
|
||||||
self.start_reply(ctx, bot);
|
self.start_reply(ctx, bot);
|
||||||
}
|
}
|
||||||
Cmd::Stop { bot } => self.stop(bot),
|
Cmd::Stop { bot } => self.stop(bot),
|
||||||
|
// The server's own zone (UTC) was never the person's choice: take theirs. A zone the
|
||||||
|
// person set on purpose stays.
|
||||||
|
Cmd::PersonTimezone(tz) => {
|
||||||
|
if crate::routines::is_server_default_zone(&self.state.timezone) && tz != self.state.timezone {
|
||||||
|
self.state.timezone = tz;
|
||||||
|
self.mark();
|
||||||
|
}
|
||||||
|
}
|
||||||
Cmd::SetBot { bot, provider, model, computer } => {
|
Cmd::SetBot { bot, provider, model, computer } => {
|
||||||
let kind = serde_json::from_value::<crate::providers::ProviderKind>(json!(provider)).ok();
|
let kind = serde_json::from_value::<crate::providers::ProviderKind>(json!(provider)).ok();
|
||||||
if let (Some(k), false) = (kind, model.is_empty()) {
|
if let (Some(k), false) = (kind, model.is_empty()) {
|
||||||
|
|
|
||||||
|
|
@ -177,7 +177,7 @@ pub fn list(pages: &[Page], bot: u64) -> String {
|
||||||
/// "11/5 09:33" in the local time zone.
|
/// "11/5 09:33" in the local time zone.
|
||||||
pub fn when(ms: i64) -> String {
|
pub fn when(ms: i64) -> String {
|
||||||
use chrono::TimeZone;
|
use chrono::TimeZone;
|
||||||
chrono::Local.timestamp_millis_opt(ms).single().map(|t| t.format("%-m/%-d %H:%M").to_string()).unwrap_or_default()
|
crate::routines::person_tz().timestamp_millis_opt(ms).single().map(|t| t.format("%-m/%-d %H:%M").to_string()).unwrap_or_default()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 「剛剛」「5 分鐘前」「昨天 09:33」「11/3」.
|
/// 「剛剛」「5 分鐘前」「昨天 09:33」「11/3」.
|
||||||
|
|
@ -190,7 +190,8 @@ pub fn ago(ms: i64, now: i64) -> String {
|
||||||
if d < 3600 {
|
if d < 3600 {
|
||||||
return format!("{} 分鐘前", d / 60);
|
return format!("{} 分鐘前", d / 60);
|
||||||
}
|
}
|
||||||
let (Some(t), Some(n)) = (chrono::Local.timestamp_millis_opt(ms).single(), chrono::Local.timestamp_millis_opt(now).single()) else { return String::new() };
|
let tz = crate::routines::person_tz();
|
||||||
|
let (Some(t), Some(n)) = (tz.timestamp_millis_opt(ms).single(), tz.timestamp_millis_opt(now).single()) else { return String::new() };
|
||||||
let days = (n.date_naive() - t.date_naive()).num_days();
|
let days = (n.date_naive() - t.date_naive()).num_days();
|
||||||
match days {
|
match days {
|
||||||
0 => format!("今天 {}", t.format("%H:%M")),
|
0 => format!("今天 {}", t.format("%H:%M")),
|
||||||
|
|
|
||||||
|
|
@ -121,6 +121,8 @@ pub enum Cmd {
|
||||||
Answer { bot: u64, answer: String },
|
Answer { bot: u64, answer: String },
|
||||||
Read { bot: u64 },
|
Read { bot: u64 },
|
||||||
NewThread { bot: u64 },
|
NewThread { bot: u64 },
|
||||||
|
/// The time zone of the phone the person uses (a brain on a server only knows UTC).
|
||||||
|
PersonTimezone(String),
|
||||||
/// The phone changes a bot's model or its computer (empty = unchanged).
|
/// The phone changes a bot's model or its computer (empty = unchanged).
|
||||||
SetBot { bot: u64, provider: String, model: String, computer: String },
|
SetBot { bot: u64, provider: String, model: String, computer: String },
|
||||||
/// Answer the last message again (重新產生) / reply to what is there (重新傳送).
|
/// Answer the last message again (重新產生) / reply to what is there (重新傳送).
|
||||||
|
|
@ -354,6 +356,15 @@ pub fn handle(req: Req, via_relay: bool) -> Resp {
|
||||||
match (req.method.as_str(), path.as_slice()) {
|
match (req.method.as_str(), path.as_slice()) {
|
||||||
("GET", ["api", "state"]) => {
|
("GET", ["api", "state"]) => {
|
||||||
let since: u64 = req.query.get("since").and_then(|v| v.parse().ok()).unwrap_or(0);
|
let since: u64 = req.query.get("since").and_then(|v| v.parse().ok()).unwrap_or(0);
|
||||||
|
// The phone says where the person is (once per change, not every poll).
|
||||||
|
if let Some(tz) = req.query.get("tz").filter(|t| t.parse::<chrono_tz::Tz>().is_ok()) {
|
||||||
|
static TOLD: Mutex<String> = Mutex::new(String::new());
|
||||||
|
let mut told = TOLD.lock().unwrap_or_else(|e| e.into_inner());
|
||||||
|
if *told != *tz {
|
||||||
|
*told = tz.clone();
|
||||||
|
hub().push(Cmd::PersonTimezone(tz.clone()));
|
||||||
|
}
|
||||||
|
}
|
||||||
let v = hub().wait_newer(since, if since == 0 { 0 } else { LONG_POLL_SECS });
|
let v = hub().wait_newer(since, if since == 0 { 0 } else { LONG_POLL_SECS });
|
||||||
// 頁面 for the phone's home: the newest first, each with its bot.
|
// 頁面 for the phone's home: the newest first, each with its bot.
|
||||||
let now = crate::model::now_ms();
|
let now = crate::model::now_ms();
|
||||||
|
|
|
||||||
|
|
@ -97,6 +97,23 @@ pub fn local_timezone() -> String {
|
||||||
iana_time_zone::get_timezone().unwrap_or_else(|_| "Asia/Taipei".into())
|
iana_time_zone::get_timezone().unwrap_or_else(|_| "Asia/Taipei".into())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The person's time zone (their setting), for times shown to them by an AnyBot whose own clock
|
||||||
|
/// may be elsewhere: a brain on a server runs in UTC, and a page 「更新於 今天 01:03」 meant 09:03.
|
||||||
|
static PERSON_TZ: std::sync::Mutex<String> = std::sync::Mutex::new(String::new());
|
||||||
|
|
||||||
|
/// A zone that only says where the computer is, not where the person is (a server's UTC).
|
||||||
|
pub fn is_server_default_zone(tz: &str) -> bool {
|
||||||
|
matches!(tz.trim(), "" | "UTC" | "Etc/UTC" | "Etc/Universal" | "Universal" | "Zulu" | "GMT" | "Etc/GMT")
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn set_person_timezone(tz: &str) {
|
||||||
|
*PERSON_TZ.lock().unwrap_or_else(|e| e.into_inner()) = tz.to_string();
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn person_tz() -> Tz {
|
||||||
|
tz_of(&PERSON_TZ.lock().unwrap_or_else(|e| e.into_inner()))
|
||||||
|
}
|
||||||
|
|
||||||
fn tz_of(name: &str) -> Tz {
|
fn tz_of(name: &str) -> Tz {
|
||||||
name.parse::<Tz>().or_else(|_| local_timezone().parse::<Tz>()).unwrap_or(chrono_tz::Asia::Taipei)
|
name.parse::<Tz>().or_else(|_| local_timezone().parse::<Tz>()).unwrap_or(chrono_tz::Asia::Taipei)
|
||||||
}
|
}
|
||||||
|
|
@ -369,6 +386,12 @@ pub fn now_in(timezone: &str) -> DateTime<Tz> {
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
|
#[test]
|
||||||
|
fn a_servers_utc_is_not_the_persons_zone() {
|
||||||
|
assert!(super::is_server_default_zone("Etc/UTC") && super::is_server_default_zone("") && super::is_server_default_zone("UTC"));
|
||||||
|
assert!(!super::is_server_default_zone("Asia/Taipei") && !super::is_server_default_zone("Europe/London"));
|
||||||
|
}
|
||||||
|
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
fn ts(y: i32, m: u32, d: u32, h: u32, mi: u32) -> i64 {
|
fn ts(y: i32, m: u32, d: u32, h: u32, mi: u32) -> i64 {
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue