domma-cms 1.9.1 → 1.10.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/CHANGELOG.md CHANGED
@@ -1,5 +1,9 @@
1
1
  # Changelog
2
2
 
3
+ ## [1.10.0] - 2026-10-24
4
+
5
+ - **A new game every day, from a queue (rotation).** The Games library gains rotation, off until a site switches it on: a **Queued** status, a **Rotate** setting (Off, Every day, Every week) in a Rotation card above each Game's list, and the site's midnight opening the next queued game and moving the last one to an archive - still playable, never on a leaderboard. A restart never rotates twice; an empty queue leaves today's game up. `[quiz rotate /]` (and `[crossword rotate]`, `[word-search rotate]`) always shows today's game with the earlier ones below; notices when the queue runs low or empty. Fresh games from Domma arrive as drafts marked **From Domma**, with **Approve all**. `plugins/_lib/games/rotation.js`, `rotation-http.js`, `_lib/public/games/rotation.js`, `_lib/admin/games/rotation.js`; archive plays in `plays.js`. Quiz 1.5.0, Crossword 1.5.0, Word Search 1.2.0 and Daily Word 1.3.0 need this release.
6
+
3
7
  ## [1.9.1] - 2026-10-24
4
8
 
5
9
  - Columns support content alignment with [col left], [col center] / [col centre], [col right] or align="...". The Page editor offers a Content alignment selector for columns.
@@ -38,8 +38,8 @@ 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` | paid (giftable) | 1.4.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. Hints for a time penalty, personal best and Share, animations, export and import. Test it from the Tool - a test go never reaches the board. |
42
- | `daily-word` | paid (giftable) | 1.2.0 | One hidden word a day, the same for everyone: six guesses, each letter marked right place, in the word or not; real words only (a bundled dictionary, switchable), an archive of past days, streaks, Share as coloured squares, a calendar of what is coming. Fewest guesses tops the day's board, then the fastest. |
41
+ | `crossword` | paid (giftable) | 1.5.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. Hints for a time penalty, personal best and Share, animations, export and import. Test it from the Tool - a test go never reaches the board. Rotation (a new quiz every day or week from a queue, off by default) and fresh quizzes from Domma for review. |
42
+ | `daily-word` | paid (giftable) | 1.3.0 | One hidden word a day, the same for everyone: six guesses, each letter marked right place, in the word or not; real words only (a bundled dictionary, switchable), an archive of past days, streaks, Share as coloured squares, a calendar of what is coming. Fewest guesses tops the day's board, then the fastest. |
43
43
  | `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. |
44
44
  | `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. |
45
45
  | `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. |
@@ -52,7 +52,7 @@ Code was treated as the truth. Where docs disagree with it, the disagreement is
52
52
  | `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. |
53
53
  | `mongodb-adapter` | paid | 1.0.2 | Provision a dedicated MongoDB database per site. Credentials are managed centrally by the manager. |
54
54
  | `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. |
55
- | `quiz` | paid (giftable) | 1.4.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. Hints for a time penalty, personal best and Share, animations, export and import. Test it from the Tool - a test go never reaches the board. |
55
+ | `quiz` | paid (giftable) | 1.5.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. Hints for a time penalty, personal best and Share, animations, export and import. Test it from the Tool - a test go never reaches the board. Rotation (a new quiz every day or week from a queue, off by default) and fresh quizzes from Domma for review. |
56
56
  | `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. |
57
57
  | `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. |
