paperlint 14.1.0 → 14.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/skills/build-label-page/scripts/label-page-core.d.ts +43 -30
- package/package.json +1 -1
- package/skills/build-label-page/SKILL.md +15 -1
- package/skills/build-label-page/SKILL.md.spec.ts +14 -0
- package/skills/build-label-page/references/closed-choice-generator.md +42 -0
- package/skills/build-label-page/scripts/label-page-answerers.test.ts +381 -0
- package/skills/build-label-page/scripts/label-page-browser.test.ts +309 -0
- package/skills/build-label-page/scripts/label-page-core.js +206 -58
- package/skills/build-label-page/scripts/label-page-core.test.ts +24 -9
- package/skills/build-label-page/scripts/label-page-core.ts +333 -77
- package/skills/build-label-page/scripts/label-page.js +5 -0
- package/skills/build-label-page/scripts/label-page.ts +5 -0
- package/skills/build-label-page/scripts/page-template.html +205 -18
|
@@ -41,6 +41,11 @@
|
|
|
41
41
|
* "seed": 7, fixes the order; change it and every id changes
|
|
42
42
|
* "groups": {"python": "Python"},
|
|
43
43
|
* "labeller_knows": ["python"], optional: items of other groups are not put on the page
|
|
44
|
+
* "answerers": ["author", "second"], optional, 2 or more: each answers under their own name;
|
|
45
|
+
* documents become `<answerer>~<id>`; an item's own
|
|
46
|
+
* `answerers` (a subset) shows it to those only
|
|
47
|
+
* "min_seconds": 30, optional: an answer saves only after its card was on
|
|
48
|
+
* screen that long; stored as `seconds`
|
|
44
49
|
* "familiarity": true, optional: a "how well do you know" question per group;
|
|
45
50
|
* left out, none is asked (an approval page)
|
|
46
51
|
* "familiarity_question": "How well do you know {group}?", optional
|
|
@@ -41,6 +41,11 @@
|
|
|
41
41
|
* "seed": 7, fixes the order; change it and every id changes
|
|
42
42
|
* "groups": {"python": "Python"},
|
|
43
43
|
* "labeller_knows": ["python"], optional: items of other groups are not put on the page
|
|
44
|
+
* "answerers": ["author", "second"], optional, 2 or more: each answers under their own name;
|
|
45
|
+
* documents become `<answerer>~<id>`; an item's own
|
|
46
|
+
* `answerers` (a subset) shows it to those only
|
|
47
|
+
* "min_seconds": 30, optional: an answer saves only after its card was on
|
|
48
|
+
* screen that long; stored as `seconds`
|
|
44
49
|
* "familiarity": true, optional: a "how well do you know" question per group;
|
|
45
50
|
* left out, none is asked (an approval page)
|
|
46
51
|
* "familiarity_question": "How well do you know {group}?", optional
|
|
@@ -103,6 +103,8 @@ button.more { width: auto; min-height: 44px; border: 0; color: var(--accent); pa
|
|
|
103
103
|
.setting { display: flex; flex-direction: column; gap: 2px; padding-top: 12px; border-top: 1px solid var(--line); }
|
|
104
104
|
.match.yes { color: var(--good); }
|
|
105
105
|
.match.no { color: var(--bad); }
|
|
106
|
+
.who { display: flex; align-items: center; justify-content: space-between; gap: 12px; color: var(--muted); }
|
|
107
|
+
.who button { width: auto; min-height: 44px; border: 0; color: var(--accent); padding: 0 4px; }
|
|
106
108
|
</style>
|
|
107
109
|
<main id="app"><p class="muted">Loading…</p></main>
|
|
108
110
|
<script type="application/json" id="data">__DATA__</script>
|
|
@@ -129,13 +131,148 @@ button.more { width: auto; min-height: 44px; border: 0; color: var(--accent); pa
|
|
|
129
131
|
failed: {}, // item/group key -> error code of the last refused write
|
|
130
132
|
loaded: { answers: false, groups: false, practice: false },
|
|
131
133
|
store: "loading", // loading | ok | none
|
|
132
|
-
filter: "all"
|
|
134
|
+
filter: "all",
|
|
135
|
+
who: null, // the answerer picked, on a page with DATA.answerers; null until one is
|
|
136
|
+
raw: { answers: {}, groups: {}, practice: {} }, // every document of the store, every answerer's
|
|
137
|
+
dwell: {}, // item id -> ms its card was on screen, clocks stopped (DATA.min_seconds)
|
|
138
|
+
seenSince: {}, // item id -> when its card came on screen, while it is
|
|
139
|
+
firstSeconds: {}, // item id -> the seconds this page stored with the item's first save
|
|
140
|
+
gates: {} // item id -> the locked buttons and the countdown of the last render
|
|
133
141
|
};
|
|
134
142
|
var db = null;
|
|
135
143
|
var chains = {};
|
|
136
144
|
var WHY_PAUSE_MS = 800;
|
|
137
145
|
|
|
138
146
|
try { if (localStorage.getItem("label-filter") === "next") state.filter = "next"; } catch (e) {}
|
|
147
|
+
// ── who is answering (DATA.answerers): each answerer's documents are "<answerer>~<id>" ─────
|
|
148
|
+
// "~" is outside the build's name pattern, so no answerer, item, group or practice id holds it.
|
|
149
|
+
var SEP = "~";
|
|
150
|
+
var ANSWERERS = DATA.answerers || [];
|
|
151
|
+
var ASKS_WHO = ANSWERERS.length > 0;
|
|
152
|
+
try {
|
|
153
|
+
var remembered = localStorage.getItem("label-answerer");
|
|
154
|
+
if (ANSWERERS.indexOf(remembered) !== -1) state.who = remembered;
|
|
155
|
+
} catch (e) {}
|
|
156
|
+
// The store's id of one of this page's documents: as before on a page without answerers.
|
|
157
|
+
function docName(id) { return ASKS_WHO ? state.who + SEP + id : id; }
|
|
158
|
+
// The documents of the current answerer, by their names without the answerer.
|
|
159
|
+
function mineOf(docs) {
|
|
160
|
+
if (!ASKS_WHO) return docs;
|
|
161
|
+
var out = {}, prefix = state.who + SEP;
|
|
162
|
+
if (state.who) Object.keys(docs).forEach(function (k) { if (k.indexOf(prefix) === 0) out[k.slice(prefix.length)] = docs[k]; });
|
|
163
|
+
return out;
|
|
164
|
+
}
|
|
165
|
+
// The items shown to the current answerer: an item's own `answerers`, else everyone's.
|
|
166
|
+
function itemsShown() {
|
|
167
|
+
return DATA.items.filter(function (i) { return !i.answerers || i.answerers.indexOf(state.who) !== -1; });
|
|
168
|
+
}
|
|
169
|
+
function groupsShown() { return DATA.groups.filter(function (g) { return groupItems(g.id).length > 0; }); }
|
|
170
|
+
function busy() {
|
|
171
|
+
return Object.keys(state.inflight).some(function (k) { return state.inflight[k] > 0; }) || Object.keys(state.whyTimers).length > 0;
|
|
172
|
+
}
|
|
173
|
+
// Picking (or switching) the answerer: everything this page holds of the last one is dropped.
|
|
174
|
+
function choose(name) {
|
|
175
|
+
if (busy()) return;
|
|
176
|
+
Object.keys(state.seenSince).forEach(stopClock);
|
|
177
|
+
state.who = name;
|
|
178
|
+
try {
|
|
179
|
+
if (name) localStorage.setItem("label-answerer", name);
|
|
180
|
+
else localStorage.removeItem("label-answerer");
|
|
181
|
+
} catch (e) {}
|
|
182
|
+
["editing", "mineWhy", "draftWhy", "pending", "refusedWhy", "mine", "failed", "dwell", "seenSince", "firstSeconds"]
|
|
183
|
+
.forEach(function (k) { state[k] = {}; });
|
|
184
|
+
state.lastAnswered = null;
|
|
185
|
+
["answers", "groups", "practice"].forEach(function (c) { state[c] = mineOf(state.raw[c]); });
|
|
186
|
+
render();
|
|
187
|
+
}
|
|
188
|
+
// ── how long a card was on screen (DATA.min_seconds): its answer saves only after that long ──
|
|
189
|
+
// On screen: half the card in view, or half the viewport filled by a card taller than it.
|
|
190
|
+
var MIN_SECONDS = DATA.min_seconds || 0;
|
|
191
|
+
function dwellMs(id) {
|
|
192
|
+
var ms = state.dwell[id] || 0;
|
|
193
|
+
return state.seenSince[id] !== undefined ? ms + Date.now() - state.seenSince[id] : ms;
|
|
194
|
+
}
|
|
195
|
+
function stopClock(id) {
|
|
196
|
+
if (state.seenSince[id] === undefined) return;
|
|
197
|
+
state.dwell[id] = dwellMs(id);
|
|
198
|
+
delete state.seenSince[id];
|
|
199
|
+
}
|
|
200
|
+
function startClock(id) { if (state.seenSince[id] === undefined) state.seenSince[id] = Date.now(); }
|
|
201
|
+
// An item already saved (by this page or in the store) is never locked again.
|
|
202
|
+
function unsaved(id) { return !state.answers[id] && state.firstSeconds[id] === undefined; }
|
|
203
|
+
function locked(id) { return MIN_SECONDS > 0 && unsaved(id) && dwellMs(id) < MIN_SECONDS * 1000; }
|
|
204
|
+
function waitText(id) {
|
|
205
|
+
return "\u23f3 You can answer in " + Math.ceil((MIN_SECONDS * 1000 - dwellMs(id)) / 1000) + " s";
|
|
206
|
+
}
|
|
207
|
+
// The seconds stored with an item: those of its first save, kept on every later write.
|
|
208
|
+
function secondsOf(id) {
|
|
209
|
+
var d = state.answers[id];
|
|
210
|
+
if (d && typeof d.seconds === "number") return d.seconds;
|
|
211
|
+
if (state.firstSeconds[id] === undefined) state.firstSeconds[id] = Math.floor(dwellMs(id) / 1000);
|
|
212
|
+
return state.firstSeconds[id];
|
|
213
|
+
}
|
|
214
|
+
// An observer reports only when a card crosses one of its thresholds, so each card gets its own
|
|
215
|
+
// observer whose threshold is that card's share: half the card, or half the viewport for a card
|
|
216
|
+
// taller than it. The share follows the card's height and the viewport's: a card that changes
|
|
217
|
+
// size, or a resized window, is watched again with its new share.
|
|
218
|
+
var observers = [];
|
|
219
|
+
var sizes = null;
|
|
220
|
+
function share(h) { return h > 0 ? Math.min(h, window.innerHeight) / 2 / h : 0.5; }
|
|
221
|
+
function onScreen(entry) {
|
|
222
|
+
var h = entry.boundingClientRect.height;
|
|
223
|
+
return entry.isIntersecting && h > 0 &&
|
|
224
|
+
entry.intersectionRect.height >= Math.min(h, window.innerHeight) / 2 - 0.5;
|
|
225
|
+
}
|
|
226
|
+
function watchCard(card) {
|
|
227
|
+
var id = card.getAttribute("data-id");
|
|
228
|
+
var o = new IntersectionObserver(function (entries) {
|
|
229
|
+
entries.forEach(function (e) {
|
|
230
|
+
if (onScreen(e) && document.visibilityState !== "hidden") startClock(id);
|
|
231
|
+
else stopClock(id);
|
|
232
|
+
});
|
|
233
|
+
}, { threshold: [0, share(card.getBoundingClientRect().height)] });
|
|
234
|
+
o.observe(card);
|
|
235
|
+
return o;
|
|
236
|
+
}
|
|
237
|
+
// Every render draws new cards: the clocks stop, and the new cards are watched from scratch.
|
|
238
|
+
function watchCards() {
|
|
239
|
+
observers.forEach(function (o) { o.disconnect(); });
|
|
240
|
+
observers = [];
|
|
241
|
+
if (sizes) sizes.disconnect();
|
|
242
|
+
sizes = null;
|
|
243
|
+
var cards = Array.prototype.slice.call(document.querySelectorAll("article.item[data-id]"));
|
|
244
|
+
if (typeof IntersectionObserver !== "function") {
|
|
245
|
+
// No way to tell what is in view: a card counts as on screen from the moment it is drawn.
|
|
246
|
+
cards.forEach(function (c) { startClock(c.getAttribute("data-id")); });
|
|
247
|
+
return;
|
|
248
|
+
}
|
|
249
|
+
observers = cards.map(watchCard);
|
|
250
|
+
if (typeof ResizeObserver !== "function") return;
|
|
251
|
+
var heights = cards.map(function (c) { return c.getBoundingClientRect().height; });
|
|
252
|
+
sizes = new ResizeObserver(function (entries) {
|
|
253
|
+
entries.forEach(function (e) {
|
|
254
|
+
var i = cards.indexOf(e.target);
|
|
255
|
+
var h = e.target.getBoundingClientRect().height;
|
|
256
|
+
if (i === -1 || Math.abs(h - heights[i]) < 1) return;
|
|
257
|
+
heights[i] = h;
|
|
258
|
+
observers[i].disconnect();
|
|
259
|
+
observers[i] = watchCard(cards[i]);
|
|
260
|
+
});
|
|
261
|
+
});
|
|
262
|
+
cards.forEach(function (c) { sizes.observe(c); });
|
|
263
|
+
}
|
|
264
|
+
// The countdown on each locked card, and its buttons let go when its time is up; no render, so
|
|
265
|
+
// a section the labeller opened stays open.
|
|
266
|
+
function tick() {
|
|
267
|
+
Object.keys(state.gates).forEach(function (id) {
|
|
268
|
+
var g = state.gates[id];
|
|
269
|
+
if (locked(id)) { g.note.textContent = waitText(id); return; }
|
|
270
|
+
g.note.textContent = "";
|
|
271
|
+
g.buttons.forEach(function (b) { b.disabled = !canWrite(); });
|
|
272
|
+
delete state.gates[id];
|
|
273
|
+
});
|
|
274
|
+
setTimeout(tick, 250);
|
|
275
|
+
}
|
|
139
276
|
|
|
140
277
|
// ── what "done" means ────────────────────────────────────────────────────────────────────
|
|
141
278
|
function answersOf(id) {
|
|
@@ -184,7 +321,7 @@ button.more { width: auto; min-height: 44px; border: 0; color: var(--accent); pa
|
|
|
184
321
|
var d = state.mine["group:" + g] || state.groups[g];
|
|
185
322
|
return d && valid(DATA.allowed.familiarity, d.familiarity) ? d.familiarity : undefined;
|
|
186
323
|
}
|
|
187
|
-
function groupItems(g) { return
|
|
324
|
+
function groupItems(g) { return itemsShown().filter(function (i) { return i.group === g; }); }
|
|
188
325
|
// ── the practice (rule 8): known answers, before the real items ──────────────────────────
|
|
189
326
|
function practiceDoc(pid) { return "r" + DATA.practice.round + "-" + pid; }
|
|
190
327
|
function practiceAnswers(pid) {
|
|
@@ -209,7 +346,7 @@ button.more { width: auto; min-height: 44px; border: 0; color: var(--accent); pa
|
|
|
209
346
|
function write(key, coll, docId, body) {
|
|
210
347
|
state.inflight[key] = (state.inflight[key] || 0) + 1;
|
|
211
348
|
delete state.failed[key];
|
|
212
|
-
var step = function () { return db.collection(coll).doc(docId).set(body); };
|
|
349
|
+
var step = function () { return db.collection(coll).doc(docName(docId)).set(body); };
|
|
213
350
|
var run = (chains[key] || Promise.resolve()).then(step, step);
|
|
214
351
|
chains[key] = run.then(function () {
|
|
215
352
|
state.inflight[key] -= 1;
|
|
@@ -223,7 +360,8 @@ button.more { width: auto; min-height: 44px; border: 0; color: var(--accent); pa
|
|
|
223
360
|
});
|
|
224
361
|
}
|
|
225
362
|
function canWrite() {
|
|
226
|
-
return db && state.loaded.answers && state.loaded.groups && state.loaded.practice && state.store === "ok"
|
|
363
|
+
return db && state.loaded.answers && state.loaded.groups && state.loaded.practice && state.store === "ok" &&
|
|
364
|
+
(!ASKS_WHO || state.who !== null);
|
|
227
365
|
}
|
|
228
366
|
// An answer that asks why is not written while its reason is empty (#380): it waits on the page, and
|
|
229
367
|
// the store keeps what it had, so an export never meets an answer without the reason it needs. Every
|
|
@@ -256,11 +394,13 @@ button.more { width: auto; min-height: 44px; border: 0; color: var(--accent); pa
|
|
|
256
394
|
else delete state.pending[id];
|
|
257
395
|
delete state.refusedWhy[id];
|
|
258
396
|
state.mine[id] = ready;
|
|
259
|
-
|
|
397
|
+
var body = { answers: ready, reasons: reasons, savedAt: new Date().toISOString(), source: "page" };
|
|
398
|
+
if (MIN_SECONDS) body.seconds = secondsOf(id);
|
|
399
|
+
write(id, "answers", id, body);
|
|
260
400
|
render();
|
|
261
401
|
}
|
|
262
402
|
function answer(id, qid, value) {
|
|
263
|
-
if (!canWrite()) return;
|
|
403
|
+
if (!canWrite() || locked(id)) return;
|
|
264
404
|
var next = Object.assign({}, answersOf(id));
|
|
265
405
|
next[qid] = value;
|
|
266
406
|
state.lastAnswered = id;
|
|
@@ -385,13 +525,20 @@ button.more { width: auto; min-height: 44px; border: 0; color: var(--accent); pa
|
|
|
385
525
|
}
|
|
386
526
|
function itemCard(item, off) {
|
|
387
527
|
var card = el("article", "item");
|
|
388
|
-
|
|
528
|
+
var shown = itemsShown();
|
|
529
|
+
if (MIN_SECONDS && unsaved(item.id)) card.setAttribute("data-id", item.id);
|
|
530
|
+
card.appendChild(el("div", "id", fill(DATA.labels.place, { id: item.id, n: shown.indexOf(item) + 1, total: shown.length })));
|
|
389
531
|
evidence(card, item);
|
|
390
532
|
var a = answersOf(item.id);
|
|
391
|
-
|
|
533
|
+
var gate = locked(item.id);
|
|
534
|
+
var ask = askOne(card, a, state.editing[item.id], function (q, v) {
|
|
392
535
|
delete state.editing[item.id];
|
|
393
536
|
answer(item.id, q.id, v);
|
|
394
|
-
}, function (q) { state.editing[item.id] = q.id; render(); }, off, false, item.situation);
|
|
537
|
+
}, function (q) { state.editing[item.id] = q.id; render(); }, off || gate, false, item.situation);
|
|
538
|
+
if (gate && ask) {
|
|
539
|
+
var note = ask.appendChild(el("p", "status missing", waitText(item.id)));
|
|
540
|
+
state.gates[item.id] = { note: note, buttons: Array.prototype.slice.call(ask.querySelectorAll("button")) };
|
|
541
|
+
}
|
|
395
542
|
DATA.questions.forEach(function (q) {
|
|
396
543
|
var o = q.options.filter(function (x) { return x.value === a[q.id] && x.ask_why; })[0];
|
|
397
544
|
if (!o) return;
|
|
@@ -594,12 +741,13 @@ button.more { width: auto; min-height: 44px; border: 0; color: var(--accent); pa
|
|
|
594
741
|
}
|
|
595
742
|
card.appendChild(line);
|
|
596
743
|
});
|
|
597
|
-
if (!asked) return;
|
|
744
|
+
if (!asked) return null;
|
|
598
745
|
var ask = el("div", "ask");
|
|
599
746
|
// A card's situation asks the page's first question in its own words; the others keep theirs.
|
|
600
747
|
ask.appendChild(situation && asked === DATA.questions[0] ? situated("p", "q", situation.question, situation.line) : rich("p", "q", asked.text));
|
|
601
748
|
ask.appendChild(options(asked.options, a[asked.id], function (v) { onPick(asked, v); }, off, withExamples));
|
|
602
749
|
card.appendChild(ask);
|
|
750
|
+
return ask;
|
|
603
751
|
}
|
|
604
752
|
// Open until the practice is done; then folded into a toggle, so the cards start near the top.
|
|
605
753
|
function howTo() {
|
|
@@ -706,14 +854,43 @@ button.more { width: auto; min-height: 44px; border: 0; color: var(--accent); pa
|
|
|
706
854
|
// Under "Next unanswered" the save that completes an item would hide it, box and all, mid-sentence:
|
|
707
855
|
// the item being typed in stays on the page until the labeller leaves its box.
|
|
708
856
|
state.typingIn = typing ? typing.key.slice(0, typing.key.indexOf("|")) : null;
|
|
857
|
+
Object.keys(state.seenSince).forEach(stopClock);
|
|
858
|
+
state.gates = {};
|
|
709
859
|
paint();
|
|
860
|
+
if (MIN_SECONDS) watchCards();
|
|
710
861
|
var again = typing && state.whyBoxes[typing.key];
|
|
711
862
|
if (again) { again.focus(); again.setSelectionRange(typing.start, typing.end); }
|
|
712
863
|
}
|
|
864
|
+
// The answerer's choice, before anything else of the page.
|
|
865
|
+
function whoBlock() {
|
|
866
|
+
var sec = el("section");
|
|
867
|
+
sec.appendChild(el("h2", "", "Who is answering?"));
|
|
868
|
+
sec.appendChild(el("p", "muted", "Each person answers under their own name and sees only their own answers."));
|
|
869
|
+
var row = el("div", "opts");
|
|
870
|
+
ANSWERERS.forEach(function (name) {
|
|
871
|
+
var b = el("button", "", name);
|
|
872
|
+
b.type = "button";
|
|
873
|
+
b.addEventListener("click", function () { choose(name); });
|
|
874
|
+
row.appendChild(b);
|
|
875
|
+
});
|
|
876
|
+
sec.appendChild(row);
|
|
877
|
+
return sec;
|
|
878
|
+
}
|
|
879
|
+
function whoLine() {
|
|
880
|
+
var line = el("p", "who");
|
|
881
|
+
line.appendChild(el("span", "", "Answering as " + state.who));
|
|
882
|
+
var b = el("button", "", "Switch");
|
|
883
|
+
b.type = "button";
|
|
884
|
+
b.disabled = busy();
|
|
885
|
+
b.addEventListener("click", function () { choose(null); });
|
|
886
|
+
line.appendChild(b);
|
|
887
|
+
return line;
|
|
888
|
+
}
|
|
713
889
|
function paint() {
|
|
714
890
|
var off = !canWrite();
|
|
715
|
-
var
|
|
716
|
-
var
|
|
891
|
+
var items = itemsShown(), groups = groupsShown();
|
|
892
|
+
var done = items.filter(function (i) { return itemDone(i.id); }).length;
|
|
893
|
+
var total = items.length;
|
|
717
894
|
app.textContent = "";
|
|
718
895
|
var top = el("header", "top");
|
|
719
896
|
app.appendChild(top);
|
|
@@ -726,6 +903,8 @@ button.more { width: auto; min-height: 44px; border: 0; color: var(--accent); pa
|
|
|
726
903
|
} else if (state.store === "loading" || !(state.loaded.answers && state.loaded.groups)) {
|
|
727
904
|
top.appendChild(el("p", "muted", "Loading saved answers…"));
|
|
728
905
|
}
|
|
906
|
+
if (ASKS_WHO && !state.who) { app.appendChild(whoBlock()); return; }
|
|
907
|
+
if (ASKS_WHO) top.appendChild(whoLine());
|
|
729
908
|
app.appendChild(howTo());
|
|
730
909
|
app.appendChild(practiceBlock(off));
|
|
731
910
|
// Without the store nothing can be answered, practice included: show the real items read-only (rule 7).
|
|
@@ -738,7 +917,7 @@ button.more { width: auto; min-height: 44px; border: 0; color: var(--accent); pa
|
|
|
738
917
|
bar.appendChild(fill);
|
|
739
918
|
progress.appendChild(bar);
|
|
740
919
|
// Done means every item AND every group's familiarity, as «Next unanswered» counts it.
|
|
741
|
-
var rated =
|
|
920
|
+
var rated = groups.every(function (g) { return familiarity(g.id) !== undefined; });
|
|
742
921
|
if (total && done === total && rated && state.filter === "all") {
|
|
743
922
|
progress.appendChild(el("p", "status saved", "All " + total + " items are complete. Tell the agent you are " +
|
|
744
923
|
"done; you can still change any answer."));
|
|
@@ -760,8 +939,8 @@ button.more { width: auto; min-height: 44px; border: 0; color: var(--accent); pa
|
|
|
760
939
|
|
|
761
940
|
var onlyOpen = state.filter === "next";
|
|
762
941
|
if (onlyOpen) {
|
|
763
|
-
var rest =
|
|
764
|
-
var open =
|
|
942
|
+
var rest = items.filter(function (i) { return !itemDone(i.id); });
|
|
943
|
+
var open = groups.filter(function (g) { return familiarity(g.id) === undefined; });
|
|
765
944
|
var note = el("div", "muted");
|
|
766
945
|
if (!rest.length && !open.length) {
|
|
767
946
|
note.appendChild(el("p", "", "Nothing left. Every item and every group is answered."));
|
|
@@ -777,7 +956,7 @@ button.more { width: auto; min-height: 44px; border: 0; color: var(--accent); pa
|
|
|
777
956
|
}
|
|
778
957
|
progress.appendChild(note);
|
|
779
958
|
}
|
|
780
|
-
|
|
959
|
+
groups.forEach(function (g) {
|
|
781
960
|
var typingHere = groupItems(g.id).some(function (i) { return i.id === state.typingIn || i.id === state.lastAnswered; });
|
|
782
961
|
if (onlyOpen && groupDone(g.id) && !typingHere) return;
|
|
783
962
|
app.appendChild(groupBlock(g, off, onlyOpen));
|
|
@@ -789,16 +968,24 @@ button.more { width: auto; min-height: 44px; border: 0; color: var(--accent); pa
|
|
|
789
968
|
return function (snap) {
|
|
790
969
|
var next = {};
|
|
791
970
|
snap.docs.forEach(function (d) { next[d.id] = d.data(); });
|
|
792
|
-
state[target] = next;
|
|
971
|
+
state.raw[target] = next;
|
|
972
|
+
state[target] = mineOf(next);
|
|
793
973
|
state.loaded[loadedKey] = true;
|
|
794
974
|
render();
|
|
795
975
|
};
|
|
796
976
|
}
|
|
797
977
|
function broken() { state.store = "none"; render(); }
|
|
798
978
|
|
|
979
|
+
// Hidden: every clock stops. Back: the cards are watched afresh, because a change of tab alone
|
|
980
|
+
// fires no intersection, and a card drawn while the tab was hidden never started its clock.
|
|
799
981
|
document.addEventListener("visibilitychange", function () {
|
|
800
|
-
if (document.visibilityState !== "visible")
|
|
982
|
+
if (document.visibilityState !== "visible") {
|
|
983
|
+
flushReasons();
|
|
984
|
+
Object.keys(state.seenSince).forEach(stopClock);
|
|
985
|
+
} else if (MIN_SECONDS) watchCards();
|
|
801
986
|
});
|
|
987
|
+
window.addEventListener("resize", function () { if (MIN_SECONDS) watchCards(); });
|
|
988
|
+
if (MIN_SECONDS) tick();
|
|
802
989
|
window.addEventListener("pagehide", flushReasons);
|
|
803
990
|
render();
|
|
804
991
|
if (!window.claude || typeof window.claude.use !== "function") { broken(); return; }
|