Skip to content

Commit

Profiles take a time zone, and the card over a name shows the person's local time (identity 0039)

Migration 0039 adds users.timezone, an IANA name such as America/Denver, optional and public like the rest of a profile. update_profile takes `timezone` (empty clears it); identity checks the name's shape. Profile carries it, and the hovercard body (/-/hovercard/user/:username, snake case) passes it on as `timezone`. Settings → Profile gains a Time zone field: the searchable shadcn combobox over every zone the browser knows, each with its UTC offset, "Not shown" to clear, and "Use my browser's time zone (…)" when the browser's differs from the saved one. The card shows "3:42 PM local time" from the viewer's clock; a zone the runtime does not know shows nothing. Helpers in lib/time-zone.ts, with tests. There is no REST or MCP profile route, so no API body changed. Docs: profiles guide (Time zone, the card's table), settings table, privacy policy's profile row.

syntaqxcommitted Parentf29eeeaBrowse files
15 files+242−90/15 viewed
+1−1
2626
2727 | Page | Address | What is on it |
2828 | --- | --- | --- |
29−| Profile | [`/settings/profile`](https://g1t.sh/settings/profile) | Your picture, and your [public profile](/guides/workspaces/#profiles): name, pronouns, bio, location and website. |
29+| Profile | [`/settings/profile`](https://g1t.sh/settings/profile) | Your picture, and your [public profile](/guides/workspaces/#profiles): name, pronouns, bio, location, website and time zone. |
3030 | Emails | [`/settings/emails`](https://g1t.sh/settings/emails) | Your [email addresses](#email-addresses), the backup address, and [keeping your address private](#keeping-your-address-private). |
3131 | Invites | [`/settings/invites`](https://g1t.sh/settings/invites) | [Making, copying and revoking invites](#invites). |
3232 | SSH keys | [`/settings/keys`](https://g1t.sh/settings/keys) | Public keys for [git over SSH](/guides/git/#ssh), each with when it was added and last used. |
+8−0
672672 `example.com` is saved as `https://example.com`. Your email address is
673673 never shown.
674674
675+**Time zone.** Pick the time zone you are in, by city or region (such as
676+`America/Denver`), and the [card over your name](#the-card-over-a-name)
677+shows your local time, so people can tell whether it is a good moment to
678+ask you something. If your browser's time zone differs from the one
679+saved, the field offers **Use my browser's time zone**. Choose **Not
680+shown** to clear it.
681+
675682 **Who sees what.** A profile is public, but the work and workspaces on it
676683 are filtered for whoever is looking:
677684
695702 | --- | --- |
696703 | Picture, name, username and pronouns | Always |
697704 | Bio and location | They filled them in |
705+| Their local time, such as **3:42 PM local time** | They set a [time zone](#profiles) |
698706 | **Member of** | The same workspaces their profile shows you, at most three named |
699707 | **Committed to this repository in the past day**, **week** or **month** | You opened it inside a repository you can read, and their latest commit on its default branch is that recent |
700708
+47−1
11 import { Check } from "lucide-react";
2−import { useState } from "react";
2+import { useEffect, useMemo, useState } from "react";
33 import { Form, Link } from "react-router";
44
55 import { PROFILE_LIMITS, type Profile } from "@g1t/contracts";
66
7+import { browserTimeZone, timeZoneLabel, timeZoneNames, utcOffset } from "../lib/time-zone";
78 import { SubmitButton, usePending } from "./ui";
9+import { Combobox } from "./ui/combobox";
810 import { Field, FieldDescription, FieldError, FieldLabel } from "./ui/field";
911 import { Input } from "./ui/input";
1012 import { Textarea } from "./ui/textarea";
2628 }) {
2729 const busy = usePending({ intent: "profile" });
2830 const [bio, setBio] = useState(profile?.bio ?? "");
31+ const [timezone, setTimezone] = useState(profile?.timezone ?? "");
32+ // The browser's zone is only known once the page runs in it.
33+ const [browserZone, setBrowserZone] = useState<string | null>(null);
34+ useEffect(() => setBrowserZone(browserTimeZone()), []);
35+ const zones = useMemo(() => {
36+ const now = Date.now();
37+ return [
38+ { value: "", label: "Not shown" },
39+ ...timeZoneNames(profile?.timezone).map((zone) => ({
40+ value: zone,
41+ label: timeZoneLabel(zone),
42+ description: utcOffset(zone, now) ?? undefined,
43+ keywords: [zone],
44+ })),
45+ ];
46+ }, [profile?.timezone]);
2947 return (
3048 <section id="profile" className="scroll-mt-20">
3149 <div className="flex flex-wrap items-baseline justify-between gap-2">
97115 />
98116 <FieldDescription>An https:// address.</FieldDescription>
99117 </Field>
118+ <Field>
119+ <FieldLabel htmlFor="profile-timezone">Time zone</FieldLabel>
120+ <Combobox
121+ id="profile-timezone"
122+ name="timezone"
123+ value={timezone}
124+ onValueChange={setTimezone}
125+ options={zones}
126+ placeholder="Not shown"
127+ searchPlaceholder="Find a city or region"
128+ emptyText="No time zone by that name."
129+ />
130+ <FieldDescription>
131+ The card over your name shows your local time.
132+ {browserZone && browserZone !== timezone && (
133+ <>
134+ {" "}
135+ <button
136+ type="button"
137+ onClick={() => setTimezone(browserZone)}
138+ className="text-accent underline-offset-4 hover:underline"
139+ >
140+ Use my browser's time zone ({timeZoneLabel(browserZone)})
141+ </button>
142+ </>
143+ )}
144+ </FieldDescription>
145+ </Field>
100146 <div className="flex flex-wrap items-center gap-3 sm:col-span-2">
101147 <SubmitButton pending="Saving…" match={{ intent: "profile" }}>
102148 Save profile
+6−2
1−import { Building2, GitCommitHorizontal, MapPin } from "lucide-react";
1+import { Building2, Clock, GitCommitHorizontal, MapPin } from "lucide-react";
22 import { type ReactElement, type ReactNode, useEffect, useState } from "react";
33 import { Link, useParams } from "react-router";
44
55 import { type Card, type UserCard as UserCardData, cardHref, committedLabel } from "../lib/hovercard";
66 import { G1T_MENTION_HREF } from "../lib/markdown-plugins";
7+import { localTime } from "../lib/time-zone";
78 import { Avatar } from "./ui";
89 import { HoverCard, HoverCardContent, HoverCardTrigger } from "./ui/hover-card";
910 import { Skeleton } from "./ui/skeleton";
8687
8788 function PersonCard({ card }: { card: UserCardData }) {
8889 const profile = `/u/${card.username}`;
90+ // Cards are only drawn in the browser, so this is the viewer's clock.
91+ const time = localTime(card.timezone, Date.now());
8992 return (
9093 <div className="space-y-3">
9194 <div className="flex items-start gap-3">
107110 </div>
108111 </div>
109112 {card.bio && <p className="leading-relaxed text-fg/90 wrap-anywhere">{card.bio}</p>}
110− {(card.location || card.workspaces.length > 0 || card.committed) && (
113+ {(card.location || time || card.workspaces.length > 0 || card.committed) && (
111114 <ul className="space-y-1.5 text-[0.8125rem] text-muted">
112115 {card.location && (
113116 <Line icon={<MapPin size={14} />}>
114117 <span className="wrap-anywhere">{card.location}</span>
115118 </Line>
116119 )}
120+ {time && <Line icon={<Clock size={14} />}>{time} local time</Line>}
117121 {card.workspaces.length > 0 && (
118122 <Line icon={<Building2 size={14} />}>
119123 Member of{" "}
+1−1
1010 | --- | --- |
1111 | Username and email address | To identify you, sign you in, and reach you about your account. Your username is public; your email address is not. |
1212 | Password | To sign you in. We store only a salted hash of it (PBKDF2-SHA256), never the password itself. |
13−| Profile: name, bio, location, website and pronouns, if you add them | Shown on your public profile. All optional. |
13+| Profile: name, bio, location, website, pronouns and time zone, if you add them | Shown on your public profile, and your local time on the card over your name. All optional. |
1414 | Avatar, if you upload one | Shown next to your name. Stored by its content's hash and served publicly at `g1t.sh/avatars/…`. |
1515 | Sessions, access tokens, SSH keys, and apps you've approved through sign-in with g1t | To keep you signed in and let your tools act for you. Session and token secrets are stored only as hashes. |
1616 | Whether your email address is confirmed | Unconfirmed accounts can't create repositories or push. |
+8−0
1515 location: "London",
1616 website: "https://ada.example",
1717 pronouns: "she/her",
18+ timezone: "Europe/London",
1819 avatar: "cafe",
1920 createdAt: "2026-01-01T00:00:00Z",
2021 };
9798 assert.equal(hidden?.kind === "user" && hidden.committed, null);
9899 });
99100
101+test("the card carries the time zone a profile gives, and none when it gives none", async () => {
102+ const card = await buildCard("ada", me, null, sources(), NOW);
103+ assert.equal(card?.kind === "user" && card.timezone, "Europe/London");
104+ const without = await buildCard("ada", me, null, sources({ profile: async () => ({ ...ada, timezone: null }) }), NOW);
105+ assert.equal(without?.kind === "user" && without.timezone, null);
106+});
107+
100108 test("a failing service leaves its part out, not the card", async () => {
101109 const card = await buildCard(
102110 "ada",
+3−0
2020 pronouns: string | null;
2121 bio: string | null;
2222 location: string | null;
23+ /** The IANA time zone they gave, such as `America/Denver`; the card shows their local time from it. */
24+ timezone: string | null;
2325 avatar: string | null;
2426 /** Workspaces the viewer may know they belong to, at most `MAX_WORKSPACES`. */
2527 workspaces: { slug: string; name: string; avatar: string | null }[];
112114 pronouns: profile.pronouns,
113115 bio: profile.bio,
114116 location: profile.location,
117+ timezone: profile.timezone ?? null,
115118 avatar: profile.avatar,
116119 workspaces: shown.slice(0, MAX_WORKSPACES).map((one) => ({ slug: one.slug, name: one.name, avatar: one.avatar })),
117120 more_workspaces: Math.max(0, shown.length - MAX_WORKSPACES),
+38−0
1+import assert from "node:assert/strict";
2+import { test } from "node:test";
3+
4+import { knownTimeZone, localTime, timeZoneLabel, timeZoneNames, utcOffset } from "./time-zone.ts";
5+
6+// 2026-10-08 21:42 UTC: 3:42 PM in Denver (MDT), the next morning in Tokyo.
7+const NOW = Date.UTC(2026, 9, 8, 21, 42);
8+
9+test("a profile's local time is the time of day in its zone", () => {
10+ // ICU puts a narrow no-break space before AM/PM in some versions.
11+ const at = (zone: string) => localTime(zone, NOW, "en-US")?.replace(/\s/gu, " ");
12+ assert.equal(at("America/Denver"), "3:42 PM");
13+ assert.equal(at("Asia/Tokyo"), "6:42 AM");
14+ assert.equal(at("UTC"), "9:42 PM");
15+});
16+
17+test("no zone, or one the runtime does not know, shows no time", () => {
18+ assert.equal(localTime(null, NOW, "en-US"), null);
19+ assert.equal(localTime("", NOW, "en-US"), null);
20+ assert.equal(localTime("Mars/Olympus_Mons", NOW, "en-US"), null);
21+ assert.ok(!knownTimeZone("Mars/Olympus_Mons"));
22+ assert.ok(knownTimeZone("Europe/Berlin"));
23+});
24+
25+test("the zones to pick from are sorted, with UTC and a saved one always there", () => {
26+ const names = timeZoneNames("Mars/Olympus_Mons");
27+ assert.ok(names.includes("UTC"));
28+ assert.ok(names.includes("America/Denver"));
29+ assert.ok(names.includes("Mars/Olympus_Mons"));
30+ assert.deepEqual(names, [...names].sort((a, b) => a.localeCompare(b)));
31+});
32+
33+test("zones read as places, with their offset", () => {
34+ assert.equal(timeZoneLabel("America/Port_of_Spain"), "America/Port of Spain");
35+ assert.equal(utcOffset("America/Denver", NOW), "UTC−06:00");
36+ assert.equal(utcOffset("Asia/Kolkata", NOW), "UTC+05:30");
37+ assert.equal(utcOffset("UTC", NOW), "UTC+00:00");
38+});
+74−0
1+/**
2+ * Time zones on a profile: the IANA names a person picks from in their
3+ * settings, and the local time the card over their name shows. Pure, so it
4+ * is tested on its own.
5+ */
6+
7+/** Whether the runtime knows `zone` as a time zone. */
8+export function knownTimeZone(zone: string | null | undefined): zone is string {
9+ if (!zone) return false;
10+ try {
11+ new Intl.DateTimeFormat("en-US", { timeZone: zone });
12+ return true;
13+ } catch {
14+ return false;
15+ }
16+}
17+
18+/**
19+ * Every IANA zone the runtime knows, sorted, with `current` kept in the
20+ * list even when the runtime does not know it, so a saved choice is never
21+ * dropped. UTC is always there.
22+ */
23+export function timeZoneNames(current?: string | null): string[] {
24+ let names: string[] = [];
25+ try {
26+ names = Intl.supportedValuesOf("timeZone");
27+ } catch {
28+ names = [];
29+ }
30+ const all = new Set(names);
31+ all.add("UTC");
32+ if (current) all.add(current);
33+ return [...all].sort((a, b) => a.localeCompare(b));
34+}
35+
36+/** A zone's name as a person reads it: `America/Port_of_Spain` as `America/Port of Spain`. */
37+export function timeZoneLabel(zone: string): string {
38+ return zone.replaceAll("_", " ");
39+}
40+
41+/** The zone's offset from UTC at `now`, such as `UTC−06:00`; null when unknown. */
42+export function utcOffset(zone: string, now: number): string | null {
43+ try {
44+ const part = new Intl.DateTimeFormat("en-US", { timeZone: zone, timeZoneName: "longOffset" })
45+ .formatToParts(now)
46+ .find((one) => one.type === "timeZoneName")?.value;
47+ if (!part) return null;
48+ // "GMT-06:00", or plain "GMT" at UTC itself.
49+ const offset = part.replace(/^GMT/, "") || "+00:00";
50+ return `UTC${offset.replace("-", "−")}`;
51+ } catch {
52+ return null;
53+ }
54+}
55+
56+/**
57+ * The time of day at `now` in `zone`, such as `3:42 PM`, in `locale` (the
58+ * viewer's own when left out); null when there is no zone or the runtime
59+ * does not know it.
60+ */
61+export function localTime(zone: string | null | undefined, now: number, locale?: string): string | null {
62+ if (!knownTimeZone(zone)) return null;
63+ return new Intl.DateTimeFormat(locale, { hour: "numeric", minute: "2-digit", timeZone: zone }).format(now);
64+}
65+
66+/** The browser's own zone, or null where it cannot say (on the server, say). */
67+export function browserTimeZone(): string | null {
68+ try {
69+ const zone = Intl.DateTimeFormat().resolvedOptions().timeZone;
70+ return knownTimeZone(zone) ? zone : null;
71+ } catch {
72+ return null;
73+ }
74+}
+1−0
3131 location: text("location"),
3232 website: text("website"),
3333 pronouns: text("pronouns"),
34+ timezone: text("timezone"),
3435 });
3536 return result.ok ? { profileSaved: true } : { profileError: result.error.message };
3637 }
+7−0
975975 pub const MAX_PROFILE_LOCATION: usize = 80;
976976 pub const MAX_PROFILE_WEBSITE: usize = 200;
977977 pub const MAX_PROFILE_PRONOUNS: usize = 40;
978+pub const MAX_PROFILE_TIMEZONE: usize = 64;
978979
979980 /// What anyone may see about a person.
980981 #[derive(Clone, Debug, Default, Serialize, Deserialize)]
989990 /// An `https://` address.
990991 pub website: Option<String>,
991992 pub pronouns: Option<String>,
993+ /// The time zone they are in, an IANA name such as `America/Denver`.
994+ #[serde(default)]
995+ pub timezone: Option<String>,
992996 /// The uploaded avatar's hash, served at `/avatars/<avatar>`.
993997 pub avatar: Option<String>,
994998 /// When the account was made. RFC 3339.
10141018 pub website: String,
10151019 #[serde(default)]
10161020 pub pronouns: String,
1021+ /// An IANA time zone name, such as `America/Denver`.
1022+ #[serde(default)]
1023+ pub timezone: String,
10171024 }
10181025
10191026 /// `profile_workspaces`: the workspaces shown on a person's profile, as
+5−1
966966 export type AgentScope = { repo: RepoPath; operations: string[]; run?: RunBinding };
967967
968968 /** The most characters each profile field takes. Mirrors `crates/contracts/src/identity.rs`. */
969−export const PROFILE_LIMITS = { name: 80, bio: 160, location: 80, website: 200, pronouns: 40 } as const;
969+export const PROFILE_LIMITS = { name: 80, bio: 160, location: 80, website: 200, pronouns: 40, timezone: 64 } as const;
970970
971971 /** What anyone may see about a person, at `g1t.sh/u/<username>`. */
972972 export type Profile = {
978978 /** Always an `https://` address. */
979979 website: string | null;
980980 pronouns: string | null;
981+ /** The time zone they are in, an IANA name such as `America/Denver`. */
982+ timezone: string | null;
981983 /** The uploaded avatar's hash, served at `/avatars/<avatar>`. */
982984 avatar: string | null;
983985 /** When the account was made. RFC 3339. */
992994 /** `https://…`; a bare `example.com` is taken as `https://example.com`. */
993995 website: string;
994996 pronouns: string;
997+ /** An IANA time zone name, such as `America/Denver`; empty clears it. */
998+ timezone: string;
995999 };
9961000
9971001 /** A workspace on a person's profile. */
+4−0
1+-- The time zone a person is in, as an IANA name such as America/Denver,
2+-- so the card over their name can show their local time. Optional and
3+-- public like the rest of a profile; null means not given.
4+ALTER TABLE users ADD COLUMN timezone TEXT;
+38−2
2121 location: Option<String>,
2222 website: Option<String>,
2323 pronouns: Option<String>,
24+ timezone: Option<String>,
2425 avatar: Option<String>,
2526 created_at: String,
2627 }
3435 location: row.location,
3536 website: row.website,
3637 pronouns: row.pronouns,
38+ timezone: row.timezone,
3739 avatar: row.avatar,
3840 created_at: row.created_at,
3941 }
4143 }
4244
4345 const PROFILE_COLUMNS: &str =
44− "username, display_name, bio, location, website, pronouns, avatar, created_at";
46+ "username, display_name, bio, location, website, pronouns, timezone, avatar, created_at";
4547
4648 /// A field as it is kept: whitespace runs made single spaces, control
4749 /// characters dropped, trimmed. Empty is none. Too long is refused.
113115 Ok(Some(address))
114116 }
115117
118+/// An IANA time zone name as it is kept, such as `America/Denver` or
119+/// `UTC`: empty is none. Only the name's shape is checked here; the web
120+/// app offers the zones its runtime knows, and one it does not know is
121+/// shown without a local time.
122+fn timezone(value: &str) -> std::result::Result<Option<String>, &'static str> {
123+ const REFUSED: &str = "That is not a time zone. Pick one from the list, such as America/Denver.";
124+ let name = value.trim();
125+ if name.is_empty() {
126+ return Ok(None);
127+ }
128+ let well_formed = name.len() <= MAX_PROFILE_TIMEZONE
129+ && name.split('/').all(|part| {
130+ part.chars().next().is_some_and(|c| c.is_ascii_alphabetic())
131+ && part.chars().all(|c| c.is_ascii_alphanumeric() || matches!(c, '_' | '-' | '+'))
132+ });
133+ if well_formed { Ok(Some(name.to_owned())) } else { Err(REFUSED) }
134+}
135+
116136 /// The fields of an update, checked, or the first thing wrong.
117137 pub struct Checked {
118138 pub name: Option<String>,
120140 pub location: Option<String>,
121141 pub website: Option<String>,
122142 pub pronouns: Option<String>,
143+ pub timezone: Option<String>,
123144 }
124145
125146 pub fn check(a: &UpdateProfileArgs) -> std::result::Result<Checked, String> {
129150 location: tidy(&a.location, MAX_PROFILE_LOCATION, "location")?,
130151 website: website(&a.website).map_err(str::to_owned)?,
131152 pronouns: tidy(&a.pronouns, MAX_PROFILE_PRONOUNS, "pronouns")?,
153+ timezone: timezone(&a.timezone).map_err(str::to_owned)?,
132154 })
133155 }
134156
162184 let row = self
163185 .db
164186 .prepare(format!(
165− "UPDATE users SET display_name = ?, bio = ?, location = ?, website = ?, pronouns = ?
187+ "UPDATE users SET display_name = ?, bio = ?, location = ?, website = ?, pronouns = ?, timezone = ?
166188 WHERE id = ? RETURNING {PROFILE_COLUMNS}"
167189 ))
168190 .bind(&[
171193 optional(&fields.location),
172194 optional(&fields.website),
173195 optional(&fields.pronouns),
196+ optional(&fields.timezone),
174197 a.actor.id.as_str().into(),
175198 ])?
176199 .first::<ProfileRow>(None)
286309 assert_eq!(fields.website.as_deref(), Some("https://syntaqx.com"));
287310 assert_eq!(fields.pronouns.as_deref(), Some("he/him"));
288311 assert_eq!(fields.location, None);
312+ assert_eq!(fields.timezone, None);
313+ }
314+
315+ #[test]
316+ fn a_time_zone_is_an_iana_name_or_nothing() {
317+ for name in ["America/Denver", "UTC", "America/Argentina/Buenos_Aires", "Etc/GMT+7", "America/Port-au-Prince"] {
318+ assert_eq!(timezone(name).unwrap().as_deref(), Some(name));
319+ }
320+ assert_eq!(timezone(" Europe/Berlin ").unwrap().as_deref(), Some("Europe/Berlin"));
321+ assert_eq!(timezone("").unwrap(), None);
322+ for bad in ["America/", "/UTC", "Europe/Ber lin", "<script>", "../etc", &"A".repeat(65)] {
323+ assert!(timezone(bad).is_err(), "{bad}");
324+ }
289325 }
290326 }
+1−1
5252 slug === "acme" ? ({ slug: "acme", name: "Acme", description: "Rockets", id: "w", createdAt: "", memberCount: 3, avatar: null } as Workspace) : null,
5353 profile: async (username) =>
5454 username === "ada"
55− ? { username: "ada", name: "Ada Lovelace", bio: "Engines.", location: null, website: null, pronouns: null, avatar: null, createdAt: "" }
55+ ? { username: "ada", name: "Ada Lovelace", bio: "Engines.", location: null, website: null, pronouns: null, timezone: null, avatar: null, createdAt: "" }
5656 : null,
5757 },
5858 repos: {