domma-cms 1.8.31 → 1.8.33

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.
Files changed (46) hide show
  1. package/CHANGELOG.md +12 -0
  2. package/docs/DommaFeatures.md +13 -1
  3. package/package.json +1 -1
  4. package/plugins/_lib/admin/games/games.css +1 -0
  5. package/plugins/_lib/admin/games/kit.js +446 -0
  6. package/plugins/_lib/games/http.js +92 -0
  7. package/plugins/_lib/games/names.js +42 -0
  8. package/plugins/_lib/games/plays.js +176 -0
  9. package/plugins/_lib/games/ranking.js +68 -0
  10. package/plugins/_lib/games/timing.js +27 -0
  11. package/plugins/_lib/public/games/format.js +38 -0
  12. package/plugins/_lib/public/games/games.css +1 -0
  13. package/plugins/_lib/public/games/games.js +204 -0
  14. package/plugins/crossword/CLAUDE.md +37 -0
  15. package/plugins/crossword/admin/css/index.css +18 -0
  16. package/plugins/crossword/admin/templates/crossword.html +1 -0
  17. package/plugins/crossword/admin/views/crossword.js +449 -0
  18. package/plugins/crossword/collections/crossword-plays.json +20 -0
  19. package/plugins/crossword/collections/crossword-puzzles.json +17 -0
  20. package/plugins/crossword/config.js +6 -0
  21. package/plugins/crossword/docs/guide.md +53 -0
  22. package/plugins/crossword/inject/body.html +1 -0
  23. package/plugins/crossword/lib/crossword.js +170 -0
  24. package/plugins/crossword/lib/store.js +56 -0
  25. package/plugins/crossword/plugin.js +274 -0
  26. package/plugins/crossword/plugin.json +79 -0
  27. package/plugins/crossword/public/crossword.css +36 -0
  28. package/plugins/crossword/public/crossword.js +519 -0
  29. package/plugins/crossword/public/lib/layout.js +199 -0
  30. package/plugins/free-tier.lock.json +43 -1
  31. package/plugins/quiz/CLAUDE.md +43 -0
  32. package/plugins/quiz/admin/css/index.css +13 -0
  33. package/plugins/quiz/admin/templates/quiz.html +1 -0
  34. package/plugins/quiz/admin/views/quiz.js +530 -0
  35. package/plugins/quiz/collections/quiz-plays.json +20 -0
  36. package/plugins/quiz/collections/quiz-quizzes.json +17 -0
  37. package/plugins/quiz/config.js +6 -0
  38. package/plugins/quiz/docs/guide.md +61 -0
  39. package/plugins/quiz/inject/body.html +1 -0
  40. package/plugins/quiz/lib/engine.js +115 -0
  41. package/plugins/quiz/lib/quiz.js +225 -0
  42. package/plugins/quiz/lib/store.js +56 -0
  43. package/plugins/quiz/plugin.js +275 -0
  44. package/plugins/quiz/plugin.json +79 -0
  45. package/plugins/quiz/public/quiz.css +36 -0
  46. package/plugins/quiz/public/quiz.js +372 -0
package/CHANGELOG.md CHANGED
@@ -1,5 +1,17 @@
1
1
  # Changelog
2
2
 
3
+ ## [1.8.33] - 2026-10-21
4
+
5
+ - **Test a quiz or a crossword from its Tool.** A **Test** button in the editor, on every quiz and crossword in the list, and on the Plays screen plays the game right there in the admin, exactly as your players see it - the same clock, marking and screens - whatever its status, so a draft can be tried before anyone sees it. A test go never reaches the leaderboard, the Plays list, the goes-per-name limit, the sidebar badge or a notification, and it tells you where it would have come. Quiz and Crossword 1.1.0.
6
+ - **Quiz and Crossword screens rebuilt.** Both Tools' list, editor and Plays screens now run on domma-reactive: right-click (or ⋮, or Shift+F10) a quiz or crossword for everything you can do with it, drag questions by their grip to reorder them, Ctrl+S saves, and help is beside the settings. The crossword preview grid keeps black squares black under dark themes.
7
+
8
+ ## [1.8.32] - 2026-10-20
9
+
10
+ - **Quiz and Crossword, two new free Tools under Tools > Games.** Visitors play by typing a name, and the server keeps the time and does the marking, so the answers never reach the browser before they are given.
11
+ - **Quiz:** questions with one answer, several answers, true or false, or a typed answer (case, accents and "the" do not matter). Time limits per question and for the whole quiz, shuffling, a random draw from a question bank, and answers shown after each question, at the end, or never. Put one on a page with `[quiz id="…" /]`.
12
+ - **Crossword:** type the answers and clues and the grid is laid out for you, with a live preview and "Another layout". Checking and wrong guesses cost time. Put one on a page with `[crossword id="…" /]`.
13
+ - **Both:** leaderboards for today, this week, this month and all time (each name's best go), `[quiz-leaderboard]` and `[crossword-leaderboard]` to show one anywhere, a Plays screen to hide, delete or reset, a sidebar badge, and a notification when someone takes the top spot. Both arrive switched off.
14
+
3
15
  ## [1.8.31] - 2026-10-20
4
16
 
5
17
  - Add `pdfjs-dist` and `pdf-lib` as optional dependencies, for the Docs Tool's PDF documents (Docs 2.13: a PDF kept as it is, searchable inside, shown in the browser's viewer) and Docs Pro's PDF signing and PDF tools (0.10 and 0.11: boxes placed on the pages, forms filled in, marks drawn, pages turned, removed, reordered, taken out and merged). A server without them still runs Docs; those features say so and stand aside.
