File size: 11,079 Bytes
bf8519f
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
// ---------------------------------------------------------------------------
// 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";
}