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.
| 26 | 26 | ||
| 27 | 27 | | Page | Address | What is on it | | |
| 28 | 28 | | --- | --- | --- | | |
| 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. | | |
| 30 | 30 | | 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). | | |
| 31 | 31 | | Invites | [`/settings/invites`](https://g1t.sh/settings/invites) | [Making, copying and revoking invites](#invites). | | |
| 32 | 32 | | 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. | |
| 672 | 672 | `example.com` is saved as `https://example.com`. Your email address is | |
| 673 | 673 | never shown. | |
| 674 | 674 | ||
| 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 | + | ||
| 675 | 682 | **Who sees what.** A profile is public, but the work and workspaces on it | |
| 676 | 683 | are filtered for whoever is looking: | |
| 677 | 684 | ||
| ⋯ | |||
| 695 | 702 | | --- | --- | | |
| 696 | 703 | | Picture, name, username and pronouns | Always | | |
| 697 | 704 | | Bio and location | They filled them in | | |
| 705 | + | | Their local time, such as **3:42 PM local time** | They set a [time zone](#profiles) | | |
| 698 | 706 | | **Member of** | The same workspaces their profile shows you, at most three named | | |
| 699 | 707 | | **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 | | |
| 700 | 708 | ||
| 1 | 1 | import { Check } from "lucide-react"; | |
| 2 | − | import { useState } from "react"; | |
| 2 | + | import { useEffect, useMemo, useState } from "react"; | |
| 3 | 3 | import { Form, Link } from "react-router"; | |
| 4 | 4 | ||
| 5 | 5 | import { PROFILE_LIMITS, type Profile } from "@g1t/contracts"; | |
| 6 | 6 | ||
| 7 | + | import { browserTimeZone, timeZoneLabel, timeZoneNames, utcOffset } from "../lib/time-zone"; | |
| 7 | 8 | import { SubmitButton, usePending } from "./ui"; | |
| 9 | + | import { Combobox } from "./ui/combobox"; | |
| 8 | 10 | import { Field, FieldDescription, FieldError, FieldLabel } from "./ui/field"; | |
| 9 | 11 | import { Input } from "./ui/input"; | |
| 10 | 12 | import { Textarea } from "./ui/textarea"; | |
| ⋯ | |||
| 26 | 28 | }) { | |
| 27 | 29 | const busy = usePending({ intent: "profile" }); | |
| 28 | 30 | 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]); | |
| 29 | 47 | return ( | |
| 30 | 48 | <section id="profile" className="scroll-mt-20"> | |
| 31 | 49 | <div className="flex flex-wrap items-baseline justify-between gap-2"> | |
| ⋯ | |||
| 97 | 115 | /> | |
| 98 | 116 | <FieldDescription>An https:// address.</FieldDescription> | |
| 99 | 117 | </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> | |
| 100 | 146 | <div className="flex flex-wrap items-center gap-3 sm:col-span-2"> | |
| 101 | 147 | <SubmitButton pending="Saving…" match={{ intent: "profile" }}> | |
| 102 | 148 | Save profile | |
| 1 | − | import { Building2, GitCommitHorizontal, MapPin } from "lucide-react"; | |
| 1 | + | import { Building2, Clock, GitCommitHorizontal, MapPin } from "lucide-react"; | |
| 2 | 2 | import { type ReactElement, type ReactNode, useEffect, useState } from "react"; | |
| 3 | 3 | import { Link, useParams } from "react-router"; | |
| 4 | 4 | ||
| 5 | 5 | import { type Card, type UserCard as UserCardData, cardHref, committedLabel } from "../lib/hovercard"; | |
| 6 | 6 | import { G1T_MENTION_HREF } from "../lib/markdown-plugins"; | |
| 7 | + | import { localTime } from "../lib/time-zone"; | |
| 7 | 8 | import { Avatar } from "./ui"; | |
| 8 | 9 | import { HoverCard, HoverCardContent, HoverCardTrigger } from "./ui/hover-card"; | |
| 9 | 10 | import { Skeleton } from "./ui/skeleton"; | |
| ⋯ | |||
| 86 | 87 | ||
| 87 | 88 | function PersonCard({ card }: { card: UserCardData }) { | |
| 88 | 89 | 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()); | |
| 89 | 92 | return ( | |
| 90 | 93 | <div className="space-y-3"> | |
| 91 | 94 | <div className="flex items-start gap-3"> | |
| ⋯ | |||
| 107 | 110 | </div> | |
| 108 | 111 | </div> | |
| 109 | 112 | {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) && ( | |
| 111 | 114 | <ul className="space-y-1.5 text-[0.8125rem] text-muted"> | |
| 112 | 115 | {card.location && ( | |
| 113 | 116 | <Line icon={<MapPin size={14} />}> | |
| 114 | 117 | <span className="wrap-anywhere">{card.location}</span> | |
| 115 | 118 | </Line> | |
| 116 | 119 | )} | |
| 120 | + | {time && <Line icon={<Clock size={14} />}>{time} local time</Line>} | |
| 117 | 121 | {card.workspaces.length > 0 && ( | |
| 118 | 122 | <Line icon={<Building2 size={14} />}> | |
| 119 | 123 | Member of{" "} | |
| 10 | 10 | | --- | --- | | |
| 11 | 11 | | 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. | | |
| 12 | 12 | | 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. | | |
| 14 | 14 | | Avatar, if you upload one | Shown next to your name. Stored by its content's hash and served publicly at `g1t.sh/avatars/…`. | | |
| 15 | 15 | | 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. | | |
| 16 | 16 | | Whether your email address is confirmed | Unconfirmed accounts can't create repositories or push. | |
| 15 | 15 | location: "London", | |
| 16 | 16 | website: "https://ada.example", | |
| 17 | 17 | pronouns: "she/her", | |
| 18 | + | timezone: "Europe/London", | |
| 18 | 19 | avatar: "cafe", | |
| 19 | 20 | createdAt: "2026-01-01T00:00:00Z", | |
| 20 | 21 | }; | |
| ⋯ | |||
| 97 | 98 | assert.equal(hidden?.kind === "user" && hidden.committed, null); | |
| 98 | 99 | }); | |
| 99 | 100 | ||
| 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 | + | ||
| 100 | 108 | test("a failing service leaves its part out, not the card", async () => { | |
| 101 | 109 | const card = await buildCard( | |
| 102 | 110 | "ada", | |
| 20 | 20 | pronouns: string | null; | |
| 21 | 21 | bio: string | null; | |
| 22 | 22 | 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; | |
| 23 | 25 | avatar: string | null; | |
| 24 | 26 | /** Workspaces the viewer may know they belong to, at most `MAX_WORKSPACES`. */ | |
| 25 | 27 | workspaces: { slug: string; name: string; avatar: string | null }[]; | |
| ⋯ | |||
| 112 | 114 | pronouns: profile.pronouns, | |
| 113 | 115 | bio: profile.bio, | |
| 114 | 116 | location: profile.location, | |
| 117 | + | timezone: profile.timezone ?? null, | |
| 115 | 118 | avatar: profile.avatar, | |
| 116 | 119 | workspaces: shown.slice(0, MAX_WORKSPACES).map((one) => ({ slug: one.slug, name: one.name, avatar: one.avatar })), | |
| 117 | 120 | more_workspaces: Math.max(0, shown.length - MAX_WORKSPACES), | |
| 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 | + | }); |
| 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 | + | } |
| 31 | 31 | location: text("location"), | |
| 32 | 32 | website: text("website"), | |
| 33 | 33 | pronouns: text("pronouns"), | |
| 34 | + | timezone: text("timezone"), | |
| 34 | 35 | }); | |
| 35 | 36 | return result.ok ? { profileSaved: true } : { profileError: result.error.message }; | |
| 36 | 37 | } |
| 975 | 975 | pub const MAX_PROFILE_LOCATION: usize = 80; | |
| 976 | 976 | pub const MAX_PROFILE_WEBSITE: usize = 200; | |
| 977 | 977 | pub const MAX_PROFILE_PRONOUNS: usize = 40; | |
| 978 | + | pub const MAX_PROFILE_TIMEZONE: usize = 64; | |
| 978 | 979 | ||
| 979 | 980 | /// What anyone may see about a person. | |
| 980 | 981 | #[derive(Clone, Debug, Default, Serialize, Deserialize)] | |
| ⋯ | |||
| 989 | 990 | /// An `https://` address. | |
| 990 | 991 | pub website: Option<String>, | |
| 991 | 992 | 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>, | |
| 992 | 996 | /// The uploaded avatar's hash, served at `/avatars/<avatar>`. | |
| 993 | 997 | pub avatar: Option<String>, | |
| 994 | 998 | /// When the account was made. RFC 3339. | |
| ⋯ | |||
| 1014 | 1018 | pub website: String, | |
| 1015 | 1019 | #[serde(default)] | |
| 1016 | 1020 | pub pronouns: String, | |
| 1021 | + | /// An IANA time zone name, such as `America/Denver`. | |
| 1022 | + | #[serde(default)] | |
| 1023 | + | pub timezone: String, | |
| 1017 | 1024 | } | |
| 1018 | 1025 | ||
| 1019 | 1026 | /// `profile_workspaces`: the workspaces shown on a person's profile, as | |
| 966 | 966 | export type AgentScope = { repo: RepoPath; operations: string[]; run?: RunBinding }; | |
| 967 | 967 | ||
| 968 | 968 | /** 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; | |
| 970 | 970 | ||
| 971 | 971 | /** What anyone may see about a person, at `g1t.sh/u/<username>`. */ | |
| 972 | 972 | export type Profile = { | |
| ⋯ | |||
| 978 | 978 | /** Always an `https://` address. */ | |
| 979 | 979 | website: string | null; | |
| 980 | 980 | pronouns: string | null; | |
| 981 | + | /** The time zone they are in, an IANA name such as `America/Denver`. */ | |
| 982 | + | timezone: string | null; | |
| 981 | 983 | /** The uploaded avatar's hash, served at `/avatars/<avatar>`. */ | |
| 982 | 984 | avatar: string | null; | |
| 983 | 985 | /** When the account was made. RFC 3339. */ | |
| ⋯ | |||
| 992 | 994 | /** `https://…`; a bare `example.com` is taken as `https://example.com`. */ | |
| 993 | 995 | website: string; | |
| 994 | 996 | pronouns: string; | |
| 997 | + | /** An IANA time zone name, such as `America/Denver`; empty clears it. */ | |
| 998 | + | timezone: string; | |
| 995 | 999 | }; | |
| 996 | 1000 | ||
| 997 | 1001 | /** A workspace on a person's profile. */ | |
| 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; |
| 21 | 21 | location: Option<String>, | |
| 22 | 22 | website: Option<String>, | |
| 23 | 23 | pronouns: Option<String>, | |
| 24 | + | timezone: Option<String>, | |
| 24 | 25 | avatar: Option<String>, | |
| 25 | 26 | created_at: String, | |
| 26 | 27 | } | |
| ⋯ | |||
| 34 | 35 | location: row.location, | |
| 35 | 36 | website: row.website, | |
| 36 | 37 | pronouns: row.pronouns, | |
| 38 | + | timezone: row.timezone, | |
| 37 | 39 | avatar: row.avatar, | |
| 38 | 40 | created_at: row.created_at, | |
| 39 | 41 | } | |
| ⋯ | |||
| 41 | 43 | } | |
| 42 | 44 | ||
| 43 | 45 | 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"; | |
| 45 | 47 | ||
| 46 | 48 | /// A field as it is kept: whitespace runs made single spaces, control | |
| 47 | 49 | /// characters dropped, trimmed. Empty is none. Too long is refused. | |
| ⋯ | |||
| 113 | 115 | Ok(Some(address)) | |
| 114 | 116 | } | |
| 115 | 117 | ||
| 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 | + | ||
| 116 | 136 | /// The fields of an update, checked, or the first thing wrong. | |
| 117 | 137 | pub struct Checked { | |
| 118 | 138 | pub name: Option<String>, | |
| ⋯ | |||
| 120 | 140 | pub location: Option<String>, | |
| 121 | 141 | pub website: Option<String>, | |
| 122 | 142 | pub pronouns: Option<String>, | |
| 143 | + | pub timezone: Option<String>, | |
| 123 | 144 | } | |
| 124 | 145 | ||
| 125 | 146 | pub fn check(a: &UpdateProfileArgs) -> std::result::Result<Checked, String> { | |
| ⋯ | |||
| 129 | 150 | location: tidy(&a.location, MAX_PROFILE_LOCATION, "location")?, | |
| 130 | 151 | website: website(&a.website).map_err(str::to_owned)?, | |
| 131 | 152 | pronouns: tidy(&a.pronouns, MAX_PROFILE_PRONOUNS, "pronouns")?, | |
| 153 | + | timezone: timezone(&a.timezone).map_err(str::to_owned)?, | |
| 132 | 154 | }) | |
| 133 | 155 | } | |
| 134 | 156 | ||
| ⋯ | |||
| 162 | 184 | let row = self | |
| 163 | 185 | .db | |
| 164 | 186 | .prepare(format!( | |
| 165 | − | "UPDATE users SET display_name = ?, bio = ?, location = ?, website = ?, pronouns = ? | |
| 187 | + | "UPDATE users SET display_name = ?, bio = ?, location = ?, website = ?, pronouns = ?, timezone = ? | |
| 166 | 188 | WHERE id = ? RETURNING {PROFILE_COLUMNS}" | |
| 167 | 189 | )) | |
| 168 | 190 | .bind(&[ | |
| ⋯ | |||
| 171 | 193 | optional(&fields.location), | |
| 172 | 194 | optional(&fields.website), | |
| 173 | 195 | optional(&fields.pronouns), | |
| 196 | + | optional(&fields.timezone), | |
| 174 | 197 | a.actor.id.as_str().into(), | |
| 175 | 198 | ])? | |
| 176 | 199 | .first::<ProfileRow>(None) | |
| ⋯ | |||
| 286 | 309 | assert_eq!(fields.website.as_deref(), Some("https://syntaqx.com")); | |
| 287 | 310 | assert_eq!(fields.pronouns.as_deref(), Some("he/him")); | |
| 288 | 311 | 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 | + | } | |
| 289 | 325 | } | |
| 290 | 326 | } | |
| 52 | 52 | slug === "acme" ? ({ slug: "acme", name: "Acme", description: "Rockets", id: "w", createdAt: "", memberCount: 3, avatar: null } as Workspace) : null, | |
| 53 | 53 | profile: async (username) => | |
| 54 | 54 | 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: "" } | |
| 56 | 56 | : null, | |
| 57 | 57 | }, | |
| 58 | 58 | repos: { |