Skip to content
193 linesCodeBlameRaw

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 managers1/**
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 */
11import 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. */
14export const IDLE_MS = 10 * 60_000;
15
16export type ClearAfter = "30m" | "1h" | "4h" | "today" | "week" | "never" | "custom";
17
18export 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
28export type StatusPreset = { emoji: string; text: string; clear: ClearAfter };
29
30/** The statuses offered first: one click each. */
31export 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
39export type PauseFor = "30m" | "1h" | "tomorrow";
40
41export 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
47const MINUTE = 60_000;
48
49/** The next midnight after `now`, the viewer's. */
50function 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 */
60export 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. */
86export 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. */
95function 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. */
102export 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
107export 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. */
112export type Shown = "active" | "away" | "dnd" | "offline";
113
114export 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
120export const SHOWN_LABEL: Record<Shown, string> = {
121 active: "Active",
122 away: "Away",
123 dnd: "Notifications paused",
124 offline: "Offline",
125};
126
127export 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 */
135export 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". */
152export 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`. */
158export 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. */
163export 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. */
169export 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 */
178export 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}