-
-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathengine.js
More file actions
171 lines (152 loc) · 5.7 KB
/
Copy pathengine.js
File metadata and controls
171 lines (152 loc) · 5.7 KB
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
// engine.js -- the live session, in the browser.
//
// On localhost, tracker.py holds the session in memory and streams it over SSE.
// With no backend (e.g. served statically from GitHub Pages), this engine plays
// the same role entirely client-side: it holds the roster, applies every UI
// action, and persists to localStorage so a refresh mid-meeting keeps the slate.
// app.js picks between this and the server transport at startup.
//
// It is a faithful port of tracker.py's State for the operations the UI drives:
// add / remove / introduced / prompt / order / randomize / reset. Zoom
// auto-reading has no browser equivalent, so hosted mode is manual-entry only —
// there is no host flag, which the shared ordering helpers already handle.
//
// No DOM or globals are touched, so it is deterministic and unit-testable (see
// tests/engine.test.js). subscribe(fn) calls back immediately with the current
// snapshot and again after every mutation, mirroring the SSE echo.
import { applyOrder, cloneRoster, findHostId, randomizeOrder } from "./session.js";
const STORE_KEY = "icebreaker.session.v1";
// A stored participant is usable if it looks like what commit() writes.
// Anything else — nulls or foreign shapes from a corrupted write, or another
// page sharing this origin's localStorage (e.g. project sites on
// username.github.io) — is dropped rather than adopted and re-persisted, since
// a null entry would make every later mutation throw on `p.id` and a missing
// joinTime would render as "Invalid Date".
const isParticipant = (p) =>
p !== null &&
typeof p === "object" &&
typeof p.id === "string" &&
typeof p.name === "string" &&
typeof p.joinTime === "number";
// Coerce the remaining fields to the exact shape commit() writes, so nothing
// downstream meets a foreign type.
const sanitizeParticipant = (p) => ({
id: p.id,
name: p.name,
joinTime: p.joinTime,
leftTime: typeof p.leftTime === "number" ? p.leftTime : null,
present: Boolean(p.present),
introduced: Boolean(p.introduced),
is_host: Boolean(p.is_host),
});
// localStorage may be blocked (private mode, embedded contexts). Both helpers
// degrade silently: a blocked read starts a fresh session, a blocked write just
// means this session won't survive a reload — the meeting still works.
function loadSaved(key) {
try {
const data = JSON.parse(localStorage.getItem(key) || "null");
if (!data || !Array.isArray(data.participants)) return null;
return {
startedAt: typeof data.startedAt === "number" ? data.startedAt : null,
prompt: typeof data.prompt === "string" ? data.prompt : "",
participants: data.participants.filter(isParticipant).map(sanitizeParticipant),
};
} catch {
return null;
}
}
function persist(key, data) {
try {
localStorage.setItem(key, JSON.stringify(data));
} catch {
/* storage unavailable — session simply won't survive a reload */
}
}
export function createEngine({ key = STORE_KEY, now = Date.now } = {}) {
const saved = loadSaved(key);
let startedAt = saved?.startedAt ?? now();
let prompt = saved?.prompt ?? "";
let participants = saved?.participants ?? [];
let seq = 0;
const listeners = new Set();
// No one is flagged host in manual/hosted mode, but resolve it anyway so the
// ordering helpers stay correct if a saved session ever carried one.
const hostId = () => findHostId(participants);
const snapshot = () => ({ startedAt, prompt, participants: cloneRoster(participants) });
const emit = () => {
const snap = snapshot();
for (const fn of listeners) fn(snap);
return snap;
};
// Save, then echo the new snapshot to every subscriber — the local stand-in
// for tracker.py's broadcast(). Returns the snapshot for direct callers/tests.
const commit = () => {
persist(key, { startedAt, prompt, participants });
return emit();
};
// Another tab of this origin may commit to the same key (the host often
// opens one tab to screen-share and one to drive). Adopt those writes so a
// stale tab reflects them instead of clobbering them with its own old state
// on its next action. The storage event only fires in the tabs that did NOT
// write, so this never loops.
if (typeof window !== "undefined") {
window.addEventListener("storage", (e) => {
if (e.key !== key) return;
const next = loadSaved(key);
if (!next) return;
startedAt = next.startedAt ?? startedAt;
prompt = next.prompt;
participants = next.participants;
emit();
});
}
return {
snapshot,
subscribe(fn) {
listeners.add(fn);
fn(snapshot());
},
add(name) {
const nm = String(name ?? "").trim();
if (nm) {
participants.push({
id: `m${now().toString(36)}${(seq++).toString(36)}`,
name: nm,
joinTime: now(),
leftTime: null,
present: true,
introduced: false,
is_host: false,
});
}
return commit();
},
remove(pid) {
participants = participants.filter((p) => p.id !== pid);
return commit();
},
setIntroduced(pid, val) {
const p = participants.find((x) => x.id === pid);
if (p) p.introduced = Boolean(val);
return commit();
},
setPrompt(next) {
prompt = String(next ?? "").trim();
return commit();
},
setOrder(orderList) {
participants = applyOrder(participants, orderList ?? [], hostId());
return commit();
},
randomize() {
participants = randomizeOrder(participants, hostId(), Math.random);
return commit();
},
// Clean slate for the next round; keep the prompt, like State.reset.
reset() {
startedAt = now();
participants = [];
return commit();
},
};
}