| 1 | /** |
| 2 | * When each person was last on a workspace's Home page, kept in their own |
| 3 | * feed (src/feed.ts) so it is the same on every device. Home reads it to |
| 4 | * show what happened while they were away, and marks it once they have |
| 5 | * looked (never on first paint), so a quick refresh does not wipe what |
| 6 | * they have not seen. |
| 7 | * |
| 8 | * Marks close together are one visit: a refresh, or coming back to Home |
| 9 | * from another page a few minutes later, keeps showing what happened |
| 10 | * before the visit began. A visit ends once no mark has come for |
| 11 | * `VISIT_GAP_MS`; the next one shows what happened since its last mark. |
| 12 | */ |
| 13 | |
| 14 | /** The longest slug a visit is kept for. */ |
| 15 | const MAX_SLUG = 64; |
| 16 | /** How far behind now a mark may be: Home marks the time the page loaded, at most a day before. */ |
| 17 | const MAX_BEHIND_MS = 24 * 60 * 60 * 1000; |
| 18 | /** Marks closer than this are one visit. */ |
| 19 | export const VISIT_GAP_MS = 30 * 60 * 1000; |
| 20 | |
| 21 | /** What is kept per workspace: the latest mark, and the last mark of the visit before this one. */ |
| 22 | export type Visit = { seen_at: number; previous_at: number | null }; |
| 23 | |
| 24 | /** A workspace slug as visits key it, or null when it is not one. */ |
| 25 | export function visitWorkspace(value: unknown): string | null { |
| 26 | if (typeof value !== "string") return null; |
| 27 | const slug = value.trim().toLowerCase(); |
| 28 | return slug && slug.length <= MAX_SLUG && /^[a-z0-9][a-z0-9-]*$/.test(slug) ? slug : null; |
| 29 | } |
| 30 | |
| 31 | /** |
| 32 | * The visit to keep after a mark at `at` (RFC 3339 or epoch ms): never |
| 33 | * later than now, never more than a day behind it, and never earlier than |
| 34 | * what is kept, so an old tab marking late does not move it back. Null |
| 35 | * when there is nothing to change. |
| 36 | */ |
| 37 | export function markVisit(kept: Visit | null, at: unknown, now: number): Visit | null { |
| 38 | const given = typeof at === "string" ? Date.parse(at) : typeof at === "number" ? at : Number.NaN; |
| 39 | if (!Number.isFinite(given)) return null; |
| 40 | const mark = Math.min(now, Math.max(given, now - MAX_BEHIND_MS)); |
| 41 | if (!kept) return { seen_at: mark, previous_at: null }; |
| 42 | if (kept.seen_at >= mark) return null; |
| 43 | // The same visit: what it shows still starts where it began. |
| 44 | if (mark - kept.seen_at < VISIT_GAP_MS) return { seen_at: mark, previous_at: kept.previous_at }; |
| 45 | return { seen_at: mark, previous_at: kept.seen_at }; |
| 46 | } |
| 47 | |
| 48 | /** |
| 49 | * When the person was last here, as Home counts from: during a visit, the |
| 50 | * end of the one before it; after one, its last mark. Null when there was |
| 51 | * none before. |
| 52 | */ |
| 53 | export function lastVisit(kept: Visit | null, now: number): number | null { |
| 54 | if (!kept) return null; |
| 55 | return now - kept.seen_at < VISIT_GAP_MS ? kept.previous_at : kept.seen_at; |
| 56 | } |