loopable / web /src /home /homeModel.ts
fsanyoto's picture
Deploy AIOS web (React glide grid + FastAPI slice)
bf8519f verified
Raw
History Blame Contribute Delete
11.1 kB
// ---------------------------------------------------------------------------
// 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";
}