@@ -38,6 +38,7 @@ Code was treated as the truth. Where docs disagree with it, the disagreement is
38
38
  | `calendar` | paid | 1.15.4 | Events calendar - month, day and list views, online meetings as a video call on your site (with the call's chat kept, and Add people during the call), call anyone now from a contact or an email, all-day and recurring events, sharing with people and roles, RSVPs and an iCal feed. |
39
39
  | `campaigns-pro` | paid | 1.0.2 | Marketing campaigns, one paid Tool: compose a message, send it to an audience imported from Contacts (shared with Surveys), schedule sends, attach an offer that closes by a date or a claim limit. Opens and clicks (cookieless), links tagged for Analytics, a linked Shop discount code, send a survey as a campaign, follow-up sequences, resend to non-openers, A/B subject tests, and a version-1 stats feed other Tools read. |
40
40
  | `contacts-pro` | paid | 0.12.0 | Contacts, with more to them: your own custom fields, birthdays and ages with the ones coming up and a reminder in the sidebar, and groups with their own emblem and colours. Share a contact, several, or a whole group with people or roles - to view, or to edit. Call a contact now on a video call on your site. Works over the same contacts as the built-in Tool, so nothing moves and nothing is lost. |
41
+ | `crossword` | free | 1.1.0 | Crosswords on your site: type the answers and clues and the grid lays itself out (live preview, Another layout). Players type a name and race the server's clock, checks and wrong guesses cost time, fastest solvers on a leaderboard. Test it from the Tool - a test go never reaches the board. |
41
42
  | `data-transfer` | paid | 1.3.3 | Move collections between file and MongoDB storage, back them up and restore them (all or some, merged or replaced) - keeping every entry's id - and clone them (cloned entries get new ids, so references stay unambiguous). Every restore and transfer takes a safety snapshot first. |
42
43
  | `docs` | paid | 2.13.1 | Document editor: Home and editor screens, sharing with Viewers/Editors, public links, import/export, send to page/blog/email, edit lock, Trash, version history, templates. PDF documents (2.13): a PDF kept as it is, shown in the browser's viewer, searchable inside, versioned on replace. |
43
44
  | `docs-pro` | paid | 0.11.0 | Domma Spaces (publish a Docs folder at `/spaces` with a Kindle-style reader), Cloud copies (Google Drive, OneDrive, Dropbox, S3), Signatures (emailed signing links, typed or drawn, signed copy with a certificate page; 0.10: boxes placed on a PDF document's pages, the signed PDF stamped with pdf-lib), PDF tools (0.11: fill the PDF's own form, annotate, pages) and protected public links (expiry date, password). Adds to Docs. |
@@ -50,6 +51,7 @@ Code was treated as the truth. Where docs disagree with it, the disagreement is
50
51
  | `messages` | paid | 1.7.2 | Internal user-to-user and group messaging with real-time delivery and video calls (what is typed in a call lands in the conversation; ring another team member into a call) - conversations you can archive, mark unread and search, drafts kept per conversation, attachments, and an unread badge in the sidebar. |
51
52
  | `mongodb-adapter` | paid | 1.0.2 | Provision a dedicated MongoDB database per site. Credentials are managed centrally by the manager. |
52
53
  | `onboard-pro` | paid | 1.0.1 | Onboarding journeys, one paid Tool: read/form/details/manual steps, a personal link per person, signature steps through Docs Pro, admin-approved account steps (never an administrator), due dates, automatic nudges and auto-enrolment from a contact group, countersigned steps, waive-with-a-reason, and a funnel showing where people stop. |
54
+ | `quiz` | free | 1.1.0 | Timed quizzes on your site: one or several right answers, true or false, or a typed answer, marked by the server so answers never reach the browser early. Name-only players, leaderboards by day, week, month and all time. Test it from the Tool - a test go never reaches the board. |
53
55
  | `security` | free | 1.1.3 | Keeps a Domma CMS site safe: a health check of what is weak and how to fix it, sign-in lockout after repeated wrong passwords, password rules, a log of every sign-in, and blocking of the scanners that probe every site for WordPress, .env files and the like. |
54
56
  | `security-pro` | paid | 1.3.2 | Everything in Security, plus two-factor sign-in (authenticator apps, recovery codes, required per role), a firewall that bans scanners for longer each time they return, per-route rate limits, country rules for the admin, breached-password checks and every signed-in session in one place. |
55
57
  | `seo-pro` | paid | 0.6.2 | More for SEO: Google Search Console in the admin (with your own Google client) - searches, clicks, positions and where to win more; structured data for rich results (FAQ, how-to, product, event, local business); links worth adding between pages, pages competing for one search, readability, near duplicates and image checks; every title and description in one grid, and moving an old site's addresses across as redirects; a not-found log with one-click redirects; a scheduled check of every link; SEO history and notices. Adds to SEO, which is built into Domma CMS (0.90 and later). |
@@ -2400,9 +2402,19 @@ Tier: paid (closed source) · Version: 0.4.0 · Requires: nothing; no MongoDB (w
2400
2402
 
2401
2403
  **Where:** Admin sidebar "Sheets" (`#/plugins/sheets`).
2402
2404
 
2405
+ ### Quiz (`quiz`) and Crossword (`crossword`)
2406
+
2407
+ Tier: free, bundled in the engine, off on arrival · Version: 1.1.0 each (needs Domma CMS 1.8.33 for the games library) · Sidebar: Tools > Games
2408
+
2409
+ **1.0.0 (CMS 1.8.32)** - see the 1.8.32 changelog: server-kept time and marking, name-only players with a play token, leaderboards by period (best go per name), Plays screen (hide, delete, reset), sidebar badge, top-spot notifications, `[quiz]`/`[crossword]` and `-leaderboard` shortcodes.
2410
+
2411
+ **1.1.0 - Test from the Tool, domma-reactive admin.** **Test** (editor, list rows, Plays screen; `#/plugins/quiz/test/<id>`, `#/plugins/crossword/test/<id>`) mounts the real public player in the admin and plays the SAVED game whatever its status or dates ("Save and test" when there are unsaved changes). The play is stored with `test: true` and played through the ordinary `/public/play` routes, but `PlayStore` leaves it out of boards, places, goes-per-name, the admin's plays list and counts and the sidebar badge; it never notifies; the next test of the game clears it. The result says where it would have come (`wouldPlace`). Routes: `GET/POST /api/plugins/quiz/quizzes/:id/test`, `GET/POST /api/plugins/crossword/puzzles/:id/test` (manage permission). Both admin screens are rebuilt on domma-reactive through the shared kit (`plugins/_lib/admin/games/kit.js`: `mountScreen`, `wireRows`, `sortableRows`, `saveKey`, `playsScreen`, `testScreen`): list rows with a context menu (right-click, ⋮, Shift+F10), questions dragged by a grip to reorder, Ctrl+S, help popovers; the crossword preview grid uses fixed ink and paper colours.
2412
+
2413
+ **Where:** Tools > Games > Quiz (`#/plugins/quiz`) and Crossword (`#/plugins/crossword`).
2414
+
2403
2415
  ### Other plugin directories
2404
2416
 
2405
- No other directories exist in `dcms-marketplace/plugins/` beyond the 29 plugins above and `_lib`. `_lib` is not a plugin (skipped by plugin discovery): it holds the shared mail core used by Mail Reader and Email Pro, plus shared `dataStore.js` and `schemaSync.js`.
2417
+ No other directories exist in `dcms-marketplace/plugins/` beyond the plugins above and `_lib`. `_lib` is not a plugin (skipped by plugin discovery): it holds the shared mail core used by Mail Reader and Email Pro, plus shared `dataStore.js` and `schemaSync.js`.
2406
2418
 
2407
2419
  ### Reserved slugs
2408
2420
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "domma-cms",
3
- "version": "1.8.31",
3
+ "version": "1.8.33",
4
4
  "description": "File-based CMS powered by Domma and Fastify. Run npx domma-cms my-site to create a new project.",
5
5
  "type": "module",
6
6
  "main": "server/server.js",
@@ -0,0 +1 @@
1
+ .gma{--gma-line: var(--dm-border, rgba(128, 128, 128, .3));--gma-soft: color-mix(in srgb, var(--dm-text) 5%, transparent);display:flex;flex-direction:column;gap:1rem;min-width:0}.gma [hidden]{display:none!important}.gma-screen{display:flex;flex-direction:column;gap:1rem;min-width:0}.gma-bar{display:flex;flex-wrap:wrap;gap:.5rem;align-items:center}.gma-spacer{flex:1}.gma-muted{color:var(--dm-text-muted)}.gma-note{margin:0;font-size:var(--dm-text-sm);color:var(--dm-text-muted)}.gma-error{color:var(--dm-danger);margin:0}.gma-danger-text{color:var(--dm-danger)!important}.gma-h{margin:0 0 .5rem;font-size:1rem;font-weight:600}.gma-card{border:1px solid var(--gma-line);border-radius:var(--dm-radius-md, 8px);padding:1rem;background:var(--dm-surface, transparent);display:flex;flex-direction:column;gap:.75rem;min-width:0}.gma-two{display:grid;grid-template-columns:minmax(0,2fr) minmax(16rem,1fr);gap:1rem;align-items:start}@media(max-width:1000px){.gma-two{grid-template-columns:minmax(0,1fr)}}.gma-chip{display:inline-flex;align-items:center;font-size:var(--dm-text-xs);font-weight:600;padding:.1rem .55rem;border-radius:999px;white-space:nowrap;background:color-mix(in srgb,var(--dm-text) 9%,transparent);color:var(--dm-text-muted);margin-left:.4rem}.gma-chip.is-success{background:color-mix(in srgb,var(--dm-success) 15%,transparent);color:var(--dm-success)}.gma-chip.is-warning{background:color-mix(in srgb,var(--dm-warning) 16%,transparent);color:color-mix(in srgb,var(--dm-warning) 55%,var(--dm-text))}.gma-chip.is-primary{background:color-mix(in srgb,var(--dm-primary) 14%,transparent);color:var(--dm-primary)}.gma-list{display:flex;flex-direction:column;gap:.5rem}.gma-row{display:flex;flex-wrap:wrap;align-items:center;gap:.75rem;padding:.75rem 1rem;border:1px solid var(--gma-line);border-radius:var(--dm-radius-md, 8px);background:var(--dm-surface, transparent)}.gma-row:hover{border-color:color-mix(in srgb,var(--dm-primary) 45%,var(--gma-line))}.gma-row-main{flex:1 1 16rem;min-width:0;display:flex;flex-direction:column;gap:.15rem}.gma-row-main a{font-weight:600;color:var(--dm-text);text-decoration:none}.gma-row-main a:hover{color:var(--dm-primary)}.gma-row-main small{color:var(--dm-text-muted)}.gma-row-actions{display:flex;flex-wrap:wrap;gap:.25rem}.gma-empty{display:flex;flex-direction:column;align-items:center;gap:.5rem;text-align:center;padding:2.5rem 1rem;color:var(--dm-text-muted)}.gma-empty strong{color:var(--dm-text)}.gma-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(14rem,1fr));gap:.25rem 1rem}.gma-field{margin:0;min-width:0}.gma-toggle{display:inline-flex;align-items:center;gap:.5rem;cursor:pointer;font-weight:500}.gma-shortcode{display:flex;gap:.5rem;align-items:center;flex-wrap:wrap}.gma-shortcode code{padding:.3rem .55rem;border-radius:6px;background:var(--gma-soft);font-size:var(--dm-text-sm)}.gma-save{position:sticky;bottom:0;z-index:5;display:flex;gap:.5rem;align-items:center;flex-wrap:wrap;padding:.75rem 1rem;border:1px solid var(--gma-line);border-radius:var(--dm-radius-md, 8px);background:var(--dm-background, var(--dm-surface))}.gma-save .is-dirty{color:color-mix(in srgb,var(--dm-warning) 60%,var(--dm-text));font-size:var(--dm-text-sm)}.gma-stats{display:grid;grid-template-columns:repeat(auto-fit,minmax(9rem,1fr));gap:.75rem}.gma-stat{padding:.75rem 1rem}.gma-table-wrap{overflow-x:auto}.gma-table{width:100%;border-collapse:collapse;font-size:var(--dm-text-sm)}.gma-table th,.gma-table td{padding:.45rem .5rem;border-bottom:1px solid var(--gma-line);text-align:left;vertical-align:middle}.gma-table th{font-weight:600;color:var(--dm-text-muted)}.gma-table tr.is-hidden td{opacity:.55}.gma-num{text-align:right!important;font-variant-numeric:tabular-nums;white-space:nowrap}.gma-actions{white-space:nowrap;text-align:right!important}.gma-board{margin:0;padding:0;list-style:none;display:flex;flex-direction:column}.gma-board li{display:grid;grid-template-columns:2.6rem 1fr auto auto;gap:.5rem;padding:.35rem 0;border-bottom:1px solid var(--gma-line);align-items:center}.gma-board li:last-child{border-bottom:0}.gma-rank{font-weight:700}.gma-name{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.gma-row{cursor:pointer}.gma-row:focus-visible{outline:2px solid var(--dm-primary);outline-offset:2px}.gma-kebab{border:0;background:transparent;color:var(--dm-text-muted);font-size:1.1rem;line-height:1;padding:.25rem .5rem;border-radius:6px;cursor:pointer}.gma-kebab:hover{background:var(--gma-soft);color:var(--dm-text)}.gma-quiet{opacity:.6}.gma-quiet:hover{opacity:1}.gma-grip{cursor:grab;color:var(--dm-text-muted);display:inline-flex;padding:.2rem;touch-action:none}.gma-grip:active{cursor:grabbing}.gma-test-stage{border:1px dashed var(--gma-line);border-radius:var(--dm-radius-md, 8px);padding:.5rem 1rem;background:var(--gma-soft)}.gma-test-stage .gm-app{margin:.5rem auto}
@@ -0,0 +1,446 @@
1
+ /**
2
+ * Games admin - what the Quiz and Crossword screens share: the admin's helpers
3
+ * (loaded at run time; a plugin never imports admin/ statically - the updater
4
+ * replaces it), API calls, mounting a domma-reactive screen, list rows with a
5
+ * context menu, drag to reorder, the Plays screen (every go at one game, its
6
+ * leaderboard, hide / remove / reset) and the Test screen (the real player,
7
+ * playing a test go that never reaches a board).
8
+ *
9
+ * Every screen is domma-reactive: a template string (in the JS - a templateUrl
10
+ * goes through _.render, which eats the {{ }} of a data-each row), observables,
11
+ * and one M.applyBindings, disposed when the view goes.
12
+ *
13
+ * Served from /plugins/_lib/admin/games/kit.js; its stylesheet is games.css
14
+ * beside it, listed in each Tool's manifest `admin.css`.
15
+ *
16
+ * @module _lib/admin/games/kit
17
+ */
18
+
19
+ import {fmtDateTime} from '/plugins/_lib/admin/ui/dates.js';
20
+ import {fmtDuration, ordinal} from '/plugins/_lib/public/games/format.js';
21
+
22
+ export {fmtDateTime, fmtDuration, ordinal};
23
+
24
+ let kit = null;
25
+ /** tool-kit, plugin-chrome, loading and help popovers, once, with stand-ins for an older admin. */
26
+ export function loadKit() {
27
+ kit ??= Promise.all([
28
+ import('/admin/js/lib/tool-kit.js').catch(() => ({})),
29
+ import('/admin/js/lib/plugin-chrome.js').catch(() => ({})),
30
+ import('/admin/js/lib/loading.js').catch(() => ({})),
31
+ import('/admin/js/lib/help-popover.js').catch(() => ({}))
32
+ ]).then(([tk, chrome, loading, help]) => ({
33
+ viewLifetime: tk.viewLifetime || (() => ({add() {}, reset() {}})),
34
+ openMenuAt: tk.openMenuAt || ((el) => {
35
+ const r = el.getBoundingClientRect();
36
+ el.dispatchEvent(new MouseEvent('contextmenu', {bubbles: true, cancelable: true, clientX: r.left, clientY: r.bottom, view: window}));
37
+ }),
38
+ addBannerAction: chrome.addBannerAction || (() => null),
39
+ whileLoading: loading.whileLoading || ((slot, p) => (typeof p === 'function' ? p() : p)),
40
+ SHAPES: loading.SHAPES || {},
41
+ helpPopovers: help.helpPopovers || (() => () => {})
42
+ }));
43
+ return kit;
44
+ }
45
+
46
+ let requester = null;
47
+ /**
48
+ * A call to the API through the admin's apiRequest (it attaches the token and
49
+ * refreshes it on a 401, and keeps `err.status` / `err.body` on an error); H is
50
+ * the stand-in on an older admin.
51
+ */
52
+ export function request(method, path, body) {
53
+ requester ??= import('/admin/js/api.js').then(m => m.apiRequest).catch(() => null);
54
+ return requester.then(fn => {
55
+ if (fn) return fn(path, {method, ...(body !== undefined ? {body: JSON.stringify(body)} : {})});
56
+ const url = `/api${path}`;
57
+ return method === 'GET' ? H.get(url) : method === 'DELETE' ? H.delete(url) : H[method.toLowerCase()](url, body);
58
+ });
59
+ }
60
+
61
+ export const errText = (err) => err?.body?.error || err?.message || String(err);
62
+
63
+ /** E.confirm, toasts and templates render HTML: user text goes in escaped. */
64
+ export const esc = (s) => String(s ?? '').replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
65
+
66
+ /** An icon for a template: drawn by I.scan. Never put a data-bind-* on it (I.scan swaps the span for an svg). */
67
+ export const ico = (name, size = 14) => `<span data-icon="${name}" data-icon-size="${size}" aria-hidden="true"></span>`;
68
+
69
+ /**
70
+ * Put `html` in `root` and bind `model` to it: the one M.applyBindings of a
71
+ * screen. `before(el)` runs first (take element refs and wire help popovers
72
+ * there - data-if lifts elements out). Icons are drawn after binding, and again
73
+ * whenever `rescan` (observables) change, so rows added later get theirs.
74
+ *
75
+ * @returns {HTMLElement} the screen's element; everything is disposed through `lifetime`
76
+ */
77
+ export function mountScreen(root, {html, model, lifetime, k, className = '', before = null, rescan = []}) {
78
+ root.innerHTML = '';
79
+ const el = document.createElement('div');
80
+ el.className = `gma-screen ${className}`.trim();
81
+ el.innerHTML = html;
82
+ root.append(el);
83
+ const disposeHelp = k?.helpPopovers ? k.helpPopovers(el, {position: 'bottom'}) : () => {};
84
+ before?.(el);
85
+ const bindings = M.applyBindings(model, el, {methods: model});
86
+ const scan = () => { if (el.isConnected && typeof I !== 'undefined') I.scan(el); };
87
+ scan();
88
+ const stops = rescan.map(obs => M.effect(() => { obs.value; queueMicrotask(scan); }));
89
+ lifetime?.add(() => { stops.forEach(s => s?.()); bindings?.dispose?.(); disposeHelp?.(); });
90
+ return el;
91
+ }
92
+
93
+ /**
94
+ * Rows with one menu and three ways in: right-click, the ⋮ button, Shift+F10.
95
+ * A click (or Enter) on the row does `main`. `items(id)` lists the menu.
96
+ */
97
+ export function wireRows(list, {items, main, k, lifetime, match = '.gma-row'}) {
98
+ const menu = typeof E !== 'undefined' && E.contextMenu
99
+ ? E.contextMenu(list, {cursor: 'glyph', match, inherit: false, density: 'compact', items: (row) => items(row.dataset.id, row)})
100
+ : null;
101
+ const onClick = (e) => {
102
+ const row = e.target.closest(match);
103
+ if (!row) return;
104
+ if (e.target.closest('.gma-kebab')) { e.stopPropagation(); k.openMenuAt(row); return; }
105
+ if (e.target.closest('a, button, input, [data-stop]')) return;
106
+ main(row.dataset.id, row);
107
+ };
108
+ const onKey = (e) => {
109
+ const row = e.target.closest(match);
110
+ if (!row) return;
111
+ if (e.key === 'F10' && e.shiftKey) { e.preventDefault(); k.openMenuAt(row); }
112
+ else if (e.key === 'Enter' && e.target === row) { e.preventDefault(); main(row.dataset.id, row); }
113
+ else if ((e.key === 'ArrowDown' || e.key === 'ArrowUp') && e.target === row) {
114
+ e.preventDefault();
115
+ const rows = [...list.querySelectorAll(match)];
116
+ rows[rows.indexOf(row) + (e.key === 'ArrowDown' ? 1 : -1)]?.focus();
117
+ }
118
+ };
119
+ list.addEventListener('click', onClick);
120
+ list.addEventListener('keydown', onKey);
121
+ lifetime?.add(() => { list.removeEventListener('click', onClick); list.removeEventListener('keydown', onKey); menu?.destroy?.(); });
122
+ }
123
+
124
+ /**
125
+ * Drag to reorder the rows of a data-each list (by their grip). E.sortable in
126
+ * live mode moves the row element itself, but a data-each row sits between a
127
+ * pair of comment anchors the reconciler places it by - so the row is put back
128
+ * where it was first, and the move is made in the data (`onMove(from, to)`),
129
+ * which redraws the list.
130
+ */
131
+ export function sortableRows(list, {items, handle, key = 'data-id', onMove, lifetime}) {
132
+ if (typeof E === 'undefined' || typeof E.sortable !== 'function') return;
133
+ let at = null;
134
+ const restore = (item) => {
135
+ if (!at) return;
136
+ at.parent.insertBefore(item, at.next?.parentNode === at.parent ? at.next : null);
137
+ at = null;
138
+ };
139
+ const drag = E.sortable(list, {
140
+ items, handle, key,
141
+ // Alt+arrows would move the row without onStart, so it could not be put back by its anchors.
142
+ keyboard: false,
143
+ onStart: ({item}) => { at = {parent: item.parentNode, next: item.nextSibling}; },
144
+ onEnd: ({item}) => restore(item),
145
+ onSort: ({item, from, to}) => { restore(item); if (from !== to) onMove(from, to); }
146
+ });
147
+ lifetime?.add(() => drag?.destroy?.());
148
+ }
149
+
150
+ /** Ctrl+S (Cmd+S) runs `save` while the screen is up. Captured: app.js has a global Ctrl+S of its own. */
151
+ export function saveKey(save, here, lifetime) {
152
+ const onKey = (e) => {
153
+ if (!(e.ctrlKey || e.metaKey) || e.key !== 's' || !here()) return;
154
+ e.preventDefault();
155
+ e.stopImmediatePropagation();
156
+ save();
157
+ };
158
+ document.addEventListener('keydown', onKey, true);
159
+ lifetime?.add(() => document.removeEventListener('keydown', onKey, true));
160
+ }
161
+
162
+ export async function copyText(text, done = 'Copied') {
163
+ try {
164
+ if (typeof _ !== 'undefined' && typeof _.copyToClipboard === 'function') await _.copyToClipboard(text);
165
+ else await navigator.clipboard.writeText(text);
166
+ E.toast(done, {type: 'success'});
167
+ } catch {
168
+ E.toast('Could not copy. Select the text and copy it yourself.', {type: 'warning'});
169
+ }
170
+ }
171
+
172
+ /** The sidebar re-asks a Tool's badge. */
173
+ export function refreshBadge(url) {
174
+ try { window.dispatchEvent(new CustomEvent('dm:sidebar-count', {detail: {url}})); } catch { /* no sidebar */ }
175
+ }
176
+
177
+ /** "Quiz › Capitals" in the plugin banner (the chrome's, outside the screen's bindings). */
178
+ export function bannerTrail(container, parts) {
179
+ const name = container.querySelector('.dm-plugin-banner-name');
180
+ if (!name) return;
181
+ name.innerHTML = parts.map((p, i) => (i ? '<span class="dm-plugin-banner-sep" aria-hidden="true">›</span>' : '')
182
+ + (i < parts.length - 1 ? `<a class="dm-plugin-banner-parent" href="${esc(p.href || '#')}">${esc(p.text)}</a>` : esc(p.text))).join('');
183
+ }
184
+
185
+ /** A loading note while `promise` runs (the admin's skeleton where it has one). */
186
+ export async function loading(root, promise, k, shape = 'rows') {
187
+ root.innerHTML = '<div class="gma-note dm-loading-slot">Loading…</div>';
188
+ return k.whileLoading(root.firstElementChild, promise, k.SHAPES[shape]);
189
+ }
190
+
191
+ /** A screen that could not load: the reason, and the way back. */
192
+ export function failed(root, text, back = null) {
193
+ root.innerHTML = `<p class="gma-error">${esc(text)}</p>${back ? `<p><a href="${esc(back.href)}">${esc(back.text)}</a></p>` : ''}`;
194
+ }
195
+
196
+ // ---- Plays ---------------------------------------------------------------------------
197
+
198
+ const STATUS_LABEL = {playing: 'Playing', finished: 'Finished', abandoned: 'Left unfinished', timeout: 'Out of time'};
199
+
200
+ const PLAYS_HTML = `
201
+ <div class="gma-bar">
202
+ <a class="btn btn-sm btn-ghost" data-bind-href="back">${ico('arrow-left')} All</a>
203
+ <a class="btn btn-sm btn-ghost" data-bind-href="testHref" hidden data-bind-hidden="!testHref">${ico('play')} Test it</a>
204
+ <span class="gma-spacer"></span>
205
+ <button type="button" class="btn btn-sm btn-outline gma-danger-text" hidden data-bind-hidden="!canManage || !rows.value.length"
206
+ data-on-click="reset">${ico('refresh-cw')} Reset the leaderboard…</button>
207
+ </div>
208
+ <div class="gma-stats">
209
+ <div class="stat-card gma-stat"><div class="stat-value" data-bind-text="stats.value.plays"></div><div class="stat-label">Plays</div></div>
210
+ <div class="stat-card gma-stat"><div class="stat-value" data-bind-text="stats.value.finished"></div><div class="stat-label">Finished</div></div>
211
+ <div class="stat-card gma-stat" hidden data-bind-hidden="!showScore"><div class="stat-value" data-bind-text="stats.value.best"></div><div class="stat-label">Best score</div></div>
212
+ <div class="stat-card gma-stat"><div class="stat-value" data-bind-text="stats.value.fastest"></div><div class="stat-label">Fastest</div></div>
213
+ </div>
214
+ <div class="gma-two">
215
+ <section class="gma-card">
216
+ <h3 class="gma-h">Every play <span data-help-title="Every play" data-help="Every go anyone has had, newest first. Hide takes a play off the leaderboard without deleting it (a rude name, a known cheat); Show puts it back. Your own test goes are never listed or counted."></span></h3>
217
+ <p class="gma-muted" hidden data-bind-hidden="rows.value.length">No plays yet. Once people play, every go shows here.</p>
218
+ <div class="gma-table-wrap" hidden data-bind-hidden="!rows.value.length">
219
+ <table class="gma-table">
220
+ <thead><tr><th>Name</th><th>State</th><th class="gma-num" hidden data-bind-hidden="!showScore">Score</th><th class="gma-num">Time</th><th>Started</th><th hidden data-bind-hidden="!canManage"></th></tr></thead>
221
+ <tbody data-part="rows" data-each="rows.value key=id"></tbody>
222
+ </table>
223
+ </div>
224
+ </section>
225
+ <section class="gma-card">
226
+ <h3 class="gma-h">Leaderboard <span class="gma-muted">(all time, best go per name)</span></h3>
227
+ <p class="gma-muted" hidden data-bind-hidden="board.value.length">Nobody has finished yet.</p>
228
+ <ol class="gma-board" data-each="board.value key=id"></ol>
229
+ </section>
230
+ </div>`;
231
+
232
+ const PLAY_ROW = `
233
+ <tr data-bind-class="hidden && 'is-hidden'">
234
+ <td><strong data-bind-text="name"></strong><span class="gma-chip" hidden data-bind-hidden="!hidden">Hidden</span></td>
235
+ <td data-bind-text="state"></td>
236
+ <td class="gma-num" hidden data-bind-hidden="!$root.showScore" data-bind-text="scoreText"></td>
237
+ <td class="gma-num" data-bind-text="timeText"></td>
238
+ <td data-bind-text="started"></td>
239
+ <td class="gma-actions" hidden data-bind-hidden="!$root.canManage">
240
+ <button type="button" class="btn btn-xs btn-ghost" data-bind-title="hidden ? 'Show on the leaderboard' : 'Hide from the leaderboard'"
241
+ data-on-click="$root.hide($data)"><span data-bind-text="hidden ? 'Show' : 'Hide'"></span></button>
242
+ <button type="button" class="btn btn-xs btn-ghost gma-quiet" title="Delete this play" aria-label="Delete this play"
243
+ data-on-click="$root.remove($data)">${ico('x')}</button>
244
+ </td>
245
+ </tr>`;
246
+
247
+ const BOARD_ROW = `
248
+ <li><span class="gma-rank" data-bind-text="place"></span><span class="gma-name" data-bind-text="name"></span>
249
+ <strong hidden data-bind-hidden="!$root.showScore" data-bind-text="scoreText"></strong><span class="gma-muted" data-bind-text="timeText"></span></li>`;
250
+
251
+ /**
252
+ * The Plays screen for one game.
253
+ *
254
+ * @param {HTMLElement} root
255
+ * @param {{api: string, gameId: string, title: string, order: 'score'|'time', canManage?: boolean, back: string,
256
+ * base?: string, badge?: () => void, lifetime: object, testHref?: string, alive?: () => boolean}} opts
257
+ * `alive` says the admin is still on this screen (checked after the load).
258
+ * `api` is the Tool's base under /api ('/plugins/quiz'), `back` the list's address.
259
+ */
260
+ export async function playsScreen(root, {api, gameId, title, order, canManage = true, back, base = '/quizzes', badge = () => {}, lifetime, testHref = '', alive = () => true}) {
261
+ const k = await loadKit();
262
+ const url = `${api}${base}/${encodeURIComponent(gameId)}/plays`;
263
+ let data;
264
+ try {
265
+ data = await loading(root, request('GET', url), k, 'table');
266
+ } catch (err) {
267
+ if (alive()) failed(root, `The plays could not load: ${errText(err)}`, {href: back, text: 'Back'});
268
+ return;
269
+ }
270
+ if (!alive()) return;
271
+
272
+ const showScore = order === 'score';
273
+ const plays = M.observable(data.plays);
274
+ const boardRows = M.observable(data.board);
275
+ const rows = M.observable([]);
276
+ const board = M.observable([]);
277
+ const stats = M.observable({});
278
+ const draw = () => {
279
+ const list = plays.peek();
280
+ const finished = list.filter(p => p.status === 'finished');
281
+ rows.value = list.map(p => ({
282
+ id: p.id, name: p.name, hidden: p.hidden === true, state: STATUS_LABEL[p.status] || p.status,
283
+ scoreText: p.status === 'finished' ? `${p.score} / ${p.maxScore}` : '-',
284
+ timeText: p.status === 'finished' ? fmtDuration(p.timeMs) : '-', started: fmtDateTime(p.startedAt)
285
+ }));
286
+ board.value = boardRows.peek().slice(0, 20).map(r => ({id: r.id, name: r.name, place: ordinal(r.rank), scoreText: `${r.score}`, timeText: fmtDuration(r.timeMs)}));
287
+ stats.value = {
288
+ plays: list.length, finished: finished.length,
289
+ best: finished.reduce((m, p) => Math.max(m, p.score || 0), 0),
290
+ fastest: finished.length ? fmtDuration(Math.min(...finished.map(p => p.timeMs || 0))) : '-'
291
+ };
292
+ };
293
+ draw();
294
+
295
+ async function reload() {
296
+ const next = await request('GET', url);
297
+ plays.value = next.plays;
298
+ boardRows.value = next.board;
299
+ draw();
300
+ badge();
301
+ }
302
+
303
+ const byId = (id) => plays.peek().find(p => p.id === id);
304
+ const model = {
305
+ back, testHref, showScore, canManage, rows, board, stats,
306
+ async hide(row) {
307
+ const p = byId(row.id);
308
+ if (!p) return;
309
+ try {
310
+ await request('PUT', `${api}/plays/${encodeURIComponent(p.id)}`, {hidden: !p.hidden});
311
+ E.toast(p.hidden ? `${p.name} is back on the leaderboard.` : `${p.name} is hidden from the leaderboard.`, {type: 'success'});
312
+ await reload();
313
+ } catch (err) { E.toast(errText(err), {type: 'error'}); }
314
+ },
315
+ async remove(row) {
316
+ const p = byId(row.id);
317
+ if (!p || !await E.confirm(`Delete <strong>${esc(p.name)}</strong>'s play? It leaves the leaderboard for good.`)) return;
318
+ try {
319
+ await request('DELETE', `${api}/plays/${encodeURIComponent(p.id)}`);
320
+ await reload();
321
+ } catch (err) { E.toast(errText(err), {type: 'error'}); }
322
+ },
323
+ async reset() {
324
+ if (!await E.confirm(`Reset the leaderboard for <strong>${esc(title)}</strong>? Every play (${plays.peek().length}) is deleted. This cannot be undone.`)) return;
325
+ try {
326
+ const r = await request('DELETE', url);
327
+ E.toast(`${r.removed} play${r.removed === 1 ? '' : 's'} deleted. The leaderboard starts again.`, {type: 'success'});
328
+ await reload();
329
+ } catch (err) { E.toast(errText(err), {type: 'error'}); }
330
+ }
331
+ };
332
+
333
+ mountScreen(root, {
334
+ html: PLAYS_HTML, model, lifetime, k, className: 'gma-plays', rescan: [rows],
335
+ before: (el) => {
336
+ el.querySelector('[data-part="rows"]').innerHTML = PLAY_ROW;
337
+ el.querySelector('.gma-board').innerHTML = BOARD_ROW;
338
+ }
339
+ });
340
+ }
341
+
342
+ // ---- Test ----------------------------------------------------------------------------
343
+
344
+ const TEST_HTML = `
345
+ <div class="gma-bar">
346
+ <a class="btn btn-sm btn-ghost" data-bind-href="editHref">${ico('arrow-left')} Back to the editor</a>
347
+ <a class="btn btn-sm btn-ghost" data-bind-href="playsHref">${ico('award')} Plays</a>
348
+ <span class="gma-spacer"></span>
349
+ <span class="gma-chip is-primary" data-bind-text="statusText"></span>
350
+ <span data-help-title="Testing" data-help="This is the real player, exactly as it is on a page - the same clock, the same marking, the same screens. It plays the version you last saved, even a draft or a closed one. A test go is not saved to the leaderboard or counted in the plays, and the next test clears it away."></span>
351
+ </div>
352
+ <p class="gma-note">Plays the saved version as your players see it. Test goes never reach the leaderboard.</p>
353
+ <div class="gma-test-stage"><div data-part="player"></div></div>`;
354
+
355
+ /**
356
+ * The Test screen: the Tool's own public player, mounted with test hooks that
357
+ * fetch the start screen and start a test play through the admin API.
358
+ *
359
+ * @param {HTMLElement} root
360
+ * @param {{api: string, base: string, gameId: string, player: string, appClass: string,
361
+ * editHref: string, playsHref: string, statusText?: string, lifetime: object, alive?: () => boolean}} opts
362
+ * `player` is the public script that exports `mount(el, {test})`.
363
+ */
364
+ export async function testScreen(root, {api, base, gameId, player, appClass, editHref, playsHref, statusText = '', lifetime, alive = () => true}) {
365
+ const k = await loadKit();
366
+ let mod;
367
+ try {
368
+ mod = await loading(root, import(player), k, 'card');
369
+ } catch (err) {
370
+ if (alive()) failed(root, `The player could not load: ${errText(err)}`, {href: editHref, text: 'Back to the editor'});
371
+ return;
372
+ }
373
+ if (!alive()) return;
374
+ const url = `${api}${base}/${encodeURIComponent(gameId)}/test`;
375
+ let host = null;
376
+ mountScreen(root, {
377
+ html: TEST_HTML, model: {editHref, playsHref, statusText: statusText ? `${statusText} - testing` : 'Testing'},
378
+ lifetime, k, className: 'gma-test',
379
+ before: (el) => { host = el.querySelector('[data-part="player"]'); }
380
+ });
381
+ host.className = `gm-app ${appClass}`;
382
+ const handle = mod.mount(host, {test: {info: () => request('GET', url).then(r => r.info), start: () => request('POST', url)}});
383
+ lifetime?.add(() => handle?.dispose?.());
384
+ }
385
+
386
+ // ---- Dates ---------------------------------------------------------------------------
387
+
388
+ /** A datetime-local value ('YYYY-MM-DDTHH:mm', the browser's time) from ISO, and back. */
389
+ export const toLocalInput = (iso) => {
390
+ const t = Date.parse(iso || '');
391
+ if (!Number.isFinite(t)) return '';
392
+ const d = new Date(t);
393
+ const p = (n) => String(n).padStart(2, '0');
394
+ return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())}T${p(d.getHours())}:${p(d.getMinutes())}`;
395
+ };
396
+ export const fromLocalInput = (v) => {
397
+ const t = Date.parse(v || '');
398
+ return Number.isFinite(t) ? new Date(t).toISOString() : '';
399
+ };
400
+
401
+ // ---- 1.0.0 ---------------------------------------------------------------------------
402
+ // Quiz and Crossword 1.0.0 drew their screens by hand with these. 1.1.0 is
403
+ // domma-reactive throughout and uses none of them; they stay so a 1.0.0 copy
404
+ // installed from the catalogue keeps working on a newer engine.
405
+
406
+ /** @deprecated 1.0.0 screens only. */
407
+ export function h(tag, props = {}, ...children) {
408
+ const el = document.createElement(tag);
409
+ for (const [key, v] of Object.entries(props || {})) {
410
+ if (v === false || v == null) continue;
411
+ if (key === 'className') el.className = v;
412
+ else if (key === 'text') el.textContent = v;
413
+ else if (key === 'html') el.innerHTML = v;
414
+ else if (key === 'dataset') Object.assign(el.dataset, v);
415
+ else if (key.startsWith('on') && typeof v === 'function') el.addEventListener(key.slice(2).toLowerCase(), v);
416
+ else if (key === 'value') el.value = v;
417
+ else if (key === 'checked') el.checked = Boolean(v);
418
+ else el.setAttribute(key, v === true ? '' : String(v));
419
+ }
420
+ for (const c of children.flat()) {
421
+ if (c == null || c === false) continue;
422
+ el.append(c instanceof Node ? c : document.createTextNode(String(c)));
423
+ }
424
+ return el;
425
+ }
426
+
427
+ /** @deprecated 1.0.0 screens only. */
428
+ export const icon = (name, size = 14) => h('span', {'data-icon': name, 'data-icon-size': size, 'aria-hidden': 'true'});
429
+
430
+ /** @deprecated 1.0.0 screens only. */
431
+ export function field(label, control, hint = '', {id} = {}) {
432
+ const fid = id || control.id || `gmf-${Math.random().toString(36).slice(2, 8)}`;
433
+ control.id = fid;
434
+ return h('div', {className: 'form-group gma-field'},
435
+ h('label', {className: 'form-label', for: fid, text: label}),
436
+ control,
437
+ hint ? h('p', {className: 'form-hint', text: hint}) : null);
438
+ }
439
+
440
+ /** @deprecated 1.0.0 screens only. */
441
+ export function toggle(label, checked, onChange, hint = '') {
442
+ const input = h('input', {type: 'checkbox', checked, onChange: (e) => onChange(e.target.checked)});
443
+ return h('div', {className: 'form-group gma-field'},
444
+ h('label', {className: 'gma-toggle'}, input, h('span', {text: label})),
445
+ hint ? h('p', {className: 'form-hint', text: hint}) : null);
446
+ }
@@ -0,0 +1,92 @@
1
+ /**
2
+ * Games - what the Quiz and Crossword routes share: the play's key from the
3
+ * request, the rate limits, the honeypot and the admin's view of plays.
4
+ *
5
+ * @module _lib/games/http
6
+ */
7
+
8
+ import {cleanPeriod} from '../public/games/format.js';
9
+ import {PlayStore} from './plays.js';
10
+
11
+ /** The play's key: the `X-Play-Token` header (kept out of addresses and logs). */
12
+ export const tokenOf = (request) => String(request.headers['x-play-token'] || request.body?.t || '').slice(0, 100);
13
+
14
+ export const fail = (reply, status, error, extra = {}) => reply.code(status).send({error, ...extra});
15
+
16
+ /** Route options for the public side: a rate limit per visitor's address. */
17
+ export const limited = (max, minutes = 1) => ({config: {rateLimit: {max, timeWindow: `${minutes} minute${minutes === 1 ? '' : 's'}`}}});
18
+
19
+ /**
20
+ * A field people never see. A bot that fills it gets nothing (a 400 with the
21
+ * ordinary words, so it learns nothing either).
22
+ */
23
+ export const isBot = (body) => Boolean(String(body?.website ?? '').trim());
24
+
25
+ /**
26
+ * The name on an admin's test play: their first name (or the part of the
27
+ * email before the @) and "(test)", within a player name's 24 characters.
28
+ */
29
+ export function testerName(request) {
30
+ const u = request?.user || {};
31
+ const who = String(u.name || u.username || u.email || '').trim().split(/[\s@]/)[0];
32
+ return who ? `${who.slice(0, 17)} (test)` : 'Test';
33
+ }
34
+
35
+ /** `?period=&limit=` for a board, the limit within the game's own size. */
36
+ export function boardQuery(query, size = 10) {
37
+ const max = Math.max(1, Math.min(100, Number(size) || 10));
38
+ const limit = Math.max(1, Math.min(max, Math.round(Number(query?.limit) || max)));
39
+ return {period: cleanPeriod(query?.period), limit};
40
+ }
41
+
42
+ /**
43
+ * The admin's routes for one game's plays, under `base` (e.g. '/quizzes'):
44
+ * GET <base>/:id/plays every play of it (no keys), newest first, plus its board
45
+ * DELETE <base>/:id/plays a fresh board: every play gone
46
+ * PUT /plays/:playId {hidden} - off or back on the board
47
+ * DELETE /plays/:playId one play gone
48
+ */
49
+ export function mountPlayAdmin(fastify, {store, base, can, exists}) {
50
+ fastify.get(`${base}/:id/plays`, can('read'), async (request, reply) => {
51
+ if (!await exists(request.params.id)) return fail(reply, 404, 'Not found.');
52
+ const all = await store.forGame(request.params.id);
53
+ const list = all.map(PlayStore.adminView).sort((a, b) => String(b.startedAt).localeCompare(String(a.startedAt)));
54
+ return {plays: list, board: await store.board(request.params.id, {period: 'all', limit: 100})};
55
+ });
56
+
57
+ fastify.delete(`${base}/:id/plays`, can('manage'), async (request, reply) => {
58
+ if (!await exists(request.params.id)) return fail(reply, 404, 'Not found.');
59
+ return {removed: await store.reset(request.params.id)};
60
+ });
61
+
62
+ fastify.put('/plays/:playId', can('manage'), async (request, reply) => {
63
+ const saved = await store.setHidden(request.params.playId, request.body?.hidden === true);
64
+ if (!saved) return fail(reply, 404, 'Not found.');
65
+ return PlayStore.adminView(saved);
66
+ });
67
+
68
+ fastify.delete('/plays/:playId', can('manage'), async (request, reply) => {
69
+ if (!await store.get(request.params.playId)) return fail(reply, 404, 'Not found.');
70
+ await store.remove(request.params.playId);
71
+ return {ok: true};
72
+ });
73
+ }
74
+
75
+ /** The sidebar badge: plays finished today across every game of the Tool. */
76
+ export async function todayBadge(store, noun = 'play') {
77
+ const start = new Date();
78
+ start.setHours(0, 0, 0, 0);
79
+ const today = (await store.all()).filter(p => p.test !== true && p.status === 'finished' && Date.parse(p.finishedAt) >= start.getTime());
80
+ return {
81
+ count: today.length,
82
+ label: `${today.length} ${noun}${today.length === 1 ? '' : 's'} finished today`,
83
+ tone: 'info',
84
+ inventory: true,
85
+ details: {
86
+ title: 'Finished today',
87
+ items: today.sort((a, b) => String(b.finishedAt).localeCompare(String(a.finishedAt))).slice(0, 8)
88
+ .map(p => ({text: p.name, meta: p.score != null && store.order === 'score' ? `${p.score} points` : ''})),
89
+ empty: `No ${noun}s finished today yet.`
90
+ }
91
+ };
92
+ }