| 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 | } |