The floor keeps up with the room
Occupancy was fetched once, on the way in. Somebody sat down and you did not find out until you left the office and came back, which for a view whose entire subject is who is in the building is the point missed by one request. `watchPresence` is `watchWeather`'s shape without the two pieces of judgement that one needs and this does not: weather has to decide whether an observation is too old to be honest about and whether it describes the place you are looking at, and a roster is neither — it is true when it is served, and it is addressed by office id, so it cannot arrive about somewhere else. Thirty seconds, and the number comes from what the data does rather than from what the network will stand. Ten minutes is right for weather because nothing upstream of it moves faster; occupancy moves when a person stands up. Backoff tops out at five minutes rather than the weather watch's hour, because the difference between the two is what the user is doing while the box is down: nobody is staring at the sky waiting for it to be redescribed, and somebody *is* standing in a room they expect to see people arrive in. An hour of silence there reads as a broken feature rather than as a quiet API. Two things it does that the weather watch does not, both because this one runs while a person is looking at the thing it describes. It stops dead while the tab is hidden and asks again the moment it comes back — a backgrounded tab polling a roster nobody can see is waste on both ends, and it is the commonest state a long-lived office tab is in; coming back has to be immediate rather than at the next tick, or you return to a floor up to thirty seconds stale at exactly the moment you are looking hardest. And it publishes only on change, by signature rather than by identity: every answer is a fresh array, so without the comparison a still floor would rebuild its presence meshes twice a minute forever. The watch belongs to the visit and not to the page, which is `weatherWatch`'s rule for a sharper reason — a roster is requested with a credential and names people, so one left running after somebody stepped out to the city is a page quietly asking about a room nobody is looking at. Stopped before the scene swap in `leaveOffice`, so the last thing it can do is abort a request rather than publish into a room already left, and stopped again in `mountCity`, which disposes the office under it. Checked in the browser, both exits. Standing in the office: one ask on arrival, the next 31 s later under failure backoff, not a flood. Stepping out by the `O` key on the office host (a real navigation) and by the scene swap on `?view=office` (which is what actually exercises `stop()`): zero requests in the following 45 s, in both.
This commit is contained in:
@@ -177,6 +177,24 @@ export interface WeatherWatch {
|
|||||||
stop(): void;
|
stop(): void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A running poll of one office's occupancy.
|
||||||
|
*
|
||||||
|
* Same shape as `WeatherWatch` and deliberately so — both are "keep telling me
|
||||||
|
* about this one thing until I leave" — but without the two pieces of judgement
|
||||||
|
* that one carries. Weather has to decide whether an observation is too old to
|
||||||
|
* be honest about and whether it describes the place you are looking at;
|
||||||
|
* occupancy is neither. A roster is true when it is served and meaningless a
|
||||||
|
* moment later, and it is addressed by office id, so it cannot arrive about
|
||||||
|
* somewhere else.
|
||||||
|
*/
|
||||||
|
export interface PresenceWatch {
|
||||||
|
/** Ask now rather than at the next tick. Ignored while a request is in flight. */
|
||||||
|
refresh(): void;
|
||||||
|
/** Stop polling, abort anything in flight, and drop any late answer. */
|
||||||
|
stop(): void;
|
||||||
|
}
|
||||||
|
|
||||||
export interface TeraClient {
|
export interface TeraClient {
|
||||||
/** What the deployment turned out to be, or `null` if there is no server. */
|
/** What the deployment turned out to be, or `null` if there is no server. */
|
||||||
health(): Promise<HealthBody | null>;
|
health(): Promise<HealthBody | null>;
|
||||||
@@ -225,6 +243,16 @@ export interface TeraClient {
|
|||||||
* correct picture of an office nobody has told it about.
|
* correct picture of an office nobody has told it about.
|
||||||
*/
|
*/
|
||||||
presence(officeId: string): Promise<PresenceBody | null>;
|
presence(officeId: string): Promise<PresenceBody | null>;
|
||||||
|
/**
|
||||||
|
* The same question, asked repeatedly, until the caller stops it.
|
||||||
|
*
|
||||||
|
* `null` reaches the callback for every refusal, exactly as the one-shot
|
||||||
|
* does, so a deployment that goes down mid-session is reported rather than
|
||||||
|
* frozen on the last roster it served — the difference between an office that
|
||||||
|
* emptied and an office you have stopped hearing about is one the caller has
|
||||||
|
* to be able to draw.
|
||||||
|
*/
|
||||||
|
watchPresence(officeId: string, onBody: (body: PresenceBody | null) => void): PresenceWatch;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -347,6 +375,10 @@ export function createTeraClient(options: TeraApiOptions = {}): TeraClient {
|
|||||||
*/
|
*/
|
||||||
presence: (officeId) =>
|
presence: (officeId) =>
|
||||||
get<PresenceBody>(`/offices/${encodeURIComponent(officeId)}/presence`),
|
get<PresenceBody>(`/offices/${encodeURIComponent(officeId)}/presence`),
|
||||||
|
|
||||||
|
watchPresence(officeId, onBody) {
|
||||||
|
return watchPresence(get, officeId, onBody);
|
||||||
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1011,3 +1043,161 @@ export function describeLiveness(live: Liveness): string {
|
|||||||
if (parts.length === 3) return "live data";
|
if (parts.length === 3) return "live data";
|
||||||
return `live ${parts.join(" + ")}`;
|
return `live ${parts.join(" + ")}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---- Presence --------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* How often to ask who is in, while somebody is standing in the room.
|
||||||
|
*
|
||||||
|
* Thirty seconds, and the number comes from what the data does rather than from
|
||||||
|
* what the network can stand. Weather is polled every ten minutes because
|
||||||
|
* nothing upstream of it moves faster; occupancy moves when a person stands up,
|
||||||
|
* which is a scale of seconds, and a floor that takes ten minutes to notice
|
||||||
|
* somebody sat down is a floor plan of the recent past. Thirty is close enough
|
||||||
|
* to feel like the room and far enough that a member with the tab open all day
|
||||||
|
* makes about a thousand requests for a few kilobytes each.
|
||||||
|
*/
|
||||||
|
const PRESENCE_INTERVAL_MS = 30_000;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The ceiling on backoff. Five minutes, not the weather watch's hour.
|
||||||
|
*
|
||||||
|
* A box that is down comes back, and the difference between the two watches is
|
||||||
|
* what the user is doing while it is down: nobody is staring at the sky waiting
|
||||||
|
* for it to be redescribed, and somebody *is* standing in a room they expect to
|
||||||
|
* see people arrive in. An hour of silence there reads as a broken feature
|
||||||
|
* rather than as a quiet API.
|
||||||
|
*/
|
||||||
|
const PRESENCE_MAX_INTERVAL_MS = 5 * 60_000;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Poll one office's occupancy until told to stop.
|
||||||
|
*
|
||||||
|
* Two things this does that `watchWeather` does not, both for the same reason —
|
||||||
|
* this one runs while a person is looking at the thing it describes:
|
||||||
|
*
|
||||||
|
* - **It stops dead while the tab is hidden**, and asks again the moment it
|
||||||
|
* comes back. A backgrounded tab polling a roster nobody can see is waste on
|
||||||
|
* both ends, and it is the commonest state a long-lived office tab is in.
|
||||||
|
* Coming back has to be immediate rather than at the next tick, or returning
|
||||||
|
* to the tab shows a floor up to thirty seconds stale at exactly the moment
|
||||||
|
* somebody is looking hardest.
|
||||||
|
* - **It publishes only on change**, by comparing a signature rather than the
|
||||||
|
* object. Every answer is a fresh array, so identity says nothing; without
|
||||||
|
* the comparison a still floor would rebuild its presence meshes twice a
|
||||||
|
* minute forever, which is a visible cost for no information.
|
||||||
|
*/
|
||||||
|
function watchPresence(
|
||||||
|
get: Get,
|
||||||
|
officeId: string,
|
||||||
|
onBody: (body: PresenceBody | null) => void,
|
||||||
|
): PresenceWatch {
|
||||||
|
let failures = 0;
|
||||||
|
let stopped = false;
|
||||||
|
let timer: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
let inFlight: AbortController | null = null;
|
||||||
|
let signature: string | null = null;
|
||||||
|
let published = false;
|
||||||
|
|
||||||
|
const path = `/offices/${encodeURIComponent(officeId)}/presence`;
|
||||||
|
|
||||||
|
function schedule(delayMs: number) {
|
||||||
|
if (stopped) return;
|
||||||
|
if (timer !== null) clearTimeout(timer);
|
||||||
|
timer = setTimeout(() => void tick(), delayMs);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What a body amounts to, for the purpose of "is this news".
|
||||||
|
*
|
||||||
|
* Seat, colour and label, in the order served. Not `observedAt`, which moves
|
||||||
|
* on every poll of an unchanged floor and would defeat the whole comparison,
|
||||||
|
* and not a `JSON.stringify` of the body for the same reason.
|
||||||
|
*/
|
||||||
|
function signatureOf(body: PresenceBody | null): string {
|
||||||
|
if (body === null) return " | ||||||