58
58
  | `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). |
@@ -63,7 +63,7 @@ Code was treated as the truth. Where docs disagree with it, the disagreement is
63
63
  | `surveys` | paid | 1.5.1 | Targeted surveys over the Forms engine: shared audiences, tokenised invites, reminders, and per-survey statistics. |
64
64
  | `theme-roller` | paid | 1.1.3 | Make your own themes: start from any built-in theme, change its colours, fonts, spacing and borders with a live preview, and save it. Saved themes join the built-in ones everywhere a theme is chosen - the site, the admin, day and night, a region of a page. Needs Domma CMS 0.73 or later to use them. |
65
65
  | `waypoint-pro` | paid | 1.3.1 | Plan and track work: projects with their own workflows, issues on a board or in a list, a backlog planned into sprints with a burndown, and comments, mentions and watchers that tell the right people. |
66
- | `word-search` | paid (giftable) | 1.1.0 | Word searches on your site: type up to 40 words and the server lays out the letter grid (across, down, diagonal, backwards too if you like). Players drag, tap or use the keyboard to mark each word against the clock; wrong marks cost time, fastest full solve wins. Hints, personal best, Share, animations, export and import. |
66
+ | `word-search` | paid (giftable) | 1.2.0 | Word searches on your site: type up to 40 words and the server lays out the letter grid (across, down, diagonal, backwards too if you like). Players drag, tap or use the keyboard to mark each word against the clock; wrong marks cost time, fastest full solve wins. Hints, personal best, Share, animations, export and import. |
67
67
 
68
68
  Versions are read from `dcms-marketplace/plugins/*/plugin.json` (every plugin folder except `_lib`).
69
69
 
@@ -2419,7 +2419,7 @@ Tier: paid (closed source) · Version: 0.4.0 · Requires: nothing; no MongoDB (w
2419
2419
 
2420
2420
  ### Games: Quiz (`quiz`), Crossword (`crossword`), Word Search (`word-search`) and Daily Word (`daily-word`)
2421
2421
 
2422
- Tier: paid and giftable (the Manager can gift them for good or for a number of days) · Versions: Quiz 1.4.0, Crossword 1.4.0, Word Search 1.1.0, Daily Word 1.2.0 (need Domma CMS 1.9.0 for the shared games library and its motion files) · Sidebar: Tools > Games · Permissions: a Games group in the role editor
2422
+ Tier: paid and giftable (the Manager can gift them for good or for a number of days) · Versions: Quiz 1.5.0, Crossword 1.5.0, Word Search 1.2.0, Daily Word 1.3.0 (need Domma CMS 1.10.0 for rotation in the shared games library; 1.4.0/1.4.0/1.1.0/1.2.0 needed 1.9.0) · Sidebar: Tools > Games · Permissions: a Games group in the role editor
2423
2423
 
2424
2424
  All four share the engine's games library (`plugins/_lib/games`: plays, tokens, names, ranking, timing; `plugins/_lib/public/games`: player parts and motion; `plugins/_lib/admin/games/kit.js`: list, editor, test and plays screens). The server keeps the clock and marks every move; nothing that gives an answer reaches the browser before the play is finished. Name-only players, honeypot and rate limits, test plays from the Tool, a sidebar badge, notifications. The generic Collections API reaches a game's collections only with the game's `manage` action (`collectionAction` in plugin.json).
2425
2425
 
@@ -2457,6 +2457,8 @@ All four share the engine's games library (`plugins/_lib/games`: plays, tokens,
2457
2457
  - **Boards** - `board="show|hide|only"` on every game shortcode; a per-game **Leaderboard shows** period (`boardPeriod`) used when no period is asked for; `[games-leaderboards games="..." period="..." limit="..."]` puts every Game's boards on one page (each game registers the same handler, so it works with any of them installed). Board shortcodes on the page refresh when a game finishes.
2458
2458
  - **Personal best** on every finished screen; **Share** (no answers) on Quiz, Crossword and Word Search, coloured squares on Daily Word.
2459
2459
  - **Animations** - a per-game setting, on unless switched off: answers, letters and tiles pop or flip in, right and wrong answers pulse, found words pop along their line, finished screens and board rows pop in. Never under `prefers-reduced-motion` or `html.dm-reduced-motion`. Shared CSS and helpers: `plugins/_lib/public/games/motion.css` and `motion.js` (engine 1.9.0).
2460
+ - **Rotation** (Quiz 1.5.0, Crossword 1.5.0, Word Search 1.2.0; engine 1.10.0) - **off by default**. A game can be **Queued** (never shown to the public until its turn). **Rotate** Off / Every day / Every week (a weekday), in a Rotation card above each list: at the site's midnight the next queued game opens and the last one rotation opened closes into an archive - still playable, its plays (`archive: true`) never on a board, place or goes-per-name. Idempotent: `lastRotatedDay` in the Tool's `data/rotation.json`, plus a check that no game was already opened by rotation that day, so a restart at 00:00:30 never rotates twice; a one-minute timer in the Tool, no cron. An empty queue leaves today's game up. Drag to reorder the queue; take one out. `[quiz rotate /]` (and `[crossword rotate]`, `[word-search rotate]`) shows today's game with the earlier ones below; `[<game>-leaderboard rotate /]` today's board. Notices `queue-low` (under three days queued) and `queue-empty`. Engine: `plugins/_lib/games/rotation.js` (pure rules + runner), `rotation-http.js` (`GET/PUT /rotation`, `PUT /queue`, `POST /approve`, `POST /unqueue`, `GET /content/status`, `GET /public/rotation`), `_lib/public/games/rotation.js`, `_lib/admin/games/rotation.js`; tests `tests/plugins/games-rotation.test.js`.
2461
+ - **Fresh games from Domma** (the Manager's Games content, off by default per site) - quizzes, crosswords and word searches written by Claude (Haiku) on the site's theme (or one picked at random each night when it has none) arrive through `POST /import` with `{source: 'domma-content', status}` as **drafts marked From Domma** (or queued, with the site's auto-publish), and the admins get a `content-ready` notice. The Rotation card's **Show only these** and **Approve all**; Approve / Take out of the queue in each row's menu. Daily Word 1.3.0: **Top up** on a word set (off by default) takes common words picked by the Manager (no Claude, never repeated within two years) into `pending` for **Approve all** / **Discard** in the editor or the list's menu (`POST /content/words`, `POST /sets/:id/pending`). Only while the Tool asks for it: Rotate on, or a set with Top up.
2460
2462
 
2461
2463
  ### Other plugin directories
2462
2464
 
@@ -2555,6 +2557,7 @@ Later changes to these features are in the CHANGELOG (for example 0.92.1: row-le
2555
2557
  ### Plugins & the Marketplace platform
2556
2558
 
2557
2559
  - A games page: a Daily Word set with a month of answers, a Word Search and a Quiz on one page, with `[games-leaderboards]` below them; hints with a penalty, and moving a game to another site with Export and Import.
2560
+ - A page that never needs editing: queue a week of quizzes, switch Rotate on, put `[quiz rotate /]` on the page, and approve Domma's fresh quizzes as they arrive.
2558
2561
  - Gifting a Tool from the Manager: gift a Game for 30 days, extend it, make it for good, and end it.
2559
2562
  - Build your first plugin in 10 minutes: New plugin in the Marketplace, add a route and a screen in the code editor, restart, see it in the sidebar.
2560
2563
  - Add a custom shortcode and let its HTML through the sanitiser (`registerShortcode` + `registerSanitizeRules`).
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "domma-cms",
3
- "version": "1.9.1",
3
+ "version": "1.10.0",
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",
@@ -1 +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}
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}.gma-inline{display:inline-flex;align-items:center;gap:.4rem;font-size:var(--dm-text-sm)}.gma-inline .form-select{width:auto}.gma-waiting{display:flex;flex-wrap:wrap;gap:.5rem;align-items:center}.gma-queue{display:flex;flex-direction:column;gap:.25rem;margin-top:.25rem}.gma-qrow{display:flex;align-items:center;gap:.5rem;padding:.35rem .5rem;border:1px solid var(--gma-line);border-radius:6px;background:var(--dm-surface, transparent)}.gma-qnum{color:var(--dm-text-muted);font-variant-numeric:tabular-nums;min-width:1.6rem}.gma-qtitle{font-weight:600;color:var(--dm-text);text-decoration:none;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.gma-qtitle:hover{color:var(--dm-primary)}
@@ -0,0 +1,174 @@
1
+ /**
2
+ * Games admin - the Rotation card a rotating Tool shows above its list: the
3
+ * Rotate setting (Off by default), today's game, the queue (drag to reorder,
4
+ * take one out), and the games from Domma waiting for review (Approve all, or
5
+ * show only them in the list).
6
+ *
7
+ * Mounted BESIDE the list's own screen, never inside it (a second
8
+ * M.applyBindings inside a bound tree does not take). domma-reactive.
9
+ * Talks to the routes of `_lib/games/rotation-http.js`.
10
+ *
11
+ * @module _lib/admin/games/rotation
12
+ */
13
+
14
+ import {errText, esc, ico, loadKit, request, sortableRows} from './kit.js';
15
+
16
+ const WEEKDAYS = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday'];
17
+
18
+ const ROW = `
19
+ <div class="gma-qrow" data-id="{{id}}">
20
+ <span class="gma-grip" title="Drag to move" aria-hidden="true">${ico('menu')}</span>
21
+ <span class="gma-qnum" data-bind-text="place"></span>
22
+ <a class="gma-qtitle" data-bind-href="href" data-bind-text="title"></a>
23
+ <span class="gma-chip is-primary" hidden data-bind-hidden="!fromDomma">From Domma</span>
24
+ <span class="gma-spacer"></span>
25
+ <button type="button" class="btn btn-xs btn-ghost gma-quiet" title="Take it out of the queue (back to draft)" data-on-click="$root.unqueue(id)">${ico('x')}</button>
26
+ </div>`;
27
+
28
+ const html = (help) => `
29
+ <section class="gma-card gma-rotation">
30
+ <div class="gma-bar">
31
+ <h3 class="gma-h">Rotation</h3>
32
+ <span data-help-title="Rotation" data-help="${esc(help)}"></span>
33
+ <span class="gma-spacer"></span>
34
+ <label class="gma-inline">Rotate
35
+ <select class="form-select form-select-sm" data-part="mode" data-model="mode.value">
36
+ <option value="off">Off</option><option value="daily">Every day</option><option value="weekly">Every week</option>
37
+ </select>
38
+ </label>
39
+ <label class="gma-inline" hidden data-bind-hidden="mode.value !== 'weekly'">on
40
+ <select class="form-select form-select-sm" data-part="weekday" data-model="weekday.value">${WEEKDAYS.map((d, i) => `<option value="${i}">${d}</option>`).join('')}</select>
41
+ </label>
42
+ </div>
43
+ <p class="gma-note" data-bind-text="summary.value"></p>
44
+ <p class="gma-note" hidden data-bind-hidden="!shortcode">Put <code data-bind-text="shortcode"></code> on a page and it always shows today's game.</p>
45
+ <div class="gma-waiting" hidden data-bind-hidden="!waiting.value.length">
46
+ <span class="gma-chip is-primary" data-bind-text="waitingText.value"></span>
47
+ <button type="button" class="btn btn-sm btn-primary" data-on-click="approveAll" data-bind-disabled="busy.value">${ico('check')} Approve all</button>
48
+ <label class="gma-toggle"><input type="checkbox" data-part="only"> <span>Show only these</span></label>
49
+ </div>
50
+ <div hidden data-bind-hidden="!queue.value.length">
51
+ <p class="gma-note"><strong>The queue</strong> - the first opens next. Drag to change the order.</p>
52
+ <div class="gma-queue" data-part="queue" data-each="queue.value key=id"></div>
53
+ </div>
54
+ <p class="gma-note" hidden data-bind-hidden="queue.value.length">Nothing is queued. Set a game's status to Queued, or Approve one Domma made.</p>
55
+ </section>`;
56
+
57
+ /**
58
+ * @param {HTMLElement} host an element of its own, outside the list's bound screen
59
+ * @param {object} o
60
+ * @param {string} o.api '/plugins/quiz'
61
+ * @param {string} o.noun 'quiz'
62
+ * @param {string} o.nouns 'quizzes'
63
+ * @param {string} o.shortcode '[quiz rotate /]'
64
+ * @param {(id: string) => string} o.href a game's edit address
65
+ * @param {object} o.lifetime
66
+ * @param {() => any} [o.onChange] the list reloads
67
+ * @returns {Promise<{fromDomma: object, reload: () => Promise<void>}>}
68
+ */
69
+ export async function rotationPanel(host, {api, noun, nouns, shortcode, href, lifetime, onChange = () => {}}) {
70
+ const s = {
71
+ mode: M.observable('off'),
72
+ weekday: M.observable('1'),
73
+ summary: M.observable(''),
74
+ queue: M.observable([]),
75
+ waiting: M.observable([]),
76
+ waitingText: M.observable(''),
77
+ busy: M.observable(false),
78
+ fromDomma: M.observable(false),
79
+ shortcode
80
+ };
81
+ const helpText = `Rotate opens the next queued ${noun} at midnight (every day, or once a week) and closes the one before - it moves to the archive, still playable but off the leaderboard. Off by default: nothing changes until you switch it on. Queued ${nouns} are never shown to the public until their turn.`;
82
+
83
+ const draw = (r) => {
84
+ s.mode.value = r.settings.mode;
85
+ s.weekday.value = String(r.settings.weekday);
86
+ const o = r.outlook;
87
+ const today = r.current ? `Today: ${r.current.title}.` : (o.mode === 'off' ? '' : `No ${noun} rotated in yet.`);
88
+ const reach = o.mode === 'off' ? `Rotation is off. ${o.queued} ${o.queued === 1 ? noun : nouns} queued.`
89
+ : `${o.queued} queued - ${o.days} day${o.days === 1 ? '' : 's'} ahead.${o.empty ? ' The queue is empty, so today\'s stays up.' : o.low ? ' Running low.' : ''}`;
90
+ s.summary.value = [today, reach].filter(Boolean).join(' ');
91
+ s.queue.value = r.queue.map((g, i) => ({id: g.id, title: g.title, place: `${i + 1}.`, href: href(g.id), fromDomma: g.origin === 'domma'}));
92
+ s.waiting.value = r.waiting;
93
+ s.waitingText.value = `${r.waiting.length} ${r.waiting.length === 1 ? noun : nouns} from Domma to review`;
94
+ if (!r.waiting.length && s.fromDomma.peek()) { s.fromDomma.value = false; if (only) only.checked = false; }
95
+ queueMicrotask(() => { if (host.isConnected && typeof I !== 'undefined') I.scan(host); });
96
+ };
97
+
98
+ async function reload() {
99
+ try { draw(await request('GET', `${api}/rotation`)); } catch (err) { s.summary.value = `Rotation could not load: ${errText(err)}`; }
100
+ }
101
+
102
+ async function saveSetting() {
103
+ try {
104
+ const r = await request('PUT', `${api}/rotation`, {mode: s.mode.peek(), weekday: Number(s.weekday.peek())});
105
+ const m = r.settings.mode;
106
+ E.toast(m === 'off' ? 'Rotation is off.' : `Rotation is on: ${m === 'daily' ? 'every day' : `every ${WEEKDAYS[r.settings.weekday]}`}.${r.rotated?.opened ? ' The first queued game is open now.' : ''}`, {type: 'success'});
107
+ await reload();
108
+ if (r.rotated?.opened) onChange();
109
+ } catch (err) { E.toast(esc(errText(err)), {type: 'error'}); }
110
+ }
111
+
112
+ s.unqueue = async (id) => {
113
+ try {
114
+ await request('POST', `${api}/unqueue`, {id});
115
+ await reload();
116
+ onChange();
117
+ } catch (err) { E.toast(esc(errText(err)), {type: 'error'}); }
118
+ };
119
+
120
+ s.approveAll = async () => {
121
+ const n = s.waiting.peek().length;
122
+ if (!await E.confirm(`Add the ${n} ${n === 1 ? noun : nouns} from Domma to the end of the queue?`)) return;
123
+ s.busy.value = true;
124
+ try {
125
+ const r = await request('POST', `${api}/approve`, {all: true});
126
+ const skipped = r.skipped.length ? ` ${r.skipped.length} could not be queued: ${r.skipped.map(x => `${esc(x.title)} (${esc(x.error)})`).join('; ')}.` : '';
127
+ E.toast(`${r.approved.length} queued.${skipped}`, {type: r.skipped.length ? 'warning' : 'success'});
128
+ await reload();
129
+ onChange();
130
+ } catch (err) { E.toast(esc(errText(err)), {type: 'error'}); }
131
+ s.busy.value = false;
132
+ };
133
+
134
+ const kit = await loadKit();
135
+ host.innerHTML = html(helpText);
136
+ host.classList.add('gma-screen');
137
+ host.querySelector('[data-part="queue"]').innerHTML = ROW;
138
+ const only = host.querySelector('[data-part="only"]');
139
+ const onOnly = () => { s.fromDomma.value = only.checked; };
140
+ only.addEventListener('change', onOnly);
141
+ const disposeHelp = kit.helpPopovers(host, {position: 'bottom'});
142
+ const bindings = M.applyBindings(s, host, {methods: s});
143
+ // After the bindings, so the model already holds the new value.
144
+ const selects = [host.querySelector('[data-part="mode"]'), host.querySelector('[data-part="weekday"]')];
145
+ for (const sel of selects) sel.addEventListener('change', saveSetting);
146
+ sortableRows(host.querySelector('[data-part="queue"]'), {
147
+ items: '.gma-qrow', handle: '.gma-grip', lifetime,
148
+ onMove: async (from, to) => {
149
+ const ids = s.queue.peek().map(q => q.id);
150
+ const [moved] = ids.splice(from, 1);
151
+ ids.splice(to, 0, moved);
152
+ s.queue.value = ids.map((id, i) => ({...s.queue.peek().find(q => q.id === id), place: `${i + 1}.`}));
153
+ try { await request('PUT', `${api}/queue`, {ids}); } catch (err) { E.toast(esc(errText(err)), {type: 'error'}); await reload(); }
154
+ }
155
+ });
156
+ lifetime?.add(() => {
157
+ bindings?.dispose?.(); disposeHelp?.(); only.removeEventListener('change', onOnly);
158
+ for (const sel of selects) sel.removeEventListener('change', saveSetting);
159
+ });
160
+ await reload();
161
+ return {fromDomma: s.fromDomma, reload};
162
+ }
163
+
164
+ /** Approve one game (a draft) from a list's row menu: it joins the queue. */
165
+ export async function approveOne(api, id) {
166
+ const r = await request('POST', `${api}/approve`, {ids: [id]});
167
+ if (r.skipped.length) throw new Error(r.skipped[0].error);
168
+ return r;
169
+ }
170
+
171
+ /** A list row's status, with Queued and the archive. */
172
+ export const STATUS_LABEL = {draft: 'Draft', queued: 'Queued', open: 'Open', closed: 'Closed'};
173
+ export const STATUS_TONE = {draft: '', queued: 'is-primary', open: 'is-success', closed: 'is-warning'};
174
+ export const statusText = (g) => (g.status === 'closed' && g.rotatedOff ? 'Archived' : g.status === 'queued' && g.queuePlace ? `Queued #${g.queuePlace}` : STATUS_LABEL[g.status] || g.status);
@@ -12,6 +12,10 @@
12
12
  * Writes go one at a time (`withLock`), so two answers sent at once cannot
