Skip to content
195 linesCodeBlameRaw
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 */
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" | "2h" | "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: "2h", label: "For 2 hours" },
45 { key: "tomorrow", label: "Until tomorrow" },
46];
47
48const MINUTE = 60_000;
49
50/** The next midnight after `now`, the viewer's. */
51function nextMidnight(now: Date): Date {
52 const at = new Date(now);
53 at.setHours(24, 0, 0, 0);
54 return at;
55}
56
57/**
58 * When a status set now clears, as an instant; null keeps it until
59 * changed. `custom` is a `datetime-local` value, in the viewer's time.
60 */
61export function clearAtFor(choice: ClearAfter, now: Date, custom?: string | null): string | null {
62 switch (choice) {
63 case "30m":
64 return new Date(now.getTime() + 30 * MINUTE).toISOString();
65 case "1h":
66 return new Date(now.getTime() + 60 * MINUTE).toISOString();
67 case "4h":
68 return new Date(now.getTime() + 240 * MINUTE).toISOString();
69 case "today":
70 return nextMidnight(now).toISOString();
71 case "week": {
72 // The end of Sunday: the coming Monday's midnight.
73 const at = nextMidnight(now);
74 while (at.getDay() !== 1) at.setDate(at.getDate() + 1);
75 return at.toISOString();
76 }
77 case "never":
78 return null;
79 case "custom": {
80 const at = custom ? new Date(custom) : null;
81 return at && Number.isFinite(at.getTime()) && at.getTime() > now.getTime() ? at.toISOString() : null;
82 }
83 }
84}
85
86/** When notifications resume: in half an hour, an hour, two hours, or at nine tomorrow morning. */
87export function pauseUntil(choice: PauseFor, now: Date): string {
88 if (choice === "30m") return new Date(now.getTime() + 30 * MINUTE).toISOString();
89 if (choice === "1h") return new Date(now.getTime() + 60 * MINUTE).toISOString();
90 if (choice === "2h") return new Date(now.getTime() + 120 * MINUTE).toISOString();
91 const at = nextMidnight(now);
92 at.setHours(9, 0, 0, 0);
93 return at.toISOString();
94}
95
96/** Whether an instant is still ahead. */
97function ahead(value: string | null | undefined, now: number): boolean {
98 if (!value) return false;
99 const at = Date.parse(value);
100 return Number.isFinite(at) && at > now;
101}
102
103/** A status as it stands now: gone once its time has passed, even before the feed says so. */
104export function liveStatus(status: PersonStatus | null | undefined, now: number): PersonStatus | null {
105 if (!status) return null;
106 return status.clear_at && !ahead(status.clear_at, now) ? null : status;
107}
108
109export function dndOn(entry: Pick<PresenceEntry, "dnd_until"> | null | undefined, now: number): boolean {
110 return ahead(entry?.dnd_until, now);
111}
112
113/** What a person's dot shows. */
114export type Shown = "active" | "away" | "dnd" | "offline";
115
116export function shownAs(entry: Pick<PresenceEntry, "presence" | "dnd_until"> | null | undefined, now: number): Shown {
117 if (!entry || entry.presence === "offline") return "offline";
118 if (dndOn(entry, now)) return "dnd";
119 return entry.presence;
120}
121
122export const SHOWN_LABEL: Record<Shown, string> = {
123 active: "Active",
124 away: "Away",
125 dnd: "Notifications paused",
126 offline: "Offline",
127};
128
129export function presenceLabel(presence: Presence): string {
130 return SHOWN_LABEL[presence];
131}
132
133/**
134 * "until 3:30 PM", "until tomorrow 9:00 AM", "until Mon 9:00 AM" or
135 * "until 12 Oct": short words for when something ends, the viewer's time.
136 */
137export function untilLabel(value: string, now: Date, locale?: string): string {
138 const at = new Date(value);
139 if (!Number.isFinite(at.getTime())) return "";
140 const clock = at.toLocaleTimeString(locale, { hour: "numeric", minute: "2-digit" });
141 const day = (d: Date) => new Date(d.getFullYear(), d.getMonth(), d.getDate()).getTime();
142 const days = Math.round((day(at) - day(now)) / (24 * 3600 * 1000));
143 // Midnight is the end of the day before: "until tomorrow" reads better as "today".
144 const midnight = at.getHours() === 0 && at.getMinutes() === 0;
145 if (days === 0) return `until ${clock}`;
146 if (days === 1 && midnight) return "until the end of today";
147 if (days === 1) return `until tomorrow ${clock}`;
148 if (days <= 7 && midnight) return `until the end of ${new Date(at.getTime() - 1).toLocaleDateString(locale, { weekday: "long" })}`;
149 if (days < 7) return `until ${at.toLocaleDateString(locale, { weekday: "short" })} ${clock}`;
150 return `until ${at.toLocaleDateString(locale, { day: "numeric", month: "short" })}`;
151}
152
153/** A status in words, for screen readers and hints: "🗓️ In a meeting, until 3:30 PM". */
154export function statusWords(status: PersonStatus, now: Date, locale?: string): string {
155 const text = [status.emoji, status.text].filter(Boolean).join(" ");
156 return status.clear_at ? `${text}, ${untilLabel(status.clear_at, now, locale)}` : text;
157}
158
159/** Whether a tab is idle: no input for `IDLE_MS`. */
160export function isIdle(lastInput: number, now: number): boolean {
161 return now - lastInput >= IDLE_MS;
162}
163
164/** A custom emoji's `:name:`, or null for an ordinary emoji. */
165export function customEmojiName(emoji: string | null | undefined): string | null {
166 const match = emoji ? /^:([a-z0-9_+-]{1,64}):$/.exec(emoji) : null;
167 return match ? match[1]! : null;
168}
169
170/** A `datetime-local` value for an instant, in the viewer's time: what the custom time field starts at. */
171export function localInputValue(at: Date): string {
172 const pad = (n: number) => String(n).padStart(2, "0");
173 return `${at.getFullYear()}-${pad(at.getMonth() + 1)}-${pad(at.getDate())}T${pad(at.getHours())}:${pad(at.getMinutes())}`;
174}
175
176/**
177 * People by id and by username (lowercased), after a word from the feed:
178 * all of a workspace (`full`), or some who changed. A later word wins.
179 */
180export function mergePeople(
181 people: Record<string, PresenceEntry>,
182 incoming: PresenceEntry[],
183 full: boolean,
184): Record<string, PresenceEntry> {
185 const next: Record<string, PresenceEntry> = { ...people };
186 let changed = false;
187 for (const entry of incoming) {
188 if (!entry?.user_id) continue;
189 const before = next[entry.user_id];
190 if (before && before.at > entry.at) continue;
191 next[entry.user_id] = entry;
192 changed = true;
193 }
194 return changed || full ? next : people;
195}