| // --------------------------------------------------------------------------- | |
| // home/homeModel.ts β WAVE 23 item 9 (ruling R7, contract C10): the Home | |
| // landing's PURE half. React-free and fetch-free, so `verify_home.py` can run it | |
| // under node the way `verify_alerts.py` runs the inbox model. | |
| // | |
| // Everything here is a function of values that were passed in β including the | |
| // clock. `todayStart` and `now` are PARAMETERS, never `Date.now()` calls, for two | |
| // reasons that pull the same way: | |
| // | |
| // Β· a gate can then assert the boundary cases (23:59 vs 00:01, exactly seven | |
| // days) instead of asserting whatever today happens to be; | |
| // Β· [[date-window-vocabulary]]'s standing rule β `today` is a parameter β was | |
| // written for the server's window vocabulary and holds just as well here. | |
| // | |
| // β THE ONE CLOCK THIS FEATURE MAY READ IS THE READER'S OWN, and that is not a | |
| // contradiction of `alertsModel.stampText`'s "never touch a clock". Two different | |
| // statements: a notification re-states WHEN A SERVER EVENT HAPPENED (re-deriving | |
| // it in the browser is how a tenant a day ahead gets told an event happened | |
| // tomorrow), while "Opened 30 minutes ago" states HOW LONG AGO THE READER | |
| // THEMSELVES DID SOMETHING, and the Today bucket means the reader's today. The | |
| // component reads the clock once and hands the numbers in here. | |
| // --------------------------------------------------------------------------- | |
| import type { DatabaseEntry, Recent } from "../shell/nav"; | |
| import type { FolderIcon } from "../customer-grid/types"; | |
| /** One rendered recents tile: the RESOLVED database, never the raw stamp. */ | |
| export interface RecentTile { | |
| key: string; | |
| label: string; | |
| icon?: FolderIcon; | |
| /** Epoch seconds, as stored. Kept so a tile can be re-sorted without re-fetching. */ | |
| at: number; | |
| /** "Opened 30 minutes ago" β already assembled, so the component holds no clock logic. */ | |
| ago: string; | |
| /** The chip's two letters. */ | |
| initials: string; | |
| /** | |
| * The entry's OWN href, carried through rather than rebuilt as `#/${key}`. | |
| * | |
| * β A TILE THAT REBUILDS ITS OWN LINK IS A SECOND ROUTER. `shapeNav` already decided what a | |
| * key resolves to β `#/key` for a native surface, the current application's deep link for a | |
| * hand-off β and a `#/${key}` assembled here would send a hand-off row to a hash this shell | |
| * cannot render. Today only native keys are ever stamped (the frame stamps `active.kind === | |
| * "native"` alone), so this is defence rather than a fix; it costs one field and removes the | |
| * class. | |
| */ | |
| href: string; | |
| /** A hand-off opens the current application in a new tab, and the tile says so. */ | |
| external: boolean; | |
| } | |
| /* β `RecentSection` (today | week | older) IS DELETED WITH ITS BUCKETS β wave 24 item 13 / R10. | |
| Home has ONE database section now, so a section id and a title lookup were two pieces of | |
| machinery describing a shape that no longer varies. */ | |
| const SECOND = 1; | |
| const MINUTE = 60 * SECOND; | |
| const HOUR = 60 * MINUTE; | |
| export const DAY = 24 * HOUR; | |
| /* β `WEEK_DAYS` deleted with the buckets (item 13 / R10). */ | |
| /** | |
| * The chip's two letters. | |
| * | |
| * β `\p{L}\p{N}` AND NOT `[A-Za-z0-9]`, and this shell has already paid for that once: the | |
| * account monogram at `Shell.tsx:299` carries the same note, because the Streamlit host's | |
| * `_account_css` uses Python's `str.isalnum()` and an ASCII-only class here would give a | |
| * non-Latin name an initial in one shell and a blank circle in the other. A database called | |
| * "ΠΠ»ΠΈΠ΅Π½ΡΡ" gets "ΠΠ", not an empty tile. | |
| * | |
| * Two letters, not one: the reference tiles read "Un" / "Aa", and a single letter over a 26px | |
| * chip is a bullet point rather than a name. | |
| */ | |
| export function initials(label: string): string { | |
| const chars = String(label ?? "").match(/[\p{L}\p{N}]/gu) ?? []; | |
| return chars.slice(0, 2).join("").toUpperCase(); | |
| } | |
| /** | |
| * "Opened N ago", in the largest unit that is still true. | |
| * | |
| * Rounds DOWN throughout (`Math.floor`), so a tile never claims more time has passed than has: | |
| * at 119 minutes this says "1 hour ago", never "2 hours ago". A stamp in the FUTURE β a clock | |
| * skew between the browser and the host β clamps to "just now" rather than rendering a negative | |
| * count, which is the only honest thing a relative label can say about it. | |
| */ | |
| export function agoText(at: number, now: number): string { | |
| const d = Math.max(0, Math.floor(now) - Math.floor(at)); | |
| if (d < MINUTE) return "Opened just now"; | |
| const unit = (n: number, one: string) => `Opened ${n} ${one}${n === 1 ? "" : "s"} ago`; | |
| if (d < HOUR) return unit(Math.floor(d / MINUTE), "minute"); | |
| if (d < DAY) return unit(Math.floor(d / HOUR), "hour"); | |
| return unit(Math.floor(d / DAY), "day"); | |
| } | |
| /* β `bucketOf`, `TITLES` and `groupRecents` STOOD HERE AND ARE DELETED (wave 24 item 13, R10). | |
| They bucketed the recents into Today / Past 7 days / Older -- and bucketing RECENTS is exactly | |
| what made a database you had never opened invisible on Home, which is the complaint item 13 | |
| answers. `allDatabases` below replaces them: it starts from `entries` (so the C10 law is | |
| STRUCTURAL now rather than a dropped-key `if`) and uses the stamps only to order. | |
| β SWEPT IN THE SAME CHANGE that removed the last call site, and its TESTS retargeted with it. | |
| `verify_home` stayed 107/107 green over this whole edit because its node section still ran | |
| `groupRecents` directly -- a gate passing on a function the product no longer calls. That is | |
| the GATE-DELTA tell, caught here rather than at close-out. */ | |
| // ββ WAVE 24 item 13 (ruling R10): EVERY database, and EVERY automation ββββββββββββββββββββββ | |
| // | |
| // β WHY THE DATE BUCKETS WENT. The deleted `groupRecents` only ever emitted a tile for a key | |
| // already in the reader's RECENTS bucket, so a database you had never opened did not appear on | |
| // Home at all β which is exactly the complaint. Sorting is the part worth keeping: | |
| // most-recently-opened first is a real ranking and costs nothing. | |
| // | |
| // β THE CLOCK IS STILL A PARAMETER and the C10 law still holds: every tile is resolved from | |
| // `entries`, the shaped server-filtered nav, so Home cannot name a database `/nav` did not send. | |
| // What changed is which of those entries get drawn β all of them now, not just the stamped ones. | |
| /** One automation, as Home draws it. The shape `AutomationSurface`'s rail already computes. */ | |
| export interface AutomationTile { | |
| id: string; | |
| name: string; | |
| /* β WAVE 26 Β· ITEM 18 (R14) β `state` STOOD HERE AND IS DELETED WITH THE DOT IT FED. | |
| It carried the dot's vocabulary (`ok` | `error` | `partial` | `running` | `idle`), computed by | |
| `stateOf` in the frame. R14 removes status colour from the automation module, so nothing reads | |
| it β and a tile field that survives its only reader is how the dot gets rendered back in by | |
| somebody who finds the data already there and assumes it is wanted. Removing it from the TYPE | |
| is what makes that impossible rather than merely unlikely: `tsc` now rejects the producer too, | |
| which is the point (`Shell.tsx` computed it). */ | |
| /** The rail's own second line: "Next β¦" when scheduled, else "Last run β¦", else "Manual only". */ | |
| sub: string; | |
| } | |
| /** | |
| * Every database this account may open, most-recently-opened first, then alphabetical. | |
| * | |
| * β NOT `recents` DRIVEN β `recents` only ORDERS it. A key with no stamp sorts after every | |
| * stamped one and keeps its place alphabetically, which is what makes a never-opened database | |
| * visible for the first time (R10's whole point). | |
| * | |
| * β `ago` IS EMPTY FOR AN UNSTAMPED TILE, deliberately: "Opened just now" is what `agoText` | |
| * returns for a missing stamp (it clamps), and printing that under a database nobody has ever | |
| * opened would be the surface inventing a fact. An empty string renders no line at all. | |
| */ | |
| /* β WAVE 25 (D-54) β `DatabaseEntry[]`, so the function that MAKES the tiles cannot be handed a | |
| list containing a surface. This is where the wave-24 defect actually rendered: `entries` was | |
| the raw nav, the Automation surface is a native entry with an href, and both of this filter's | |
| conditions passed it through. The filter is unchanged and still right for what it tests | |
| (a folder head has no destination); what it never tested is whether a destination is a | |
| DATABASE, which is not a question this module can answer and is now settled before the call. */ | |
| export function allDatabases( | |
| entries: DatabaseEntry[], | |
| recents: Recent[], | |
| now: number | |
| ): RecentTile[] { | |
| const at = new Map(recents.map((r) => [r.key, r.at])); | |
| return entries | |
| .filter((e) => e.kind !== "group" && !!e.href) | |
| .map((e) => { | |
| const stamp = at.get(e.key); | |
| return { | |
| key: e.key, | |
| label: e.label, | |
| ...(e.icon ? { icon: e.icon } : {}), | |
| at: stamp ?? 0, | |
| ago: stamp === undefined ? "" : agoText(stamp, now), | |
| initials: initials(e.label), | |
| href: e.href as string, | |
| external: e.kind === "handoff", | |
| }; | |
| }) | |
| .sort((a, b) => (b.at - a.at) || a.label.localeCompare(b.label)); | |
| } | |
| /** | |
| * The server's recents, plus the stamps THIS SESSION made since the nav was fetched. | |
| * | |
| * β WITHOUT THIS, HOME IS ALWAYS ONE NAVIGATION STALE β and it is the wave's headline feature | |
| * that would be stale. `recents` rides the `GET /nav` payload (one round trip per sign-in, per | |
| * `navEpoch` bump), while the stamp is a fire-and-forget POST that changes nothing the client | |
| * holds. So: open a database, go Home, and the database you just opened is not in Today. The | |
| * alternative β refetching the whole nav on every route change to pick up one integer β is a | |
| * request per navigation for a decoration. | |
| * | |
| * LOCAL WINS ON A KEY, always: it is strictly newer by construction (it happened after the | |
| * fetch). And it is deliberately NOT persisted anywhere β a reload re-asks the server, which is | |
| * the only end that actually knows. | |
| */ | |
| export function mergeRecents(server: Recent[], local: Record<string, number>): Recent[] { | |
| const byKey = new Map<string, number>(); | |
| for (const r of server) byKey.set(r.key, r.at); | |
| for (const [key, at] of Object.entries(local)) { | |
| const cur = byKey.get(key); | |
| if (cur === undefined || at > cur) byKey.set(key, at); | |
| } | |
| return [...byKey.entries()] | |
| .map(([key, at]) => ({ key, at })) | |
| .sort((a, b) => b.at - a.at); | |
| } | |
| // ββ the list/grid toggle ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| export type HomeLayout = "grid" | "list"; | |
| /** Per-browser, like the nav's collapsed state β a display preference, not account state. */ | |
| export const HOME_LAYOUT_KEY = "aios-home-layout"; | |
| /** Anything unrecognised reads as `grid`, which is the reference layout and the denser one. */ | |
| export function parseLayout(raw: unknown): HomeLayout { | |
| return raw === "list" ? "list" : "grid"; | |
| } | |