13
13
  * both be marked.
14
14
  *
15
+ * An ARCHIVE play (`archive: true`) is a go at a game rotation has closed
16
+ * (_lib/games/rotation.js): played and timed like any other, listed for the
17
+ * admin, but never on a board, never a place, never counted for goes per name.
18
+ *
15
19
  * A TEST play (`test: true`) is an admin trying the game from its Tool. It
16
20
  * plays exactly like any other, but it is nobody's go: it never counts for a
17
21
  * board, a place, the goes-per-name limit, the badge or the admin's list of
@@ -27,6 +31,8 @@ import {placeOf, rankPlays} from './ranking.js';
27
31
  import {ABANDON_MS} from './timing.js';
28
32
 
29
33
  const flat = (e) => (e ? {id: e.id, ...e.data} : null);
34
+ /** Plays that may stand on a board: not archive plays (tests are left out by forGame). */
35
+ const onBoard = (p) => p?.archive !== true;
30
36
 
31
37
  export class PlayStore {
32
38
  /**
@@ -88,21 +94,21 @@ export class PlayStore {
88
94
  /** How many goes this name has had at this game (any state but hidden ones the admin removed). */
89
95
  async attemptsBy(gameId, name) {
90
96
  const key = nameKey(name);
91
- return (await this.forGame(gameId)).filter(p => p.nameKey === key).length;
97
+ return (await this.forGame(gameId)).filter(p => p.nameKey === key && p.archive !== true).length;
92
98
  }
93
99
 
94
100
  /**
95
101
  * @param {{gameId: string, name: string, maxScore?: number, state?: object, userId?: string, test?: boolean}} play
96
- * `test`: an admin trying the game - see the module comment
102
+ * `test`: an admin trying the game; `archive`: a go at a game rotation has closed - see the module comment
97
103
  * @returns {Promise<object>} the play, token included - for the player, once
98
104
  */
99
- async start({gameId, name, maxScore = 0, state = {}, userId = '', test = false}) {
105
+ async start({gameId, name, maxScore = 0, state = {}, userId = '', test = false, archive = false}) {
100
106
  return this.withLock(async () => {
101
107
  const now = new Date().toISOString();
102
108
  const saved = await createEntry(this.slug, {
103
109
  gameId, name, nameKey: nameKey(name), userId,
104
110
  status: 'playing', startedAt: now, finishedAt: '', timeMs: 0, score: 0, maxScore,
105
- hidden: false, ...(test ? {test: true} : {}), token: randomBytes(24).toString('base64url'), state
111
+ hidden: false, ...(test ? {test: true} : {}), ...(archive ? {archive: true} : {}), token: randomBytes(24).toString('base64url'), state
106
112
  }, {source: test ? 'admin' : 'public'});
107
113
  this.forget();
108
114
  return flat(saved);
@@ -159,11 +165,13 @@ export class PlayStore {
159
165
  }
160
166
 
161
167
  async board(gameId, {period = 'all', limit = 10} = {}) {
162
- return rankPlays(await this.forGame(gameId), {order: this.order, period, limit});
168
+ return rankPlays((await this.forGame(gameId)).filter(onBoard), {order: this.order, period, limit});
163
169
  }
164
170
 
171
+ /** Where a play stands; 0 for an archive play, which is on no board. */
165
172
  async placeOf(play, period = 'all') {
166
- return placeOf(play, await this.forGame(play.gameId), {order: this.order, period});
173
+ if (!onBoard(play)) return 0;
174
+ return placeOf(play, (await this.forGame(play.gameId)).filter(onBoard), {order: this.order, period});
167
175
  }
168
176
 
169
177
  /** A play as the admin sees it: everything but the key and the game's inner state. */
@@ -0,0 +1,105 @@
1
+ /**
2
+ * Games - the rotation routes every rotating Tool mounts (Quiz, Crossword,
3
+ * Word Search), beside its own:
4
+ *
5
+ * GET /rotation (read) the Rotate setting, today's game, the queue, what waits for review
6
+ * PUT /rotation (manage) {mode: off|daily|weekly, weekday} - the setting
7
+ * PUT /queue (manage) {ids: [...]} - the queue's new order
8
+ * POST /approve (manage) {ids: [...]} or {all: true} - Domma's drafts (or any draft named) into the queue
9
+ * POST /unqueue (manage) {id} - a queued game back to draft
10
+ * GET /content/status (manage) what the Manager's content builder asks before making more
11
+ * GET /public/rotation (anyone) today's rotated game and the archive - slugs and titles only
12
+ *
13
+ * @module _lib/games/rotation-http
14
+ */
15
+
16
+ import {fail, limited} from './http.js';
17
+ import {contentStatus, currentOf, nextQueuePos, publicRotation, queueOf, queueOutlook, waitingOf} from './rotation.js';
18
+
19
+ /** A game's record without what the collection keeps itself. */
20
+ export const recordOf = ({id, createdAt, updatedAt, ...rest}) => rest;
21
+
22
+ /**
23
+ * @param {object} fastify
24
+ * @param {object} o
25
+ * @param {(action: string) => object} o.can route options for the Tool's permission
26
+ * @param {object} o.rotation createRotation()'s runner
27
+ * @param {() => Promise<object[]>} o.list every game, flat
28
+ * @param {(id: string, record: object) => Promise<any>} o.save write one game whole (no id/meta)
29
+ * @param {(game: object) => string[]} [o.usedOf] what a game used (answers, words) for the builder's dedupe
30
+ * @param {(game: object) => string} [o.why] why a game cannot be queued ('' when it can)
31
+ */
32
+ export function mountRotationRoutes(fastify, {can, rotation, list, save, usedOf = () => [], why = () => ''}) {
33
+ const row = (g) => ({id: g.id, title: g.title, slug: g.slug, status: g.status, origin: g.origin || '', rotatedOn: g.rotatedOn || '', updatedAt: g.updatedAt || ''});
34
+
35
+ fastify.get('/rotation', can('read'), async () => {
36
+ const games = await list();
37
+ const {settings, lastRotatedDay} = await rotation.read();
38
+ const cur = currentOf(games);
39
+ return {settings, lastRotatedDay, outlook: queueOutlook(settings, games), current: cur ? row(cur) : null,
40
+ queue: queueOf(games).map(row), waiting: waitingOf(games).map(row)};
41
+ });
42
+
43
+ fastify.put('/rotation', can('manage'), async (request) => {
44
+ const settings = await rotation.saveSettings(request.body || {});
45
+ // Switching it on opens the first queued game now rather than at the next minute's look.
46
+ const done = settings.mode === 'off' ? null : await rotation.tick().catch(() => null);
47
+ return {settings, rotated: done};
48
+ });
49
+
50
+ // Every write below runs in the rotation's turn (rotation.run), reading the games inside it, so a
51
+ // midnight rotation and these never act on each other's stale copy.
52
+ fastify.put('/queue', can('manage'), async (request, reply) => {
53
+ const ids = Array.isArray(request.body?.ids) ? request.body.ids.map(String) : null;
54
+ if (!ids) return fail(reply, 400, 'Send the queue as a list of ids.');
55
+ await rotation.run(async () => {
56
+ const queued = queueOf(await list());
57
+ const order = [...ids.filter(id => queued.some(g => g.id === id)), ...queued.map(g => g.id).filter(id => !ids.includes(id))];
58
+ let n = 0;
59
+ for (const id of order) {
60
+ const g = queued.find(x => x.id === id);
61
+ n++;
62
+ if (Number(g.queuePos) !== n) await save(id, {...recordOf(g), queuePos: n});
63
+ }
64
+ });
65
+ return {queue: queueOf(await list()).map(row)};
66
+ });
67
+
68
+ fastify.post('/approve', can('manage'), async (request, reply) => {
69
+ const out = await rotation.run(async () => {
70
+ const games = await list();
71
+ const named = Array.isArray(request.body?.ids) ? request.body.ids.map(String) : [];
72
+ const wanted = request.body?.all === true ? waitingOf(games) : games.filter(g => g.status === 'draft' && named.includes(g.id));
73
+ if (!wanted.length) return null;
74
+ let pos = nextQueuePos(games);
75
+ const approved = [];
76
+ const skipped = [];
77
+ for (const g of wanted) {
78
+ const reason = why(g);
79
+ if (reason) { skipped.push({id: g.id, title: g.title, error: reason}); continue; }
80
+ await save(g.id, {...recordOf(g), status: 'queued', queuePos: pos++});
81
+ approved.push(row({...g, status: 'queued'}));
82
+ }
83
+ return {approved, skipped};
84
+ });
85
+ if (!out) return fail(reply, 400, 'There is nothing to approve.');
86
+ // Rotation on with an empty queue: the first approved opens now (outside the turn above - tick takes its own).
87
+ await rotation.tick().catch(() => null);
88
+ return out;
89
+ });
90
+
91
+ fastify.post('/unqueue', can('manage'), async (request, reply) => {
92
+ const done = await rotation.run(async () => {
93
+ const g = (await list()).find(x => x.id === String(request.body?.id || ''));
94
+ if (!g || g.status !== 'queued') return false;
95
+ const {queuePos, ...rest} = recordOf(g);
96
+ await save(g.id, {...rest, status: 'draft'});
97
+ return true;
98
+ });
99
+ return done ? {ok: true} : fail(reply, 404, 'That game is not in the queue.');
100
+ });
101
+
102
+ fastify.get('/content/status', can('manage'), async () => contentStatus((await rotation.read()).settings, await list(), {usedOf}));
103
+
104
+ fastify.get('/public/rotation', limited(120), async () => publicRotation(await list()));
105
+ }
@@ -0,0 +1,323 @@
1
+ /**
2
+ * Games - rotation: a new game every day (or every week) from a queue.
3
+ *
4
+ * A Tool with Rotate on opens the next QUEUED game at the site's day boundary
5
+ * and closes the one rotation opened before (its board and plays stay; it
6
+ * becomes an archived game - still playable, never on a board). Off is the
7
+ * default, and off does nothing at all.
8
+ *
9
+ * A game record carries, beside its own fields (server-owned; a save from the
10
+ * editor never sets them, the routes carry them over with `rotationFields`):
11
+ *
12
+ * status 'queued' - waiting its turn (between draft and open; never shown to the public)
13
+ * queuePos its place in the queue (lower first)
14
+ * rotatedOn 'YYYY-MM-DD' the day rotation opened it
15
+ * rotatedOff 'YYYY-MM-DD' the day rotation closed it (an archived game)
16
+ * origin 'domma' when the content builder made it; originAt
17
+ *
18
+ * The Tool's rotation file (`data/rotation.json`, beside its other runtime data)
19
+ * holds `{settings: {mode, weekday}, lastRotatedDay}`. `lastRotatedDay` is the
20
+ * guard against rotating twice: a restart at 00:00:30 reads it back and does
21
+ * nothing. A second guard covers a crash between the game writes and that file:
22
+ * a game already opened by rotation today means today's rotation happened.
23
+ *
24
+ * Days are the SITE'S day (the server's local time), as the leaderboards' "today"
25
+ * (_lib/games/ranking.js) and Daily Word.
26
+ *
27
+ * Pure parts first (tested on their own); `createRotation()` is the runner a
28
+ * Tool starts: a one-minute timer, no cron inside the site.
29
+ *
30
+ * @module _lib/games/rotation
31
+ */
32
+
33
+ import path from 'node:path';
34
+ import {createStore} from '../dataStore.js';
35
+
36
+ export const ROTATE_MODES = ['off', 'daily', 'weekly'];
37
+ export const ROTATE_LABELS = {off: 'Off', daily: 'Every day', weekly: 'Every week'};
38
+ /** Fewer days than this queued raises the queue-low notice. */
39
+ export const LOW_DAYS = 3;
40
+ /** How often the runner looks at the clock. */
41
+ export const TICK_MS = 60 * 1000;
42
+
43
+ const pad = (n) => String(n).padStart(2, '0');
44
+ const DAY_RE = /^\d{4}-\d{2}-\d{2}$/;
45
+
46
+ /** The site's day at `now` ('YYYY-MM-DD', local time). */
47
+ export function dayOf(now = Date.now()) {
48
+ const d = new Date(now);
49
+ return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`;
50
+ }
51
+
52
+ const utc = (day) => { const [y, m, d] = day.split('-').map(Number); return Date.UTC(y, m - 1, d); };
53
+ const fromUtc = (t) => { const d = new Date(t); return `${d.getUTCFullYear()}-${pad(d.getUTCMonth() + 1)}-${pad(d.getUTCDate())}`; };
54
+ /** 0 Sunday ... 6 Saturday, of a 'YYYY-MM-DD'. */
55
+ export const weekdayOf = (day) => new Date(utc(day)).getUTCDay();
56
+ export const addDays = (day, n) => fromUtc(utc(day) + Math.round(n) * 86400000);
57
+
58
+ /** The Rotate setting, made safe. Off unless it says otherwise; the week starts on Monday (1). */
59
+ export function cleanRotation(input = {}) {
60
+ const mode = ROTATE_MODES.includes(input?.mode) ? input.mode : 'off';
61
+ const w = Math.round(Number(input?.weekday));
62
+ return {mode, weekday: Number.isFinite(w) && w >= 0 && w <= 6 ? w : 1};
63
+ }
64
+
65
+ /**
66
+ * The last rotation day on or before today: today when daily, the latest
67
+ * chosen weekday when weekly, '' when off.
68
+ */
69
+ export function boundaryDay(settings, now = Date.now()) {
70
+ const s = cleanRotation(settings);
71
+ if (s.mode === 'off') return '';
72
+ const today = dayOf(now);
73
+ if (s.mode === 'daily') return today;
74
+ return addDays(today, -((weekdayOf(today) - s.weekday + 7) % 7));
75
+ }
76
+
77
+ /** The day to rotate for now, or '' when it is not due (off, or already done for this boundary). */
78
+ export function rotationDue(settings, state = {}, now = Date.now()) {
79
+ const day = boundaryDay(settings, now);
80
+ const last = DAY_RE.test(String(state?.lastRotatedDay || '')) ? state.lastRotatedDay : '';
81
+ return day && day > last ? day : '';
82
+ }
83
+
84
+ const byQueue = (a, b) => (Number(a.queuePos) || 0) - (Number(b.queuePos) || 0) || String(a.createdAt || '').localeCompare(String(b.createdAt || ''));
85
+
86
+ /** The queued games in their turn. */
87
+ export const queueOf = (games) => (games || []).filter(g => g && g.status === 'queued').sort(byQueue);
88
+
89
+ /** The game rotation has open now (status open, opened by rotation, not closed by it). */
90
+ export const currentOf = (games) => (games || []).filter(g => g && g.status === 'open' && g.rotatedOn && !g.rotatedOff)
91
+ .sort((a, b) => String(b.rotatedOn).localeCompare(String(a.rotatedOn)))[0] || null;
92
+
93
+ /** A game rotation has closed: still playable, off the boards. */
94
+ export const isArchived = (game) => Boolean(game && game.status === 'closed' && game.rotatedOff);
95
+
96
+ /** May the public see it at all? Never a draft or a queued game. */
97
+ export const isPublic = (game) => Boolean(game && game.status !== 'draft' && game.status !== 'queued');
98
+
99
+ /** The next place at the back of the queue. */
100
+ export const nextQueuePos = (games) => (games || []).reduce((m, g) => Math.max(m, Number(g?.queuePos) || 0), 0) + 1;
101
+
102
+ /**
103
+ * What rotating on `day` does:
104
+ * {already: true} a game was opened by rotation on `day` - nothing to do
105
+ * {empty: true, close: []} nothing queued: leave today's game open, try again later
106
+ * {close: [ids], open: id} close what rotation had open, open the next queued
107
+ */
108
+ export function planRotation(games, day) {
109
+ const list = games || [];
110
+ if (list.some(g => g && g.status === 'open' && g.rotatedOn === day && !g.rotatedOff)) return {already: true, close: [], open: null};
111
+ const next = queueOf(list)[0];
112
+ if (!next) return {empty: true, close: [], open: null};
113
+ const close = list.filter(g => g && g.status === 'open' && g.rotatedOn && !g.rotatedOff).map(g => g.id);
114
+ return {close, open: next.id};
115
+ }
116
+
117
+ /** The game as rotation closes it on `day`. */
118
+ export const closedBy = (game, day) => ({...game, status: 'closed', rotatedOff: day});
119
+ /** The game as rotation opens it on `day`: its own dates cleared, so the queue decides. */
120
+ export const openedBy = (game, day) => ({...game, status: 'open', rotatedOn: day, rotatedOff: '', opensAt: '', closesAt: '', queuePos: 0});
121
+
122
+ /** How many days the queue covers: one a game daily, seven weekly. */
123
+ export function queueOutlook(settings, games) {
124
+ const s = cleanRotation(settings);
125
+ const queued = queueOf(games).length;
126
+ const days = s.mode === 'weekly' ? queued * 7 : queued;
127
+ return {mode: s.mode, queued, days, low: s.mode !== 'off' && queued > 0 && days < LOW_DAYS, empty: s.mode !== 'off' && queued === 0};
128
+ }
129
+
130
+ /** The server-owned rotation fields of a stored game, to carry over a save from the editor. */
131
+ export function rotationFields(game) {
132
+ const out = {};
133
+ for (const k of ['queuePos', 'rotatedOn', 'rotatedOff', 'origin', 'originAt']) if (game?.[k] !== undefined && game[k] !== '') out[k] = game[k];
134
+ return out;
135
+ }
136
+
137
+ /**
138
+ * A save from the editor: the cleaned game plus the stored game's rotation
139
+ * fields. Turning a game to queued puts it at the back of the queue.
140
+ */
141
+ export function withRotation(clean, stored, games) {
142
+ const out = {...clean, ...rotationFields(stored)};
143
+ // An editor opened before rotation moved this game (queued -> open at midnight, or open -> archived) would
144
+ // put it back in the queue on save. Rotation's move wins: a game it has opened is never re-queued from the editor.
145
+ if (clean.status === 'queued' && stored && stored.status !== 'queued' && stored.rotatedOn) out.status = stored.status;
146
+ if (out.status === 'queued' && stored?.status !== 'queued') out.queuePos = nextQueuePos(games);
147
+ if (out.status !== 'queued') delete out.queuePos;
148
+ return out;
149
+ }
150
+
151
+ /** What the public may know: today's rotated game and the archive (newest first). */
152
+ export function publicRotation(games, {archiveMax = 60} = {}) {
153
+ const cur = currentOf(games);
154
+ const archive = (games || []).filter(isArchived)
155
+ .sort((a, b) => String(b.rotatedOff).localeCompare(String(a.rotatedOff)) || String(b.rotatedOn).localeCompare(String(a.rotatedOn)))
156
+ .slice(0, archiveMax)
157
+ .map(g => ({slug: g.slug, title: g.title, day: g.rotatedOn || '', until: g.rotatedOff}));
158
+ return {current: cur ? {slug: cur.slug, title: cur.title, day: cur.rotatedOn} : null, archive};
159
+ }
160
+
161
+ /**
162
+ * An admin's list row's rotation part: where it stands in the queue, whether
163
+ * Domma made it, when rotation opened or closed it.
164
+ */
165
+ export function rotationSummary(game, games) {
166
+ const q = game.status === 'queued' ? queueOf(games).findIndex(g => g.id === game.id) + 1 : 0;
167
+ return {queuePlace: q, origin: game.origin || '', rotatedOn: game.rotatedOn || '', rotatedOff: game.rotatedOff || '', archived: isArchived(game)};
168
+ }
169
+
170
+ /**
171
+ * Games arriving from the Manager's content builder (`POST /import` with
172
+ * `{source: 'domma-content', status}`): drafts for review, or queued when the
173
+ * site has auto-publish on. Anything else imported is a draft, as before.
174
+ */
175
+ export function importIntent(body) {
176
+ const fromDomma = body?.source === 'domma-content';
177
+ return {fromDomma, status: fromDomma && body?.status === 'queued' ? 'queued' : 'draft'};
178
+ }
179
+
180
+ /** Games made by Domma that wait for the admin's Approve. */
181
+ export const waitingOf = (games) => (games || []).filter(g => g && g.origin === 'domma' && g.status === 'draft')
182
+ .sort((a, b) => String(a.originAt || a.createdAt || '').localeCompare(String(b.originAt || b.createdAt || '')) || String(a.createdAt || '').localeCompare(String(b.createdAt || '')));
183
+
184
+ /**
185
+ * What the Manager asks before making more (GET /content/status): the Rotate
186
+ * setting, how far the queue reaches, how many wait for review, and what was used
187
+ * already (titles and the Tool's own `usedOf` - answers, words - for dedupe).
188
+ */
189
+ export function contentStatus(settings, games, {usedOf = () => [], max = 400} = {}) {
190
+ const o = queueOutlook(settings, games);
191
+ const used = [];
192
+ for (const g of games || []) used.push(...(usedOf(g) || []));
193
+ return {
194
+ rotate: o.mode, queued: o.queued, days: o.days, waiting: waitingOf(games).length,
195
+ titles: [...new Set((games || []).map(g => g.title).filter(Boolean))].slice(-max),
196
+ used: [...new Set(used.map(String).filter(Boolean))].slice(-max)
197
+ };
198
+ }
199
+
200
+ // ---- The runner ------------------------------------------------------------------------
201
+
202
+ /**
203
+ * One Tool's rotation: its settings file, the timer and the notices.
204
+ *
205
+ * @param {object} opts
206
+ * @param {string} opts.file the rotation file (`<plugin>/data/rotation.json`)
207
+ * @param {() => Promise<object[]>} opts.list every game, flat ({id, status, slug, title, ...})
208
+ * @param {(id: string, game: object) => Promise<any>} opts.save write one game whole
209
+ * @param {object} [opts.log]
210
+ * @param {() => number} [opts.clock] the time (tests move it; the site uses its own clock)
211
+ */
212
+ export function createRotation({file, list, save, log = console, clock = Date.now}) {
213
+ const store = createStore(path.resolve(file));
214
+ let queue = Promise.resolve();
215
+ let timer = null;
216
+ let boot = null;
217
+ const locked = (fn) => {
218
+ const run = queue.then(fn, fn);
219
+ queue = run.catch(() => {});
220
+ return run;
221
+ };
222
+
223
+ const read = async () => {
224
+ const raw = await store.read();
225
+ return {settings: cleanRotation(raw.settings), lastRotatedDay: DAY_RE.test(String(raw.lastRotatedDay || '')) ? raw.lastRotatedDay : ''};
226
+ };
227
+
228
+ /** Rotate now if it is due. Answers what happened ({day, opened, closed} / {empty} / null). */
229
+ function tick(now = clock()) {
230
+ return locked(async () => {
231
+ const state = await read();
232
+ const day = rotationDue(state.settings, state, now);
233
+ if (!day) return null;
234
+ const games = await list();
235
+ const plan = planRotation(games, day);
236
+ if (plan.empty) return {day, empty: true};
237
+ if (!plan.already) {
238
+ const byId = new Map(games.map(g => [g.id, g]));
239
+ for (const id of plan.close) await save(id, closedBy(byId.get(id), day));
240
+ await save(plan.open, openedBy(byId.get(plan.open), day));
241
+ log.info?.(`[games] rotated on ${day}: opened ${byId.get(plan.open)?.slug}${plan.close.length ? `, closed ${plan.close.length}` : ''}`);
242
+ }
243
+ await store.write({...(await store.read()), settings: state.settings, lastRotatedDay: day});
244
+ return {day, opened: plan.open, closed: plan.close, already: Boolean(plan.already)};
245
+ });
246
+ }
247
+
248
+ return {
249
+ tick,
250
+ read,
251
+ /**
252
+ * Run `fn` in the rotation's turn: editor saves and the queue routes go through here, so a midnight
253
+ * rotation never interleaves with them. Never call tick() from inside `fn` (it waits for the same turn).
254
+ */
255
+ run: (fn) => locked(fn),
256
+ /** Save the Rotate setting. Switching it does not rotate by itself; the next tick does when due. */
257
+ async saveSettings(input) {
258
+ const settings = cleanRotation(input);
259
+ await locked(async () => store.write({...(await store.read()), settings}));
260
+ return settings;
261
+ },
262
+ /** The one-minute timer (and a first look shortly after boot). Never keeps the process alive. */
263
+ start() {
264
+ if (timer) return;
265
+ const run = () => tick(clock()).catch(err => log.warn?.(`[games] rotation: ${err.message}`));
266
+ boot = setTimeout(run, 5000);
267
+ timer = setInterval(run, TICK_MS);
268
+ boot.unref?.();
269
+ timer.unref?.();
270
+ },
271
+ stop() { clearTimeout(boot); clearInterval(timer); timer = null; }
272
+ };
273
+ }
274
+
275
+ /**
276
+ * The two notices of a Tool with rotation: `queue-low` (fewer than LOW_DAYS
277
+ * days queued) and `queue-empty` (rotation on, nothing queued). Both only when
278
+ * Rotate is on; both resolve when fixed.
279
+ */
280
+ export function rotationNoticeSources({rotation, list, label, noun, nouns = `${noun}s`, link}) {
281
+ const status = async () => queueOutlook((await rotation.read()).settings, await list());
282
+ return [
283
+ {id: 'queue-low', label: `${label} queue running low`, severity: 'warning', defaultOn: true, every: 'daily', atBoot: true,
284
+ description: `${label} rotates and fewer than ${LOW_DAYS} days of ${nouns} are queued.`,
285
+ check: async (ctx) => {
286
+ const o = await status();
287
+ const key = `${noun}-queue-low`;
288
+ if (!o.low) { await ctx.resolve(key); return; }
289
+ await ctx.notify({title: `${label}: ${o.queued} ${o.queued === 1 ? noun : nouns} left in the queue`,
290
+ body: `Rotation has ${o.days} day${o.days === 1 ? '' : 's'} left. Queue more so the page never goes stale.`,
291
+ severity: 'warning', link, dedupeKey: key});
292
+ }},
293
+ {id: 'queue-empty', label: `${label} queue empty`, severity: 'warning', defaultOn: true, every: 'hourly', atBoot: true,
294
+ description: `${label} rotates but nothing is queued, so today's ${noun} stays up.`,
295
+ check: async (ctx) => {
296
+ const o = await status();
297
+ const key = `${noun}-queue-empty`;
298
+ if (!o.empty) { await ctx.resolve(key); return; }
299
+ await ctx.notify({title: `${label}: the queue is empty`,
300
+ body: `Rotation is on but no ${noun} is queued, so the last one stays up. Queue one and it opens within a minute.`,
301
+ severity: 'warning', link, dedupeKey: key});
302
+ }}
303
+ ];
304
+ }
305
+
306
+ /** The notice source for fresh games from Domma (registered by every Tool that takes them). */
307
+ export const contentReadySource = (label) => ({id: 'content-ready', label: `${label}: new games from Domma`, severity: 'info', defaultOn: true,
308
+ description: `Domma has made new ${label} games for this site - to review, or already queued when auto-publish is on.`});
309
+
310
+ /** Tell the admins what arrived. Never throws: a notice never breaks an import. */
311
+ export function notifyContentReady(hooks, {label, noun, nouns = `${noun}s`, count, queued, link}) {
312
+ if (!count) return;
313
+ try {
314
+ const what = `${count} new ${count === 1 ? noun : nouns}`;
315
+ hooks?.notify?.({
316
+ title: queued ? `${label}: ${what} queued` : `${label}: ${what} to review`,
317
+ body: queued ? `Domma made ${what} for this site and put ${count === 1 ? 'it' : 'them'} in the queue.`
318
+ : `Domma made ${what} for this site. Look ${count === 1 ? 'it' : 'them'} over and Approve to queue ${count === 1 ? 'it' : 'them'}.`,
319
+ severity: 'info', link, source: 'content-ready', // One notice per arrival: two runs in a day that bring the same number are still two arrivals.
320
+ dedupeKey: `content-ready:${noun}:${Date.now()}`
321
+ });
322
+ } catch { /* a notice never breaks an import */ }
323
+ }
@@ -0,0 +1,114 @@
1
+ /**
2
+ * Games - a rotating game on a page: `[quiz rotate]` (and the same on every
3
+ * Game) shows whichever game rotation has open today, and the earlier ones
4
+ * underneath, still playable but off the leaderboard. So the page never needs
5
+ * editing.
6
+ *
7
+ * The Tool's player calls `mountRotation()` for a placeholder whose config says
8
+ * `rotate`; it asks `<api>/rotation` and mounts the game into a slot of its own
9
+ * with the Tool's ordinary `mountOne(slotEl)`, so the game is the same player
10
+ * as anywhere else. domma-reactive, text only through bindings.
11
+ *
12
+ * @module _lib/public/games/rotation
13
+ */
14
+
15
+ import {request} from './games.js';
16
+
17
+ const M = window.M;
18
+
19
+ const TEMPLATE = `
20
+ <p class="gm-muted" hidden data-bind-hidden="state.value !== 'loading'">Loading…</p>
21
+ <section class="gm-card" hidden data-bind-hidden="state.value !== 'none'">
22
+ <p class="gm-muted" data-bind-text="noneText"></p>
23
+ </section>
24
+ <p class="gm-note gm-archive-note" hidden data-bind-hidden="!picked.value">
25
+ <span data-bind-text="pickedText.value"></span>
26
+ <button type="button" class="btn btn-sm btn-ghost" hidden data-bind-hidden="!hasToday.value" data-on-click="backToToday">Back to today's</button>
27
+ </p>
28
+ <div class="gm-rotation-slot"></div>
29
+ <section class="gm-card gm-archive" hidden data-bind-hidden="!archive.value.length || boardOnly">
30
+ <h3 class="gm-title gm-archive-title" data-bind-text="archiveTitle"></h3>
31
+ <ul class="gm-archive-list" data-each="archive.value key=slug">
32
+ <li><button type="button" class="btn btn-sm btn-ghost" data-bind-text="label" data-bind-aria-pressed="current ? 'true' : 'false'" data-on-click="$root.pick(slug)"></button></li>
33
+ </ul>
34
+ </section>`;
35
+
36
+ const shortDay = (day) => {
37
+ if (!/^\d{4}-\d{2}-\d{2}$/.test(String(day || ''))) return '';
38
+ const [y, m, d] = day.split('-').map(Number);
39
+ const t = new Date(Date.UTC(y, m - 1, d));
40
+ return `${['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'][t.getUTCDay()]} ${d} ${['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'][m - 1]}`;
41
+ };
42
+
43
+ /**
44
+ * @param {HTMLElement} el the placeholder
45
+ * @param {object} opts
46
+ * @param {string} opts.api the Tool's public API ('/api/plugins/quiz/public')
47
+ * @param {string} opts.attr the placeholder attribute the Tool's player reads ('data-quiz')
48
+ * @param {object} opts.cfg the placeholder's config ({rotate: true, board})
49
+ * @param {(slot: HTMLElement) => any} opts.mountOne the Tool's own mount for one game
50
+ * @param {string} opts.noun 'quiz', 'crossword'...
51
+ * @param {string} opts.nouns 'quizzes'...
52
+ */
53
+ export async function mountRotation(el, {api, attr, cfg, mountOne, noun, nouns}) {
54
+ if (!M) return;
55
+ el.innerHTML = TEMPLATE;
56
+ const slot = el.querySelector('.gm-rotation-slot');
57
+ const s = {
58
+ state: M.observable('loading'),
59
+ picked: M.observable(''),
60
+ pickedText: M.observable(''),
61
+ hasToday: M.observable(false),
62
+ archive: M.observable([]),
63
+ boardOnly: cfg.board === 'only',
64
+ noneText: `No ${noun} today. Come back soon.`,
65
+ archiveTitle: `Earlier ${nouns}`
66
+ };
67
+ let today = null;
68
+ let mounted = null;
69
+ let list = [];
70
+
71
+ const show = (slug, archived) => {
72
+ mounted?.dispose?.();
73
+ slot.innerHTML = '';
74
+ const one = document.createElement('div');
75
+ one.className = el.className.replace(/\bgm-rotation\b/, '').trim() || 'gm-app';
76
+ one.setAttribute(attr, JSON.stringify({slug, board: archived ? 'hide' : (cfg.board || 'show')}));
77
+ one.dataset.gmMounted = '1';
78
+ slot.append(one);
79
+ mounted = mountOne(one) || null;
80
+ s.archive.value = list.map(a => ({...a, current: a.slug === slug}));
81
+ };
82
+
83
+ s.pick = (slug) => {
84
+ const a = list.find(x => x.slug === slug);
85
+ if (!a) return;
86
+ s.picked.value = slug;
87
+ s.pickedText.value = `${a.title}${a.day ? ` (${shortDay(a.day)})` : ''} - an earlier ${noun}. Play it for fun: it is not on the leaderboard.`;
88
+ show(slug, true);
89
+ el.scrollIntoView?.({block: 'start', behavior: 'smooth'});
90
+ };
91
+ s.backToToday = () => {
92
+ if (!today) return;
93
+ s.picked.value = '';
94
+ show(today.slug, false);
95
+ };
96
+
97
+ const bindings = M.applyBindings(s, el, {methods: s});
98
+ let r = null;
99
+ try { r = await request(`${api}/rotation`); } catch { /* shown as none */ }
100
+ list = (r?.archive || []).map(a => ({slug: a.slug, title: a.title, day: a.day, label: `${a.title}${a.day ? ` - ${shortDay(a.day)}` : ''}`, current: false}));
101
+ today = r?.current || null;
102
+ s.hasToday.value = Boolean(today);
103
+ s.archive.value = list;
104
+ if (today) { s.state.value = 'ready'; show(today.slug, false); }
105
+ else s.state.value = 'none';
106
+
107
+ const watch = new MutationObserver(() => { if (!el.isConnected) { mounted?.dispose?.(); bindings?.dispose?.(); watch.disconnect(); } });
108
+ watch.observe(document.body, {childList: true, subtree: true});
109
+ }
110
+
111
+ /** `[<game>-leaderboard rotate]`: today's rotated game's slug, or '' when none. */
112
+ export async function rotationSlug(api) {
113
+ try { return (await request(`${api}/rotation`))?.current?.slug || ''; } catch { return ''; }
114
+ }