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:
王性驊 2026-11-06 20:33:06 +08:00
parent 39cd7e85d7
commit ce566b97f0
6 changed files with 51 additions and 3 deletions

View File

@ -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;

View File

@ -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,

View File

@ -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()) {

View File

@ -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")),

View File

@ -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();

View File

@ -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 {