diff --git a/.dockerignore b/.dockerignore index b802835d..c0dfb1d0 100644 --- a/.dockerignore +++ b/.dockerignore @@ -1,5 +1,6 @@ # Dependencies node_modules/ +vendor/ui/node_modules/ npm-debug.log* yarn-debug.log* yarn-error.log* diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 00000000..5dd08e5c --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,24 @@ +# Dependabot: PR-based notifications when dependencies advance. +# +# - npm: watches the root package.json/lock (includes @kerebron/* pins). +# @kerebron packages are grouped into a single PR so a release train of +# editor + kits + extensions lands together instead of as 10 PRs. +# - gitsubmodule: bumps the vendor/ui (and vendor/meteor-wormhole) submodule +# pointers when their upstream default branches advance. Kerebron bumps +# *inside* vendor/ui must be configured in the mieweb/ui repo itself — +# Dependabot cannot update a submodule's package.json from here. +version: 2 +updates: + - package-ecosystem: npm + directory: / + schedule: + interval: weekly + groups: + kerebron: + patterns: + - '@kerebron/*' + + - package-ecosystem: gitsubmodule + directory: / + schedule: + interval: weekly diff --git a/.github/workflows/pr-preview.yml b/.github/workflows/pr-preview.yml index 9726f5ea..3dc0fbc5 100644 --- a/.github/workflows/pr-preview.yml +++ b/.github/workflows/pr-preview.yml @@ -55,7 +55,13 @@ jobs: steps: - uses: actions/checkout@v4 with: - submodules: true + # Only init meteor-wormhole — vendor/ui is on a branch not pushed to + # the public mieweb/ui repo, so a full submodules: true would fail. + # The app installs @mieweb/ui from the committed vendor/mieweb-ui.tgz. + submodules: false + + - name: Init meteor-wormhole submodule + run: git submodule update --init vendor/meteor-wormhole - id: container name: Compute container name diff --git a/.gitignore b/.gitignore index ca829cd8..e140b83f 100644 --- a/.gitignore +++ b/.gitignore @@ -46,6 +46,8 @@ pollenate-test-*.png # Meteor build output and local packages meteor-backend/prod-build/ meteor-backend/packages/ +meteor-backend/.meteor/local/ +meteor-backend/.meteor/local-test/ # Local development scripts and configs (machine-specific, never commit) backend/nginx-apple-proxy.conf diff --git a/.gitmodules b/.gitmodules index 6e19fa29..91e560a2 100644 --- a/.gitmodules +++ b/.gitmodules @@ -2,3 +2,6 @@ [submodule "vendor/meteor-wormhole"] path = vendor/meteor-wormhole url = https://github.com/mieweb/meteor-wormhole +[submodule "vendor/ui"] + path = vendor/ui + url = https://github.com/mieweb/ui diff --git a/Dockerfile b/Dockerfile index 6a1246a7..d1062103 100644 --- a/Dockerfile +++ b/Dockerfile @@ -30,7 +30,13 @@ COPY packages/README.md ./packages/ COPY meteor-backend/package.json meteor-backend/package-lock.json ./meteor-backend/ COPY scripts ./scripts -# Install all dependencies (dev included — needed for Vite build + Meteor) +# @mieweb/ui is installed from a committed tarball (see package.json: +# "@mieweb/ui": "file:vendor/mieweb-ui.tgz") — the vendor/ui submodule branch is +# not pushed to the public mieweb/ui repo, so it can't be built here. Copy the +# tarball before install so npm can resolve the file: dependency. +COPY vendor/mieweb-ui.tgz ./vendor/mieweb-ui.tgz + +# Install all dependencies (dev included — needed for Vite build + Meteor). RUN npm install RUN cd meteor-backend && npm install @@ -75,6 +81,10 @@ WORKDIR /app # Root production deps (serve, etc.) COPY package.json package-lock.json ./ COPY scripts ./scripts +# @mieweb/ui resolves to file:vendor/mieweb-ui.tgz — copy it so the install can +# resolve the dependency (it's bundled into dist/ at build time, but npm still +# needs the file present to satisfy the manifest). +COPY vendor/mieweb-ui.tgz ./vendor/mieweb-ui.tgz RUN npm install --production # Copy pre-built artifacts from builder — no source, no Meteor, no vendor diff --git a/clock-post-simple-plan.md b/clock-post-simple-plan.md new file mode 100644 index 00000000..ff34917e --- /dev/null +++ b/clock-post-simple-plan.md @@ -0,0 +1,154 @@ +# Clock ↔ Huddle Plan-First Flow — Minimal Plan + +The core loop, nothing else: **write a plan as a Huddle post → clock in → add a wrap-up to that post → clock out.** One post per **clock session**, gated per team by one setting, off by default. + +## What's in + +- One team setting: `settings.requirePlanForClock` (default `false`), toggled by admins in the existing Team Settings modal. +- Posts get a `postDate` (`YYYY-MM-DD`); session posts also get a `clockEventId` linking them to the clock session they're the plan/wrap-up for. +- Gate on (per session): every Clock In needs a fresh plan (a post linked to that session); Clock Out is blocked until that session's post has a wrap-up. A second clock-in the same day needs its own plan. Clear inline message when blocked — no modals, no overrides. +- Gate off (default): everything behaves exactly as today. +- Clock page is the gate (design iteration, shipped): status banner → plain textarea → punch-clock module, with single combined actions — “Post plan and clock in” / “Post wrap-up and clock out”. The gate state is centralized in `useClockToggle.planGate` (realtime via DDP) so every clock surface (clock page, bottom-nav FAB, work/tickets prompts) agrees. This replaced the earlier `?prompt=clockin|clockout` redirect idea. +- Composer on the Huddle tab = `RichEditor` from `@mieweb/ui/kerebron` (Kerebron/ProseMirror, markdown in/out). Feed = `SuperChat` panel (`order="desc"`, read-only thread) so posts render rich markdown for free. The clock page uses the **same** theme-aware RichEditor (see Milestone 9 — the earlier plain-textarea design was replaced). + +## Dependency prerequisite (blocks Milestones 5–6) + +Work against `mieweb/ui` as a **git submodule** so we can build it locally and PR changes upstream (the repo already uses this convention: `vendor/meteor-wormhole`). + +- [x] `git submodule add https://github.com/mieweb/ui vendor/ui` (track `main`). +- [x] Build it locally (`npm install && npm run build` inside `vendor/ui`) and point `package.json` at the build: `"@mieweb/ui": "file:vendor/ui"`. +- [x] Smoke-test existing `@mieweb/ui` usage across the app (0.2.4 → 0.6.1: typecheck, lint, 76 unit tests, production build, and login-page render all clean — no breaking changes in the 40 components we import). +- [x] Wire the submodule build into dev docs/scripts so `nvm use && npm install` after a fresh clone doesn't silently break: `postinstall` runs `scripts/ensure-ui-build.mjs` (no-op when `vendor/ui/dist` exists, `SKIP_UI_BUILD=1` to skip); `npm run setup:ui` for manual runs. +- [ ] Fallback/CI note: `@mieweb/ui@0.6.1-dev.169` is the closest published release with SuperChat + kerebron if `file:` causes CI friction; swap to the full release when it lands and drop the submodule once no local patches remain. (CI checkouts must init submodules — the repo already does this for `vendor/meteor-wormhole`.) + +### Upstream PR from the submodule + +- [x] Branch in `vendor/ui` (`feat/richeditor-collab-yjs`): add a `collab` prop to `RichEditor` so hosts can enable Kerebron's Yjs live sync. (Implemented as a focused `collab={{ room, wsUrl?, params? }}` prop rather than a raw `extensions` list: `@kerebron/extension-yjs` **conflicts** with the default `history` extension, so collaborative mode swaps `AdvancedEditorKit` for the same extensions minus `history`, then adds `MarkYChange` + `ExtensionYjs`. The websocket provider threads caller `params` — e.g. an auth token — which editor-kits' own `YjsEditorKit` can't. `src/components/RichEditor/collabKits.ts`.) +- [ ] PR it to `mieweb/ui`; pin the submodule to our branch commit until merged, then move the pointer back to `main`. (Submodule currently pinned to the `feat/richeditor-collab-yjs` branch commit.) + +Milestones 1–4 (setting, data model, gates, drafts) have no dependency on this and can land first. + +## What's deliberately out (add later only if people ask) + +- "Plan tomorrow before clocking out" requirement, future-dated plans +- Next-workday/weekend date math +- Override/confirm modals, readiness-checklist endpoints +- Live clock-status dot on posts + +(Drafts were originally out — promoted to Milestone 4 by request. Kerebron/Yjs live sync was deferred here previously — the submodule + upstream `extensions` prop PR unblocks it, so it's now stretch Milestone 8.) + +--- + +## Milestone 1 — Team setting + +- [x] `teams` doc: `settings.requirePlanForClock` (absent = `false`). +- [x] Admin-only `teams.updateSettings({ teamId, requirePlanForClock })` in `meteor-backend/server/teams.js` (copy the `teams.rename` auth pattern). +- [x] `Team` type + `teamApi.updateSettings` in `src/lib/api.ts`. +- [x] On/off toggle in the Team Settings modal in `src/features/teams/TeamsPage.tsx` (reuse `canManageTeamSettings`). + +## Milestone 2 — Today's post + +- [x] `huddle.createPost`: accept `postDate` (client sends `toDateString(new Date())` from `src/lib/timeUtils.ts`). +- [x] `huddle.updatePost`: accept optional `wrapUp: boolean` → sets `wrapUpAt: new Date()` on the post. +- [x] `huddle.getMyPostForDate({ teamId, postDate })` → `{ post }` or `{ post: null }`. +- [x] Frontend: `HuddlePost` gets `postDate` + `wrapUpAt`; `huddleApi.getMyPostForDate` wrapper; tiny `useDailyPost(teamId)` hook returning `{ todayPost, refetch }`. + +## Milestone 3 — Gates + +- [x] `ClockPage.tsx`: when the team setting is on and there's no `todayPost`, disable Clock In with a one-line "Write today's plan first" link to Huddle. Setting off → unchanged. +- [x] `clock.stop` in `meteor-backend/server/clock.js`: when the setting is on and today's post has no `wrapUpAt`, throw `Meteor.Error('plan-required', 'Add a wrap-up to today's post first')`. Setting off → unchanged. (Accepts an optional client-local `localDate` so "today" matches the user's timezone; falls back to the server-local date.) +- [x] `useClockToggle` / `ClockPage.tsx`: show the `plan-required` message inline with a link to Huddle. + +Added along the way (shipped): + +- [x] Gate centralized in `useClockToggle.planGate`; realtime via the `huddlePosts.byTeam` DDP publication (`useDailyPost`) — no reloads. +- [x] All clock surfaces respect the gate: bottom-nav FAB (dimmed "plan required" state, navigates to the clock page), Work/Tickets clock-in prompts. +- [x] Clock page redesigned as the gate: banner → composer → punch clock; combined “Post plan and clock in” / “Post wrap-up and clock out” actions. + +## Milestone 4 — Drafts (added by request) + +Save a plan without publishing (and without clocking in); publish it later to start the shift. Drafts are author-only — never in the team feed, never notify, never satisfy the gate. + +- [x] `huddle.createPost`: accept `draft: true` → stores `status: 'draft'` (no `postDate`, no team notifications). Absent status = published; legacy posts unaffected. +- [x] Feed excludes drafts everywhere: `huddlePosts.byTeam` publication + change stream (publish arrives as a realtime `added`), `huddle.getPosts`, `huddle.getPostsByTicket`. +- [x] Gate ignores drafts: `huddle.getMyPostForDate` and the `clock.stop` check only match published posts. +- [x] `huddle.publishPost({ postId, content?, postDate })`: author-only; updates content if provided, stamps `postDate` (client-local today), clears draft status. +- [x] `huddle.getMyLatestDraft({ teamId })` → `{ post | null }` (requireIdentity + wormhole exposure). +- [x] Clock page composer: "Save draft"/"Update draft" secondary action; an existing draft is prefilled and the primary action becomes "Publish plan and clock in". +- [x] Tests: draft doesn't satisfy the gate; publish does (and enters the feed); drafts invisible in the feed; only the author can publish. (`meteor-backend/tests/plan-gate.test.ts`, 14 passing) +- [x] **Drafts tab (added by request):** a Feed/Drafts toggle on the Huddle page; `huddle.getMyDrafts` lists all of a user's drafts; `DraftsPanel` supports creating multiple drafts and editing/publishing/deleting each. Publishing a draft as a session plan links it to the session (`huddle.publishPost` accepts `clockEventId`). + +## Milestone 4b — Per-session gate (added by request) + +Every clock session gets its own plan + wrap-up post, not one shared per day. + +- [x] Posts carry `clockEventId`; `clock.start` accepts `planPostId` and links the plan to the new session; `clock.stop` gates on the session's linked post (not the date). +- [x] `huddle.getMyPostForSession({ teamId, clockEventId })`; `useSessionPost` hook (realtime via DDP); `useClockToggle.planGate` exposes `sessionPost`; `planMissing` = gate on && no active session (a fresh plan is always required to start). +- [x] Clock page: "Post plan and clock in" links the plan; "Post wrap-up and clock out" wraps up the session post; recovery path creates a linked wrap-up post if a session somehow has none (`createPost` accepts `clockEventId` + `wrapUp`). +- [x] Huddle-tab composer always creates a new post (no auto-edit of "today's post"). +- [x] Bigger editor: RichEditor `min-h-52` + larger text; clock-page textarea `rows={10}` / `min-h-52`. +- [x] Tests updated for per-session semantics incl. "second clock-in of the day needs its own plan" (`plan-gate.test.ts`, 15 passing). In-browser verified 2026-07-23: two sessions each gated independently, two separate posts in the feed, drafts tab creates/hides multiple drafts. + +## Milestone 5 — Composer → RichEditor (Kerebron) + +- [x] Install `@kerebron/editor`, `@kerebron/editor-kits`, `@kerebron/wasm`; import `@mieweb/ui/kerebron.css`. +- [x] Serve `@kerebron/wasm`'s `assets/` at `/kerebron-wasm` (inline Vite plugin: dev middleware + copy into `dist/` on build — the editor fetches tree-sitter grammars from there at runtime). +- [x] Swap the Huddle composer's textarea for `` (markdown out). Posts store markdown in the existing `content` field — plain-text legacy posts render fine as markdown. (The old manual markdown toolbar + preview tabs are gone — RichEditor is WYSIWYG; mentions are picked from a chip list and **appended into the post text as `@name` on submit** — see Milestone 9 — since RichEditor has no insert-at-cursor API.) +- [x] `RichEditor` is uncontrolled (initial `value` applies on mount only) — remount it via `key={editingPostId ?? 'new'}` when switching between new-post and edit-today's-post. +- [x] If `todayPost` exists, the composer opens it for editing ("Edit today's post…" / "Update post") and submit updates instead of creating. + +## Milestone 6 — Feed → SuperChat panel + +- [x] Map huddle posts → a `SuperChatConversation`: one participant per post author, one message per post (`createdAt`, markdown `text`, `editedAt` when edited); image attachments embedded as markdown images, other attachments as links; ticket title as an inline tag. (`src/features/huddle/superChatFeed.ts`) +- [x] Render `` for the feed — newest-first, composer disabled (authoring goes through the RichEditor above the feed, since SuperChat's built-in composer can't be swapped out). +- [x] Enable `renderPlugins` (`createCodePlugin`, `createImagePlugin`, `createMermaidPlugin`) — math/KaTeX skipped. (Plugins import from `@mieweb/ui/components/SuperChat/plugins`.) +- [x] Wire `onMessageEdited` (self-authored messages only) → `huddle.updatePost`, so "edit today's post" also works inline from the feed. +- [x] Comment handling decided: per-post comments and likes stay in the classic card view — a chat/cards toggle on the feed header switches views (SuperChat has no per-message thread concept; not force-fit). + +## Milestone 7 — Verify + +- [x] Backend tests: gate off → clock in/out unchanged; gate on → in blocked without today's post, out blocked without wrap-up, both pass once satisfied; drafts never satisfy the gate. (`meteor-backend/tests/plan-gate.test.ts`, 14 passing) +- [x] Full meteor-backend integration suite green post-M6: 137/137 (2026-07-23; first run after a server restart cold-start-flaked — warm reruns pass, see repo notes). +- [x] `npm run lint && npm run typecheck` clean; 76 unit tests; production build (incl. `/kerebron-wasm` asset copy) clean. (`test:all` — full Playwright e2e — still pending.) +- [x] Manual: publish plan (RichEditor) → feed → edit today's post → update; confirmed in-browser 2026-07-23: composer expands to Kerebron RichEditor (toolbar + markdown input rules), posting lands in the SuperChat thread with real markdown rendering (bold/code), composer flips to "Edit today's post…" and prefills rich content, updates show an "(edited)" badge, chat↔cards toggle keeps comments/likes in the card view, no duplicate posts. (Gates/drafts/clock flow smoke-tested 2026-07-22/23.) + +## Milestone 8 (stretch) — Live collaborative sync (Yjs) + +Only after the upstream `extensions` prop PR merges (or against our pinned submodule branch). Ship independently of 1–7. + +> **[mieweb/yorm](https://github.com/mieweb/yorm) evaluated (2026-07-23) — right idea, wrong milestone.** YORM is a strong fit when the Y.Doc is the persisted canonical object with relational projections (and its suggestion mode is exactly a propose/review workflow). M8 is deliberately smaller: symmetric co-editing of one rich-text post where **markdown in Mongo stays the source of truth** and the Y.Doc is ephemeral — so plain `y-websocket` (the same primitive YORM builds on) covers it. **Revisit YORM when** (a) we want a suggest/approve mode on huddle posts, or (b) posts become structured collaborative objects needing queryable projections — and its MongoDB backend (PLAN.md M9) has landed. Adopting it then would mean flipping to Y.Doc-canonical persistence, which is a product decision, not a drop-in. + +- [x] `/yjs` WebSocket route on the existing server: a minimal `y-websocket`-compatible relay attached to Meteor's HTTP server on its own path (clear of DDP's `/websocket`), auth via the same token-in-query pattern the DDP client uses (`resolveToken`). Anonymous upgrades are rejected. (`meteor-backend/server/yjs.js`; `ws` moved to runtime deps + `yjs`/`y-protocols`/`lib0` added.) +- [x] Room per post id; Y.Doc held in memory, seeded from the post's stored markdown on first join (the first client loads the markdown into the editor and the Yjs binding seeds the empty shared doc from it — no server-side markdown→Y.Doc conversion). Markdown stays the source of truth — saves still go through `huddle.updatePost`, no Y.Doc persistence layer; the room is dropped from memory once the last peer leaves. +- [x] Wire the Yjs kit into `RichEditor` via the new `collab` prop, room = post id. The Huddle composer enables it when **editing an existing post** (`collabRoom={post.id}` → `MarkdownEditor` → `RichEditor`; auth token from `localStorage.meteor_resume_token`, WS URL points at the Meteor backend, not the Vite origin). New-post composing stays single-user. (`src/features/huddle/collab.ts`, `MarkdownEditor.tsx`, `HuddleComposer.tsx`, `PostCard/index.tsx`.) +- [x] **Bug fix (2026-07-27):** `PostCard/index.tsx` was opening the edit `HuddleComposer` without passing `collabRoom` — so Yjs never connected and edits were local-only. Fixed by adding `collabRoom={post.id}` to the edit-mode composer render. +- [x] **E2E test (2026-07-27):** `tests/e2e/huddle/yjs-collab-editing.spec.ts` — two isolated browser contexts (member1 + admin1): member creates a post, both open the edit composer, admin types and member sees it live (≤8 s), member types back and admin sees it; second test asserts a `/yjs/` WebSocket is established when the edit composer opens. +- [x] Verify: same post open in two browsers → edits and cursors sync live; a save from either persists the merged markdown. (Relay verified live 2026-07-24: startup log present, unauthenticated upgrades rejected with 401, and two authenticated peers in the same room synced an edit end-to-end through `/yjs` — "hello from A" propagated A→B. Frontend `typecheck`/`lint`/`build` and backend boot all clean.) + +> **Known follow-ups (not blockers for the co-edit MVP):** per-post authorization on the `/yjs` room (currently any authenticated user with a room id can join — a room id is only obtainable by a client that can already see the post); enabling collab on the clock-page wrap-up composer (single-user today). + +## Milestone 9 — Composer parity & polish (added by request) + +A round of editor/composer fixes and feature parity across the clock page and the Huddle composer. + +**Clock-page RichEditor (replaces the plain textarea):** + +- [x] Clock page now uses the same Kerebron `MarkdownEditor` wrapper as the Huddle composer, theme-aware (dark/light). +- [x] Fixed "can't type": the wrapper's capturing `mousedown` handler was `preventDefault`-ing clicks on the editable content itself (it lives inside `.kb-custom-menu__wrapper` next to the toolbar), which blocked caret placement. It now only preserves the selection for toolbar/menu clicks, never the editable body. +- [x] Dark mode: mapped Kerebron's `--kb-color-*` / `--kb-menu-*` vars under `[data-theme='dark']` in `src/styles.css` (the editor themes via `.kb-component--dark` / `prefers-color-scheme`, but the app drives theme with `data-theme`). +- [x] Content continuity: the wrap-up composer is seeded with this session's plan post so clock-out continues the same content; submit saves the edited content directly (no plan duplication). + +**Huddle composer:** + +- [x] Removed the camera button from the collapsed bar. +- [x] Added a **Pulse** video button with full parity to ticket details: QR-record-with-phone **and** device upload, via a shared `PulseUploadModal` (extracted so the ticket-scoped `PulseUploadButton` and the new library-mode `PulseAttachButton` share one modal). Library uploads reserve with no ticket and poll `mediaApi.list` by `videoid` for completion. +- [x] **Mentions now render**: they were stored in `content.mentions` but never inserted into the text, so posts showed nothing. The composer appends `@name` into the post text on submit. +- [x] **Edit uses the full composer**: post editing opens the whole `HuddleComposer` (RichEditor + Photo/Video/Doc/Pulse/Ticket/@Mention), replacing the plain textarea, so attachments/tickets/mentions are all editable. Backend `huddle.updatePost` now accepts + persists `attachments` and `ticketId` (omitted → untouched, so the clock flow is unaffected). +- [x] Fixed a z-index bug: Kerebron's sticky toolbar (`z-index: 1000`) bled over `@mieweb/ui` modals (`z-50`); pulled it into the app's z-scale (`z-index: 10`). + +Verified in-browser 2026-07-23: camera gone; Pulse modal (QR + device); mentions render in the feed + persist in the DB; edit preloads content and saves with the "edited" badge; clean modal stacking. `typecheck` / `eslint` / `prettier` clean. + +**Dependency hygiene:** + +- [x] Aligned `@kerebron/extension-basic-editor` to `^0.8.6` (was `^0.7.9`, which pulled a second `@kerebron/editor` / `prosemirror-model` copy → duplicate-instance warnings). Deduped the lockfile. +- [x] Added `.github/dependabot.yml`: groups `@kerebron/*` into one weekly PR (release train) and bumps the `vendor/ui` / `vendor/meteor-wormhole` submodule pointers. diff --git a/meteor-backend/package-lock.json b/meteor-backend/package-lock.json index a39477f7..a13c7c89 100644 --- a/meteor-backend/package-lock.json +++ b/meteor-backend/package-lock.json @@ -18,11 +18,15 @@ "busboy": "^1.6.0", "firebase-admin": "^14.0.0", "jose": "^6.2.3", + "lib0": "^0.2.109", "lodash.throttle": "^4.1.1", "meteor-node-stubs": "^1.2.12", "nodemailer": "^9.0.1", "srvx": "^0.11.18", - "web-push": "^3.6.7" + "web-push": "^3.6.7", + "ws": "^8.21.0", + "y-protocols": "^1.0.6", + "yjs": "^13.6.30" }, "devDependencies": { "@eslint/js": "^9.17.0", @@ -32,8 +36,7 @@ "mongodb": "^6.12.0", "typescript": "^5.7.0", "typescript-eslint": "^8.19.1", - "vitest": "^4.1.9", - "ws": "^8.21.0" + "vitest": "^4.1.9" } }, "node_modules/@agendajs/mongo-backend": { @@ -4663,6 +4666,16 @@ "devOptional": true, "license": "ISC" }, + "node_modules/isomorphic.js": { + "version": "0.2.5", + "resolved": "https://registry.npmjs.org/isomorphic.js/-/isomorphic.js-0.2.5.tgz", + "integrity": "sha512-PIeMbHqMt4DnUP3MA/Flc0HElYjMXArsw1qwJZcm9sqR8mq3l8NYizFMty0pWwE/tzIGH3EKK5+jes5mAr85yw==", + "license": "MIT", + "funding": { + "type": "GitHub Sponsors ❤", + "url": "https://github.com/sponsors/dmonad" + } + }, "node_modules/jackspeak": { "version": "3.4.3", "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-3.4.3.tgz", @@ -4844,6 +4857,27 @@ "node": ">= 0.8.0" } }, + "node_modules/lib0": { + "version": "0.2.117", + "resolved": "https://registry.npmjs.org/lib0/-/lib0-0.2.117.tgz", + "integrity": "sha512-DeXj9X5xDCjgKLU/7RR+/HQEVzuuEUiwldwOGsHK/sfAfELGWEyTcf0x+uOvCvK3O2zPmZePXWL85vtia6GyZw==", + "license": "MIT", + "dependencies": { + "isomorphic.js": "^0.2.4" + }, + "bin": { + "0ecdsa-generate-keypair": "bin/0ecdsa-generate-keypair.js", + "0gentesthtml": "bin/gentesthtml.js", + "0serve": "bin/0serve.js" + }, + "engines": { + "node": ">=16" + }, + "funding": { + "type": "GitHub Sponsors ❤", + "url": "https://github.com/sponsors/dmonad" + } + }, "node_modules/light-my-request": { "version": "6.6.0", "resolved": "https://registry.npmjs.org/light-my-request/-/light-my-request-6.6.0.tgz", @@ -8550,7 +8584,6 @@ "version": "8.21.0", "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.0.tgz", "integrity": "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==", - "dev": true, "license": "MIT", "engines": { "node": ">=10.0.0" @@ -8584,6 +8617,26 @@ "node": ">=16.0.0" } }, + "node_modules/y-protocols": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/y-protocols/-/y-protocols-1.0.7.tgz", + "integrity": "sha512-YSVsLoXxO67J6eE/nV4AtFtT3QEotZf5sK5BHxFBXso7VDUT3Tx07IfA6hsu5Q5OmBdMkQVmFZ9QOA7fikWvnw==", + "license": "MIT", + "dependencies": { + "lib0": "^0.2.85" + }, + "engines": { + "node": ">=16.0.0", + "npm": ">=8.0.0" + }, + "funding": { + "type": "GitHub Sponsors ❤", + "url": "https://github.com/sponsors/dmonad" + }, + "peerDependencies": { + "yjs": "^13.0.0" + } + }, "node_modules/y18n": { "version": "5.0.8", "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", @@ -8675,6 +8728,23 @@ "node": ">=8" } }, + "node_modules/yjs": { + "version": "13.6.31", + "resolved": "https://registry.npmjs.org/yjs/-/yjs-13.6.31.tgz", + "integrity": "sha512-Eq+5BRfbeGyqGVrTJL3bEcr8gKkxPuyuoHmAwpk52fDb8kOVMrfVSTRPd6yiGgX5Fskb96qCRjzjbRjrL4YEnw==", + "license": "MIT", + "dependencies": { + "lib0": "^0.2.99" + }, + "engines": { + "node": ">=16.0.0", + "npm": ">=8.0.0" + }, + "funding": { + "type": "GitHub Sponsors ❤", + "url": "https://github.com/sponsors/dmonad" + } + }, "node_modules/yocto-queue": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", diff --git a/meteor-backend/package.json b/meteor-backend/package.json index 22312d47..3c3dcc4f 100644 --- a/meteor-backend/package.json +++ b/meteor-backend/package.json @@ -12,7 +12,7 @@ "@agendajs/mongo-backend": "^4.0.2", "@babel/runtime": "^7.23.5", "@casl/ability": "^6.8.1", - "@mieweb/pulsevault": "^0.1.0", + "@mieweb/pulsevault": "github:mieweb/pulsevault", "@parse/node-apn": "^8.1.0", "@swc/helpers": "^0.5.17", "agenda": "^6.2.5", @@ -21,11 +21,15 @@ "busboy": "^1.6.0", "firebase-admin": "^14.0.0", "jose": "^6.2.3", + "lib0": "^0.2.109", "lodash.throttle": "^4.1.1", "meteor-node-stubs": "^1.2.12", "nodemailer": "^9.0.1", "srvx": "^0.11.18", - "web-push": "^3.6.7" + "web-push": "^3.6.7", + "ws": "^8.21.0", + "y-protocols": "^1.0.6", + "yjs": "^13.6.30" }, "devDependencies": { "@eslint/js": "^9.17.0", @@ -35,8 +39,7 @@ "mongodb": "^6.12.0", "typescript": "^5.7.0", "typescript-eslint": "^8.19.1", - "vitest": "^4.1.9", - "ws": "^8.21.0" + "vitest": "^4.1.9" }, "meteor": { "mainModule": { diff --git a/meteor-backend/server/clock.js b/meteor-backend/server/clock.js index 210bd02b..5ffb8850 100644 --- a/meteor-backend/server/clock.js +++ b/meteor-backend/server/clock.js @@ -123,7 +123,7 @@ Meteor.methods({ }, /** Clock in: close any dangling open events, open a new one, fire side-effects. */ - async 'clock.start'({ teamId } = {}) { + async 'clock.start'({ teamId, planPostId } = {}) { const identity = await requireIdentity(this); const userId = identity.userId; const team = await findUserTeam(userId, teamId); @@ -147,6 +147,15 @@ Meteor.methods({ const created = await ClockEvents.findOneAsync(_id); const pub = toPublicClockEvent(created, []); + // Plan-first flow: link the just-posted plan to this session so the + // per-session clock-out gate can find it (one post per clock session). + if (planPostId && isValidId(planPostId)) { + await rawDb().collection('huddlePosts').updateOne( + { _id: new ObjectId(planPostId), userId }, + { $set: { clockEventId: created._id.toHexString(), updatedAt: new Date() } } + ); + } + // Schedule 4h break reminder + 7h45m shift-end reminder. scheduleClockJobs(created._id.toHexString(), userId, teamId, now).catch((err) => console.error('[agenda] scheduleClockJobs failed:', err) @@ -190,6 +199,24 @@ Meteor.methods({ const event = await ClockEvents.findOneAsync({ userId, teamId, endTime: null }); if (!event) throw new Meteor.Error('not-found', 'No active clock event'); + const team = await findUserTeam(userId, teamId); + + // Plan-first flow: when the team requires a plan, block clock-out until + // THIS session's Huddle post has a wrap-up. One post per clock session, + // linked by clockEventId (see clock-post-simple-plan.md). Drafts never + // count — only published posts. + if (team?.settings?.requirePlanForClock) { + const post = await rawDb() + .collection('huddlePosts') + .findOne( + { teamId, userId, clockEventId: event._id.toHexString(), status: { $ne: 'draft' } }, + { sort: { createdAt: -1 } } + ); + if (!post?.wrapUpAt) { + throw new Meteor.Error('plan-required', "Add a wrap-up to this session's post first"); + } + } + const now = Date.now(); const eventId = event._id.toHexString(); @@ -221,7 +248,6 @@ Meteor.methods({ const updated = await ClockEvents.findOneAsync(event._id); const pub = toPublicClockEvent(updated, closedBreaks); - const team = await findUserTeam(userId, teamId); if (team) { const userName = await userDisplayName(userId); const totalSecs = pub.accumulatedTime ?? 0; diff --git a/meteor-backend/server/huddle.js b/meteor-backend/server/huddle.js index 2a69ea81..3a11b14d 100644 --- a/meteor-backend/server/huddle.js +++ b/meteor-backend/server/huddle.js @@ -10,6 +10,12 @@ function toId(id) { return /^[a-f0-9]{24}$/i.test(id) ? new ObjectId(id) : id; } +// postDate is a plain calendar date string (client-local), e.g. "2026-07-22" +const POST_DATE_RE = /^\d{4}-\d{2}-\d{2}$/; + +// Drafts carry status: 'draft'; absent status = published (legacy posts included). +const PUBLISHED = { status: { $ne: 'draft' } }; + // Permission helpers async function getTeam(teamId) { // Try plain string first (Meteor-created teams) @@ -83,6 +89,12 @@ async function enrichPost(post) { })), likes: post.likes ?? [], commentCount: post.commentCount ?? 0, + status: post.status ?? undefined, + postDate: post.postDate ?? undefined, + clockEventId: post.clockEventId ?? undefined, + wrapUpAt: post.wrapUpAt instanceof Date + ? post.wrapUpAt.toISOString() + : (post.wrapUpAt ? String(post.wrapUpAt) : null), createdAt: post.createdAt instanceof Date ? post.createdAt.toISOString() : String(post.createdAt), updatedAt: post.updatedAt instanceof Date ? post.updatedAt.toISOString() : String(post.updatedAt ?? post.createdAt), }; @@ -128,12 +140,13 @@ Meteor.publish('huddlePosts.byTeam', async function (teamId) { const db = rawDb(); const collection = db.collection('huddlePosts'); - // Initial fetch and send - let posts = await collection.find({ teamId }).sort({ createdAt: -1 }).toArray(); + // Initial fetch and send — published posts only (drafts are author-only + // and never appear in the team feed). + let posts = await collection.find({ teamId, ...PUBLISHED }).sort({ createdAt: -1 }).toArray(); // Also fetch posts where teamId was stored as ObjectId (legacy) if (/^[a-f0-9]{24}$/i.test(teamId)) { const legacyPosts = await collection - .find({ teamId: new ObjectId(teamId) }) + .find({ teamId: new ObjectId(teamId), ...PUBLISHED }) .sort({ createdAt: -1 }) .toArray(); // Merge, deduplicate by _id hex string @@ -145,9 +158,13 @@ Meteor.publish('huddlePosts.byTeam', async function (teamId) { posts.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()); } + // Track which ids this subscription has sent, so a draft being published + // (an update) is delivered as `added` rather than a no-op `changed`. + const sentIds = new Set(); for (const post of posts) { const enriched = await enrichPost(post); this.added('huddlePosts', enriched.id, enriched); + sentIds.add(enriched.id); } this.ready(); @@ -166,15 +183,31 @@ Meteor.publish('huddlePosts.byTeam', async function (teamId) { : String(change.fullDocument?.teamId ?? ''); if (tdStr !== teamId) return; } + const docId = change.fullDocument._id.toHexString + ? change.fullDocument._id.toHexString() + : String(change.fullDocument._id); + // Drafts never reach the feed. + if (change.fullDocument.status === 'draft') { + if (sentIds.has(docId)) { + sentIds.delete(docId); + self.removed('huddlePosts', docId); + } + return; + } const enriched = await enrichPost(change.fullDocument); - if (change.operationType === 'insert') { - self.added('huddlePosts', enriched.id, enriched); - } else { + if (sentIds.has(docId)) { self.changed('huddlePosts', enriched.id, enriched); + } else { + // Covers both fresh inserts and drafts being published. + sentIds.add(docId); + self.added('huddlePosts', enriched.id, enriched); } } else if (change.operationType === 'delete') { const deletedId = change.documentKey._id.toHexString(); - self.removed('huddlePosts', deletedId); + if (sentIds.has(deletedId)) { + sentIds.delete(deletedId); + self.removed('huddlePosts', deletedId); + } } } catch (err) { console.error('[huddle] change stream error:', err); @@ -213,7 +246,7 @@ Meteor.methods({ } const posts = await rawDb().collection('huddlePosts') - .find({ teamId }) + .find({ teamId, ...PUBLISHED }) .sort({ createdAt: -1 }) .toArray(); @@ -221,7 +254,7 @@ Meteor.methods({ return { posts: enriched }; }, - async 'huddle.createPost'({ teamId, content, ticketId, attachments }) { + async 'huddle.createPost'({ teamId, content, ticketId, attachments, postDate, draft, clockEventId, wrapUp }) { if (!this.userId) { throw new Meteor.Error('not-authorized', 'Authentication required'); } @@ -231,6 +264,9 @@ Meteor.methods({ if (!content || typeof content.text !== 'string') { throw new Meteor.Error('bad-request', 'content.text is required'); } + if (postDate !== undefined && (typeof postDate !== 'string' || !POST_DATE_RE.test(postDate))) { + throw new Meteor.Error('bad-request', 'postDate must be a YYYY-MM-DD string'); + } const team = await getTeam(teamId); if (!team) { @@ -275,6 +311,13 @@ Meteor.methods({ attachments: attachments ?? [], likes: [], commentCount: 0, + // Drafts are author-only and date-less — postDate is stamped at publish. + ...(draft === true ? { status: 'draft' } : postDate ? { postDate } : {}), + // Optionally link to a clock session (per-session gate) and/or stamp a + // wrap-up at creation — used by the clock-out recovery path when a + // session somehow has no plan post. + ...(clockEventId ? { clockEventId } : {}), + ...(wrapUp === true ? { wrapUpAt: new Date() } : {}), createdAt: new Date(), updatedAt: new Date(), }; @@ -284,7 +327,7 @@ Meteor.methods({ return { id: doc._id.toHexString() }; }, - async 'huddle.updatePost'({ postId, content }) { + async 'huddle.updatePost'({ postId, content, wrapUp, attachments, ticketId }) { if (!this.userId) { throw new Meteor.Error('not-authorized', 'Authentication required'); } @@ -319,7 +362,18 @@ Meteor.methods({ } } } - + + // Validate ticketId when the caller is (re)assigning one. `null` clears it. + if (ticketId !== undefined && ticketId !== null) { + const ticket = await rawDb().collection('tickets').findOne({ _id: toId(ticketId) }); + if (!ticket) { + throw new Meteor.Error('not-found', 'Ticket not found'); + } + if (ticket.teamId !== post.teamId) { + throw new Meteor.Error('bad-request', 'Ticket does not belong to this team'); + } + } + await rawDb().collection('huddlePosts').updateOne( { _id: toId(postId) }, { @@ -328,6 +382,11 @@ Meteor.methods({ text: content.text, mentions: content.mentions ?? [], }, + // Only touch attachments/ticketId when the editor sends them, so the + // plan-first clock flow (which omits them) leaves them untouched. + ...(attachments !== undefined ? { attachments: attachments ?? [] } : {}), + ...(ticketId !== undefined ? { ticketId: ticketId ?? undefined } : {}), + ...(wrapUp === true ? { wrapUpAt: new Date() } : {}), updatedAt: new Date(), }, } @@ -335,6 +394,149 @@ Meteor.methods({ return { id: postId }; }, + + /** The caller's own post for a given calendar date in a team, or null. */ + async 'huddle.getMyPostForDate'({ teamId, postDate }) { + // requireIdentity: reachable via wormhole REST (bearer) and DDP alike. + const identity = await requireIdentity(this); + const userId = identity.userId; + if (!teamId || typeof teamId !== 'string') { + throw new Meteor.Error('bad-request', 'teamId is required'); + } + if (typeof postDate !== 'string' || !POST_DATE_RE.test(postDate)) { + throw new Meteor.Error('bad-request', 'postDate must be a YYYY-MM-DD string'); + } + + const team = await getTeam(teamId); + if (!team) { + throw new Meteor.Error('not-found', 'Team not found'); + } + const isMember = (team.members ?? []).includes(userId) || (team.admins ?? []).includes(userId); + if (!isMember) { + throw new Meteor.Error('forbidden', 'Not a team member'); + } + + const post = await rawDb().collection('huddlePosts').findOne( + { teamId, userId, postDate, ...PUBLISHED }, + { sort: { createdAt: -1 } } + ); + return { post: post ? await enrichPost(post) : null }; + }, + + /** The caller's newest unpublished draft in a team, or null. */ + async 'huddle.getMyLatestDraft'({ teamId }) { + const identity = await requireIdentity(this); + const userId = identity.userId; + if (!teamId || typeof teamId !== 'string') { + throw new Meteor.Error('bad-request', 'teamId is required'); + } + + const team = await getTeam(teamId); + if (!team) { + throw new Meteor.Error('not-found', 'Team not found'); + } + const isMember = (team.members ?? []).includes(userId) || (team.admins ?? []).includes(userId); + if (!isMember) { + throw new Meteor.Error('forbidden', 'Not a team member'); + } + + const post = await rawDb().collection('huddlePosts').findOne( + { teamId, userId, status: 'draft' }, + { sort: { createdAt: -1 } } + ); + return { post: post ? await enrichPost(post) : null }; + }, + + /** All of the caller's unpublished drafts in a team, newest first. */ + async 'huddle.getMyDrafts'({ teamId }) { + const identity = await requireIdentity(this); + const userId = identity.userId; + if (!teamId || typeof teamId !== 'string') { + throw new Meteor.Error('bad-request', 'teamId is required'); + } + + const team = await getTeam(teamId); + if (!team) { + throw new Meteor.Error('not-found', 'Team not found'); + } + const isMember = (team.members ?? []).includes(userId) || (team.admins ?? []).includes(userId); + if (!isMember) { + throw new Meteor.Error('forbidden', 'Not a team member'); + } + + const drafts = await rawDb().collection('huddlePosts') + .find({ teamId, userId, status: 'draft' }) + .sort({ createdAt: -1 }) + .toArray(); + return { posts: await Promise.all(drafts.map(enrichPost)) }; + }, + + /** The caller's post linked to a clock session (by clockEventId), or null. */ + async 'huddle.getMyPostForSession'({ teamId, clockEventId }) { + const identity = await requireIdentity(this); + const userId = identity.userId; + if (!teamId || typeof teamId !== 'string') { + throw new Meteor.Error('bad-request', 'teamId is required'); + } + if (!clockEventId || typeof clockEventId !== 'string') { + throw new Meteor.Error('bad-request', 'clockEventId is required'); + } + + const post = await rawDb().collection('huddlePosts').findOne( + { teamId, userId, clockEventId, status: { $ne: 'draft' } }, + { sort: { createdAt: -1 } } + ); + return { post: post ? await enrichPost(post) : null }; + }, + + /** + * Publish a draft: optionally update its content, stamp the client-local + * postDate, and clear the draft status so it enters the team feed (the + * publication's change stream delivers it as an `added`). + */ + async 'huddle.publishPost'({ postId, content, postDate, clockEventId }) { + if (!this.userId) { + throw new Meteor.Error('not-authorized', 'Authentication required'); + } + if (!postId || !isValidId(postId)) { + throw new Meteor.Error('bad-request', 'Invalid postId'); + } + if (typeof postDate !== 'string' || !POST_DATE_RE.test(postDate)) { + throw new Meteor.Error('bad-request', 'postDate must be a YYYY-MM-DD string'); + } + if (content !== undefined && typeof content?.text !== 'string') { + throw new Meteor.Error('bad-request', 'content.text is required when content is provided'); + } + + const post = await rawDb().collection('huddlePosts').findOne({ _id: toId(postId) }); + if (!post) { + throw new Meteor.Error('not-found', 'Post not found'); + } + if (post.status !== 'draft') { + throw new Meteor.Error('bad-request', 'Post is not a draft'); + } + // Drafts are strictly author-only — admins can't see or publish them. + if (post.userId !== this.userId) { + throw new Meteor.Error('forbidden', 'Only the author can publish a draft'); + } + + await rawDb().collection('huddlePosts').updateOne( + { _id: toId(postId) }, + { + $set: { + ...(content !== undefined + ? { content: { text: content.text, mentions: content.mentions ?? [] } } + : {}), + ...(clockEventId ? { clockEventId } : {}), + postDate, + updatedAt: new Date(), + }, + $unset: { status: '' }, + } + ); + + return { id: postId }; + }, async 'huddle.deletePost'({ postId }) { if (!this.userId) { @@ -592,7 +794,7 @@ Meteor.methods({ if (!ticketId) throw new Meteor.Error('bad-request', 'ticketId is required'); const posts = await rawDb() .collection('huddlePosts') - .find({ ticketId }) + .find({ ticketId, ...PUBLISHED }) .sort({ createdAt: -1 }) .toArray(); const enriched = await Promise.all(posts.map(enrichPost)); diff --git a/meteor-backend/server/main.js b/meteor-backend/server/main.js index 55d8113a..c2d6362e 100644 --- a/meteor-backend/server/main.js +++ b/meteor-backend/server/main.js @@ -37,6 +37,8 @@ import './team-join-requests'; import './pulsevault'; import './messages'; import './huddle'; +// M8 — Live collaborative editing relay (Yjs) on /yjs +import './yjs'; // M3 — Org & profiles import './users'; import './organizations'; @@ -798,7 +800,10 @@ Meteor.startup(async() => { description: 'Clock in to a team (closes any dangling open events first)', inputSchema: { type: 'object', - properties: { teamId: { type: 'string' } }, + properties: { + teamId: { type: 'string' }, + planPostId: { type: 'string', description: 'Link this plan post to the new session' }, + }, required: ['teamId'], }, }); @@ -807,7 +812,9 @@ Meteor.startup(async() => { description: 'Clock out of a team (computes worked time minus meal breaks)', inputSchema: { type: 'object', - properties: { teamId: { type: 'string' } }, + properties: { + teamId: { type: 'string' }, + }, required: ['teamId'], }, }); @@ -960,6 +967,42 @@ Meteor.startup(async() => { }, }); + Wormhole.expose('huddle.getMyPostForDate', { + description: "The caller's own huddle post for a calendar date in a team, or null", + inputSchema: { + type: 'object', + properties: { teamId: { type: 'string' }, postDate: { type: 'string' } }, + required: ['teamId', 'postDate'], + }, + }); + + Wormhole.expose('huddle.getMyLatestDraft', { + description: "The caller's newest unpublished draft post in a team, or null", + inputSchema: { + type: 'object', + properties: { teamId: { type: 'string' } }, + required: ['teamId'], + }, + }); + + Wormhole.expose('huddle.getMyDrafts', { + description: "All of the caller's unpublished drafts in a team, newest first", + inputSchema: { + type: 'object', + properties: { teamId: { type: 'string' } }, + required: ['teamId'], + }, + }); + + Wormhole.expose('huddle.getMyPostForSession', { + description: "The caller's post linked to a clock session, or null", + inputSchema: { + type: 'object', + properties: { teamId: { type: 'string' }, clockEventId: { type: 'string' } }, + required: ['teamId', 'clockEventId'], + }, + }); + // ─── Timers ──────────────────────────────────────────────────────────────── Wormhole.expose('timers.getDay', { description: 'List WorkItems with timers for a local calendar day', @@ -1186,6 +1229,15 @@ Meteor.startup(async() => { }, }); + Wormhole.expose('teams.updateSettings', { + description: 'Update team settings, e.g. requirePlanForClock (admin only)', + inputSchema: { + type: 'object', + properties: { teamId: { type: 'string' }, requirePlanForClock: { type: 'boolean' } }, + required: ['teamId', 'requirePlanForClock'], + }, + }); + Wormhole.expose('teams.delete', { description: 'Delete a team (admin only)', inputSchema: { diff --git a/meteor-backend/server/teams.js b/meteor-backend/server/teams.js index 62e9cec2..0fca3557 100644 --- a/meteor-backend/server/teams.js +++ b/meteor-backend/server/teams.js @@ -101,6 +101,9 @@ function toPublicTeam(team) { admins: team.admins, code: team.code, isPersonal: team.isPersonal ?? false, + settings: { + requirePlanForClock: team.settings?.requirePlanForClock ?? false, + }, createdAt: team.createdAt instanceof Date ? team.createdAt.toISOString() : String(team.createdAt), updatedAt: team.updatedAt instanceof Date ? team.updatedAt.toISOString() : (team.updatedAt ?? null), }; @@ -364,6 +367,25 @@ Meteor.methods({ return { team: toPublicTeam(updated) }; }, + async 'teams.updateSettings'({ teamId, requirePlanForClock }) { + const identity = await requireIdentity(this); + const userId = identity.userId; + if (!isValidId(teamId)) throw new Meteor.Error('not-found', 'Invalid team id'); + if (typeof requirePlanForClock !== 'boolean') { + throw new Meteor.Error('bad-request', 'requirePlanForClock must be a boolean'); + } + const team = await Teams.findOneAsync(new Mongo.ObjectID(teamId)); + if (!team) throw new Meteor.Error('not-found', 'Team not found'); + if (!(await isTeamAdminOrOrgOwner(team, userId))) { + throw new Meteor.Error('forbidden', 'Admin access required'); + } + await Teams.updateAsync(team._id, { + $set: { 'settings.requirePlanForClock': requirePlanForClock, updatedAt: new Date() }, + }); + const updated = await Teams.findOneAsync(team._id); + return { team: toPublicTeam(updated) }; + }, + async 'teams.delete'({ teamId }) { const identity = await requireIdentity(this); const userId = identity.userId; diff --git a/meteor-backend/server/yjs.js b/meteor-backend/server/yjs.js new file mode 100644 index 00000000..fd37d5b3 --- /dev/null +++ b/meteor-backend/server/yjs.js @@ -0,0 +1,274 @@ +/** + * Live collaborative editing relay (Yjs) — Milestone 8. + * + * A minimal `y-websocket`-compatible server attached to Meteor's HTTP server on + * a dedicated `/yjs` path (clear of DDP's `/websocket`). Each huddle post gets + * its own room (`/yjs/`); the shared Y.Doc is held in memory only and + * relayed between connected peers. There is **no** Y.Doc persistence: the + * markdown stored on the post stays the source of truth (saves still go through + * `huddle.updatePost`), and the first client to join seeds the room from that + * markdown. + * + * Auth: the token is passed in the query string (`?token=…`), matching how the + * frontend already authenticates — see `src/lib/ddp.ts`. Anonymous upgrades are + * rejected. Per-post authorization (team membership) is intentionally left as a + * follow-up; a room id is only obtainable by a client that can already see the + * post. + * + * Protocol constants mirror the Kerebron `WebsocketProvider` (sync=0, + * awareness=1), so the client's provider works unchanged. + */ +import { Meteor } from 'meteor/meteor'; +import { WebApp } from 'meteor/webapp'; +import { WebSocketServer } from 'ws'; +import * as Y from 'yjs'; +import * as syncProtocol from 'y-protocols/sync'; +import * as awarenessProtocol from 'y-protocols/awareness'; +import * as encoding from 'lib0/encoding'; +import * as decoding from 'lib0/decoding'; +import { resolveToken } from './auth-bridge'; + +const MESSAGE_SYNC = 0; +const MESSAGE_AWARENESS = 1; + +const WS_PATH_PREFIX = '/yjs/'; + +/** Ping interval to detect and reap dead sockets (ms). */ +const PING_TIMEOUT = 30000; + +/** roomName -> WSSharedDoc */ +const docs = new Map(); + +/** + * A shared document for one room: a Y.Doc plus its awareness state and the set + * of connected sockets. Updates and awareness changes are broadcast to peers. + */ +class WSSharedDoc extends Y.Doc { + constructor(name) { + super({ gc: true }); + this.name = name; + /** @type {Map>} conn -> controlled client ids */ + this.conns = new Map(); + this.awareness = new awarenessProtocol.Awareness(this); + this.awareness.setLocalState(null); + + this.awareness.on('update', ({ added, updated, removed }, conn) => { + const changedClients = added.concat(updated, removed); + if (conn !== null) { + const controlledIds = this.conns.get(conn); + if (controlledIds) { + added.forEach((clientId) => controlledIds.add(clientId)); + removed.forEach((clientId) => controlledIds.delete(clientId)); + } + } + const encoder = encoding.createEncoder(); + encoding.writeVarUint(encoder, MESSAGE_AWARENESS); + encoding.writeVarUint8Array( + encoder, + awarenessProtocol.encodeAwarenessUpdate(this.awareness, changedClients), + ); + const buf = encoding.toUint8Array(encoder); + this.conns.forEach((_, c) => send(this, c, buf)); + }); + + this.on('update', (update, origin) => { + const encoder = encoding.createEncoder(); + encoding.writeVarUint(encoder, MESSAGE_SYNC); + syncProtocol.writeUpdate(encoder, update); + const buf = encoding.toUint8Array(encoder); + this.conns.forEach((_, conn) => { + if (conn !== origin) send(this, conn, buf); + }); + }); + } +} + +/** Get (or lazily create) the shared doc for a room. */ +function getYDoc(name) { + let doc = docs.get(name); + if (!doc) { + doc = new WSSharedDoc(name); + docs.set(name, doc); + } + return doc; +} + +function send(doc, conn, message) { + if (conn.readyState !== conn.OPEN && conn.readyState !== conn.CONNECTING) { + closeConn(doc, conn); + return; + } + try { + conn.send(message, (err) => { + if (err) closeConn(doc, conn); + }); + } catch { + closeConn(doc, conn); + } +} + +function closeConn(doc, conn) { + const controlledIds = doc.conns.get(conn); + if (controlledIds) { + doc.conns.delete(conn); + awarenessProtocol.removeAwarenessStates( + doc.awareness, + Array.from(controlledIds), + null, + ); + // Drop the doc from memory once the last peer leaves — markdown in Mongo is + // the source of truth, so nothing is lost. + if (doc.conns.size === 0) { + doc.destroy(); + docs.delete(doc.name); + } + } + try { + conn.close(); + } catch { + /* already closed */ + } +} + +function messageListener(conn, doc, message) { + try { + const encoder = encoding.createEncoder(); + const decoder = decoding.createDecoder(message); + const messageType = decoding.readVarUint(decoder); + switch (messageType) { + case MESSAGE_SYNC: + encoding.writeVarUint(encoder, MESSAGE_SYNC); + syncProtocol.readSyncMessage(decoder, encoder, doc, conn); + if (encoding.length(encoder) > 1) { + send(doc, conn, encoding.toUint8Array(encoder)); + } + break; + case MESSAGE_AWARENESS: + awarenessProtocol.applyAwarenessUpdate( + doc.awareness, + decoding.readVarUint8Array(decoder), + conn, + ); + break; + default: + break; + } + } catch (err) { + console.error('[yjs] message error:', err); + doc.emit('error', [err]); + } +} + +function setupConnection(conn, docName) { + conn.binaryType = 'arraybuffer'; + const doc = getYDoc(docName); + doc.conns.set(conn, new Set()); + + conn.on('message', (message) => + messageListener(conn, doc, new Uint8Array(message)), + ); + + // Liveness ping/pong — reap sockets that stop responding. + let pongReceived = true; + const pingInterval = setInterval(() => { + if (!pongReceived) { + if (doc.conns.has(conn)) closeConn(doc, conn); + clearInterval(pingInterval); + return; + } + if (doc.conns.has(conn)) { + pongReceived = false; + try { + conn.ping(); + } catch { + closeConn(doc, conn); + clearInterval(pingInterval); + } + } + }, PING_TIMEOUT); + conn.on('pong', () => { + pongReceived = true; + }); + conn.on('close', () => { + closeConn(doc, conn); + clearInterval(pingInterval); + }); + + // Send the initial sync step 1 (our state vector) so the client can diff. + { + const encoder = encoding.createEncoder(); + encoding.writeVarUint(encoder, MESSAGE_SYNC); + syncProtocol.writeSyncStep1(encoder, doc); + send(doc, conn, encoding.toUint8Array(encoder)); + } + // Send current awareness states, if any. + const awarenessStates = doc.awareness.getStates(); + if (awarenessStates.size > 0) { + const encoder = encoding.createEncoder(); + encoding.writeVarUint(encoder, MESSAGE_AWARENESS); + encoding.writeVarUint8Array( + encoder, + awarenessProtocol.encodeAwarenessUpdate( + doc.awareness, + Array.from(awarenessStates.keys()), + ), + ); + send(doc, conn, encoding.toUint8Array(encoder)); + } +} + +Meteor.startup(() => { + const wss = new WebSocketServer({ noServer: true }); + wss.on('connection', (conn, _req, docName) => setupConnection(conn, docName)); + + const httpServer = WebApp.httpServer; + if (!httpServer) { + console.error('[yjs] WebApp.httpServer unavailable — /yjs relay not started'); + return; + } + + // Coexists with DDP/sockjs upgrade handlers: only act on our own path, + // never touch or destroy sockets for other routes. + httpServer.on('upgrade', (req, socket, head) => { + let pathname = ''; + let token = ''; + try { + const url = new URL(req.url, 'http://localhost'); + pathname = url.pathname; + token = url.searchParams.get('token') || ''; + } catch { + return; + } + + if (!pathname.startsWith(WS_PATH_PREFIX)) return; + + const docName = decodeURIComponent(pathname.slice(WS_PATH_PREFIX.length)); + if (!docName) { + socket.destroy(); + return; + } + + // Authenticate before completing the handshake. + Promise.resolve(resolveToken(token)) + .then((identity) => { + if (!identity) { + socket.write('HTTP/1.1 401 Unauthorized\r\n\r\n'); + socket.destroy(); + return; + } + wss.handleUpgrade(req, socket, head, (conn) => { + wss.emit('connection', conn, req, docName); + }); + }) + .catch((err) => { + console.error('[yjs] auth error:', err); + try { + socket.destroy(); + } catch { + /* ignore */ + } + }); + }); + + console.log('[yjs] collaborative editing relay listening on /yjs/'); +}); diff --git a/meteor-backend/tests/helpers.ts b/meteor-backend/tests/helpers.ts index 690dff94..53a80f9e 100644 --- a/meteor-backend/tests/helpers.ts +++ b/meteor-backend/tests/helpers.ts @@ -19,7 +19,7 @@ interface DDPMessage { error?: { error: string; reason: string; message: string }; } -class DDPConnection { +export class DDPConnection { private ws: any; private messageId = 0; private pending = new Map void; reject: (err: Error) => void }>(); diff --git a/meteor-backend/tests/plan-gate.test.ts b/meteor-backend/tests/plan-gate.test.ts new file mode 100644 index 00000000..41fd6cf7 --- /dev/null +++ b/meteor-backend/tests/plan-gate.test.ts @@ -0,0 +1,343 @@ +/** + * Plan-first clock flow — wormhole REST + DDP integration tests. + * + * Covers the `settings.requirePlanForClock` team setting (Milestone 1), the + * postDate/wrapUpAt post fields (Milestone 2), and the clock-out gate + * (Milestone 3): gate off → clock in/out unchanged; gate on → clock-out is + * blocked until today's post has a wrap-up. + */ +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import { + createUserAndGetJwt, + wormhole, + getDb, + closeDb, + purgeUser, + ObjectId, + DDPConnection, +} from './helpers'; +import { METEOR_URL } from './setup'; + +const ADMIN = { name: 'Plan Admin', email: 'wh-plan-admin@test.dev', password: 'Password1!' }; +const MEMBER = { name: 'Plan Member', email: 'wh-plan-member@test.dev', password: 'Password1!' }; + +function todayString(): string { + const d = new Date(); + const m = String(d.getMonth() + 1).padStart(2, '0'); + const day = String(d.getDate()).padStart(2, '0'); + return `${d.getFullYear()}-${m}-${day}`; +} + +let adminJwt: string; +let memberJwt: string; +let memberUserId: string; +let teamId: string; +let memberDdp: DDPConnection; + +beforeAll(async () => { + await purgeUser(ADMIN.email); + await purgeUser(MEMBER.email); + const adminAuth = await createUserAndGetJwt(ADMIN); + const memberAuth = await createUserAndGetJwt(MEMBER); + adminJwt = adminAuth.jwt; + memberJwt = memberAuth.jwt; + + const db = await getDb(); + const adminUserId = String( + (await db.collection('users').findOne({ 'emails.address': ADMIN.email }))!._id, + ); + memberUserId = String( + (await db.collection('users').findOne({ 'emails.address': MEMBER.email }))!._id, + ); + + const teamDoc = { + _id: new ObjectId(), + name: 'WH Plan Team', + members: [adminUserId, memberUserId], + admins: [adminUserId], + code: 'WHPLAN01', + isPersonal: false, + createdAt: new Date(), + }; + await db.collection('teams').insertOne(teamDoc); + teamId = teamDoc._id.toHexString(); + + // DDP session as the member — huddle.createPost/updatePost are DDP-only + // (they authenticate via this.userId). + memberDdp = new DDPConnection(METEOR_URL.replace('http://', 'ws://') + '/websocket'); + await memberDdp.connect(); + await memberDdp.login(MEMBER.email, MEMBER.password); +}); + +afterAll(async () => { + memberDdp?.close(); + const db = await getDb(); + await db.collection('teams').deleteMany({ code: 'WHPLAN01' }); + await db.collection('clockevents').deleteMany({ teamId }); + await db.collection('clockbreaks').deleteMany({ teamId }); + await db.collection('huddlePosts').deleteMany({ teamId }); + await purgeUser(ADMIN.email); + await purgeUser(MEMBER.email); + await closeDb(); +}); + +describe('plan-first clock flow (wormhole)', () => { + let postId: string; + + it('gate off by default: clock in and out work with no post', async () => { + const start = await wormhole<{ id: string }>('clock.start', { teamId }, memberJwt); + expect(start.ok).toBe(true); + const stop = await wormhole<{ id: string }>('clock.stop', { teamId }, memberJwt); + expect(stop.ok).toBe(true); + }); + + it('rejects teams.updateSettings from a non-admin member', async () => { + const res = await wormhole( + 'teams.updateSettings', + { teamId, requirePlanForClock: true }, + memberJwt, + ); + expect(res.ok).toBe(false); + expect(res.error).toMatch(/admin/i); + }); + + it('lets a team admin enable requirePlanForClock', async () => { + const res = await wormhole<{ team: { settings: { requirePlanForClock: boolean } } }>( + 'teams.updateSettings', + { teamId, requirePlanForClock: true }, + adminJwt, + ); + expect(res.ok).toBe(true); + expect(res.result.team.settings.requirePlanForClock).toBe(true); + }); + + it('getMyPostForDate returns null before any post exists', async () => { + const res = await wormhole<{ post: null }>( + 'huddle.getMyPostForDate', + { teamId, postDate: todayString() }, + memberJwt, + ); + expect(res.ok).toBe(true); + expect(res.result.post).toBeNull(); + }); + + it('gate on: clock-in itself is not backend-blocked', async () => { + const res = await wormhole<{ id: string }>('clock.start', { teamId }, memberJwt); + expect(res.ok).toBe(true); + }); + + it('gate on: clock-out is blocked when this session has no linked post', async () => { + const res = await wormhole('clock.stop', { teamId }, memberJwt); + expect(res.ok).toBe(false); + expect(res.error).toMatch(/wrap-up/i); + }); + + it('recovery: a session post created for the active session unblocks clock-out', async () => { + // The session started bare (previous test). Its active event: + const active = await wormhole<{ id: string }>('clock.activeForUser', {}, memberJwt); + expect(active.ok).toBe(true); + const clockEventId = active.result.id; + + // A plain post for today (no session link) does NOT satisfy the gate. + await memberDdp.call('huddle.createPost', [ + { teamId, content: { text: 'unrelated update' }, postDate: todayString() }, + ]); + const stillBlocked = await wormhole('clock.stop', { teamId }, memberJwt); + expect(stillBlocked.ok).toBe(false); + + // A post linked to this session WITH a wrap-up satisfies it. + await memberDdp.call('huddle.createPost', [ + { + teamId, + content: { text: '**Wrap-up:** recovered session' }, + postDate: todayString(), + clockEventId, + wrapUp: true, + }, + ]); + const stop = await wormhole<{ id: string; endTime: number }>('clock.stop', { teamId }, memberJwt); + expect(stop.ok).toBe(true); + expect(stop.result.endTime).toBeGreaterThan(0); + }); + + it('per-session flow: plan → clock-in links it → wrap-up → clock-out', async () => { + const db = await getDb(); + await db.collection('huddlePosts').deleteMany({ teamId }); + + // 1) Post the plan (published, dated). + const plan = (await memberDdp.call('huddle.createPost', [ + { teamId, content: { text: 'Session plan: ship per-session gates.' }, postDate: todayString() }, + ])) as { id: string }; + postId = plan.id; + + // 2) Clock in with planPostId → links the plan to the new session. + const start = await wormhole<{ id: string }>( + 'clock.start', + { teamId, planPostId: postId }, + memberJwt, + ); + expect(start.ok).toBe(true); + const clockEventId = start.result.id; + + const linked = await wormhole<{ post: { id: string; clockEventId?: string } }>( + 'huddle.getMyPostForSession', + { teamId, clockEventId }, + memberJwt, + ); + expect(linked.ok).toBe(true); + expect(linked.result.post?.id).toBe(postId); + + // 3) No wrap-up yet → clock-out blocked. + const blocked = await wormhole('clock.stop', { teamId }, memberJwt); + expect(blocked.ok).toBe(false); + expect(blocked.error).toMatch(/wrap-up/i); + + // 4) Wrap up the session post → clock-out works. + await memberDdp.call('huddle.updatePost', [ + { postId, content: { text: 'Session plan. Wrap-up: shipped.' }, wrapUp: true }, + ]); + const stop = await wormhole<{ id: string; endTime: number }>('clock.stop', { teamId }, memberJwt); + expect(stop.ok).toBe(true); + expect(stop.result.endTime).toBeGreaterThan(0); + }); + + it('second clock-in of the day needs its OWN plan (per session, not per day)', async () => { + // Today's post from the previous session already exists and is wrapped up, + // but a fresh session's post is a different one. + const start = await wormhole<{ id: string }>('clock.start', { teamId }, memberJwt); + expect(start.ok).toBe(true); + // No post linked to this new session → clock-out blocked even though an + // earlier wrapped-up post exists for today. + const blocked = await wormhole('clock.stop', { teamId }, memberJwt); + expect(blocked.ok).toBe(false); + expect(blocked.error).toMatch(/wrap-up/i); + + // Recover so the suite leaves no open session. + const active = await wormhole<{ id: string }>('clock.activeForUser', {}, memberJwt); + await memberDdp.call('huddle.createPost', [ + { + teamId, + content: { text: '**Wrap-up:** second session' }, + postDate: todayString(), + clockEventId: active.result.id, + wrapUp: true, + }, + ]); + const stop = await wormhole('clock.stop', { teamId }, memberJwt); + expect(stop.ok).toBe(true); + }); + + it('gate turned back off: clock in/out works again without a post', async () => { + const db = await getDb(); + await db.collection('huddlePosts').deleteMany({ teamId }); + + const res = await wormhole<{ team: { settings: { requirePlanForClock: boolean } } }>( + 'teams.updateSettings', + { teamId, requirePlanForClock: false }, + adminJwt, + ); + expect(res.ok).toBe(true); + expect(res.result.team.settings.requirePlanForClock).toBe(false); + + const start = await wormhole<{ id: string }>('clock.start', { teamId }, memberJwt); + expect(start.ok).toBe(true); + const stop = await wormhole<{ id: string }>('clock.stop', { teamId }, memberJwt); + expect(stop.ok).toBe(true); + }); +}); + +describe('drafts (plan-first)', () => { + let draftId: string; + + it('creates an author-only draft (no postDate, status draft)', async () => { + // Re-enable the gate for this suite. + const res = await wormhole( + 'teams.updateSettings', + { teamId, requirePlanForClock: true }, + adminJwt, + ); + expect(res.ok).toBe(true); + + const created = (await memberDdp.call('huddle.createPost', [ + { teamId, content: { text: 'Tomorrow: finish drafts milestone.' }, draft: true }, + ])) as { id: string }; + draftId = created.id; + expect(draftId).toBeTruthy(); + + const fetched = await wormhole<{ post: { id: string; status?: string; postDate?: string } }>( + 'huddle.getMyLatestDraft', + { teamId }, + memberJwt, + ); + expect(fetched.ok).toBe(true); + expect(fetched.result.post?.id).toBe(draftId); + expect(fetched.result.post?.status).toBe('draft'); + expect(fetched.result.post?.postDate).toBeUndefined(); + }); + + it('drafts are invisible in the team feed', async () => { + const feed = (await memberDdp.call('huddle.getPosts', [{ teamId }])) as { + posts: Array<{ id: string }>; + }; + expect(feed.posts.some((p) => p.id === draftId)).toBe(false); + }); + + it('a draft does not satisfy the gate', async () => { + // Start a bare session; a draft alone can't unblock clock-out. + const start = await wormhole<{ id: string }>('clock.start', { teamId }, memberJwt); + expect(start.ok).toBe(true); + const stop = await wormhole('clock.stop', { teamId }, memberJwt); + expect(stop.ok).toBe(false); + expect(stop.error).toMatch(/wrap-up/i); + }); + + it('only the author can publish a draft', async () => { + const adminDdp = new DDPConnection(METEOR_URL.replace('http://', 'ws://') + '/websocket'); + await adminDdp.connect(); + await adminDdp.login(ADMIN.email, ADMIN.password); + await expect( + adminDdp.call('huddle.publishPost', [{ postId: draftId, postDate: todayString() }]), + ).rejects.toThrow(/author/i); + adminDdp.close(); + }); + + it('publishing a draft as the session plan enters the feed and unblocks clock-out', async () => { + const active = await wormhole<{ id: string }>('clock.activeForUser', {}, memberJwt); + const clockEventId = active.result.id; + + // Publish the draft, linking it to the active session. + await memberDdp.call('huddle.publishPost', [ + { + postId: draftId, + postDate: todayString(), + content: { text: 'Today: finish drafts milestone.' }, + clockEventId, + }, + ]); + + // Now it's the session post (published, linked), but no wrap-up yet. + const linked = await wormhole<{ post: { id: string; status?: string } }>( + 'huddle.getMyPostForSession', + { teamId, clockEventId }, + memberJwt, + ); + expect(linked.ok).toBe(true); + expect(linked.result.post?.id).toBe(draftId); + expect(linked.result.post?.status).toBeUndefined(); + + const feed = (await memberDdp.call('huddle.getPosts', [{ teamId }])) as { + posts: Array<{ id: string }>; + }; + expect(feed.posts.some((p) => p.id === draftId)).toBe(true); + + const noDraft = await wormhole<{ post: null }>('huddle.getMyLatestDraft', { teamId }, memberJwt); + expect(noDraft.result.post).toBeNull(); + + // Wrap up and clock out to close the session opened in the previous test. + await memberDdp.call('huddle.updatePost', [ + { postId: draftId, content: { text: 'Done. Wrap-up: drafts work.' }, wrapUp: true }, + ]); + const stop = await wormhole<{ id: string }>('clock.stop', { teamId }, memberJwt); + expect(stop.ok).toBe(true); + }); +}); diff --git a/package-lock.json b/package-lock.json index efbdc781..3ae814ae 100644 --- a/package-lock.json +++ b/package-lock.json @@ -28,16 +28,19 @@ "@fortawesome/free-regular-svg-icons": "^7.0.1", "@fortawesome/free-solid-svg-icons": "^7.0.1", "@fortawesome/react-fontawesome": "^3.0.2", - "@kerebron/editor": "^0.7.9", + "@kerebron/editor": "^0.8.6", + "@kerebron/editor-kits": "^0.8.6", "@kerebron/extension-automerge": "^0.2.1", - "@kerebron/extension-basic-editor": "^0.7.9", - "@mieweb/ui": "^0.2.4", + "@kerebron/extension-basic-editor": "^0.8.6", + "@kerebron/extension-yjs": "^0.8.6", + "@kerebron/wasm": "^0.8.6", + "@mieweb/ui": "file:vendor/mieweb-ui.tgz", "@mieweb/ychart": "^1.1.0", "@swc/helpers": "^0.5.17", "cmdk": "^1.1.1", "date-fns": "^4.4.0", "katex": "^0.17.0", - "mermaid": "^11.15.0", + "mermaid": "^11.16.0", "motion": "^12.34.3", "postcss-load-config": "^6.0.1", "qrcode.react": "^4.2.0", @@ -47,12 +50,15 @@ "react-markdown": "^10.1.0", "rehype-highlight": "^7.0.2", "rehype-katex": "^7.0.1", + "rehype-sanitize": "^6.0.0", "remark-breaks": "^4.0.0", "remark-gfm": "^4.0.1", "remark-math": "^6.0.0", "serve": "^14.2.4", "tus-js-client": "^4.3.1", + "y-protocols": "^1.0.6", "yaml": "^2.8.1", + "yjs": "^13.6.30", "zod": "^4.3.6" }, "devDependencies": { @@ -61,6 +67,7 @@ "@capacitor/cli": "^8.3.1", "@capacitor/ios": "^8.3.1", "@eslint/js": "^9.35.0", + "@mieweb/datavis": "^1.5.0", "@playwright/test": "^1.61.1", "@tailwindcss/oxide": "4.1.13", "@tailwindcss/postcss": "^4.1.13", @@ -75,6 +82,7 @@ "@vitejs/plugin-react": "^6.0.1", "autoprefixer": "^10.4.19", "bcrypt": "^6.0.0", + "datavis-ace": "^4.1.0", "eslint": "^9.35.0", "eslint-plugin-import": "^2.31.0", "eslint-plugin-jsx-a11y": "^6.9.0", @@ -237,13 +245,13 @@ } }, "node_modules/@babel/code-frame": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz", - "integrity": "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", + "integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==", "dev": true, "license": "MIT", "dependencies": { - "@babel/helper-validator-identifier": "^7.28.5", + "@babel/helper-validator-identifier": "^7.29.7", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" }, @@ -252,9 +260,9 @@ } }, "node_modules/@babel/helper-validator-identifier": { - "version": "7.28.5", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", - "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", "dev": true, "license": "MIT", "engines": { @@ -262,9 +270,9 @@ } }, "node_modules/@babel/runtime": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.28.6.tgz", - "integrity": "sha512-05WQkdpL9COIMz4LjTxGpPNCdlpyimKppYNoJ5Di5EUObifl8t4tuLuUBBZEpoLYOmfvIWrsp9fCl0HoPRVTdA==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.7.tgz", + "integrity": "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==", "license": "MIT", "engines": { "node": ">=6.9.0" @@ -1257,6 +1265,63 @@ "node": ">=20.19.0" } }, + "node_modules/@dnd-kit/accessibility": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@dnd-kit/accessibility/-/accessibility-3.1.1.tgz", + "integrity": "sha512-2P+YgaXF+gRsIihwwY1gCsQSYnu9Zyj2py8kY5fFvUM1qm2WA2u639R6YNVfU4GWr+ZM5mqEsfHZZLoRONbemw==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "tslib": "^2.0.0" + }, + "peerDependencies": { + "react": ">=16.8.0" + } + }, + "node_modules/@dnd-kit/core": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/@dnd-kit/core/-/core-6.3.1.tgz", + "integrity": "sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "@dnd-kit/accessibility": "^3.1.1", + "@dnd-kit/utilities": "^3.2.2", + "tslib": "^2.0.0" + }, + "peerDependencies": { + "react": ">=16.8.0", + "react-dom": ">=16.8.0" + } + }, + "node_modules/@dnd-kit/sortable": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/@dnd-kit/sortable/-/sortable-10.0.0.tgz", + "integrity": "sha512-+xqhmIIzvAYMGfBYYnbKuNicfSsk4RksY2XdmJhT+HAC01nix6fHCztU68jooFiMUB01Ky3F0FyOvhG/BZrWkg==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "@dnd-kit/utilities": "^3.2.2", + "tslib": "^2.0.0" + }, + "peerDependencies": { + "@dnd-kit/core": "^6.3.0", + "react": ">=16.8.0" + } + }, + "node_modules/@dnd-kit/utilities": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/@dnd-kit/utilities/-/utilities-3.2.2.tgz", + "integrity": "sha512-+MKAJEOfaBe5SmV6t34p80MMKhjvUz0vRrvVJbPT0WElzaOJ/1xs+D+KDv+tD/NE5ujfrChEcshd4fLn0wpiqg==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "tslib": "^2.0.0" + }, + "peerDependencies": { + "react": ">=16.8.0" + } + }, "node_modules/@emnapi/core": { "version": "1.11.1", "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.1.tgz", @@ -1984,7 +2049,6 @@ "version": "0.3.13", "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", - "dev": true, "license": "MIT", "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", @@ -1995,7 +2059,6 @@ "version": "2.3.5", "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", - "dev": true, "license": "MIT", "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", @@ -2021,7 +2084,6 @@ "version": "0.3.31", "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", - "dev": true, "license": "MIT", "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", @@ -2029,17 +2091,39 @@ } }, "node_modules/@kerebron/editor": { - "version": "0.7.9", - "resolved": "https://registry.npmjs.org/@kerebron/editor/-/editor-0.7.9.tgz", - "integrity": "sha512-DqbKdAc1IJHlo52Z/IHKhYjLcPqlvQroYgPlwJjsPbhX+m0N6NoEtN/fSy1I4+ElhP8egledgZTiymiDXY04Pg==", + "version": "0.8.6", + "resolved": "https://registry.npmjs.org/@kerebron/editor/-/editor-0.8.6.tgz", + "integrity": "sha512-E9S5MQy04wSzLMd1T+S12bqFJL3p7m3jrj4tTkJ8CPuCPvbgu752JBy1n1xM3aR/ht6CtCRKvP9cTRUNjDn+Kg==", "license": "MIT", "dependencies": { - "prosemirror-model": "1.25.3", + "@kerebron/workspace": "0.8.6", + "prosemirror-model": "1.25.9", "prosemirror-state": "1.4.3", "prosemirror-transform": "1.10.4", "prosemirror-view": "1.40.0" } }, + "node_modules/@kerebron/editor-kits": { + "version": "0.8.6", + "resolved": "https://registry.npmjs.org/@kerebron/editor-kits/-/editor-kits-0.8.6.tgz", + "integrity": "sha512-RYLlHLhu4BO9La2XCVJysA2n8Lx4wD33jqo1SYNyYBgcKNfYK/InDRzMtBJOXlZbfEUMQM+2c9s8f7ijOR7idw==", + "license": "MIT", + "dependencies": { + "@kerebron/editor": "0.8.6", + "@kerebron/extension-basic-editor": "0.8.6", + "@kerebron/extension-codecrock": "0.8.6", + "@kerebron/extension-dev-toolkit": "0.8.6", + "@kerebron/extension-lsp": "0.8.6", + "@kerebron/extension-markdown": "0.8.6", + "@kerebron/extension-menu": "0.8.6", + "@kerebron/extension-menu-legacy": "0.8.6", + "@kerebron/extension-odt": "0.8.6", + "@kerebron/extension-tables": "0.8.6", + "@kerebron/extension-ui": "0.8.6", + "@kerebron/extension-yjs": "0.8.6", + "yjs": "13.6.30" + } + }, "node_modules/@kerebron/extension-automerge": { "version": "0.2.1", "resolved": "https://registry.npmjs.org/@kerebron/extension-automerge/-/extension-automerge-0.2.1.tgz", @@ -2067,19 +2151,184 @@ } }, "node_modules/@kerebron/extension-basic-editor": { - "version": "0.7.9", - "resolved": "https://registry.npmjs.org/@kerebron/extension-basic-editor/-/extension-basic-editor-0.7.9.tgz", - "integrity": "sha512-HCS/JMgCyn6Kn/Oz5cxhgv3BjvgNRSW1b2161pRF+EL3jnDX3Ecp1HbFeTU/02a6V36PShO0paMr/Cz43lwdDQ==", + "version": "0.8.6", + "resolved": "https://registry.npmjs.org/@kerebron/extension-basic-editor/-/extension-basic-editor-0.8.6.tgz", + "integrity": "sha512-LPxF5NRoEPD1aIKIW+VT5klUt+TgRrksaPRJBxiUW0B4DCJML6e1JZfQ9o7iSujPrSiekWRWpCpsITs98fAmMQ==", + "license": "MIT", + "dependencies": { + "@kerebron/editor": "0.8.6", + "prosemirror-model": "1.25.9", + "prosemirror-state": "1.4.3", + "prosemirror-transform": "1.10.4", + "prosemirror-view": "1.40.0" + } + }, + "node_modules/@kerebron/extension-codecrock": { + "version": "0.8.6", + "resolved": "https://registry.npmjs.org/@kerebron/extension-codecrock/-/extension-codecrock-0.8.6.tgz", + "integrity": "sha512-10Zo+e5WoTURS+JsjK4GjQru3NFXERjYl0S1Pq0CrocQjrp0AI+jiluDUSLQHE2BiwNnl9EjoH0kJrQGLJLygw==", + "license": "MIT", + "dependencies": { + "@kerebron/editor": "0.8.6", + "@kerebron/extension-basic-editor": "0.8.6", + "@kerebron/tree-sitter": "0.8.6", + "@kerebron/wasm": "0.8.6", + "@kerebron/workspace": "0.8.6", + "prosemirror-model": "1.25.9", + "prosemirror-state": "1.4.3", + "prosemirror-view": "1.40.0" + } + }, + "node_modules/@kerebron/extension-dev-toolkit": { + "version": "0.8.6", + "resolved": "https://registry.npmjs.org/@kerebron/extension-dev-toolkit/-/extension-dev-toolkit-0.8.6.tgz", + "integrity": "sha512-PbjcTRQbzWBsFAi5ZNVjgljTR1qWpCG+AJHq4kPh5nHRW8Z2F2x2v7RNOCy35LTEvFz3WAPwr3losmT/YRovTg==", + "license": "MIT", + "dependencies": { + "@kerebron/editor": "0.8.6", + "prosemirror-dev-toolkit": "1.1.8", + "prosemirror-view": "1.40.0" + } + }, + "node_modules/@kerebron/extension-lsp": { + "version": "0.8.6", + "resolved": "https://registry.npmjs.org/@kerebron/extension-lsp/-/extension-lsp-0.8.6.tgz", + "integrity": "sha512-m+M5qS8YlPMFzsdnVQmBuSnZ03oWt/WEYsDLSEYSpGiFuqH8/9wISach0jZ1ts6X2HV+R+9Yg7QW51hl2We6Wg==", + "license": "MIT", + "dependencies": { + "@kerebron/editor": "0.8.6", + "@kerebron/extension-ui": "0.8.6", + "@kerebron/workspace": "0.8.6", + "prosemirror-state": "1.4.3", + "prosemirror-view": "1.40.0", + "vscode-languageserver-protocol": "3.17.5" + } + }, + "node_modules/@kerebron/extension-markdown": { + "version": "0.8.6", + "resolved": "https://registry.npmjs.org/@kerebron/extension-markdown/-/extension-markdown-0.8.6.tgz", + "integrity": "sha512-5/f+8oAozp1sR1pJE9puEI+AAwB0CduRiGFumBf7XteZPwiGKBp/m1IF3SjPNeJkZypxVjqrc2oJeH3UNCf5TQ==", + "license": "MIT", + "dependencies": { + "@kerebron/editor": "0.8.6", + "@kerebron/extension-basic-editor": "0.8.6", + "@kerebron/tree-sitter": "0.8.6", + "@kerebron/wasm": "0.8.6", + "@kerebron/workspace": "0.8.6", + "mathml2latex": "1.1.3", + "prosemirror-model": "1.25.9", + "prosemirror-state": "1.4.3", + "web-tree-sitter": "0.26.6" + } + }, + "node_modules/@kerebron/extension-menu": { + "version": "0.8.6", + "resolved": "https://registry.npmjs.org/@kerebron/extension-menu/-/extension-menu-0.8.6.tgz", + "integrity": "sha512-cGSbEgcOh0+8d5YxJ/5xwYMsIXKeHLsZthII1FFpKgm16ayZTovc+vXX9ydKoe+hSA35YsWn0po1xFpdCDgl2A==", "license": "MIT", "dependencies": { - "@kerebron/editor": "0.7.9", - "prosemirror-history": "1.4.1", - "prosemirror-model": "1.25.3", + "@kerebron/editor": "0.8.6", + "prosemirror-model": "1.25.9", + "prosemirror-state": "1.4.3", + "prosemirror-view": "1.40.0" + } + }, + "node_modules/@kerebron/extension-menu-legacy": { + "version": "0.8.6", + "resolved": "https://registry.npmjs.org/@kerebron/extension-menu-legacy/-/extension-menu-legacy-0.8.6.tgz", + "integrity": "sha512-VyoRQ/DhuQkoJS+vOXQ3Qh6tJppnn3uhuijvm0HNn3u/Zso3S+QXIK7zGKAF0C6GRM+9tAR4hEkOdE3+zV+WRg==", + "license": "MIT", + "dependencies": { + "@kerebron/editor": "0.8.6", + "prosemirror-model": "1.25.9", + "prosemirror-state": "1.4.3", + "prosemirror-view": "1.40.0" + } + }, + "node_modules/@kerebron/extension-odt": { + "version": "0.8.6", + "resolved": "https://registry.npmjs.org/@kerebron/extension-odt/-/extension-odt-0.8.6.tgz", + "integrity": "sha512-oKGMvo3fTm2jQm1LQFPnTtldsnKtnrUnQFwo4qjSImelAwv9BA6uVVDe5OdlOi/WPz1uRUSwjgDHYuYutWRjJA==", + "license": "MIT", + "dependencies": { + "@kerebron/editor": "0.8.6", + "@kerebron/odt-wasm": "0.8.6", + "prosemirror-model": "1.25.9", + "prosemirror-state": "1.4.3" + } + }, + "node_modules/@kerebron/extension-tables": { + "version": "0.8.6", + "resolved": "https://registry.npmjs.org/@kerebron/extension-tables/-/extension-tables-0.8.6.tgz", + "integrity": "sha512-r8K5rT7CoCrCBOx6RnrAjWpJ8JkkYc7ZHoEj1hqkWiKsrC7RqMEqTMo3aLvhHxxs52ldMBTTFu7plBarry5pQA==", + "license": "MIT", + "dependencies": { + "@kerebron/editor": "0.8.6", + "prosemirror-model": "1.25.9", "prosemirror-state": "1.4.3", "prosemirror-transform": "1.10.4", "prosemirror-view": "1.40.0" } }, + "node_modules/@kerebron/extension-ui": { + "version": "0.8.6", + "resolved": "https://registry.npmjs.org/@kerebron/extension-ui/-/extension-ui-0.8.6.tgz", + "integrity": "sha512-1mINJ/HIbO4uqVhA4BhkkLbaouCkn8brD9QIftX+1lygfmFw0Z9uKCyfGmkuk/wbzb9rdY6FMnvQ2j2sbXo6TA==", + "license": "MIT", + "dependencies": { + "@kerebron/editor": "0.8.6", + "prosemirror-model": "1.25.9", + "prosemirror-state": "1.4.3", + "prosemirror-view": "1.40.0" + } + }, + "node_modules/@kerebron/extension-yjs": { + "version": "0.8.6", + "resolved": "https://registry.npmjs.org/@kerebron/extension-yjs/-/extension-yjs-0.8.6.tgz", + "integrity": "sha512-RTnKEDU+Z5ipEghEEC3dh4bmwFh4TyGIqIfFImftzdDEDIh31jxAN5/RSMlchbS3rNhaLbwWSuHfRa0vhXAGiw==", + "license": "MIT", + "dependencies": { + "@kerebron/editor": "0.8.6", + "@kerebron/extension-basic-editor": "0.8.6", + "lib0": "0.2.109", + "prosemirror-model": "1.25.9", + "prosemirror-state": "1.4.3", + "prosemirror-view": "1.40.0", + "y-protocols": "1.0.6", + "yjs": "13.6.30" + } + }, + "node_modules/@kerebron/odt-wasm": { + "version": "0.8.6", + "resolved": "https://registry.npmjs.org/@kerebron/odt-wasm/-/odt-wasm-0.8.6.tgz", + "integrity": "sha512-IoFg3W1uES0I9u3jlsGggvsRNtjutt1VVRe//FJFOBgiDU5exsbtkk+vpn2qehxpoYCnKhEI1QRSLCrsvmzFwA==", + "license": "MIT" + }, + "node_modules/@kerebron/tree-sitter": { + "version": "0.8.6", + "resolved": "https://registry.npmjs.org/@kerebron/tree-sitter/-/tree-sitter-0.8.6.tgz", + "integrity": "sha512-JXiSfvu94lUTu4PJ5w8RzzDMoNnTV2sPH4kTnja7RDRWRWGnDcQbzlPinAwvE6xhfqMIlo7YR2EN5904x16ipA==", + "license": "MIT", + "dependencies": { + "@kerebron/editor": "0.8.6", + "web-tree-sitter": "0.26.6" + } + }, + "node_modules/@kerebron/wasm": { + "version": "0.8.6", + "resolved": "https://registry.npmjs.org/@kerebron/wasm/-/wasm-0.8.6.tgz", + "integrity": "sha512-LACORzX1lAeX6P2vewsYDhwpEqswZgpGctMXw3VTSWcIv508YIQ7uSBXmfQOPyuv4O06o5mR92ETeWkRGUhszw==", + "license": "MIT", + "dependencies": { + "@kerebron/editor": "0.8.6" + } + }, + "node_modules/@kerebron/workspace": { + "version": "0.8.6", + "resolved": "https://registry.npmjs.org/@kerebron/workspace/-/workspace-0.8.6.tgz", + "integrity": "sha512-Pd91tOp3YCF4elwPGgLAh+JL+tiSNPBLHNXEOGobwu+oTVYDMO80NSj24tT/soBKySA8qAs0dXz7xciWd9sh3Q==", + "license": "MIT" + }, "node_modules/@lezer/common": { "version": "1.5.1", "resolved": "https://registry.npmjs.org/@lezer/common/-/common-1.5.1.tgz", @@ -2122,55 +2371,290 @@ "license": "MIT" }, "node_modules/@mermaid-js/parser": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/@mermaid-js/parser/-/parser-1.1.1.tgz", - "integrity": "sha512-VuHdsYMK1bT6X2JbcAaWAhugTRvRBRyuZgd+c22swUeI9g/ntaxF7CY7dYarhZovofCbUNO0G7JesfmNtjYOCw==", + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@mermaid-js/parser/-/parser-1.2.0.tgz", + "integrity": "sha512-oYPyv8A4As1yH5Bx+04iQEQxXuIQDe0GKCNSRgao6z8AM9jixXIfP0vsppRLvGf+nKIOb9/LdpWA4YuJiVvESA==", "license": "MIT", "dependencies": { - "@chevrotain/types": "~11.1.1" + "@chevrotain/types": "~11.1.2" } }, - "node_modules/@mieweb/ui": { - "version": "0.2.4", - "resolved": "https://registry.npmjs.org/@mieweb/ui/-/ui-0.2.4.tgz", - "integrity": "sha512-1OBv6wCgxCGY+s2Di5LVGyqFoYWmX14/zLX1+kUc/qRjI+2NGywvLht73pOvcqomvHhzEtVp3hRuHm5bCsHx3g==", + "node_modules/@mieweb/datavis": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/@mieweb/datavis/-/datavis-1.5.0.tgz", + "integrity": "sha512-/qzRLIzkL2TRNi6A0y3MTSRfW4FKdo1/TOdxwUuuzbaRfESNQHNM1ua5ayleuTC8CCOJTMzntY1ad5nfQFH/KQ==", + "devOptional": true, + "dependencies": { + "@dnd-kit/core": "^6.3.1", + "@dnd-kit/sortable": "^10.0.0", + "@dnd-kit/utilities": "^3.2.2", + "@mieweb/ui": "=0.6.1-dev.148", + "datavis-ace": "=4.1.0", + "i18next": "^26.3.4", + "lucide-react": "^1.23.0", + "react": "^19.2.7", + "react-dom": "^19.2.7", + "react-i18next": "^17.0.8", + "react-is": "^18.3.1", + "recharts": "^3.9.2" + }, + "peerDependencies": { + "datavis-ace": ">=4.1.0", + "react": "^19.0.0", + "react-dom": "^19.0.0" + } + }, + "node_modules/@mieweb/datavis/node_modules/@mieweb/ui": { + "version": "0.6.1-dev.148", + "resolved": "https://registry.npmjs.org/@mieweb/ui/-/ui-0.6.1-dev.148.tgz", + "integrity": "sha512-JJQDSEytjZN6hoz4O52om3p6AXilTxT8veCObz02fIMasZZxP5UCPCGHrMGPZqXWpbz1fOBf1b/YGw+k5577pw==", + "devOptional": true, + "hasInstallScript": true, "license": "SEE LICENSE IN LICENSE", "dependencies": { "@swc/helpers": "^0.5.19", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", + "dompurify": "^3.4.0", + "google-libphonenumber": "^3.2.44", + "highlight.js": "^11.11.1", "lucide-react": "^0.562.0", "luxon": "^3.7.2", + "marked": "^17.0.3", "tailwind-merge": "^2.6.1" }, "engines": { - "node": ">=18.0.0" + "node": "^20.19.0 || >=22.12.0" }, "peerDependencies": { + "@kerebron/editor": ">=0.7.9", + "@kerebron/editor-kits": ">=0.7.9", + "@kerebron/wasm": ">=0.7.9", + "@mieweb/datavis": "=0.0.0-PRE.4", "ag-grid-community": ">=32.0.0", "ag-grid-react": ">=32.0.0", + "datavis-ace": "=4.0.0-PRE.2", + "js-yaml": ">=4.0.0", + "mermaid": ">=10.0.0", + "papaparse": ">=5.0.0", "react": ">=18.0.0", "react-dom": ">=18.0.0", "wavesurfer.js": ">=7.0.0" }, "peerDependenciesMeta": { + "@kerebron/editor": { + "optional": true + }, + "@kerebron/editor-kits": { + "optional": true + }, + "@kerebron/wasm": { + "optional": true + }, + "@mieweb/datavis": { + "optional": true + }, + "ag-grid-community": { + "optional": true + }, + "ag-grid-react": { + "optional": true + }, + "datavis-ace": { + "optional": true + }, + "js-yaml": { + "optional": true + }, + "mermaid": { + "optional": true + }, + "papaparse": { + "optional": true + }, + "react": { + "optional": false + }, + "react-dom": { + "optional": false + }, + "wavesurfer.js": { + "optional": true + } + } + }, + "node_modules/@mieweb/datavis/node_modules/@mieweb/ui/node_modules/lucide-react": { + "version": "0.562.0", + "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.562.0.tgz", + "integrity": "sha512-82hOAu7y0dbVuFfmO4bYF1XEwYk/mEbM5E+b1jgci/udUBEE/R7LF5Ip0CCEmXe8AybRM8L+04eP+LGZeDvkiw==", + "devOptional": true, + "license": "ISC", + "peerDependencies": { + "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/@mieweb/datavis/node_modules/lucide-react": { + "version": "1.27.0", + "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-1.27.0.tgz", + "integrity": "sha512-rJicGl/3Fly/E0rOH1YmPZ6e49JCnKknh1ox1vpHnkfjujAkKA6sqUZvH3MTAaXXjgexyUwgNwTJzTtYuAFYJw==", + "devOptional": true, + "license": "ISC", + "peerDependencies": { + "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/@mieweb/datavis/node_modules/marked": { + "version": "17.0.6", + "resolved": "https://registry.npmjs.org/marked/-/marked-17.0.6.tgz", + "integrity": "sha512-gB0gkNafnonOw0obSTEGZTT86IuhILt2Wfx0mWH/1Au83kybTayroZ/V6nS25mN7u8ASy+5fMhgB3XPNrOZdmA==", + "devOptional": true, + "license": "MIT", + "bin": { + "marked": "bin/marked.js" + }, + "engines": { + "node": ">= 20" + } + }, + "node_modules/@mieweb/datavis/node_modules/react-is": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", + "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/@mieweb/ui": { + "version": "0.6.1", + "resolved": "file:vendor/mieweb-ui.tgz", + "integrity": "sha512-9lXD1FSno7T/97GPPfMWKlYVSGdxFtD5ez9wnKqU9Iljc5afF//tVV67Hh1FiEoFV5+Qv8hUEWjuLTe1OHdeeQ==", + "hasInstallScript": true, + "license": "SEE LICENSE IN LICENSE", + "dependencies": { + "@swc/helpers": "^0.5.19", + "@tanstack/react-virtual": "^3.14.2", + "class-variance-authority": "^0.7.1", + "clsx": "^2.1.1", + "dompurify": "^3.4.0", + "google-libphonenumber": "^3.2.44", + "highlight.js": "^11.11.1", + "lucide-react": "^0.562.0", + "luxon": "^3.7.2", + "marked": "^17.0.3", + "onnxruntime-web": "1.19.0", + "tailwind-merge": "^2.6.1" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "peerDependencies": { + "@kerebron/editor": ">=0.7.9", + "@kerebron/editor-kits": ">=0.7.9", + "@kerebron/extension-yjs": ">=0.8.6", + "@kerebron/wasm": ">=0.7.9", + "@mieweb/datavis": "=1.5.0", + "ag-grid-community": ">=32.0.0", + "ag-grid-react": ">=32.0.0", + "datavis-ace": "=4.1.0", + "js-yaml": ">=4.0.0", + "katex": ">=0.16.0", + "mermaid": ">=11.0.0", + "papaparse": ">=5.0.0", + "react": ">=18.0.0", + "react-dom": ">=18.0.0", + "react-markdown": ">=9.0.0", + "rehype-highlight": ">=7.0.0", + "rehype-katex": ">=7.0.0", + "rehype-sanitize": ">=6.0.0", + "remark-gfm": ">=4.0.0", + "remark-math": ">=6.0.0", + "wavesurfer.js": ">=7.0.0", + "y-protocols": ">=1.0.6", + "yjs": ">=13.6.0" + }, + "peerDependenciesMeta": { + "@kerebron/editor": { + "optional": true + }, + "@kerebron/editor-kits": { + "optional": true + }, + "@kerebron/extension-yjs": { + "optional": true + }, + "@kerebron/wasm": { + "optional": true + }, + "@mieweb/datavis": { + "optional": true + }, "ag-grid-community": { "optional": true }, "ag-grid-react": { "optional": true }, + "datavis-ace": { + "optional": true + }, + "js-yaml": { + "optional": true + }, + "katex": { + "optional": true + }, + "mermaid": { + "optional": true + }, + "papaparse": { + "optional": true + }, "react": { "optional": false }, "react-dom": { "optional": false }, + "react-markdown": { + "optional": true + }, + "rehype-highlight": { + "optional": true + }, + "rehype-katex": { + "optional": true + }, + "rehype-sanitize": { + "optional": true + }, + "remark-gfm": { + "optional": true + }, + "remark-math": { + "optional": true + }, "wavesurfer.js": { "optional": true + }, + "y-protocols": { + "optional": true + }, + "yjs": { + "optional": true } } }, + "node_modules/@mieweb/ui/node_modules/marked": { + "version": "17.0.6", + "resolved": "https://registry.npmjs.org/marked/-/marked-17.0.6.tgz", + "integrity": "sha512-gB0gkNafnonOw0obSTEGZTT86IuhILt2Wfx0mWH/1Au83kybTayroZ/V6nS25mN7u8ASy+5fMhgB3XPNrOZdmA==", + "license": "MIT", + "bin": { + "marked": "bin/marked.js" + }, + "engines": { + "node": ">= 20" + } + }, "node_modules/@mieweb/ychart": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@mieweb/ychart/-/ychart-1.1.0.tgz", @@ -2202,6 +2686,47 @@ "node": ">=24.0.0" } }, + "node_modules/@mieweb/ychart/node_modules/@mieweb/ui": { + "version": "0.2.4", + "resolved": "https://registry.npmjs.org/@mieweb/ui/-/ui-0.2.4.tgz", + "integrity": "sha512-1OBv6wCgxCGY+s2Di5LVGyqFoYWmX14/zLX1+kUc/qRjI+2NGywvLht73pOvcqomvHhzEtVp3hRuHm5bCsHx3g==", + "license": "SEE LICENSE IN LICENSE", + "dependencies": { + "@swc/helpers": "^0.5.19", + "class-variance-authority": "^0.7.1", + "clsx": "^2.1.1", + "lucide-react": "^0.562.0", + "luxon": "^3.7.2", + "tailwind-merge": "^2.6.1" + }, + "engines": { + "node": ">=18.0.0" + }, + "peerDependencies": { + "ag-grid-community": ">=32.0.0", + "ag-grid-react": ">=32.0.0", + "react": ">=18.0.0", + "react-dom": ">=18.0.0", + "wavesurfer.js": ">=7.0.0" + }, + "peerDependenciesMeta": { + "ag-grid-community": { + "optional": true + }, + "ag-grid-react": { + "optional": true + }, + "react": { + "optional": false + }, + "react-dom": { + "optional": false + }, + "wavesurfer.js": { + "optional": true + } + } + }, "node_modules/@mongodb-js/saslprep": { "version": "1.4.12", "resolved": "https://registry.npmjs.org/@mongodb-js/saslprep/-/saslprep-1.4.12.tgz", @@ -2304,6 +2829,63 @@ "prettier": ">=2.4.0" } }, + "node_modules/@protobufjs/aspromise": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/aspromise/-/aspromise-1.1.2.tgz", + "integrity": "sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/base64": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/base64/-/base64-1.1.2.tgz", + "integrity": "sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/codegen": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@protobufjs/codegen/-/codegen-2.0.5.tgz", + "integrity": "sha512-zgXFLzW3Ap33e6d0Wlj4MGIm6Ce8O89n/apUaGNB/jx+hw+ruWEp7EwGUshdLKVRCxZW12fp9r40E1mQrf/34g==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/eventemitter": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@protobufjs/eventemitter/-/eventemitter-1.1.1.tgz", + "integrity": "sha512-vW1GmwMZNnL+gMRaovlh9yZX74kc+TTU3FObkkurpMaRtBfLP3ldjS9KQWlwZgraRE0+dheEEoAxdzcJQ8eXZg==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/fetch": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@protobufjs/fetch/-/fetch-1.1.1.tgz", + "integrity": "sha512-GpptLrs57adMSuHi3VNj0mAF8dwh36LMaYF6XyJ6JMWlVsc+t42tm1HSEDmOs3A8fC9yyeisgLhsTVQokOZ0zw==", + "license": "BSD-3-Clause", + "dependencies": { + "@protobufjs/aspromise": "^1.1.1" + } + }, + "node_modules/@protobufjs/float": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@protobufjs/float/-/float-1.0.2.tgz", + "integrity": "sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/path": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/path/-/path-1.1.2.tgz", + "integrity": "sha512-6JOcJ5Tm08dOHAbdR3GrvP+yUUfkjG5ePsHYczMFLq3ZmMkAD98cDgcT2iA1lJ9NVwFd4tH/iSSoe44YWkltEA==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/pool": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@protobufjs/pool/-/pool-1.1.0.tgz", + "integrity": "sha512-0kELaGSIDBKvcgS4zkjz1PeddatrjYcmMWOlAuAPwAeccUrPHdUqo/J6LiymHHEiJT5NrF1UVwxY14f+fy4WQw==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/utf8": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/utf8/-/utf8-1.1.2.tgz", + "integrity": "sha512-b1UQwcEZ4yCnMCD8DAL1VlbvBJE9/IX4FTIp7BG1xYpf29SLazLSrqUkj4w7Y5y7cCVP6E5tcqqcI0xemPkHug==", + "license": "BSD-3-Clause" + }, "node_modules/@radix-ui/primitive": { "version": "1.1.3", "resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.3.tgz", @@ -2745,6 +3327,33 @@ } } }, + "node_modules/@reduxjs/toolkit": { + "version": "2.12.0", + "resolved": "https://registry.npmjs.org/@reduxjs/toolkit/-/toolkit-2.12.0.tgz", + "integrity": "sha512-KiT+RzZbp6mQET+Mg+h2c97+9j1sNflUxQkIHI7Yuzf6Peu+OYpmkn6nbHWmLLWj+1ZODUJFwGZ7gx3L9R9EOw==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.0.0", + "@standard-schema/utils": "^0.3.0", + "immer": "^11.0.0", + "redux": "^5.0.1", + "redux-thunk": "^3.1.0", + "reselect": "^5.1.0" + }, + "peerDependencies": { + "react": "^16.9.0 || ^17.0.0 || ^18 || ^19", + "react-redux": "^7.2.1 || ^8.1.3 || ^9.0.0" + }, + "peerDependenciesMeta": { + "react": { + "optional": true + }, + "react-redux": { + "optional": true + } + } + }, "node_modules/@rolldown/binding-android-arm64": { "version": "1.1.5", "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.1.5.tgz", @@ -3038,9 +3647,26 @@ "version": "1.1.0", "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", - "dev": true, + "devOptional": true, "license": "MIT" }, + "node_modules/@standard-schema/utils": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/@standard-schema/utils/-/utils-0.3.0.tgz", + "integrity": "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/@sveltejs/acorn-typescript": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/@sveltejs/acorn-typescript/-/acorn-typescript-1.0.11.tgz", + "integrity": "sha512-LFuZUkjJ9iF7JZye/aG5XM0SFcQ5VyL0oVX4WJ9dc0Va3R3s0OauX1BESVCb+YN/ol8TAfqGDDAQsTG627Y5kw==", + "license": "MIT", + "peer": true, + "peerDependencies": { + "acorn": "^8.9.0" + } + }, "node_modules/@swc/helpers": { "version": "0.5.19", "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.19.tgz", @@ -3066,34 +3692,6 @@ "tailwindcss": "4.2.1" } }, - "node_modules/@tailwindcss/node/node_modules/enhanced-resolve": { - "version": "5.20.0", - "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.20.0.tgz", - "integrity": "sha512-/ce7+jQ1PQ6rVXwe+jKEg5hW5ciicHwIQUagZkp6IufBoY3YDgdTTY1azVs0qoRgVmvsNB+rbjLJxDAeHHtwsQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "graceful-fs": "^4.2.4", - "tapable": "^2.3.0" - }, - "engines": { - "node": ">=10.13.0" - } - }, - "node_modules/@tailwindcss/node/node_modules/tapable": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.0.tgz", - "integrity": "sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/webpack" - } - }, "node_modules/@tailwindcss/oxide": { "version": "4.1.13", "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.1.13.tgz", @@ -3729,7 +4327,34 @@ "postcss-selector-parser": "6.0.10" }, "peerDependencies": { - "tailwindcss": ">=3.0.0 || >=4.0.0 || insiders" + "tailwindcss": ">=3.0.0 || >=4.0.0 || insiders" + } + }, + "node_modules/@tanstack/react-virtual": { + "version": "3.14.8", + "resolved": "https://registry.npmjs.org/@tanstack/react-virtual/-/react-virtual-3.14.8.tgz", + "integrity": "sha512-O39GJQpAYEJcIu3uN1//YtmhjSEOyw75vg9CKCatBDPiD5hKtZQoJHfferyrB/LdOD3UWaoMLWtdEjarwIwdDw==", + "license": "MIT", + "dependencies": { + "@tanstack/virtual-core": "3.17.6" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/@tanstack/virtual-core": { + "version": "3.17.6", + "resolved": "https://registry.npmjs.org/@tanstack/virtual-core/-/virtual-core-3.17.6.tgz", + "integrity": "sha512-h0/Ebo18CkOrChlQIhNtQkM5ySUnh/GumQ/D1st3hG2HWUPEF+ILUc2k29UtivCi/9G7w7G3/f7Xyd5cCFbKBw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" } }, "node_modules/@testing-library/dom": { @@ -4351,9 +4976,9 @@ "license": "MIT" }, "node_modules/@types/estree": { - "version": "1.0.8", - "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", - "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", "license": "MIT" }, "node_modules/@types/estree-jsx": { @@ -4478,8 +5103,7 @@ "version": "2.0.7", "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz", "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==", - "license": "MIT", - "optional": true + "license": "MIT" }, "node_modules/@types/tus-js-client": { "version": "1.8.0", @@ -4494,6 +5118,13 @@ "integrity": "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==", "license": "MIT" }, + "node_modules/@types/use-sync-external-store": { + "version": "0.0.6", + "resolved": "https://registry.npmjs.org/@types/use-sync-external-store/-/use-sync-external-store-0.0.6.tgz", + "integrity": "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==", + "devOptional": true, + "license": "MIT" + }, "node_modules/@types/webidl-conversions": { "version": "7.0.3", "resolved": "https://registry.npmjs.org/@types/webidl-conversions/-/webidl-conversions-7.0.3.tgz", @@ -4641,7 +5272,7 @@ "version": "8.56.1", "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.56.1.tgz", "integrity": "sha512-dbMkdIUkIkchgGDIv7KLUpa0Mda4IYjo4IAMJUZ+3xNoUXxMsk9YtKpTHSChRS85o+H9ftm51gsK1dZReY9CVw==", - "dev": true, + "devOptional": true, "license": "MIT", "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -5442,7 +6073,6 @@ "version": "4.1.0", "resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-4.1.0.tgz", "integrity": "sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==", - "dev": true, "license": "Apache-2.0", "engines": { "node": ">= 0.4" @@ -5665,6 +6295,16 @@ "node": ">=0.6" } }, + "node_modules/bignumber.js": { + "version": "9.3.1", + "resolved": "https://registry.npmjs.org/bignumber.js/-/bignumber.js-9.3.1.tgz", + "integrity": "sha512-Ko0uX15oIUS7wJ3Rb30Fs6SkVbLmPBAKdlm7q9+ak9bbIeFf0MwuBsQV6z7+X768/cHsfg+WlysDWJcmthjsjQ==", + "devOptional": true, + "license": "MIT", + "engines": { + "node": "*" + } + }, "node_modules/binary-extensions": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", @@ -6715,6 +7355,57 @@ "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", "license": "MIT" }, + "node_modules/concat-stream": { + "version": "1.6.2", + "resolved": "https://registry.npmjs.org/concat-stream/-/concat-stream-1.6.2.tgz", + "integrity": "sha512-27HBghJxjiZtIk3Ycvn/4kbJk/1uZuJFfuPEns6LaEvpvG1f0hTea8lilrouyo9mVc2GWdcEZ8OLoGmSADlrCw==", + "engines": [ + "node >= 0.8" + ], + "license": "MIT", + "dependencies": { + "buffer-from": "^1.0.0", + "inherits": "^2.0.3", + "readable-stream": "^2.2.2", + "typedarray": "^0.0.6" + } + }, + "node_modules/concat-stream/node_modules/isarray": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", + "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==", + "license": "MIT" + }, + "node_modules/concat-stream/node_modules/readable-stream": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", + "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", + "license": "MIT", + "dependencies": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.3", + "isarray": "~1.0.0", + "process-nextick-args": "~2.0.0", + "safe-buffer": "~5.1.1", + "string_decoder": "~1.1.1", + "util-deprecate": "~1.0.1" + } + }, + "node_modules/concat-stream/node_modules/safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", + "license": "MIT" + }, + "node_modules/concat-stream/node_modules/string_decoder": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", + "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.1.0" + } + }, "node_modules/content-disposition": { "version": "0.5.2", "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.2.tgz", @@ -6980,11 +7671,22 @@ "dev": true, "license": "MIT" }, + "node_modules/core-js": { + "version": "3.49.0", + "resolved": "https://registry.npmjs.org/core-js/-/core-js-3.49.0.tgz", + "integrity": "sha512-es1U2+YTtzpwkxVLwAFdSpaIMyQaq0PBgm3YD1W3Qpsn1NAmO3KSgZfu+oGSWVu6NvLHoHCV/aYcsE5wiB7ALg==", + "devOptional": true, + "hasInstallScript": true, + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/core-js" + } + }, "node_modules/core-util-is": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz", "integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==", - "dev": true, "license": "MIT" }, "node_modules/cose-base": { @@ -7195,6 +7897,20 @@ "integrity": "sha512-dp3s92+uNI1hWIpPGH3jK2kxE2lMjdXdr+DH8ynZHpd6PUlH6x6cbuXnoMmiNumznqaNO31xu9e79F0uuZ0JFg==", "license": "MIT" }, + "node_modules/d": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/d/-/d-1.0.2.tgz", + "integrity": "sha512-MOqHvMWF9/9MX6nza0KgvFH4HpMU0EF5uUDXqX/BtxtU8NfB0QzRtJ8Oe/6SuS4kbhyzVJwjd97EA4PKrzJ8bw==", + "devOptional": true, + "license": "ISC", + "dependencies": { + "es5-ext": "^0.10.64", + "type": "^2.7.2" + }, + "engines": { + "node": ">=0.12" + } + }, "node_modules/d3": { "version": "7.9.0", "resolved": "https://registry.npmjs.org/d3/-/d3-7.9.0.tgz", @@ -7374,18 +8090,6 @@ "node": ">= 10" } }, - "node_modules/d3-dsv/node_modules/iconv-lite": { - "version": "0.6.3", - "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", - "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", - "license": "MIT", - "dependencies": { - "safer-buffer": ">= 2.1.2 < 3.0.0" - }, - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/d3-ease": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz", @@ -7819,6 +8523,24 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/datavis-ace": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/datavis-ace/-/datavis-ace-4.1.0.tgz", + "integrity": "sha512-heaXzdklp3jiqGRoK7YjcklyPT/2aigEEGMSV8Is2Z58oqHf8/rFPmztXjKb5ZQSqn6ygyPtV+u/iwJjTkhoFg==", + "devOptional": true, + "license": "see LICENSE", + "dependencies": { + "bignumber.js": "=9.3.1", + "core-js": "=3.49.0", + "es6-symbol": "=3.1.4", + "json-formatter-js": "git+https://github.com/mieweb/json-formatter-js.git", + "moment": "=2.30.1", + "numeral": "=2.0.6", + "papaparse": "=5.5.4", + "sprintf-js": "=1.1.3", + "underscore": "=1.13.8" + } + }, "node_modules/date-fns": { "version": "4.4.0", "resolved": "https://registry.npmjs.org/date-fns/-/date-fns-4.4.0.tgz", @@ -7906,6 +8628,13 @@ "dev": true, "license": "MIT" }, + "node_modules/decimal.js-light": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/decimal.js-light/-/decimal.js-light-2.5.1.tgz", + "integrity": "sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==", + "devOptional": true, + "license": "MIT" + }, "node_modules/decode-named-character-reference": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/decode-named-character-reference/-/decode-named-character-reference-1.3.0.tgz", @@ -8051,6 +8780,13 @@ "integrity": "sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ==", "license": "MIT" }, + "node_modules/devalue": { + "version": "5.8.2", + "resolved": "https://registry.npmjs.org/devalue/-/devalue-5.8.2.tgz", + "integrity": "sha512-DObPPAfdtFbXjxLqK8s2Xk9ZuWz5+ZoFEhC7J76es4GU/rEiXwHTmbImoCdyoCOcBH1UF3+Cz6Z2sYD4hyl5TA==", + "license": "MIT", + "peer": true + }, "node_modules/devlop": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/devlop/-/devlop-1.1.0.tgz", @@ -8139,6 +8875,12 @@ "url": "https://github.com/fb55/domhandler?sponsor=1" } }, + "node_modules/domino": { + "version": "2.1.7", + "resolved": "https://registry.npmjs.org/domino/-/domino-2.1.7.tgz", + "integrity": "sha512-3rcXhx0ixJV2nj8J0tljzejTF73A35LVVdnTQu79UAqTBFEgYPMgGtykMuu/BDqaOZphATku1ddRUn/RtqUHYQ==", + "license": "BSD-2-Clause" + }, "node_modules/dompurify": { "version": "3.4.11", "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.11.tgz", @@ -8233,6 +8975,20 @@ "once": "^1.4.0" } }, + "node_modules/enhanced-resolve": { + "version": "5.24.3", + "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.24.3.tgz", + "integrity": "sha512-PwKooW9JUzh5chmYfHM3IQl5OkK2u2Nm011MgeZrss3JmFraUx/fqrf78kk8GUMYoibx/14MdwTl/1WKkG7TpQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.4", + "tapable": "^2.3.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, "node_modules/entities": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/entities/-/entities-2.2.0.tgz", @@ -8470,6 +9226,49 @@ "benchmarks" ] }, + "node_modules/es5-ext": { + "version": "0.10.64", + "resolved": "https://registry.npmjs.org/es5-ext/-/es5-ext-0.10.64.tgz", + "integrity": "sha512-p2snDhiLaXe6dahss1LddxqEm+SkuDvV8dnIQG0MWjyHpcMNfXKPE+/Cc0y+PhxJX3A4xGNeFCj5oc0BUh6deg==", + "devOptional": true, + "hasInstallScript": true, + "license": "ISC", + "dependencies": { + "es6-iterator": "^2.0.3", + "es6-symbol": "^3.1.3", + "esniff": "^2.0.1", + "next-tick": "^1.1.0" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/es6-iterator": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/es6-iterator/-/es6-iterator-2.0.3.tgz", + "integrity": "sha512-zw4SRzoUkd+cl+ZoE15A9o1oQd920Bb0iOJMQkQhl3jNc03YqVjAhG7scf9C5KWRU/R13Orf588uCC6525o02g==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "d": "1", + "es5-ext": "^0.10.35", + "es6-symbol": "^3.1.1" + } + }, + "node_modules/es6-symbol": { + "version": "3.1.4", + "resolved": "https://registry.npmjs.org/es6-symbol/-/es6-symbol-3.1.4.tgz", + "integrity": "sha512-U9bFFjX8tFiATgtkJ1zg25+KviIXpgRvRHS8sau3GfhVzThRQrOeksPeT0BWW2MNZs1OEWJ1DPXOQMn0KKRkvg==", + "devOptional": true, + "license": "ISC", + "dependencies": { + "d": "^1.0.2", + "ext": "^1.7.0" + }, + "engines": { + "node": ">=0.12" + } + }, "node_modules/escalade": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", @@ -8998,6 +9797,29 @@ "node": "*" } }, + "node_modules/esm-env": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/esm-env/-/esm-env-1.2.2.tgz", + "integrity": "sha512-Epxrv+Nr/CaL4ZcFGPJIYLWFom+YeV1DqMLHJoEd9SYRxNbaFruBwfEX/kkHUJf55j2+TUbmDcmuilbP1TmXHA==", + "license": "MIT", + "peer": true + }, + "node_modules/esniff": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/esniff/-/esniff-2.0.1.tgz", + "integrity": "sha512-kTUIGKQ/mDPFoJ0oVfcmyJn4iBDRptjNVIzwIFR7tqWXdVI9xfA2RMwY/gbSpJG3lkdWNEjLap/NqVHZiJsdfg==", + "devOptional": true, + "license": "ISC", + "dependencies": { + "d": "^1.0.1", + "es5-ext": "^0.10.62", + "event-emitter": "^0.3.5", + "type": "^2.7.2" + }, + "engines": { + "node": ">=0.10" + } + }, "node_modules/espree": { "version": "10.4.0", "resolved": "https://registry.npmjs.org/espree/-/espree-10.4.0.tgz", @@ -9042,6 +9864,24 @@ "node": ">=0.10" } }, + "node_modules/esrap": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/esrap/-/esrap-2.3.0.tgz", + "integrity": "sha512-GQ/7RN8uOtEfNpzZzBMTzW9JBcX42oaSVtPzdF+6cEL8pqIL094iUpr9jzYGn4O4P/1S60dJ6izyT8F4LYARng==", + "license": "MIT", + "peer": true, + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.4.15" + }, + "peerDependencies": { + "@typescript-eslint/types": "^8.2.0" + }, + "peerDependenciesMeta": { + "@typescript-eslint/types": { + "optional": true + } + } + }, "node_modules/esrecurse": { "version": "4.3.0", "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", @@ -9095,6 +9935,17 @@ "node": ">=0.10.0" } }, + "node_modules/event-emitter": { + "version": "0.3.5", + "resolved": "https://registry.npmjs.org/event-emitter/-/event-emitter-0.3.5.tgz", + "integrity": "sha512-D9rRn9y7kLPnJ+hMq7S/nhvoKwwvVJahBi2BPmx3bvbsEdK3W9ii8cBSGjP+72/LnM4n6fo3+dkCX5FeTQruXA==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "d": "1", + "es5-ext": "~0.10.14" + } + }, "node_modules/eventemitter3": { "version": "5.0.4", "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.4.tgz", @@ -9155,6 +10006,16 @@ "node": ">=12.0.0" } }, + "node_modules/ext": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/ext/-/ext-1.7.0.tgz", + "integrity": "sha512-6hxeJYaL110a9b5TEJSj0gojyHQAmA2ch5Os+ySCiA1QGdS697XWY1pzsrSjqA9LDEEgdB/KypIlR59RcLuHYw==", + "devOptional": true, + "license": "ISC", + "dependencies": { + "type": "^2.7.2" + } + }, "node_modules/extend": { "version": "3.0.2", "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz", @@ -9302,6 +10163,12 @@ "node": ">=16" } }, + "node_modules/flatbuffers": { + "version": "1.12.0", + "resolved": "https://registry.npmjs.org/flatbuffers/-/flatbuffers-1.12.0.tgz", + "integrity": "sha512-c7CZADjRcl6j0PlvFy0ZqXQ67qSEZfrVPynmnL+2zPc+NtMvrF8Y0QceMo7QqnSPc7+uWjUIAbvCQ5WIKlMVdQ==", + "license": "SEE LICENSE IN LICENSE.txt" + }, "node_modules/flatted": { "version": "3.4.2", "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.2.tgz", @@ -9943,6 +10810,15 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/google-libphonenumber": { + "version": "3.2.44", + "resolved": "https://registry.npmjs.org/google-libphonenumber/-/google-libphonenumber-3.2.44.tgz", + "integrity": "sha512-9p2TghluF2LTChFMLWsDRD5N78SZDsILdUk4gyqYxBXluCyxoPiOq+Fqt7DKM+LUd33+OgRkdrc+cPR93AypCQ==", + "license": "(MIT AND Apache-2.0)", + "engines": { + "node": ">=0.10" + } + }, "node_modules/gopd": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", @@ -9975,6 +10851,12 @@ "gradle-to-js": "cli.js" } }, + "node_modules/guid-typescript": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/guid-typescript/-/guid-typescript-1.0.9.tgz", + "integrity": "sha512-Y8T4vYhEfwJOTbouREvG+3XDsjr8E3kIr7uf+JZ0BYloFsttiHU0WfvANVsR7TxNUJa/WpCnw/Ino/p+DeBhBQ==", + "license": "ISC" + }, "node_modules/hachure-fill": { "version": "0.5.2", "resolved": "https://registry.npmjs.org/hachure-fill/-/hachure-fill-0.5.2.tgz", @@ -10225,6 +11107,21 @@ "url": "https://opencollective.com/unified" } }, + "node_modules/hast-util-sanitize": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/hast-util-sanitize/-/hast-util-sanitize-5.0.2.tgz", + "integrity": "sha512-3yTWghByc50aGS7JlGhk61SPenfE/p1oaFeNwkOOyrscaOkMGrcW9+Cy/QAIOBpZxP1yqDIzFMR0+Np0i0+usg==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@ungap/structured-clone": "^1.0.0", + "unist-util-position": "^5.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/hast-util-to-jsx-runtime": { "version": "2.3.6", "resolved": "https://registry.npmjs.org/hast-util-to-jsx-runtime/-/hast-util-to-jsx-runtime-2.3.6.tgz", @@ -10330,6 +11227,18 @@ "node": ">=10" } }, + "node_modules/html": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/html/-/html-1.0.0.tgz", + "integrity": "sha512-lw/7YsdKiP3kk5PnR1INY17iJuzdAtJewxr14ozKJWbbR97znovZ0mh+WEMZ8rjc3lgTK+ID/htTjuyGKB52Kw==", + "license": "BSD", + "dependencies": { + "concat-stream": "^1.4.7" + }, + "bin": { + "html": "bin/html.js" + } + }, "node_modules/html-encoding-sniffer": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-6.0.0.tgz", @@ -10343,6 +11252,16 @@ "node": "^20.19.0 || ^22.12.0 || >=24.0.0" } }, + "node_modules/html-parse-stringify": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/html-parse-stringify/-/html-parse-stringify-4.0.1.tgz", + "integrity": "sha512-0zHsZJrK7S3K2aucXWL6ycoYJ/iNtIcFHC/nYQgFklPtrv5LpJctIiSCroWZWeuoXvuyFdzp6KzjJQ+OT5MfFw==", + "devOptional": true, + "license": "MIT", + "funding": { + "url": "https://locize.com" + } + }, "node_modules/html-url-attributes": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/html-url-attributes/-/html-url-attributes-3.0.1.tgz", @@ -10379,6 +11298,47 @@ "url": "https://github.com/sponsors/typicode" } }, + "node_modules/i18next": { + "version": "26.3.6", + "resolved": "https://registry.npmjs.org/i18next/-/i18next-26.3.6.tgz", + "integrity": "sha512-Bu5Z2nAXgfVyM8xvW3jk9EKRIuX37PudsrBViThNFx7CR7aaYTpP01cxNB/E4c4UUzTDiAZRstEhsRfPOL/8xA==", + "devOptional": true, + "funding": [ + { + "type": "individual", + "url": "https://www.locize.com/i18next" + }, + { + "type": "individual", + "url": "https://www.i18next.com/how-to/faq#i18next-is-awesome.-how-can-i-support-the-project" + }, + { + "type": "individual", + "url": "https://www.locize.com" + } + ], + "license": "MIT", + "peerDependencies": { + "typescript": "^5 || ^6 || ^7" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/iconv-lite": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", + "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/ieee754": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", @@ -10410,6 +11370,17 @@ "node": ">= 4" } }, + "node_modules/immer": { + "version": "11.1.15", + "resolved": "https://registry.npmjs.org/immer/-/immer-11.1.15.tgz", + "integrity": "sha512-VrNANlmnWQnh5COXIIOQXM9oOJw7naGKlBT74ZOOR6lpVXc3gFEu9FJLDFcpCJ2j+NWr8TIwtWD//T6ZX6TKiQ==", + "devOptional": true, + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/immer" + } + }, "node_modules/import-fresh": { "version": "3.3.1", "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", @@ -10461,7 +11432,6 @@ "version": "2.0.4", "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", - "dev": true, "license": "ISC" }, "node_modules/ini": { @@ -10882,6 +11852,16 @@ "dev": true, "license": "MIT" }, + "node_modules/is-reference": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/is-reference/-/is-reference-3.0.3.tgz", + "integrity": "sha512-ixkJoqQvAP88E6wLydLGGqCJsrFUnqoH6HnaczB8XmDH1oaWU+xxdptvikTgaEhtZ53Ky6YXiBuUI2WXLMCwjw==", + "license": "MIT", + "peer": true, + "dependencies": { + "@types/estree": "^1.0.6" + } + }, "node_modules/is-regex": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.2.1.tgz", @@ -11078,6 +12058,16 @@ "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", "license": "ISC" }, + "node_modules/isomorphic.js": { + "version": "0.2.5", + "resolved": "https://registry.npmjs.org/isomorphic.js/-/isomorphic.js-0.2.5.tgz", + "integrity": "sha512-PIeMbHqMt4DnUP3MA/Flc0HElYjMXArsw1qwJZcm9sqR8mq3l8NYizFMty0pWwE/tzIGH3EKK5+jes5mAr85yw==", + "license": "MIT", + "funding": { + "type": "GitHub Sponsors ❤", + "url": "https://github.com/sponsors/dmonad" + } + }, "node_modules/iterator.prototype": { "version": "1.1.5", "resolved": "https://registry.npmjs.org/iterator.prototype/-/iterator.prototype-1.1.5.tgz", @@ -11097,9 +12087,9 @@ } }, "node_modules/jiti": { - "version": "2.6.1", - "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.6.1.tgz", - "integrity": "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==", + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.7.0.tgz", + "integrity": "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==", "devOptional": true, "license": "MIT", "bin": { @@ -11256,6 +12246,12 @@ "dev": true, "license": "MIT" }, + "node_modules/json-formatter-js": { + "version": "2.3.4", + "resolved": "git+ssh://git@github.com/mieweb/json-formatter-js.git#503be5a8368ecdea777f8a7e98bda827391aa6ac", + "devOptional": true, + "license": "MIT" + }, "node_modules/json-parse-better-errors": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/json-parse-better-errors/-/json-parse-better-errors-1.0.2.tgz", @@ -11460,6 +12456,27 @@ "node": ">= 0.8.0" } }, + "node_modules/lib0": { + "version": "0.2.109", + "resolved": "https://registry.npmjs.org/lib0/-/lib0-0.2.109.tgz", + "integrity": "sha512-jP0gbnyW0kwlx1Atc4dcHkBbrVAkdHjuyHxtClUPYla7qCmwIif1qZ6vQeJdR5FrOVdn26HvQT0ko01rgW7/Xw==", + "license": "MIT", + "dependencies": { + "isomorphic.js": "^0.2.4" + }, + "bin": { + "0ecdsa-generate-keypair": "bin/0ecdsa-generate-keypair.js", + "0gentesthtml": "bin/gentesthtml.js", + "0serve": "bin/0serve.js" + }, + "engines": { + "node": ">=16" + }, + "funding": { + "type": "GitHub Sponsors ❤", + "url": "https://github.com/sponsors/dmonad" + } + }, "node_modules/lightningcss": { "version": "1.31.1", "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.31.1.tgz", @@ -11733,6 +12750,13 @@ "url": "https://github.com/sponsors/antonk52" } }, + "node_modules/lines-and-columns": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", + "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==", + "dev": true, + "license": "MIT" + }, "node_modules/lint-staged": { "version": "15.5.2", "resolved": "https://registry.npmjs.org/lint-staged/-/lint-staged-15.5.2.tgz", @@ -11832,6 +12856,13 @@ "node": ">=4" } }, + "node_modules/locate-character": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/locate-character/-/locate-character-3.0.0.tgz", + "integrity": "sha512-SW13ws7BjaeJ6p7Q6CO2nchbYEc3X3J6WrmTTDto7yMPqVSZTUyY5Tjbid+Ab8gLnATtygYtiDIJGQRRn2ZOiA==", + "license": "MIT", + "peer": true + }, "node_modules/locate-path": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", @@ -12032,6 +13063,12 @@ "url": "https://github.com/chalk/strip-ansi?sponsor=1" } }, + "node_modules/long": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/long/-/long-5.3.2.tgz", + "integrity": "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==", + "license": "Apache-2.0" + }, "node_modules/longest-streak": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/longest-streak/-/longest-streak-3.1.0.tgz", @@ -12123,7 +13160,6 @@ "version": "0.30.21", "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", - "dev": true, "license": "MIT", "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" @@ -12180,6 +13216,15 @@ "node": ">= 0.4" } }, + "node_modules/mathml2latex": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/mathml2latex/-/mathml2latex-1.1.3.tgz", + "integrity": "sha512-/ykNcqkOyxl3V2U/avnMMK0BHGASbQZzsLJU6f98BdP96XR6VXcpRarfpRM3Td7hxHomr7JK5XDC4Enzbhy6/g==", + "license": "MIT", + "dependencies": { + "domino": "^2.1.6" + } + }, "node_modules/mdast-util-find-and-replace": { "version": "3.0.2", "resolved": "https://registry.npmjs.org/mdast-util-find-and-replace/-/mdast-util-find-and-replace-3.0.2.tgz", @@ -12692,26 +13737,26 @@ "license": "MIT" }, "node_modules/mermaid": { - "version": "11.15.0", - "resolved": "https://registry.npmjs.org/mermaid/-/mermaid-11.15.0.tgz", - "integrity": "sha512-pTMbcf3rWdtLiYGpmoTjHEpeY8seiy6sR+9nD7LOs8KfUbHE4lOUAprTRqRAcWSQ6MQpdX+YEsxShtGsINtPtw==", + "version": "11.16.0", + "resolved": "https://registry.npmjs.org/mermaid/-/mermaid-11.16.0.tgz", + "integrity": "sha512-Zvm3kbstgdpvIJPPItlL7fppIZ3kibvc1oZIGxdvk9t6UFz6flv+Jw7FtRGKwfcI8OckmH04LqG6LlS6X4B1pA==", "license": "MIT", "dependencies": { - "@braintree/sanitize-url": "^7.1.1", + "@braintree/sanitize-url": "^7.1.2", "@iconify/utils": "^3.0.2", - "@mermaid-js/parser": "^1.1.1", + "@mermaid-js/parser": "^1.2.0", "@types/d3": "^7.4.3", "@upsetjs/venn.js": "^2.0.0", - "cytoscape": "^3.33.1", + "cytoscape": "^3.33.3", "cytoscape-cose-bilkent": "^4.1.0", "cytoscape-fcose": "^2.2.0", "d3": "^7.9.0", "d3-sankey": "^0.12.3", "dagre-d3-es": "7.0.14", - "dayjs": "^1.11.19", - "dompurify": "^3.3.1", + "dayjs": "^1.11.20", + "dompurify": "^3.3.3", "es-toolkit": "^1.45.1", - "katex": "^0.16.25", + "katex": "^0.16.45", "khroma": "^2.1.0", "marked": "^16.3.0", "roughjs": "^4.6.6", @@ -13556,6 +14601,16 @@ "node": ">=0.10.0" } }, + "node_modules/moment": { + "version": "2.30.1", + "resolved": "https://registry.npmjs.org/moment/-/moment-2.30.1.tgz", + "integrity": "sha512-uEmtNhbDOrWPFS+hdjFCBfy9f2YoyzRpwcl+DqpC6taX21FzsTLQVbMV/W7PzNSX6x/bhC1zA3c2UQ5NzH6how==", + "devOptional": true, + "license": "MIT", + "engines": { + "node": "*" + } + }, "node_modules/mongodb": { "version": "7.5.0", "resolved": "https://registry.npmjs.org/mongodb/-/mongodb-7.5.0.tgz", @@ -13816,6 +14871,13 @@ "dev": true, "license": "MIT" }, + "node_modules/next-tick": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/next-tick/-/next-tick-1.1.0.tgz", + "integrity": "sha512-CXdUiJembsNjuToQvxayPZF9Vqht7hewsvy2sOWafLvi2awflj9mOC6bHIg50orX8IJvWKY9wYQ/zB2kogPslQ==", + "devOptional": true, + "license": "ISC" + }, "node_modules/node-abi": { "version": "3.89.0", "resolved": "https://registry.npmjs.org/node-abi/-/node-abi-3.89.0.tgz", @@ -14005,6 +15067,16 @@ "url": "https://github.com/fb55/nth-check?sponsor=1" } }, + "node_modules/numeral": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/numeral/-/numeral-2.0.6.tgz", + "integrity": "sha512-qaKRmtYPZ5qdw4jWJD6bxEf1FJEqllJrwxCLIm0sQU/A7v2/czigzOb+C2uSiFsa9lBUzeH7M1oK+Q+OLxL3kA==", + "devOptional": true, + "license": "MIT", + "engines": { + "node": "*" + } + }, "node_modules/object-assign": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", @@ -14174,6 +15246,26 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/onnxruntime-common": { + "version": "1.19.0", + "resolved": "https://registry.npmjs.org/onnxruntime-common/-/onnxruntime-common-1.19.0.tgz", + "integrity": "sha512-Oo16UIJ/xLOtZDVGcL4bL8EP8MiNFztyBmR3pB14D+cl/UCpOgHHzEk0MADSmYXQ0FgyEegPXtOFcJqhq1YRsw==", + "license": "MIT" + }, + "node_modules/onnxruntime-web": { + "version": "1.19.0", + "resolved": "https://registry.npmjs.org/onnxruntime-web/-/onnxruntime-web-1.19.0.tgz", + "integrity": "sha512-EY2KjvfJ/f5nxiXDii4eD0xe5KIn+mRs55F6z2qomALyG05Qj+kqF81CKqzTa4cXowE9b9aVSqsvEVZkjOI5yA==", + "license": "MIT", + "dependencies": { + "flatbuffers": "^1.12.0", + "guid-typescript": "^1.0.9", + "long": "^5.2.3", + "onnxruntime-common": "1.19.0", + "platform": "^1.3.6", + "protobufjs": "^7.2.4" + } + }, "node_modules/open": { "version": "8.4.2", "resolved": "https://registry.npmjs.org/open/-/open-8.4.2.tgz", @@ -14289,6 +15381,13 @@ "integrity": "sha512-61A5ThoTiDG/C8s8UMZwSorAGwMJ0ERVGj2OjoW5pAalsNOg15+iQiPzrLJ4jhZ1HJzmC2PIHT2oEiH3R5fzNA==", "license": "MIT" }, + "node_modules/papaparse": { + "version": "5.5.4", + "resolved": "https://registry.npmjs.org/papaparse/-/papaparse-5.5.4.tgz", + "integrity": "sha512-SwzWD9gl/ElwYLCI0nUja1mFJzjq2D8ziShfNBa7zCHzkOozeOGDwHWQ+tvCzEZcewecWZ5U7kUopDnG+DFYEQ==", + "devOptional": true, + "license": "MIT" + }, "node_modules/parent-module": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", @@ -14346,13 +15445,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/parse-json/node_modules/lines-and-columns": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", - "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==", - "dev": true, - "license": "MIT" - }, "node_modules/parse5": { "version": "8.0.1", "resolved": "https://registry.npmjs.org/parse5/-/parse5-8.0.1.tgz", @@ -14530,6 +15622,12 @@ "node": ">=0.10.0" } }, + "node_modules/platform": { + "version": "1.3.6", + "resolved": "https://registry.npmjs.org/platform/-/platform-1.3.6.tgz", + "integrity": "sha512-fnWVljUchTro6RiCFvCXBbNhJc2NijN7oIQxbwsyL0buWJPG85v81ehlHI9fXrJsMNgTofEoWIQeClKpgxFLrg==", + "license": "MIT" + }, "node_modules/playwright": { "version": "1.61.1", "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.61.1.tgz", @@ -15079,7 +16177,6 @@ "version": "2.0.1", "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz", "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==", - "dev": true, "license": "MIT" }, "node_modules/prompts": { @@ -15163,16 +16260,15 @@ "prosemirror-transform": "^1.0.0" } }, - "node_modules/prosemirror-history": { - "version": "1.4.1", - "resolved": "https://registry.npmjs.org/prosemirror-history/-/prosemirror-history-1.4.1.tgz", - "integrity": "sha512-2JZD8z2JviJrboD9cPuX/Sv/1ChFng+xh2tChQ2X4bB2HeK+rra/bmJ3xGntCcjhOqIzSDG6Id7e8RJ9QPXLEQ==", + "node_modules/prosemirror-dev-toolkit": { + "version": "1.1.8", + "resolved": "https://registry.npmjs.org/prosemirror-dev-toolkit/-/prosemirror-dev-toolkit-1.1.8.tgz", + "integrity": "sha512-Qi549XA+DqU5cCkn/xv+M53gy1sQbyOTjiOfiG7Gjq5gm6ZxLilGN04UITWTAYx9kzLBi7Y9RJmvmWB4xiRauA==", "license": "MIT", "dependencies": { - "prosemirror-state": "^1.2.2", - "prosemirror-transform": "^1.0.0", - "prosemirror-view": "^1.31.0", - "rope-sequence": "^1.3.0" + "html": "^1.0.0", + "prosemirror-model": "^1.20.0", + "svelte-tree-view": "^1.4.2" } }, "node_modules/prosemirror-keymap": { @@ -15186,9 +16282,9 @@ } }, "node_modules/prosemirror-model": { - "version": "1.25.3", - "resolved": "https://registry.npmjs.org/prosemirror-model/-/prosemirror-model-1.25.3.tgz", - "integrity": "sha512-dY2HdaNXlARknJbrManZ1WyUtos+AP97AmvqdOQtWtrrC5g4mohVX5DTi9rXNFSk09eczLq9GuNTtq3EfMeMGA==", + "version": "1.25.9", + "resolved": "https://registry.npmjs.org/prosemirror-model/-/prosemirror-model-1.25.9.tgz", + "integrity": "sha512-pRTklkDDMMRopyoAcrr9wV/8g/RYgrLHBuJAb5hlEuYZRdm5yqmPjWId83fpBwPpSFqEdja0H7Dfd7z1X/npcA==", "license": "MIT", "dependencies": { "orderedmap": "^2.0.0" @@ -15225,6 +16321,29 @@ "prosemirror-transform": "^1.1.0" } }, + "node_modules/protobufjs": { + "version": "7.6.5", + "resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.6.5.tgz", + "integrity": "sha512-/FPD0nUc9jH6rfFjji9IBqOz4pcSE3CsT1m7Ep6Mdb0LxSUMj8hgl6GomOvZzpNpAqqGaXA0P3VSrZLFzIhQrw==", + "hasInstallScript": true, + "license": "BSD-3-Clause", + "dependencies": { + "@protobufjs/aspromise": "^1.1.2", + "@protobufjs/base64": "^1.1.2", + "@protobufjs/codegen": "^2.0.5", + "@protobufjs/eventemitter": "^1.1.1", + "@protobufjs/fetch": "^1.1.1", + "@protobufjs/float": "^1.0.2", + "@protobufjs/path": "^1.1.2", + "@protobufjs/pool": "^1.1.0", + "@protobufjs/utf8": "^1.1.1", + "@types/node": ">=13.7.0", + "long": "^5.3.2" + }, + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/proxy-from-env": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-2.1.0.tgz", @@ -15333,24 +16452,24 @@ } }, "node_modules/react": { - "version": "19.2.4", - "resolved": "https://registry.npmjs.org/react/-/react-19.2.4.tgz", - "integrity": "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==", + "version": "19.2.8", + "resolved": "https://registry.npmjs.org/react/-/react-19.2.8.tgz", + "integrity": "sha512-PWaYA1L/q9u2u7xYQi+Y3L3Yfnie7XyLeaJICV1MGD6LprsBxcAqGjYyr0eY3p+QdsA+x/Irkt4Qif8D63+Sbw==", "license": "MIT", "engines": { "node": ">=0.10.0" } }, "node_modules/react-dom": { - "version": "19.2.4", - "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.4.tgz", - "integrity": "sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ==", + "version": "19.2.8", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.8.tgz", + "integrity": "sha512-rVprimfGBG3DR+Tq0IQG2DT5PxKth1WIGDmj5yPmlzr4YBe7uyE+Du4oVqTDXZSHGGGXRtTJEGSSePyQCMBglQ==", "license": "MIT", "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { - "react": "^19.2.4" + "react": "^19.2.8" } }, "node_modules/react-easy-crop": { @@ -15367,11 +16486,39 @@ "react-dom": ">=16.4.0" } }, + "node_modules/react-i18next": { + "version": "17.0.11", + "resolved": "https://registry.npmjs.org/react-i18next/-/react-i18next-17.0.11.tgz", + "integrity": "sha512-cDtkXgxjuFTWUH6V+aQn1Ve5vDiUztCNPWW5GtSHDccsgRXO1nE6QFWCEmc1KAutrb3OUv87wFShJL5RhUwPXg==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.29.2", + "html-parse-stringify": "^4.0.1", + "use-sync-external-store": "^1.6.0" + }, + "peerDependencies": { + "i18next": ">= 26.2.0", + "react": ">= 16.8.0", + "typescript": "^5 || ^6 || ^7" + }, + "peerDependenciesMeta": { + "react-dom": { + "optional": true + }, + "react-native": { + "optional": true + }, + "typescript": { + "optional": true + } + } + }, "node_modules/react-is": { "version": "16.13.1", "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/react-markdown": { @@ -15401,6 +16548,30 @@ "react": ">=18" } }, + "node_modules/react-redux": { + "version": "9.3.0", + "resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.3.0.tgz", + "integrity": "sha512-KQopgqFo/p/fgmAs5qz6p5RWaNAzq40WAu7fJIXnQpYxFPbJYtsJPWvGeF2rOBaY/kEuV77AVsX8TsQzKm+A/g==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "@types/use-sync-external-store": "^0.0.6", + "use-sync-external-store": "^1.4.0" + }, + "peerDependencies": { + "@types/react": "^18.2.25 || ^19", + "react": "^18.0 || ^19", + "redux": "^5.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "redux": { + "optional": true + } + } + }, "node_modules/react-refresh": { "version": "0.17.0", "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz", @@ -15663,6 +16834,37 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/recharts": { + "version": "3.10.1", + "resolved": "https://registry.npmjs.org/recharts/-/recharts-3.10.1.tgz", + "integrity": "sha512-QXFrvt6IVcw7eeZCoyXTwkIJAX3Dv1nyVhMicXJ47GsGDDpcN8z6o644DibE9XjpBTThtsomLKnTV6lc+cVFUA==", + "devOptional": true, + "license": "MIT", + "workspaces": [ + "www" + ], + "dependencies": { + "@reduxjs/toolkit": "^1.9.0 || 2.x.x", + "clsx": "^2.1.1", + "decimal.js-light": "^2.5.1", + "es-toolkit": "^1.39.3", + "eventemitter3": "^5.0.1", + "immer": "^11.1.8", + "react-redux": "8.x.x || 9.x.x", + "reselect": "5.2.0", + "tiny-invariant": "^1.3.3", + "use-sync-external-store": "^1.2.2", + "victory-vendor": "^37.0.2" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-is": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, "node_modules/redent": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/redent/-/redent-3.0.0.tgz", @@ -15677,6 +16879,23 @@ "node": ">=8" } }, + "node_modules/redux": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz", + "integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/redux-thunk": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/redux-thunk/-/redux-thunk-3.1.0.tgz", + "integrity": "sha512-NW2r5T6ksUKXCabzhL9z+h206HQw/NJkcLm1GPImRQ8IzfXwRGqjVhKJGauHirT0DAuyy6hjdnMZaRoAcy0Klw==", + "devOptional": true, + "license": "MIT", + "peerDependencies": { + "redux": "^5.0.0" + } + }, "node_modules/reflect.getprototypeof": { "version": "1.0.10", "resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz", @@ -15811,6 +17030,20 @@ "katex": "cli.js" } }, + "node_modules/rehype-sanitize": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/rehype-sanitize/-/rehype-sanitize-6.0.0.tgz", + "integrity": "sha512-CsnhKNsyI8Tub6L4sm5ZFsme4puGfc6pYylvXo1AeqaGbjOYyzNv3qZPwvs0oMJ39eryyeOdmxwUIo94IpEhqg==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "hast-util-sanitize": "^5.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/remark-breaks": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/remark-breaks/-/remark-breaks-4.0.0.tgz", @@ -16263,6 +17496,13 @@ "integrity": "sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ==", "license": "MIT" }, + "node_modules/reselect": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/reselect/-/reselect-5.2.0.tgz", + "integrity": "sha512-AgZ3UOZm3YndfrJ4OYjgrT7bmCm/1iqkjvEfH/oYjzh6PD2qw4QuT3jjnXIrpdt4MTpMXclMT3lXbmRY+XRakw==", + "devOptional": true, + "license": "MIT" + }, "node_modules/resolve": { "version": "1.22.11", "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz", @@ -16401,12 +17641,6 @@ "dev": true, "license": "MIT" }, - "node_modules/rope-sequence": { - "version": "1.3.4", - "resolved": "https://registry.npmjs.org/rope-sequence/-/rope-sequence-1.3.4.tgz", - "integrity": "sha512-UT5EDe2cu2E/6O4igUr5PSFs23nvvukicWHx6GnOPlHAiiYbzNuCRQCuiUdHJQcqKalLKlrYJnjY0ySGsXNQXQ==", - "license": "MIT" - }, "node_modules/roughjs": { "version": "4.6.6", "resolved": "https://registry.npmjs.org/roughjs/-/roughjs-4.6.6.tgz", @@ -17128,6 +18362,13 @@ "readable-stream": "^3.0.0" } }, + "node_modules/sprintf-js": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.1.3.tgz", + "integrity": "sha512-Oo+0REFV59/rz3gfJNKQiBlwfHaSESl1pcGyABQsnnIfWOFt6JNj5gCog2U6MLZ//IGYD+nA8nI+mTShREReaA==", + "devOptional": true, + "license": "BSD-3-Clause" + }, "node_modules/stackback": { "version": "0.0.2", "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", @@ -17495,6 +18736,53 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/svelte": { + "version": "5.56.7", + "resolved": "https://registry.npmjs.org/svelte/-/svelte-5.56.7.tgz", + "integrity": "sha512-5qERUZX80oQj6XrDMUmD2Uhd/cIpCPDWWKBK3ZHmyRUC9apPyamWM8xMo31mbWsIQxwG2hVoSnOJ/EcnhVkkzQ==", + "license": "MIT", + "peer": true, + "dependencies": { + "@jridgewell/remapping": "^2.3.4", + "@jridgewell/sourcemap-codec": "^1.5.0", + "@sveltejs/acorn-typescript": "^1.0.10", + "@types/estree": "^1.0.5", + "@types/trusted-types": "^2.0.7", + "acorn": "^8.12.1", + "aria-query": "5.3.1", + "axobject-query": "^4.1.0", + "clsx": "^2.1.1", + "devalue": "^5.8.1", + "esm-env": "^1.2.1", + "esrap": "^2.2.12", + "is-reference": "^3.0.3", + "locate-character": "^3.0.0", + "magic-string": "^0.30.11", + "zimmerframe": "^1.1.2" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/svelte-tree-view": { + "version": "1.4.2", + "resolved": "https://registry.npmjs.org/svelte-tree-view/-/svelte-tree-view-1.4.2.tgz", + "integrity": "sha512-z3yQq+/4CceyefVj03t9BG9AiZ7syovmRRo96aj8V65d1FsRS/BL2AxzcD4vl5GJg7o9qbz2mWWJzyFWENISCQ==", + "license": "MIT", + "peerDependencies": { + "svelte": ">=3" + } + }, + "node_modules/svelte/node_modules/aria-query": { + "version": "5.3.1", + "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.1.tgz", + "integrity": "sha512-Z/ZeOgVl7bcSYZ/u/rh0fOpvEpq//LZmdbkXyc7syVzjPAhfOa9ebsdTSjEBDU4vs5nC98Kfduj1uFo0qyET3g==", + "license": "Apache-2.0", + "peer": true, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/symbol-tree": { "version": "3.2.4", "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz", @@ -17535,6 +18823,20 @@ "dev": true, "license": "MIT" }, + "node_modules/tapable": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.3.tgz", + "integrity": "sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, "node_modules/tar": { "version": "7.5.13", "resolved": "https://registry.npmjs.org/tar/-/tar-7.5.13.tgz", @@ -17676,6 +18978,13 @@ "readable-stream": "3" } }, + "node_modules/tiny-invariant": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz", + "integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==", + "devOptional": true, + "license": "MIT" + }, "node_modules/tiny-typed-emitter": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/tiny-typed-emitter/-/tiny-typed-emitter-2.1.0.tgz", @@ -17964,6 +19273,13 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/type": { + "version": "2.7.3", + "resolved": "https://registry.npmjs.org/type/-/type-2.7.3.tgz", + "integrity": "sha512-8j+1QmAbPvLZow5Qpi6NCaN8FB60p/6x8/vfNqOk/hC+HuvFZhL4+WfekuhQLiqFZXOgQdrs3B+XxEmCc6b3FQ==", + "devOptional": true, + "license": "ISC" + }, "node_modules/type-check": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", @@ -18068,6 +19384,12 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/typedarray": { + "version": "0.0.6", + "resolved": "https://registry.npmjs.org/typedarray/-/typedarray-0.0.6.tgz", + "integrity": "sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA==", + "license": "MIT" + }, "node_modules/typescript": { "version": "5.9.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", @@ -18114,6 +19436,13 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/underscore": { + "version": "1.13.8", + "resolved": "https://registry.npmjs.org/underscore/-/underscore-1.13.8.tgz", + "integrity": "sha512-DXtD3ZtEQzc7M8m4cXotyHR+FAS18C64asBYY5vqZexfYryNNnDc02W4hKg3rdQuqOYas1jkseX0+nZXjTXnvQ==", + "devOptional": true, + "license": "MIT" + }, "node_modules/undici": { "version": "7.28.0", "resolved": "https://registry.npmjs.org/undici/-/undici-7.28.0.tgz", @@ -18397,11 +19726,20 @@ } } }, + "node_modules/use-sync-external-store": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz", + "integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==", + "devOptional": true, + "license": "MIT", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, "node_modules/util-deprecate": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", - "dev": true, "license": "MIT" }, "node_modules/uuid": { @@ -18482,6 +19820,29 @@ "url": "https://opencollective.com/unified" } }, + "node_modules/victory-vendor": { + "version": "37.3.6", + "resolved": "https://registry.npmjs.org/victory-vendor/-/victory-vendor-37.3.6.tgz", + "integrity": "sha512-SbPDPdDBYp+5MJHhBCAyI7wKM3d5ivekigc2Dk2s7pgbZ9wIgIBYGVw4zGHBml/qTFbexrofXW6Gu4noGxrOwQ==", + "devOptional": true, + "license": "MIT AND ISC", + "dependencies": { + "@types/d3-array": "^3.0.3", + "@types/d3-ease": "^3.0.0", + "@types/d3-interpolate": "^3.0.1", + "@types/d3-scale": "^4.0.2", + "@types/d3-shape": "^3.1.0", + "@types/d3-time": "^3.0.0", + "@types/d3-timer": "^3.0.0", + "d3-array": "^3.1.6", + "d3-ease": "^3.0.1", + "d3-interpolate": "^3.0.1", + "d3-scale": "^4.0.2", + "d3-shape": "^3.1.0", + "d3-time": "^3.0.0", + "d3-timer": "^3.0.1" + } + }, "node_modules/vite": { "version": "8.1.5", "resolved": "https://registry.npmjs.org/vite/-/vite-8.1.5.tgz", @@ -18911,6 +20272,31 @@ } } }, + "node_modules/vscode-jsonrpc": { + "version": "8.2.0", + "resolved": "https://registry.npmjs.org/vscode-jsonrpc/-/vscode-jsonrpc-8.2.0.tgz", + "integrity": "sha512-C+r0eKJUIfiDIfwJhria30+TYWPtuHJXHtI7J0YlOmKAo7ogxP20T0zxB7HZQIFhIyvoBPwWskjxrvAtfjyZfA==", + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/vscode-languageserver-protocol": { + "version": "3.17.5", + "resolved": "https://registry.npmjs.org/vscode-languageserver-protocol/-/vscode-languageserver-protocol-3.17.5.tgz", + "integrity": "sha512-mb1bvRJN8SVznADSGWM9u/b07H7Ecg0I3OgXDuLdn307rl/J3A9YD6/eYOssqhecL27hK1IPZAsaqh00i/Jljg==", + "license": "MIT", + "dependencies": { + "vscode-jsonrpc": "8.2.0", + "vscode-languageserver-types": "3.17.5" + } + }, + "node_modules/vscode-languageserver-types": { + "version": "3.17.5", + "resolved": "https://registry.npmjs.org/vscode-languageserver-types/-/vscode-languageserver-types-3.17.5.tgz", + "integrity": "sha512-Ld1VelNuX9pdF39h2Hgaeb5hEZM2Z3jUrrMgWQAu82jMtZp7p3vJT3BzToKtZI7NgQssZje5o0zryOrhQvzQAg==", + "license": "MIT" + }, "node_modules/w3c-keyname": { "version": "2.2.8", "resolved": "https://registry.npmjs.org/w3c-keyname/-/w3c-keyname-2.2.8.tgz", @@ -18960,6 +20346,12 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/web-tree-sitter": { + "version": "0.26.6", + "resolved": "https://registry.npmjs.org/web-tree-sitter/-/web-tree-sitter-0.26.6.tgz", + "integrity": "sha512-fSPR7VBW/fZQdUSp/bXTDLT+i/9dwtbnqgEBMzowrM4U3DzeCwDbY3MKo0584uQxID4m/1xpLflrlT/rLIRPew==", + "license": "MIT" + }, "node_modules/webidl-conversions": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", @@ -19399,6 +20791,26 @@ "node": ">=0.4" } }, + "node_modules/y-protocols": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/y-protocols/-/y-protocols-1.0.6.tgz", + "integrity": "sha512-vHRF2L6iT3rwj1jub/K5tYcTT/mEYDUppgNPXwp8fmLpui9f7Yeq3OEtTLVF012j39QnV+KEQpNqoN7CWU7Y9Q==", + "license": "MIT", + "dependencies": { + "lib0": "^0.2.85" + }, + "engines": { + "node": ">=16.0.0", + "npm": ">=8.0.0" + }, + "funding": { + "type": "GitHub Sponsors ❤", + "url": "https://github.com/sponsors/dmonad" + }, + "peerDependencies": { + "yjs": "^13.0.0" + } + }, "node_modules/y18n": { "version": "5.0.8", "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", @@ -19506,6 +20918,23 @@ "fd-slicer": "~1.1.0" } }, + "node_modules/yjs": { + "version": "13.6.30", + "resolved": "https://registry.npmjs.org/yjs/-/yjs-13.6.30.tgz", + "integrity": "sha512-vv/9h42eCMC81ZHDFswuu/MKzkl/vyq1BhaNGfHyOonwlG4CJbQF4oiBBJPvfdeCt/PlVDWh7Nov9D34YY09uQ==", + "license": "MIT", + "dependencies": { + "lib0": "^0.2.99" + }, + "engines": { + "node": ">=16.0.0", + "npm": ">=8.0.0" + }, + "funding": { + "type": "GitHub Sponsors ❤", + "url": "https://github.com/sponsors/dmonad" + } + }, "node_modules/yn": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/yn/-/yn-3.1.1.tgz", @@ -19528,10 +20957,17 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/zimmerframe": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/zimmerframe/-/zimmerframe-1.1.4.tgz", + "integrity": "sha512-B58NGBEoc8Y9MWWCQGl/gq9xBCe4IiKM0a2x7GZdQKOW5Exr8S1W24J6OgM1njK8xCRGvAJIL/MxXHf6SkmQKQ==", + "license": "MIT", + "peer": true + }, "node_modules/zod": { - "version": "4.3.6", - "resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz", - "integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==", + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz", + "integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==", "license": "MIT", "funding": { "url": "https://github.com/sponsors/colinhacks" diff --git a/package.json b/package.json index 75e71186..d1861f00 100644 --- a/package.json +++ b/package.json @@ -30,6 +30,7 @@ "test:all": "npm run test:unit && npm run test", "test:watch": "vitest", "prepare": "node scripts/prepare-husky.cjs", + "setup:ui": "git submodule update --init vendor/ui && cd vendor/ui && npm install && npm run build && npm pack && mv mieweb-ui-*.tgz ../mieweb-ui.tgz", "dev:mobile": "vite --host", "dev:ios": "bash scripts/dev-ios.sh", "testflight:ios": "CAPACITOR=1 vite build --mode testflight && npx cap sync ios && npx cap open ios", @@ -58,16 +59,19 @@ "@fortawesome/free-regular-svg-icons": "^7.0.1", "@fortawesome/free-solid-svg-icons": "^7.0.1", "@fortawesome/react-fontawesome": "^3.0.2", - "@kerebron/editor": "^0.7.9", + "@kerebron/editor": "^0.8.6", + "@kerebron/editor-kits": "^0.8.6", "@kerebron/extension-automerge": "^0.2.1", - "@kerebron/extension-basic-editor": "^0.7.9", - "@mieweb/ui": "^0.2.4", + "@kerebron/extension-basic-editor": "^0.8.6", + "@kerebron/extension-yjs": "^0.8.6", + "@kerebron/wasm": "^0.8.6", + "@mieweb/ui": "file:vendor/mieweb-ui.tgz", "@mieweb/ychart": "^1.1.0", "@swc/helpers": "^0.5.17", "cmdk": "^1.1.1", "date-fns": "^4.4.0", "katex": "^0.17.0", - "mermaid": "^11.15.0", + "mermaid": "^11.16.0", "motion": "^12.34.3", "postcss-load-config": "^6.0.1", "qrcode.react": "^4.2.0", @@ -77,12 +81,15 @@ "react-markdown": "^10.1.0", "rehype-highlight": "^7.0.2", "rehype-katex": "^7.0.1", + "rehype-sanitize": "^6.0.0", "remark-breaks": "^4.0.0", "remark-gfm": "^4.0.1", "remark-math": "^6.0.0", "serve": "^14.2.4", "tus-js-client": "^4.3.1", + "y-protocols": "^1.0.6", "yaml": "^2.8.1", + "yjs": "^13.6.30", "zod": "^4.3.6" }, "devDependencies": { @@ -91,6 +98,7 @@ "@capacitor/cli": "^8.3.1", "@capacitor/ios": "^8.3.1", "@eslint/js": "^9.35.0", + "@mieweb/datavis": "^1.5.0", "@playwright/test": "^1.61.1", "@tailwindcss/oxide": "4.1.13", "@tailwindcss/postcss": "^4.1.13", @@ -105,6 +113,7 @@ "@vitejs/plugin-react": "^6.0.1", "autoprefixer": "^10.4.19", "bcrypt": "^6.0.0", + "datavis-ace": "^4.1.0", "eslint": "^9.35.0", "eslint-plugin-import": "^2.31.0", "eslint-plugin-jsx-a11y": "^6.9.0", diff --git a/scripts/ensure-ui-build.mjs b/scripts/ensure-ui-build.mjs new file mode 100644 index 00000000..64f6cd46 --- /dev/null +++ b/scripts/ensure-ui-build.mjs @@ -0,0 +1,49 @@ +#!/usr/bin/env node +/** + * ensure-ui-build.mjs — postinstall guard for the @mieweb/ui submodule. + * + * The app depends on "@mieweb/ui": "file:vendor/ui", which npm links as-is — + * after a fresh clone the submodule is empty and dist/ doesn't exist, so the + * app would fail at import time with no obvious cause. This script makes + * `npm install` self-healing: it initializes the submodule and builds the + * library once, and is a fast no-op when dist/ is already present. + * + * Skip with SKIP_UI_BUILD=1 (e.g. CI legs that don't run the frontend). + */ +import { execSync } from 'node:child_process'; +import { existsSync } from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); +const uiDir = path.join(root, 'vendor', 'ui'); +const artifact = path.join(uiDir, 'dist', 'index.js'); + +if (process.env.SKIP_UI_BUILD === '1') { + console.log('[ensure-ui-build] SKIP_UI_BUILD=1 — skipping'); + process.exit(0); +} + +if (existsSync(artifact)) { + process.exit(0); // already built +} + +const run = (cmd, cwd = root) => { + console.log(`[ensure-ui-build] ${cmd}`); + execSync(cmd, { cwd, stdio: 'inherit' }); +}; + +try { + if (!existsSync(path.join(uiDir, 'package.json'))) { + run('git submodule update --init vendor/ui'); + } + run('npm install', uiDir); + run('npm run build', uiDir); + console.log('[ensure-ui-build] @mieweb/ui built at vendor/ui/dist'); +} catch (err) { + console.error( + '[ensure-ui-build] failed to build vendor/ui — the app will not start until it is built.\n' + + 'Run manually: git submodule update --init vendor/ui && cd vendor/ui && npm install && npm run build', + ); + throw err; +} diff --git a/src/features/clock/ClockPage.tsx b/src/features/clock/ClockPage.tsx index d4228db9..ebac01cd 100644 --- a/src/features/clock/ClockPage.tsx +++ b/src/features/clock/ClockPage.tsx @@ -1,17 +1,47 @@ /** - * ClockPage — Clock in/out with live session timer. + * ClockPage — plan-first shift screen. + * + * Reads top-to-bottom as a gate rather than a dashboard: + * 1. Banner — status lamp + one plain sentence that always says what's + * blocking you (Ready to work → Plan posted → On shift). + * 2. Composer — plain textarea with a single combined action: "Post plan + * and clock in" / "Post wrap-up and clock out" (⌘/Ctrl+↵ submits). + * 3. Clock module — compact seven-segment punch clock pinned near the + * bottom with a live status readout line. + * + * Gate state comes from useClockToggle.planGate (realtime via DDP), so this + * page never needs a reload. With the team setting off, it's a plain + * clock-in/out screen. */ -import { faCircleStop, faStopwatch } from '@fortawesome/free-solid-svg-icons'; -import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; -import { Button, Card, CardContent, CardHeader, CardTitle, Spinner, Text } from '@mieweb/ui'; -import React from 'react'; +import { Button, Spinner, Text } from '@mieweb/ui'; +import React, { useEffect, useRef, useState } from 'react'; +import { huddleApi, type HuddlePost } from '../../lib/api'; +import { getDdpClient } from '../../lib/ddp'; import { useTeam } from '../../lib/TeamContext'; -import { formatTimer, getActiveClockSeconds } from '../../lib/timeUtils'; -import { AppPage } from '../../ui/AppPage'; +import { formatTimer, getActiveClockSeconds, toDateString } from '../../lib/timeUtils'; import { useClockToggle } from '../../lib/useClockToggle'; +import { MarkdownEditor } from '../huddle/MarkdownEditor'; +import { AppPage } from '../../ui/AppPage'; import { useRouter } from '../../ui/router'; +// Figure space keeps single-digit hours aligned against the 88:88:88 backdrop. +const FIGURE_SPACE = '\u2007'; + +function clockParts(now: number) { + const d = new Date(now); + let hours = d.getHours() % 12; + if (hours === 0) hours = 12; + const hh = String(hours).padStart(2, FIGURE_SPACE); + const mm = String(d.getMinutes()).padStart(2, '0'); + const ss = String(d.getSeconds()).padStart(2, '0'); + const meridiem = d.getHours() >= 12 ? 'PM' : 'AM'; + const date = d + .toLocaleDateString([], { weekday: 'short', month: 'short', day: 'numeric' }) + .toUpperCase(); + return { time: `${hh}:${mm}:${ss}`, meridiem, date }; +} + // ─── ClockPage ──────────────────────────────────────────────────────────────── export const ClockPage: React.FC = () => { @@ -26,25 +56,220 @@ export const ClockPage: React.FC = () => { clockInLoading, clockOutLoading, clockPauseLoading, + clockOutBlockedReason, + planGate, } = useClockToggle(); - // Session duration - const sessionSeconds = getActiveClockSeconds(activeClockEvent, currentTime); + const { + teamId: gateTeamId, + teamName, + requirePlan, + sessionPost, + planMissing, + wrapUpMissing, + } = planGate; + + const isClockedIn = !!activeClockEvent; const isPaused = !!activeClockEvent?.isPaused; + const sessionSeconds = getActiveClockSeconds(activeClockEvent, currentTime); + + // ── Composer state (plan before clock-in, wrap-up before clock-out) ── + const [text, setText] = useState(''); + const [posting, setPosting] = useState(false); + const [postError, setPostError] = useState(null); + // Bumped to remount the (uncontrolled) editor — clears it after posting and + // re-seeds it when a draft loads. + const [editorKey, setEditorKey] = useState(0); + + // ── Drafts — save a plan without publishing/clocking in ── + type DraftRef = Pick; + const [draft, setDraft] = useState(null); + const [savingDraft, setSavingDraft] = useState(false); + const [draftSaved, setDraftSaved] = useState(false); + + const composerMode: 'plan' | 'wrapup' | null = !isClockedIn + ? planMissing + ? 'plan' + : null + : wrapUpMissing + ? 'wrapup' + : null; + + // Load the latest draft when the plan composer opens (prefill source below). + useEffect(() => { + if (composerMode !== 'plan' || !gateTeamId) { + setDraft(null); + return; + } + let cancelled = false; + huddleApi + .getMyLatestDraft(gateTeamId) + .then((post) => { + if (!cancelled && post) setDraft(post); + }) + .catch(() => {}); + return () => { + cancelled = true; + }; + }, [composerMode, gateTeamId]); + + // Content pre-loaded into the (uncontrolled) editor for the current composer: + // • plan → the latest saved draft, so you keep editing it. + // • wrapup → THIS session's plan post, so clocking out continues the same + // content you wrote at clock-in instead of a blank box. + const seedText = + composerMode === 'plan' + ? (draft?.content.text ?? '') + : composerMode === 'wrapup' + ? (sessionPost?.content.text ?? '') + : ''; + + // Apply the seed once it becomes available (draft / session post load async), + // unless the user has already started typing. Remount the uncontrolled editor + // via `editorKey` so it picks up the seeded value. + const seededTokenRef = useRef(null); + useEffect(() => { + if (!composerMode || !seedText) return; + const token = `${composerMode}:${seedText}`; + if (seededTokenRef.current === token) return; + seededTokenRef.current = token; + setText((current) => (current.trim() ? current : seedText)); + setEditorKey((k) => k + 1); + }, [composerMode, seedText]); - // Live wall-clock display - const currentTimeDisplay = new Date(currentTime).toLocaleTimeString([], { - hour: '2-digit', - minute: '2-digit', - second: '2-digit', - }); - const currentDateDisplay = new Date(currentTime).toLocaleDateString([], { - weekday: 'long', - month: 'long', - day: 'numeric', - }); + async function saveDraft() { + const trimmed = text.trim(); + if (!gateTeamId || !trimmed || savingDraft || posting) return; + setSavingDraft(true); + setPostError(null); + try { + if (draft) { + await huddleApi.updatePost(draft.id, { + text: trimmed, + mentions: draft.content.mentions, + }); + setDraft({ ...draft, content: { ...draft.content, text: trimmed } }); + } else { + const created = await huddleApi.saveDraft(gateTeamId, { text: trimmed, mentions: [] }); + setDraft({ id: created.id, content: { text: trimmed, mentions: [] } }); + } + setDraftSaved(true); + setTimeout(() => setDraftSaved(false), 2500); + } catch (e) { + setPostError(e instanceof Error ? e.message : 'Failed to save draft. Please try again.'); + } finally { + setSavingDraft(false); + } + } + + async function postPlanAndClockIn() { + const trimmed = text.trim(); + if (!gateTeamId || !trimmed || posting) return; + setPosting(true); + setPostError(null); + try { + let planPostId: string; + if (draft) { + // Publishing the draft (with any edits) is this session's plan post. + await huddleApi.publishPost(draft.id, toDateString(new Date()), { + text: trimmed, + mentions: draft.content.mentions, + }); + planPostId = draft.id; + setDraft(null); + } else { + const created = (await getDdpClient().call('huddle.createPost', { + teamId: gateTeamId, + content: { text: trimmed, mentions: [] }, + postDate: toDateString(new Date()), + })) as { id: string }; + planPostId = created.id; + } + setText(''); + // Link this plan to the new session so the per-session gate finds it. + await clockIn({ planJustPosted: true, planPostId }); + } catch (e) { + setPostError(e instanceof Error ? e.message : 'Failed to post. Please try again.'); + } finally { + setPosting(false); + } + } + + async function postWrapUpAndClockOut() { + const trimmed = text.trim(); + if (!activeClockEvent || !trimmed || posting) return; + setPosting(true); + setPostError(null); + try { + if (sessionPost) { + // The editor was seeded with this session's plan post, so `trimmed` is + // the full continued content — save it as-is and stamp the wrap-up. + await huddleApi.updatePost( + sessionPost.id, + { + text: trimmed, + mentions: sessionPost.content.mentions, + }, + { wrapUp: true }, + ); + } else { + // Recovery: this session has no plan post (e.g. gate was enabled + // mid-shift). Create one that doubles as the wrap-up, linked to the + // session so the gate is satisfied. + await getDdpClient().call('huddle.createPost', { + teamId: gateTeamId, + content: { text: `**Wrap-up:** ${trimmed}`, mentions: [] }, + postDate: toDateString(new Date()), + clockEventId: activeClockEvent.id, + wrapUp: true, + }); + } + setText(''); + await clockOut(); + } catch (e) { + setPostError(e instanceof Error ? e.message : 'Failed to post. Please try again.'); + } finally { + setPosting(false); + } + } + + // ── Banner copy — always says what's blocking you ── + const teamSuffix = teamName && gateTeamId !== selectedTeamId ? ` in “${teamName}”` : ''; + let eyebrow: string; + let headline: string; + let subline: React.ReactNode = null; + if (!isClockedIn) { + eyebrow = 'Ready to work'; + if (composerMode === 'plan') { + headline = 'Write a plan before starting this session.'; + subline = ( + <> + Posting starts your shift.{' '} + + + ); + } else if (requirePlan) { + headline = 'Plan posted — you’re set to clock in.'; + } else { + headline = 'You’re set to clock in.'; + } + } else { + eyebrow = isPaused ? 'On break' : 'On shift'; + if (composerMode === 'wrapup') { + headline = `Add a wrap-up to this session’s post${teamSuffix} before clocking out.`; + subline = 'Posting ends your shift.'; + } else { + headline = `Clocked in — ${formatTimer(sessionSeconds)} this shift.`; + } + } - const timeZone = Intl.DateTimeFormat().resolvedOptions().timeZone; + const { time, meridiem, date } = clockParts(currentTime); if (!teamsReady) { return ( @@ -55,111 +280,172 @@ export const ClockPage: React.FC = () => { } return ( - - {/* ── Clock Button ── */} - - - {/* Clock button — full width on mobile, 1/4 on sm+ */} -
- {activeClockEvent ? ( + +
+ {/* ── Banner — the gate, in one sentence ── */} +
+
+ + {eyebrow} +
+ + {headline} + + {subline &&

{subline}

} +
+ + {/* ── Composer — one box, one combined action ── */} + {composerMode && ( +
+ + void (composerMode === 'plan' ? postPlanAndClockIn() : postWrapUpAndClockOut()) + } + /> +
+ + {composerMode === 'plan' && ( + + )} + + {draftSaved + ? 'Draft saved — publish to start your shift · ' + : !text.trim() + ? composerMode === 'plan' + ? 'Write a plan first · ' + : 'Write a wrap-up first · ' + : ''} + ⌘↵ to post and {composerMode === 'plan' ? 'clock in' : 'clock out'} + +
+ {postError && ( + + {postError} + + )} +
+ )} + + {/* ── Plain actions when the gate is satisfied (or off) ── */} + {!composerMode && ( +
+ {!isClockedIn ? ( + + ) : ( <> - - ) : ( - <> - )}
+ )} - {/* Time display — full width on mobile, 3/4 on sm+; border switches from top to left */} -
-
- {currentTimeDisplay} -
- - {currentDateDisplay} - - {activeClockEvent ? ( - - {isPaused ? 'On break' : 'Session active'} — {formatTimer(sessionSeconds)} - - ) : ( - - Ready to work - - )} + {/* Break/Resume stays reachable while the wrap-up composer is up */} + {composerMode === 'wrapup' && ( +
+
- - - {timeZone} - - - - {/* ── Quick Ticket Creation ── */} - {activeClockEvent && ( - - - Quick Actions - - - - Coming soon… In the meantime, track your time on the{' '} - {' '} - page or manage your{' '} - - . - - - - )} + )} + + {clockOutBlockedReason && ( + + {clockOutBlockedReason} + + )} + + {/* ── Clock module — compact punch clock near the bottom ── */} +
+
+ + 88:88:88 + + {time} + + {meridiem} + +
+
+ + + {isClockedIn + ? `${isPaused ? 'On break' : 'On shift'} ${formatTimer(sessionSeconds)}` + : 'Not clocked in'} + {' · '} + {date} + +
+
+
); }; diff --git a/src/features/huddle/DraftsPanel.tsx b/src/features/huddle/DraftsPanel.tsx new file mode 100644 index 00000000..7a19246c --- /dev/null +++ b/src/features/huddle/DraftsPanel.tsx @@ -0,0 +1,174 @@ +/** + * DraftsPanel — manage multiple author-only draft plans. + * + * Drafts never appear in the team feed and don't satisfy the clock gate; + * they're a place to prepare plans ahead of time. Create as many as you like, + * edit them, delete them, or publish one to the feed. Publishing a draft as a + * session plan (with clock-in) happens on the Clock page. + */ +import { Button, Spinner, Text } from '@mieweb/ui'; +import { useCallback, useEffect, useState } from 'react'; + +import { huddleApi, type HuddlePost } from '@lib/api'; +import { toDateString } from '@lib/timeUtils'; +import { HuddleComposer } from './HuddleComposer'; +import { MarkdownContent } from './MarkdownContent'; +import type { ComposerContent } from './types'; + +interface DraftsPanelProps { + teamId: string; + userInitials: string; + userColor: 'indigo' | 'teal' | 'coral' | 'amber' | 'pink' | 'green'; +} + +export function DraftsPanel({ teamId, userInitials, userColor }: DraftsPanelProps) { + const [drafts, setDrafts] = useState([]); + const [loading, setLoading] = useState(true); + const [editingId, setEditingId] = useState(null); + const [busyId, setBusyId] = useState(null); + + const refetch = useCallback(async () => { + setLoading(true); + try { + setDrafts(await huddleApi.getMyDrafts(teamId)); + } catch { + setDrafts([]); + } finally { + setLoading(false); + } + }, [teamId]); + + useEffect(() => { + void refetch(); + }, [refetch]); + + async function createDraft(content: ComposerContent) { + const mentions = (content.mentions ?? []).map((m) => m.userId); + await huddleApi.saveDraft(teamId, { text: content.text, mentions }); + await refetch(); + } + + async function updateDraft(id: string, content: ComposerContent) { + const mentions = (content.mentions ?? []).map((m) => m.userId); + await huddleApi.updatePost(id, { text: content.text, mentions }); + setEditingId(null); + await refetch(); + } + + async function publishDraft(id: string) { + setBusyId(id); + try { + await huddleApi.publishPost(id, toDateString(new Date())); + await refetch(); + } finally { + setBusyId(null); + } + } + + async function deleteDraft(id: string) { + setBusyId(id); + try { + await huddleApi.deletePost(id); + await refetch(); + } finally { + setBusyId(null); + } + } + + return ( +
+ {/* New draft composer */} +
+ +
+ +
+ {loading && ( +
+ +
+ )} + + {!loading && drafts.length === 0 && ( +
+ + No drafts yet. Draft a plan above and it stays private until you publish it. + +
+ )} + + {!loading && + drafts.map((draft) => + editingId === draft.id ? ( +
+ void updateDraft(draft.id, content)} + userInitials={userInitials} + userColor={userColor} + initialText={draft.content.text} + submitLabel="Save draft" + /> +
+ +
+
+ ) : ( +
+
+ + Draft + +
+ + + +
+
+ +
+ ), + )} +
+
+ ); +} diff --git a/src/features/huddle/HuddleComposer.tsx b/src/features/huddle/HuddleComposer.tsx index 51f770f7..0a582936 100644 --- a/src/features/huddle/HuddleComposer.tsx +++ b/src/features/huddle/HuddleComposer.tsx @@ -1,98 +1,64 @@ -import { useState, useRef, useEffect } from 'react'; +/** + * HuddleComposer — post/edit composer for the Huddle feed. + * + * The editing surface is Kerebron's RichEditor (via @mieweb/ui/kerebron): + * WYSIWYG ProseMirror with markdown in/out, replacing the old + * textarea + markdown-toolbar + preview tabs. Posts keep storing markdown in + * the existing `content.text` field, so legacy plain-text posts are + * unaffected. + * + * RichEditor is uncontrolled — `initialText` applies on mount only. Hosts + * editing an existing post must remount the composer with + * `key={editingPostId ?? 'new'}`. + */ +import { useEffect, useRef, useState } from 'react'; import { useTeam } from '@lib/TeamContext'; import { attachmentApi } from '@lib/api'; import { TicketPicker } from './TicketPicker'; import { AttachmentBar } from './AttachmentBar'; +import { PulseAttachButton } from './PulseAttachButton'; +import { MarkdownEditor } from './MarkdownEditor'; import { MentionMenu } from './MentionMenu'; -import { MarkdownContent } from './MarkdownContent'; +import { huddlePostCollab } from './collab'; import type { ComposerContent, MediaItem } from './types'; -// ─── Table picker popover ───────────────────────────────────────────────────── -const TABLE_ROWS = 6; -const TABLE_COLS = 6; - -function TablePicker({ - onSelect, - onClose, -}: { - onSelect: (rows: number, cols: number) => void; - onClose: () => void; -}) { - const [hovered, setHovered] = useState<{ row: number; col: number } | null>(null); - const ref = useRef(null); - - useEffect(() => { - const handler = (e: MouseEvent) => { - if (ref.current && !ref.current.contains(e.target as Node)) onClose(); - }; - document.addEventListener('mousedown', handler); - return () => document.removeEventListener('mousedown', handler); - }, [onClose]); - - return ( -
-

