Pick any line to see why it is the way it is: the commit, the pull request and issue it came from, and what the agent was thinking.
| One kind of access token; presence and status; usernames keep their case; the tour is a miniature of the real app; icons for password managers | 1 | /** |
| 2 | * Presence and status in the browser, apart from the browser: the status | |
| 3 | * presets, what "clear after" and "pause notifications" mean as times, how | |
| 4 | * a person shows (dot and words), and when a tab counts as idle. Pure, so | |
| 5 | * it is tested on its own; the store and the socket are in | |
| 6 | * lib/notify-client.ts, the menus in components/presence.tsx. | |
| 7 | * | |
| 8 | * Times are the viewer's own: "today" ends at their midnight, "until | |
| 9 | * tomorrow" is their 9 in the morning. The feed only keeps the instant. | |
| 10 | */ | |
| 11 | import type { PersonStatus, Presence, PresenceEntry } from "@g1t/contracts"; | |
| 12 | ||
| 13 | /** A tab with no input for this long is idle; when every tab is, the person shows away. */ | |
| 14 | export const IDLE_MS = 10 * 60_000; | |
| 15 | ||
| 16 | export type ClearAfter = "30m" | "1h" | "4h" | "today" | "week" | "never" | "custom"; | |
| 17 | ||
| 18 | export const CLEAR_AFTER: { key: ClearAfter; label: string }[] = [ | |
| 19 | { key: "30m", label: "30 minutes" }, | |
| 20 | { key: "1h", label: "1 hour" }, | |
| 21 | { key: "4h", label: "4 hours" }, | |
| 22 | { key: "today", label: "Today" }, | |
| 23 | { key: "week", label: "This week" }, | |
| 24 | { key: "never", label: "Don't clear" }, | |
| 25 | { key: "custom", label: "Choose a time…" }, | |
| 26 | ]; | |
| 27 | ||
| 28 | export type StatusPreset = { emoji: string; text: string; clear: ClearAfter }; | |
| 29 | ||
| 30 | /** The statuses offered first: one click each. */ | |
| 31 | export const STATUS_PRESETS: StatusPreset[] = [ | |
| 32 | { emoji: "🗓️", text: "In a meeting", clear: "1h" }, | |
| 33 | { emoji: "🚌", text: "Commuting", clear: "30m" }, | |
| 34 | { emoji: "🎯", text: "Focusing", clear: "1h" }, | |
| 35 | { emoji: "🤒", text: "Out sick", clear: "today" }, | |
| 36 | { emoji: "🌴", text: "On vacation", clear: "never" }, | |
| 37 | ]; | |
| 38 | ||
| 39 | export type PauseFor = "30m" | "1h" | "tomorrow"; | |
| 40 | ||
| 41 | export const PAUSE_FOR: { key: PauseFor; label: string }[] = [ | |
| 42 | { key: "30m", label: "For 30 minutes" }, | |
| 43 | { key: "1h", label: "For 1 hour" }, | |
| 44 | { key: "tomorrow", label: "Until tomorrow" }, | |
| 45 | ]; | |
| 46 | ||
| 47 | const MINUTE = 60_000; | |
| 48 | ||
| 49 | /** The next midnight after `now`, the viewer's. */ | |
| 50 | function nextMidnight(now: Date): Date { | |
| 51 | const at = new Date(now); | |
| 52 | at.setHours(24, 0, 0, 0); | |
| 53 | return at; | |
| 54 | } | |
| 55 | ||
| 56 | /** | |
| 57 | * When a status set now clears, as an instant; null keeps it until | |
| 58 | * changed. `custom` is a `datetime-local` value, in the viewer's time. | |
| 59 | */ | |
| 60 | export function clearAtFor(choice: ClearAfter, now: Date, custom?: string | null): string | null { | |
| 61 | switch (choice) { | |
| 62 | case "30m": | |
| 63 | return new Date(now.getTime() + 30 * MINUTE).toISOString(); | |
| 64 | case "1h": | |
| 65 | return new Date(now.getTime() + 60 * MINUTE).toISOString(); | |
| 66 | case "4h": | |
| 67 | return new Date(now.getTime() + 240 * MINUTE).toISOString(); | |
| 68 | case "today": | |
| 69 | return nextMidnight(now).toISOString(); | |
| 70 | case "week": { | |
| 71 | // The end of Sunday: the coming Monday's midnight. | |
| 72 | const at = nextMidnight(now); | |
| 73 | while (at.getDay() !== 1) at.setDate(at.getDate() + 1); | |
| 74 | return at.toISOString(); | |
| 75 | } | |
| 76 | case "never": | |
| 77 | return null; | |
| 78 | case "custom": { | |
| 79 | const at = custom ? new Date(custom) : null; | |
| 80 | return at && Number.isFinite(at.getTime()) && at.getTime() > now.getTime() ? at.toISOString() : null; | |
| 81 | } | |
| 82 | } | |
| 83 | } | |
| 84 | ||
| 85 | /** When notifications resume: in half an hour, an hour, or at nine tomorrow morning. */ | |
| 86 | export function pauseUntil(choice: PauseFor, now: Date): string { | |
| 87 | if (choice === "30m") return new Date(now.getTime() + 30 * MINUTE).toISOString(); | |
| 88 | if (choice === "1h") return new Date(now.getTime() + 60 * MINUTE).toISOString(); | |
| 89 | const at = nextMidnight(now); | |
| 90 | at.setHours(9, 0, 0, 0); | |
| 91 | return at.toISOString(); | |
| 92 | } | |
| 93 | ||
| 94 | /** Whether an instant is still ahead. */ | |
| 95 | function ahead(value: string | null | undefined, now: number): boolean { | |
| 96 | if (!value) return false; | |
| 97 | const at = Date.parse(value); | |
| 98 | return Number.isFinite(at) && at > now; | |
| 99 | } | |
| 100 | ||
| 101 | /** A status as it stands now: gone once its time has passed, even before the feed says so. */ | |
| 102 | export function liveStatus(status: PersonStatus | null | undefined, now: number): PersonStatus | null { | |
| 103 | if (!status) return null; | |
| 104 | return status.clear_at && !ahead(status.clear_at, now) ? null : status; | |
| 105 | } | |
| 106 | ||
| 107 | export function dndOn(entry: Pick<PresenceEntry, "dnd_until"> | null | undefined, now: number): boolean { | |
| 108 | return ahead(entry?.dnd_until, now); | |
| 109 | } | |
| 110 | ||
| 111 | /** What a person's dot shows. */ | |
| 112 | export type Shown = "active" | "away" | "dnd" | "offline"; | |
| 113 | ||
| 114 | export function shownAs(entry: Pick<PresenceEntry, "presence" | "dnd_until"> | null | undefined, now: number): Shown { | |
| 115 | if (!entry || entry.presence === "offline") return "offline"; | |
| 116 | if (dndOn(entry, now)) return "dnd"; | |
| 117 | return entry.presence; | |
| 118 | } | |
| 119 | ||
| 120 | export const SHOWN_LABEL: Record<Shown, string> = { | |
| 121 | active: "Active", | |
| 122 | away: "Away", | |
| 123 | dnd: "Notifications paused", | |
| 124 | offline: "Offline", | |
| 125 | }; | |
| 126 | ||
| 127 | export function presenceLabel(presence: Presence): string { | |
| 128 | return SHOWN_LABEL[presence]; | |
| 129 | } | |
| 130 | ||
| 131 | /** | |
| 132 | * "until 3:30 PM", "until tomorrow 9:00 AM", "until Mon 9:00 AM" or | |
| 133 | * "until 12 Oct": short words for when something ends, the viewer's time. | |
| 134 | */ | |
| 135 | export function untilLabel(value: string, now: Date, locale?: string): string { | |
| 136 | const at = new Date(value); | |
| 137 | if (!Number.isFinite(at.getTime())) return ""; | |
| 138 | const clock = at.toLocaleTimeString(locale, { hour: "numeric", minute: "2-digit" }); | |
| 139 | const day = (d: Date) => new Date(d.getFullYear(), d.getMonth(), d.getDate()).getTime(); | |
| 140 | const days = Math.round((day(at) - day(now)) / (24 * 3600 * 1000)); | |
| 141 | // Midnight is the end of the day before: "until tomorrow" reads better as "today". | |
| 142 | const midnight = at.getHours() === 0 && at.getMinutes() === 0; | |
| 143 | if (days === 0) return `until ${clock}`; | |
| 144 | if (days === 1 && midnight) return "until the end of today"; | |
| 145 | if (days === 1) return `until tomorrow ${clock}`; | |
| 146 | if (days <= 7 && midnight) return `until the end of ${new Date(at.getTime() - 1).toLocaleDateString(locale, { weekday: "long" })}`; | |
| 147 | if (days < 7) return `until ${at.toLocaleDateString(locale, { weekday: "short" })} ${clock}`; | |
| 148 | return `until ${at.toLocaleDateString(locale, { day: "numeric", month: "short" })}`; | |
| 149 | } | |
| 150 | ||
| 151 | /** A status in words, for screen readers and hints: "🗓️ In a meeting, until 3:30 PM". */ | |
| 152 | export function statusWords(status: PersonStatus, now: Date, locale?: string): string { | |
| 153 | const text = [status.emoji, status.text].filter(Boolean).join(" "); | |
| 154 | return status.clear_at ? `${text}, ${untilLabel(status.clear_at, now, locale)}` : text; | |
| 155 | } | |
| 156 | ||
| 157 | /** Whether a tab is idle: no input for `IDLE_MS`. */ | |
| 158 | export function isIdle(lastInput: number, now: number): boolean { | |
| 159 | return now - lastInput >= IDLE_MS; | |
| 160 | } | |
| 161 | ||
| 162 | /** A custom emoji's `:name:`, or null for an ordinary emoji. */ | |
| 163 | export function customEmojiName(emoji: string | null | undefined): string | null { | |
| 164 | const match = emoji ? /^:([a-z0-9_+-]{1,64}):$/.exec(emoji) : null; | |
| 165 | return match ? match[1]! : null; | |
| 166 | } | |
| 167 | ||
| 168 | /** A `datetime-local` value for an instant, in the viewer's time: what the custom time field starts at. */ | |
| 169 | export function localInputValue(at: Date): string { | |
| 170 | const pad = (n: number) => String(n).padStart(2, "0"); | |
| 171 | return `${at.getFullYear()}-${pad(at.getMonth() + 1)}-${pad(at.getDate())}T${pad(at.getHours())}:${pad(at.getMinutes())}`; | |
| 172 | } | |
| 173 | ||
| 174 | /** | |
| 175 | * People by id and by username (lowercased), after a word from the feed: | |
| 176 | * all of a workspace (`full`), or some who changed. A later word wins. | |
| 177 | */ | |
| 178 | export function mergePeople( | |
| 179 | people: Record<string, PresenceEntry>, | |
| 180 | incoming: PresenceEntry[], | |
| 181 | full: boolean, | |
| 182 | ): Record<string, PresenceEntry> { | |
| 183 | const next: Record<string, PresenceEntry> = { ...people }; | |
| 184 | let changed = false; | |
| 185 | for (const entry of incoming) { | |
| 186 | if (!entry?.user_id) continue; | |
| 187 | const before = next[entry.user_id]; | |
| 188 | if (before && before.at > entry.at) continue; | |
| 189 | next[entry.user_id] = entry; | |
| 190 | changed = true; | |
| 191 | } | |
| 192 | return changed || full ? next : people; | |
| 193 | } |