- {hovered ? `${hovered.row} × ${hovered.col} table` : 'Hover to select size'} -

-
- {Array.from({ length: TABLE_ROWS }, (_, r) => ( -
- {Array.from({ length: TABLE_COLS }, (_, c) => { - const active = hovered && r < hovered.row && c < hovered.col; - return ( -
setHovered({ row: r + 1, col: c + 1 })} - onClick={() => { - onSelect(r + 1, c + 1); - onClose(); - }} - /> - ); - })} -
- ))} -
-
- ); -} - -// ─── Generate markdown table ────────────────────────────────────────────────── -function generateTable(rows: number, cols: number): string { - const header = '| ' + Array.from({ length: cols }, (_, i) => `Col ${i + 1}`).join(' | ') + ' |'; - const divider = '| ' + Array.from({ length: cols }, () => '-------').join(' | ') + ' |'; - const row = '| ' + Array.from({ length: cols }, () => 'Cell').join(' | ') + ' |'; - const dataRows = Array.from({ length: rows - 1 }, () => row).join('\n'); - return [header, divider, dataRows].filter(Boolean).join('\n'); -} - -// ─── Toolbar snippets ───────────────────────────────────────────────────────── -const SNIPPETS = { - bold: { wrap: ['**', '**'], placeholder: 'bold text' }, - italic: { wrap: ['*', '*'], placeholder: 'italic text' }, - code: { wrap: ['`', '`'], placeholder: 'code' }, - quote: { wrap: ['> ', ''], placeholder: 'quote' }, - codeblock: { block: '```typescript\n\n```', cursor: 14 }, - math: { block: '$$\n\n$$', cursor: 3 }, - mermaid: { block: '```mermaid\nflowchart LR\n A --> B\n```', cursor: 10 }, - link: { block: '[link text](url)', cursor: 1 }, -} as const; - -type SnippetKey = keyof typeof SNIPPETS; +type MentionRef = { userId: string; name: string }; // ─── Types ──────────────────────────────────────────────────────────────────── interface HuddleComposerProps { onPost: (content: ComposerContent) => void; userInitials?: string; userColor?: 'indigo' | 'teal' | 'coral' | 'amber' | 'pink' | 'green'; + /** + * Editing mode: initial markdown (e.g. today's post) loaded into the + * editor. RichEditor is uncontrolled — the host must remount the composer + * (key={editingPostId ?? 'new'}) when this changes. + */ + initialText?: string; + /** Label for the submit button (e.g. "Update post"). Defaults to "Post". */ + submitLabel?: string; + /** Placeholder for the collapsed bar. */ + collapsedLabel?: string; + /** + * Edit mode: start expanded with no collapsed bar / click-outside collapse. + * Submit and Cancel defer to the host (via onPost / onCancel) instead of + * resetting the composer in place. + */ + editing?: boolean; + /** Called when the user cancels in edit mode. */ + onCancel?: () => void; + /** Attachments preloaded into the composer (edit mode). */ + initialAttachments?: MediaItem[]; + /** Ticket preselected in the composer (edit mode). */ + initialTicketId?: string; + /** Mentions preloaded into the composer (edit mode). */ + initialMentions?: MentionRef[]; + /** + * When set, enables live collaborative editing (Yjs) for this room — every + * peer editing the same post co-edits one shared document. Only meaningful + * for an existing post (room = post id); leave unset for new posts. + */ + collabRoom?: string; } // ─── HuddleComposer ─────────────────────────────────────────────────────────── @@ -100,17 +66,40 @@ export function HuddleComposer({ onPost, userInitials = 'PD', userColor = 'indigo', + initialText = '', + submitLabel = 'Post', + collapsedLabel = 'Share an update...', + editing = false, + onCancel, + initialAttachments, + initialTicketId, + initialMentions, + collabRoom, }: HuddleComposerProps) { - const [expanded, setExpanded] = useState(false); - const [tab, setTab] = useState<'write' | 'preview'>('write'); - const [text, setText] = useState(''); - const [selectedTicketId, setSelectedTicketId] = useState(); - const [attachments, setAttachments] = useState([]); + const [expanded, setExpanded] = useState(editing); + const [text, setText] = useState(initialText); + const [selectedTicketId, setSelectedTicketId] = useState(initialTicketId); + const [attachments, setAttachments] = useState(initialAttachments ?? []); const [ticketVideos, setTicketVideos] = useState([]); - const [mentions, setMentions] = useState>([]); - const [showTablePicker, setShowTablePicker] = useState(false); - const textareaRef = useRef(null); + const [mentions, setMentions] = useState(initialMentions ?? []); const { selectedTeamId } = useTeam(); + const composerRef = useRef(null); + + // Click-outside → collapse (only when empty, so in-progress writing is never + // lost). Frees up feed space when you're not actively composing. Disabled in + // edit mode — the host owns dismissal there. + useEffect(() => { + if (!expanded || editing) return; + const onDocMouseDown = (e: MouseEvent) => { + if (composerRef.current?.contains(e.target as Node)) return; + // Kerebron popovers (toolbar dropdowns) can portal outside the composer. + if ((e.target as HTMLElement).closest?.('.kb-custom-menu__wrapper, [role="menu"]')) return; + const hasContent = text.trim() || attachments.length > 0; + if (!hasContent) handleCancel(); + }; + document.addEventListener('mousedown', onDocMouseDown); + return () => document.removeEventListener('mousedown', onDocMouseDown); + }, [expanded, text, attachments.length]); // Fetch videos attached to the selected ticket useEffect(() => { @@ -152,46 +141,6 @@ export function HuddleComposer({ }; }, [selectedTicketId]); - const insertSnippet = (key: SnippetKey) => { - const snippet = SNIPPETS[key]; - const el = textareaRef.current; - if (!el) return; - const start = el.selectionStart; - const end = el.selectionEnd; - const selected = text.slice(start, end); - let newText = text; - let newCursor = start; - if ('wrap' in snippet) { - const inner = selected || snippet.placeholder; - newText = text.slice(0, start) + snippet.wrap[0] + inner + snippet.wrap[1] + text.slice(end); - newCursor = start + snippet.wrap[0].length + inner.length + snippet.wrap[1].length; - } else { - const prefix = start > 0 && text[start - 1] !== '\n' ? '\n' : ''; - newText = text.slice(0, start) + prefix + snippet.block + '\n' + text.slice(end); - newCursor = start + prefix.length + snippet.cursor; - } - setText(newText); - requestAnimationFrame(() => { - el.focus(); - el.setSelectionRange(newCursor, newCursor); - }); - }; - - const insertTable = (rows: number, cols: number) => { - const el = textareaRef.current; - if (!el) return; - const start = el.selectionStart; - const prefix = start > 0 && text[start - 1] !== '\n' ? '\n' : ''; - const table = generateTable(rows, cols); - const newText = text.slice(0, start) + prefix + table + '\n' + text.slice(start); - setText(newText); - const newCursor = start + prefix.length + table.indexOf('\n') + 2; - requestAnimationFrame(() => { - el.focus(); - el.setSelectionRange(newCursor, newCursor); - }); - }; - const handleSubmit = () => { try { if (!text.trim() && attachments.length === 0 && ticketVideos.length === 0) return; @@ -199,16 +148,28 @@ export function HuddleComposer({ // Combine user attachments with ticket videos const allAttachments = [...attachments, ...ticketVideos]; + // RichEditor has no insert-at-cursor API, so append any selected mentions + // that aren't already written in the body as trailing @name tokens — + // otherwise they'd be tracked but never visible in the post. + const base = text.trim(); + const mentionSuffix = mentions + .filter((m) => !base.includes(`@${m.name}`)) + .map((m) => `@${m.name}`) + .join(' '); + const finalText = [base, mentionSuffix].filter(Boolean).join(' ') || '(Image post)'; + onPost({ - text: text.trim() || '(Image post)', - json: { text: text.trim() || '(Image post)' }, + text: finalText, + json: { text: finalText }, ticketId: selectedTicketId, attachments: allAttachments, mentions, }); - setText(''); + // In edit mode the host closes the composer once the update resolves; keep + // the fields intact so nothing flickers before it unmounts. + if (editing) return; + setText(initialText); setExpanded(false); - setTab('write'); setSelectedTicketId(undefined); setAttachments([]); setTicketVideos([]); @@ -220,9 +181,12 @@ export function HuddleComposer({ }; const handleCancel = () => { - setText(''); + if (editing) { + onCancel?.(); + return; + } + setText(initialText); setExpanded(false); - setTab('write'); setSelectedTicketId(undefined); setAttachments([]); setTicketVideos([]); @@ -233,21 +197,15 @@ export function HuddleComposer({ const handleAttachmentRemove = (mediaId: string) => setAttachments((prev) => prev.filter((m) => m.id !== mediaId)); + // RichEditor has no insert-at-cursor API, so mentions are tracked as chips + // below the editor instead of injected inline. const handleMentionSelect = (userId: string, name: string) => { - setMentions((prev) => [...prev, { userId, name }]); - const el = textareaRef.current; - if (el) { - const pos = el.selectionStart; - const newText = text.slice(0, pos) + `@${name} ` + text.slice(pos); - setText(newText); - requestAnimationFrame(() => { - el.focus(); - el.setSelectionRange(pos + name.length + 2, pos + name.length + 2); - }); - } else { - setText((prev) => prev + `@${name} `); - } + setMentions((prev) => + prev.some((m) => m.userId === userId) ? prev : [...prev, { userId, name }], + ); }; + const handleMentionRemove = (userId: string) => + setMentions((prev) => prev.filter((m) => m.userId !== userId)); const avatarColorClasses = { indigo: 'bg-indigo-100 text-indigo-600', @@ -258,10 +216,6 @@ export function HuddleComposer({ green: 'bg-green-100 text-green-600', }; - const btnBase = - 'h-7 px-1.5 flex items-center justify-center rounded text-xs text-gray-600 dark:text-neutral-400 hover:bg-gray-200 dark:hover:bg-neutral-700 hover:text-gray-900 dark:hover:text-neutral-100 transition-colors'; - const divider =
; - // ─── Collapsed ────────────────────────────────────────────────────────────── if (!expanded) { return ( @@ -275,36 +229,18 @@ export function HuddleComposer({ {userInitials}
- Share an update... + {collapsedLabel}
-
); } // ─── Expanded ─────────────────────────────────────────────────────────────── return ( -
+
- {/* ── Tabs ── */} -
- {(['write', 'preview'] as const).map((t) => ( - - ))} -
- - {/* ── Toolbar (write only) ── */} - {tab === 'write' && ( -
- - - - {divider} - - - {divider} - - -
- - {showTablePicker && ( - setShowTablePicker(false)} /> - )} -
- -
- )} - - {/* ── Textarea ── */} - {tab === 'write' && ( -