Commit graph

2559 commits

Author SHA1 Message Date
Till JS
0aec1d43c0 fix(docs): astro starlight repo URL → Memo-2023/managarten
Vorbestehender Bug: zeigte auf nicht-existente Org `mana/`. Korrigiert
auf den tatsächlichen GitHub-Pfad. Edit-Link auf docs-Seite + GitHub-
Icon im Footer funktionieren jetzt.
2026-05-09 01:28:50 +02:00
Till JS
b1b9bbc269 chore: rename repo mana-monorepo → managarten
Some checks are pending
CD Mac Mini / Detect Changes (push) Waiting to run
CD Mac Mini / Deploy (push) Blocked by required conditions
CI / Detect Changes (push) Waiting to run
CI / Validate (push) Waiting to run
CI / Build mana-search (push) Blocked by required conditions
CI / Build mana-sync (push) Blocked by required conditions
CI / Build mana-api-gateway (push) Blocked by required conditions
CI / Build mana-crawler (push) Blocked by required conditions
Docker Validate / Validate Dockerfiles (push) Waiting to run
Docker Validate / Build calendar-web (push) Blocked by required conditions
Docker Validate / Build quotes-web (push) Blocked by required conditions
Docker Validate / Build todo-backend (push) Blocked by required conditions
Docker Validate / Build todo-web (push) Blocked by required conditions
Docker Validate / Build mana-auth (push) Blocked by required conditions
Docker Validate / Build mana-sync (push) Blocked by required conditions
Docker Validate / Build mana-media (push) Blocked by required conditions
Mirror to Forgejo / Push to Forgejo (push) Waiting to run
Phase-3-Rename des ehemaligen Multi-App-Monorepos zum eigenständigen
Produkt-Repo. Verein heißt mana e.V., Plattform-Domain bleibt mana.how,
apps/mana/ bleibt unverändert — nur der Repo-Container kriegt den
neuen Namen "managarten" (Garten der mana-Apps).

Geändert:
- package.json#name + #description
- README.md (Titel + erster Absatz)
- TROUBLESHOOTING.md
- alle Mac-Mini-Skripte (Pfade ~/projects/mana-monorepo → ~/projects/managarten)
- COMPOSE_PROJECT_NAME-default in scripts/mac-mini/status.sh
- .github/workflows/cd-macmini.yml + mirror-to-forgejo.yml
- apps/docs (astro.config.mjs + content)
- .claude/settings.local.json (Bash-Permission-Pfade)
- alle docs/*.md Pfad-Referenzen
- launchd plists, .env.macmini.example, infrastructure/

Forgejo-Repo + GitHub-Repo bereits via API umbenannt. Lokales
Verzeichnis-Rename + Mac-Mini-Cutover folgen separat.
2026-05-09 01:16:02 +02:00
Till JS
ac15de280b chore(decommission): remove cards module from mana web app
Some checks are pending
Docker Validate / Build todo-web (push) Blocked by required conditions
Docker Validate / Build mana-auth (push) Blocked by required conditions
Docker Validate / Build mana-sync (push) Blocked by required conditions
Docker Validate / Build mana-media (push) Blocked by required conditions
Mirror to Forgejo / Push to Forgejo (push) Waiting to run
CD Mac Mini / Detect Changes (push) Waiting to run
CD Mac Mini / Deploy (push) Blocked by required conditions
CI / Detect Changes (push) Waiting to run
CI / Validate (push) Waiting to run
CI / Build mana-search (push) Blocked by required conditions
CI / Build mana-sync (push) Blocked by required conditions
CI / Build mana-api-gateway (push) Blocked by required conditions
CI / Build mana-crawler (push) Blocked by required conditions
Docker Validate / Validate Dockerfiles (push) Waiting to run
Docker Validate / Build calendar-web (push) Blocked by required conditions
Docker Validate / Build quotes-web (push) Blocked by required conditions
Docker Validate / Build todo-backend (push) Blocked by required conditions
Cards-Modul war im unified mana-Frontend tief verzahnt. Cardecky
ist seit 2026-05-08 standalone auf cardecky.mana.how — Dual-Stack
ist nicht das Ziel. Entfernt:

  - apps/mana/apps/web/src/lib/modules/cards/ (UI + stores + queries
    + collections + module.config + tools + cloze + fsrs + render)
  - apps/mana/apps/web/src/routes/(app)/cards/ (alle Routes)
  - apps/mana/apps/web/src/lib/i18n/locales/cards/ (5 Locales)
  - apps/mana/apps/web/src/lib/search/providers/cards.ts
  - apps/mana/apps/web/src/lib/components/dashboard/widgets/
    CardsProgressWidget.svelte + 'cards-progress' WidgetType-Eintrag

Cross-Refs aufgeräumt:
  - app-registry/apps.ts: Cards-Icon-Import + registerApp-Block raus
  - shared-branding/mana-apps.ts: 'cards'-App-Eintrag raus
  - data/cross-app-queries.ts: useCardsProgress + Cards-Queries-Block
    raus (Konsument war nur das gelöschte Dashboard-Widget)
  - data/seed-registry.ts: CARDS_GUEST_SEED-Import + register-Aufruf
  - data/module-registry.ts: cardsModuleConfig-Import + Eintrag
  - data/privacy/exposed-records.ts: Cards-Block (cardDecks visibility)
  - data/tools/init.ts: cardsTools-Import + registerTools
  - modules/website/embeds.ts: 'cards.decks'-Source + resolveCardDecks
  - apps/mana/apps/web/package.json: @mana/cards-core dependency
  - pnpm-lock.yaml regeneriert
  - dashboard.test.ts: cards-progress-Assertion

Dexie-Tabellen `cardDecks`/`cardReviews`/`cards` (lokal pro User-IndexedDB)
und ggf. mana_platform.cards.* in der prod-DB werden NICHT in diesem
Commit gedroppt — bleibt offen als separater Migrations-Schritt, sobald
sicher ist dass kein anderer Pfad mehr darauf zugreift.

Type-check (svelte-check) 7669 files 0 errors.

Rollback: git checkout cards-decommission-base -- apps/mana/apps/web

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 20:36:33 +02:00
Till JS
9cd8717494 chore(decommission): remove apps/cards/
Cards-App ist seit 2026-05-08 ein eigenständiges Repo
git.mana.how/till/cards (Strategie-B-Greenfield, beschlossen
2026-05-08), live auf cardecky.mana.how + cardecky-api.mana.how.
Alte cards-web-Container wurden gestoppt + entfernt.

Rollback: git checkout cards-decommission-base -- apps/cards/

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 20:27:24 +02:00
Till JS
467d8339cc fix(apps/api): COPY packages/eslint-config in Dockerfile
Root package.json devDeps reference @mana/eslint-config (workspace:*).
Even with `--filter @mana/api...`, pnpm resolves the root workspace
manifest and chokes on the missing package. Copy eslint-config into the
builder stage so the root resolves cleanly. Same pattern as the platform
mana-auth Dockerfile already uses.

Discovered while running the 8-Doppel-Cutover smoke test on Mac Mini:
mana-api build failed with ERR_PNPM_WORKSPACE_PKG_NOT_FOUND.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 18:44:57 +02:00
Till JS
774852ba2d feat(cutover): platform services build from ../mana, not from this repo
Part of the 8-Doppel-Cutover (2026-05-08, plan
~/.claude/plans/floating-swinging-flurry.md):

- docker-compose.{macmini,dev,test}.yml: build context for
  mana-{auth,credits,media,llm,notify} switched to ../mana/services/...
  so the Mac Mini stack pulls platform services from the platform repo
  (sibling clone), not from services/ in this monorepo.
- .npmrc + apps/api/{Dockerfile,package.json}: @mana/media-client now
  resolved from Verdaccio (npm.mana.how, ^0.1.0) instead of as a
  workspace COPY from services/mana-media/packages/client. Build-arg
  NPM_TOKEN flows through .npmrc for pnpm install auth. Required
  before services/mana-media/ can be deleted.
- .github/workflows/{ci,cd-macmini,daily-tests}.yml: removed the
  detect-/build-/test-jobs that targeted services/mana-{auth,credits,
  notify,media}/. Those services build out of the platform repo now —
  CI for them belongs in mana/-repo (open). cd-macmini's
  workflow_dispatch can still rebuild any of them on demand;
  auto-detect on path-change is gone for these five.
- scripts/{mac-mini/push-schemas.sh,run-integration-tests.sh}:
  rewritten to look in ../mana/ for the platform services.
- package.json dev:{auth,credits,notify,media}: paths point at
  ../mana/services/... so local dev still works post-cutover.

What this commit does NOT do: delete services/mana-{auth,credits,...}
from this repo. That waits for Phase 7 once the Mac Mini stack has
booted cleanly from the new build paths.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 18:40:08 +02:00
Till JS
8acf35eecf chore(dev): finish --watch → --hot sweep across remaining Bun services
Some checks failed
CD Mac Mini / Detect Changes (push) Failing after 11s
CI / Detect Changes (push) Successful in 7s
CI / Validate (push) Has been skipped
CI / Build mana-auth (push) Waiting to run
CI / Build mana-search (push) Waiting to run
CI / Build mana-sync (push) Waiting to run
CI / Build mana-notify (push) Waiting to run
CI / Build mana-api-gateway (push) Waiting to run
CI / Build mana-crawler (push) Waiting to run
CI / Build mana-media (push) Waiting to run
CI / Build mana-credits (push) Waiting to run
CI / Auth flow integration test (push) Has been skipped
Docker Validate / Validate Dockerfiles (push) Failing after 1m35s
Docker Validate / Build calendar-web (push) Has been skipped
Docker Validate / Build quotes-web (push) Has been skipped
Docker Validate / Build todo-backend (push) Has been skipped
Docker Validate / Build todo-web (push) Has been skipped
Docker Validate / Build mana-auth (push) Has been skipped
Docker Validate / Build mana-sync (push) Has been skipped
Docker Validate / Build mana-media (push) Has been skipped
Mirror to Forgejo / Push to Forgejo (push) Failing after 1s
CD Mac Mini / Deploy (push) Has been cancelled
Catches the service-level package.json files that the previous
sweep (4cca25ed0) missed — they don't appear in any dev:*:full
orchestrator but get invoked when someone runs `pnpm --filter
@mana/<service> dev` directly.

Touched: mana-geocoding, mana-mail, mana-subscriptions, mana-mcp,
news-ingester, mana-persona-runner, mana-research, mana-user,
plus apps/memoro (server + audio-server).

mana-ai stays on --watch on purpose: its entry uses an explicit
`Bun.serve({...})` call instead of `export default { port,
fetch }`, plus a SIGTERM/SIGINT handler that calls
`server.stop()`. --hot would replace the module without releasing
the old server reference and produce exactly the EADDRINUSE we're
trying to avoid. If mana-ai gets refactored to the standard
default-export shape, flip its dev script too.
2026-05-08 14:33:27 +02:00
Till JS
4cca25ed03 chore(dev): switch all Bun services from --watch to --hot
Fans out the cards-server fix from 08f422340 to every other Bun
service in the monorepo. --hot keeps the port bound across HMR
reloads via globalThis[hmrSymbol]; --watch restarts the process
and races old/new Bun.serve for the port.

Touched: dev:auth, dev:credits, dev:events, dev:analytics,
dev:memoro:server, dev:memoro:audio-server, dev:uload:server in
the root package.json plus the matching `dev` script in each
service's own package.json. All six services already export the
`{ port, fetch }` default that Bun's --hot expects.

Smoke-tested: pnpm dev:cardecky:full boots clean, then touching
auth/credits/cards-server entry files all hot-reload without
dropping their port.

(memoro/apps/audio-server doesn't have a `dev: bun --watch ...`
script in its own package.json, so only the root entry got the
swap there.)
2026-05-08 14:24:24 +02:00
Till JS
61f2772789 chore(brand): rename Cards → Cardecky (display, infra, license-IDs)
- App display name → Cardecky in mana-apps.ts, MODULE_REGISTRY, alle Docs
- Domains: cardecky.mana.how (App), cardecky-api.mana.how (Marketplace
  API), cardecky.com (Marketing-Landing — cloudflared-route + nginx-Block
  vorbereitet, DNS muss noch gesetzt werden)
- 301-Redirect cards.mana.how → cardecky.mana.how (nginx + cloudflared)
  für alte Bookmarks; kann nach 6–12 Monaten wieder raus
- SPDX license IDs Cards-Personal-Use/Pro-Only-1.0 → Cardecky-* via
  Drizzle 0001-Migration (DROP CHECK → UPDATE rows → SET DEFAULT → ADD
  CHECK), inkl. _journal- und 0001_snapshot-Update
- In-mana cards-Modul: dezenter Banner zur Standalone-App (GUIDELINES
  §12), einmal schließbar via localStorage
- Docker-CORS-Listen, sso-origins.ts, Prometheus-Target aktualisiert

Technische IDs bleiben bewusst: appId 'cards', schema
mana_platform.cards.*, Verzeichnis apps/cards/, Package @cards/web,
services/cards-server, Env-Vars CARDS_*, UMAMI_WEBSITE_ID_CARDS*, Class
CardsEvents — Mana-Konvention (Brand ≠ technischer Identifier).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 13:49:47 +02:00
Till JS
39e508075a feat(cards): CardFace v2 — 3D-Flip + tap-anywhere reveal
- CardFace now renders one card surface that flips on Y-axis when
  the user taps it. Both faces share a CSS-grid cell so the parent
  height is the max of front/back, no jumpy reflow on flip.
- Tap-anywhere on the surface reveals (only while showBack is
  false). The /learn page keeps the keyboard space/enter shortcut
  via the existing handler; the standalone "Aufdecken" button is
  now type-in-only (where the input field on the front breaks the
  flip-mental-model).
- prefers-reduced-motion: reduce collapses the rotateY into an
  instant cross-fade — same affordance, no vestibular trigger.
- Added a subtle "Tippe auf die Karte oder drücke Leertaste" hint
  on the front face so the new affordance is discoverable.
- Fixed the last Phase-A leftover: focus:border-indigo-400 in the
  type-in input → focus:border-app-accent.
2026-05-08 02:48:40 +02:00
Till JS
ad3b99fe6d refactor(cards): Phase A + C — adopt @mana/shared-theme + per-app accent
Phase A — Cards joins the unified theme system:
- Drop placeholder --color-cards-* palette; app.css imports
  @mana/shared-tailwind/themes.css + sources.css.
- Remove hardcoded class="dark" from app.html; body uses
  bg-background text-foreground.
- New $lib/stores/theme.ts: createThemeStore({ appId: 'cards' }).
  ThemeToggle from @mana/shared-theme-ui in the header next to
  the streak chip.
- Sweep all neutral / red / emerald / amber / indigo utilities in
  apps/cards/apps/web/src to semantic tokens (560 substitutions
  across 19 files): bg-neutral-900 → bg-card, text-neutral-400 →
  text-muted-foreground, bg-red-500 → bg-error, etc. Domain
  literals kept (FSRS grade colors red/orange/green/blue, GitHub-
  violet PR-merged badge, marketplace-amber Buy button, admin-
  inbox category palette).
- Cards added to validate-theme-utilities scope so future drift
  fails CI.

Phase C — per-app accent token:
- New --color-app-accent in shared-tailwind/themes.css. Theme-
  agnostic (registered in validate-theme-parity's THEME_AGNOSTIC
  regex), so it stays the same across light/dark/lume/etc. Defaults
  to Mana indigo at :root.
- Cards layout writes 258 90% 66% (= #8b5cf6 violet, from
  MANA_APPS.cards.color) onto documentElement at boot via
  applyCardsAccent(). All Cards CTAs (Lernen, Abonnieren, Senden,
  links inside cloze cards) flow through bg-app-accent /
  text-app-accent now.

Net effect: Cards gets light/dark + 4 palette variants + a11y
toggles for free, and any future app can drop in by setting its
own --color-app-accent without touching shared-tailwind.
2026-05-08 01:54:16 +02:00
Till JS
863311eefa docs(cards): Phasen-Statusupdate nach η.1 — Stand 2026-05-07
Marketplace-Plan auf den realen Code-Stand gebracht:

- §11 Phasen-Liste: pro Phase  shipped / 🟡 partial /  pending
  Marker mit Kurzbegründung. β/γ/δ/ε sind , ζ und η sind 🟡 mit
  ζ.2 / η.2 als nächste Sub-Phasen.
- §13a „Bekannte Limitierungen": neue Einträge für ζ (Refunds,
  Reconciler, CSV) und η (Community-Verify-Cron, Mod-Permissions,
  Public-Changelog, Rate-Limit). PR-Merge-Stale-Blindness und
  Card-Preview-Heuristik bleiben dokumentiert.
- §15 ersetzt: Status-Tabelle pro Phase + Empfehlung für die
  nächsten 4 Schritte (ζ.2 Reconciler → η.2 Verify-Cron →
  Update-Mail δ.4 → Phase θ Auto-Tags/Summary).
2026-05-07 23:38:42 +02:00
Till JS
c05022611e feat(cards): Phase η.1 — Reports + admin moderation actions
Server (cards-server):
- ModerationService: createReport, listOpen, resolveReport,
  takedownDeck, banAuthor, setVerifiedMana, restoreDeck. Takedown
  also auto-closes any sibling open reports against the deck and
  closes any open PRs (so contributors see clean state). Ban
  cascades to all of the author's decks.
- routes/moderation.ts: POST /v1/reports (any authed user),
  GET /v1/admin/reports (admin only), POST /v1/admin/reports/:id/
  resolve, POST /v1/admin/decks/:slug/{takedown,restore}, POST
  /v1/admin/authors/:slug/verify. Admin gate is `role === 'admin'`
  for now — verified-mana-only mods land later.
- Notify hooks: takedown emails the deck owner, mana-verify status
  change emails the author.

Frontend (cards-web):
- <ReportButton> (icon or inline variant) with category picker
  + optional explanation. On /d/<slug> as a discreet 🚩 next to
  the published-date stamp; in <CardDiscussions> per non-own
  comment.
- /d/<slug> shows a red "wurde von der Moderation entfernt" banner
  when isTakedown is true.
- /admin/reports inbox: lists open reports with category badges,
  Abweisen / Deck entfernen / Author bannen actions. Renders a
  forbidden state if the current user isn't admin.
2026-05-07 23:24:23 +02:00
Till JS
5dbc9ace2d feat(cards): Phase ζ.1 — Paid decks via mana-credits
Server (cards-server):
- lib/credits.ts: thin internal-API client for mana-credits
  (reserve / commit / refund-reservation / grant). Service-to-
  service via X-Service-Key. Throws InsufficientCreditsError
  separately so the buy flow can branch on UX.
- services/purchases.ts: 4-step purchase pipeline: reserve →
  insert deck_purchases row → commit reservation → grant
  author share + insert author_payouts. Idempotent on
  (buyer, deck) so a refresh-spam-click can't double-charge.
  Verified-mana authors get the 90/10 split, others 80/20
  (already in config). Refunds intentionally out of scope —
  see MARKETPLACE_PLAN §13a.
- routes/purchases.ts: POST /v1/decks/:slug/purchase,
  GET /v1/me/purchases, GET /v1/authors/me/payouts.
- decks.bySlug now returns hasPurchased (null when anonymous,
  bool when authed) so the deck-detail page can pick the right
  CTA.
- subscriptions.subscribe now blocks paid decks unless the
  caller has a non-refunded purchase row (owner exempt for
  testing).
- Notify: author gets a "Verkauf"-Email at grant time, with a
  deterministic externalId for dedup.

Frontend (cards-web):
- /d/<slug> shows "Kaufen für N 💎" instead of "Abonnieren"
  when paid + not yet bought; flips to subscribe path once
  purchased.
- /me/purchases page listing buyer history + (when present)
  author-payout history. Linked from the top nav.
2026-05-07 23:10:18 +02:00
Till JS
46fefd5cc4 feat(cards): Phase ε.4 — Card list + discussions on /d/<slug>
- DiscussionService.countsForDeck: bulk count (visible) comments per
  card-content-hash for one deck. Mounted at GET
  /v1/decks/:slug/discussion-counts so the public deck page can
  render comment badges without N+1 fetches.
- <DeckCardList> on /d/<slug>: lists the latest version's cards,
  renders a one-line preview + "💬 N" badge, and expands the
  inline <CardDiscussions> on click. Anonymous visitors see counts;
  posting requires auth (CardDiscussions already gates that).
2026-05-07 22:46:47 +02:00
Till JS
a8ddb6dea4 feat(cards): Phase ε.3 — PR notifications + Card-Discussions UI
- mana-notify integration in cards-server: PR-create notifies the
  deck owner, merge/reject notifies the PR author. Fire-and-forget
  via lib/notify.ts so a notify-service outage never rolls back a
  domain action. ExternalIDs are deterministic (cards.pr.{event}.{id})
  so retries dedupe.
- <CardDiscussions> on the learn page: collapsed by default, opens
  via "💬 Diskussion" alongside the "✏️ Verbessern" trigger. Resets
  whenever the current card changes so the panel doesn't bleed
  between flashcards.
- MARKETPLACE_PLAN.md §13a — known limitations: PR-merge is
  stale-blind (no rebase yet), diff-preview flat, threading 1-level.
2026-05-07 22:24:45 +02:00
Till JS
61fc16e8e9 feat(cards): Phase ε — Pull-Requests + Card-Discussions
Server (cards-server):
- PullRequestService: create / list / get / merge / close / reject.
  Merge applies the PR's {add, modify, remove} diff to the latest
  version's cards in a single transaction, writes a new
  deck_version + deck_cards, bumps latest_version_id, and stamps
  the PR with mergedIntoVersionId.
- DiscussionService: post / listForCard / hide. Threads are keyed
  by card_content_hash so they survive version bumps.
- Routes mounted under /v1: POST/GET /decks/:slug/pull-requests,
  GET /pull-requests/:id, POST /pull-requests/:id/{merge,close,reject},
  GET/POST /cards/:contentHash/discussions, POST /discussions/:id/hide.

Frontend (cards-web):
- cardsApi.pullRequests + cardsApi.discussions client surface.
- <PullRequestsSection> on /d/:slug — lists PRs with diff preview;
  owner sees Merge/Reject/Close buttons.
- <SuggestEditModal> + "✏️ Verbessern" button on /learn/:deckId for
  cards from a subscribed deck — submits a one-card modify (or
  remove) PR using the card's serverContentHash as the previous
  hash.
- Deck/Card DTOs gain subscribedFromSlug + serverContentHash so the
  learn page can decide whether to show the suggest-edit affordance.
2026-05-07 21:56:20 +02:00
Till JS
521ae52a62 feat(cards-web): Phase δ.3 — Smart-merge updates + read-only subscribed decks
Closes the marketplace subscribe loop. When the author publishes a
new version of a subscribed deck, the user sees a compact banner
with a per-card breakdown and one-click "Update anwenden" — FSRS
learning state survives across the merge.

  - lib/services/subscribe.ts:
    - previewUpdate(slug): server diff /v1/decks/:slug/diff?from=…
      with a small UpdatePreview shape ({added, changed, removed,
      unchanged} as counts).
    - applyUpdate(slug):
      - removed → soft-delete by [deckId+serverContentHash], cascade
        reviewStore.softDeleteForCard.
      - changed → update local card in place; ensureReviewsForCard
        re-runs to pick up cloze-cluster changes. FSRS reviews stay
        attached because the card-id never changes.
      - added → fresh local card + fresh FSRS reviews.
      - unchanged → only re-stamp ord if the diff moved it.
      Bumps subscribedAtVersion locally + re-stamps server-side.
  - lib/data/database.ts: Dexie v3 adds compound index
    [deckId+serverContentHash] for the smart-merge lookup.
  - routes/decks/[id]/+page.svelte:
    - subscribed decks get a green "📥 Abonniert"-banner with the
      version stamp + a deep-link to the public marketplace page.
    - if previewUpdate returns a non-null diff, the banner inlines
      the counts and an "Update anwenden" button.
    - "Veröffentlichen", "Neue Karte", "Aus Text generieren" and
      the per-card delete buttons are hidden when the deck is
      subscribed — read-only mirror of the author's content.

Validated: svelte-check 0/0, vite build green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 20:21:32 +02:00
Till JS
58c057f6c5 feat(cards-web): Phase δ.2 — Subscribe + initial pull
End-to-end subscribe flow on cards.mana.how. From a public deck page
the user can now pull the deck into their own Cards instance with
one click; subscribed decks live alongside own decks but carry a
`subscribedFromSlug` marker so the editor knows to hide mutate
controls (UI gating in δ.3).

  - cards-core types: LocalDeck gains subscribedFromSlug +
    subscribedAtVersion. LocalCard gains serverContentHash. Both
    optional — own decks/cards are unaffected.
  - data/database.ts: Dexie v2 adds index on cardDecks.subscribedFromSlug
    so the lookup-by-slug path is O(1).
  - lib/api/cards-api.ts: subscriptions.{list,subscribe,unsubscribe,
    version,diff} + the SubscriptionInfo / ServerCard / DeckVersionPayload
    / DiffPayload types.
  - lib/services/subscribe.ts: subscribeAndPull() sequences server
    POST /subscribe → GET /decks/:slug → GET /versions/:semver →
    create LocalDeck + LocalCards + ensure FSRS reviews. Re-pull
    refreshes in place (Phase δ.3 will swap to real diff-apply that
    keeps FSRS state). unsubscribe() soft-deletes the local mirror.
    isSubscribedLocally() backs the deck-page state check.
  - routes/d/[slug]/+page.svelte: full subscribe UI — Abonnieren →
    Abonniert + Lernen-Button (deep-links into the existing learn
    session route).

Validated: svelte-check 0/0, vite build green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 19:56:48 +02:00
Till JS
86a01426e8 feat(cards-server): Phase δ.1 — subscriptions + version reads + smart-merge diff
Server-side plumbing for Phase δ. Frontend hookup follows in δ.2.

  - services/subscriptions.ts: subscribe/unsubscribe (idempotent
    upsert on (user, deck), stamps the latest_version_id at
    subscribe-time so the client knows what it pulled). listForUser
    returns each sub with `updateAvailable: currentVersion !== latest`
    so the client can render an update indicator without a second
    round-trip. Refuses paid decks with 403 — that path comes back
    in Phase ζ once the credits Marketplace lands.
  - versionWithCards: deterministic ord-ordered card payload for a
    specific version. Read-public so anonymous browsers can preview
    a deck's content.
  - diffSince: smart-merge payload between any two versions. Splits
    the latest cards into added/changed/unchanged + lists removed
    by content_hash. The 'changed' bucket is heuristic (ord-position
    pair where one was removed and one was added) — solid enough
    until Phase ε's pull-request pipeline gives us real card
    lineage.
  - routes/subscriptions.ts mounts: GET /v1/me/subscriptions,
    POST/DELETE /v1/decks/:slug/subscribe (auth required),
    GET /v1/decks/:slug/versions/:semver (public),
    GET /v1/decks/:slug/diff?from=<semver> (public).

cards-web layout fix:
  - Marketplace surface (/explore, /u/, /d/) was previously gated
    behind the AuthGate — anonymous browsers got pushed to /login
    via client-side navigate. PUBLIC_PATHS extended so those routes
    SSR + render unauthed.

Validated: tsc clean on cards-server, svelte-check 0/0 on cards-web.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 19:33:58 +02:00
Till JS
e77134bd8b docs(infra): Phase 2f added to PLAN_OPTION_C + hostname table updated to v28
- PLAN_OPTION_C.md: new row covers verdaccio + news-ingester + mana-ai
  with the cross-arch + workspace-deps gotchas
- infrastructure/README.md: hostname table catches up to npm.mana.how
  (Phase 2f-1) and mana-ai.mana.how (Phase 2f-3); config v26 → v28
- infrastructure/.env.gpu-box.example: MANA_SERVICE_KEY +
  MANA_AI_PRIVATE_KEY_PEM block added with note that the values mirror
  Mini's .env.macmini (the latter's matching public-half stays on
  mana-auth, that's what makes Mission-Grant decryption work)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 17:09:28 +02:00
Till JS
ec8abfe6b8 feat(cards-web): Phase β.2 — author onboarding + publish flow
End-to-end "publish my local deck to the marketplace" surface in
the Cards standalone app. Hooks into cards-server (Phase β) so a
user can take a deck they've been editing locally and put it under
cards.mana.how/d/<slug> with one modal.

Pipeline:
  • lib/api/cards-api.ts — typed fetch wrapper around the cards-server
    /v1 surface. Reads the JWT from authStore, never from storage
    directly. CardsApiError carries `{status, message, details}`
    so UI can branch on 401/409/etc.
  • lib/stores/author.svelte.ts — lazy-loaded author state. Caches
    `cardsApi.authors.me()` on first access; resets cleanly on logout.
  • lib/util/slug.ts — best-effort slugify mirror of the server-side
    validator (server still has final say).
  • lib/components/PublishDeckModal.svelte — three-stage flow:
    become-author (slug + displayName + pseudonym), deck-meta (title,
    description, language, license picker, semver, changelog), then
    publishing → done with moderation-flag surface if AI mod returned
    'flag'. Keys off authorStore.isAuthor to skip stage 1 for
    returning authors.
  • routes/decks/[id]/+page.svelte gets a "🌍 Veröffentlichen" button
    next to "Lernen". Disabled until the deck has cards.

Wiring:
  • hooks.server.ts injects __PUBLIC_CARDS_API_URL__ on every SSR'd
    page so the client knows where cards-server lives.
  • compose adds PUBLIC_CARDS_API_URL_CLIENT=https://cards-api.mana.how
    to the cards-web container.

Validated: svelte-check 0/0, vite build green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 16:53:17 +02:00
Till JS
0686300243 docs(cards): Marktplatz Plan — Vollvision mit mana-credits + dual verification
Vollvision-Plan für den Cards-Decks-Marktplatz, basierend auf der
Konkurrenz-Analyse vom selben Tag. Bewusst nicht MVP-getrieben —
unbegrenzte Ressourcen-Annahme, optimale Lösung als Ziel.

Kern-Entscheidungen aus dem User-Briefing:
  - Versionierte Decks + Live-Updates + Pull-Requests = volle Vision
  - mana-credits zentral: User kaufen, Authoren verdienen (80/20 Cut,
    90/10 für verified-mana)
  - Verifizierung zweigleisig: Mana-Verein-Kuration UND Community-
    Schwelle, mit unterschiedlichen Badges (🛡️ und )
  - Co-Learn-Sessions explizit auf Phase λ verschoben
  - Mobile-Apps explizit auf Phase μ verschoben

Inhalt:
  - 17 Tabellen-Schema (Authoren, Decks, Versionen, Karten,
    Subscriptions, Forks, Stars, PRs, Discussions, Reports,
    Tags, Käufe, Auszahlungen, AI-Mod-Log)
  - mana-credits Integration end-to-end (2-phase Reservation,
    Author-Payout, Refund-Workflow, Buyer-Protection)
  - Service-Architektur: cards-server (neu), cards-search (neu,
    später), Erweiterungen an mana-llm/mana-credits/mana-notify/
    mana-media
  - 7 API-Endpoint-Bereiche mit konkreten Routes
  - 9 Phasen (α–ι) plus 4 explizit-später-Phasen
  - Cold-Start: Verein-Seed + Anki-Top-100 + Influencer-Outreach
  - Risiko-Matrix mit Mitigationen
  - "Was wir NICHT tun"-Sektion (Sterne-Bewertung, Reddit-Voting,
    Anki-Bashing, Klarname-Pflicht, > 30% Cut)
  - 7 konkrete Differenzierungs-Hebel gegen die 17 Konkurrenten

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 15:48:45 +02:00
Till JS
8a90cd296c docs(cards): competitor analysis Mai 2026
Recherche zu 17 Spaced-Repetition-Konkurrenten (Anki, Quizlet,
RemNote, Mochi, Brainscape, Memrise, SuperMemo, AnkiPro/Noji,
AnkiApp/AlgoApp, Quizgecko, Knowt, Wisdolia, Mnemosyne, Traverse,
Cerego, NeuraCache, AnkiHub) inkl. USP, Lizenz, Kosten, User-
Stimmen, Firma + Bedrohungs-Ranking.

Wichtigste Erkenntnisse für die Cards-Strategie:
  - Free Markdown+FSRS+Cloud-Sync ist eine objektive Marktlücke
    (Mochi: $5/mo Sync, RemNote: $8/mo, Brainscape: ~$20/mo).
  - AI-Karten-Generierung ist Tischeinsatz, kein USP mehr — Quizlet,
    Quizgecko, Knowt, RemNote, Wisdolia haben es alle.
  - Quizlet ist mit Trustpilot 1.4/5 verwundbar (Paywall-Walls);
    Quizlet-Refugee-Markt ist offen.
  - AnkiPro (Noji) und AnkiApp (AlgoApp) haben mit "Anki"-Brand-
    Sniping ihre Reputation verbrannt — Lehre für unsere Brand-
    Hygiene.

Empfohlene Hebel: (1) Free Sync explizit ausspielen, (2) Anki-
Migration als First-Class-Feature (eigene from-anki-Landing),
(3) Local-First PWA als Tech-Identität.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 15:28:02 +02:00
Till JS
c1423d2f72 fix(cards-web): missing static assets — sql-wasm-browser.wasm + PWA icons
Two 404s reported from prod:

1) sql.js's production-bundled loader requested /sql-wasm-browser.wasm
   but only sql-wasm.wasm was in static/. The browser bundle is its own
   file; copy both so the loader hits whichever variant the runtime
   picks. Without this the .apkg import dies before reading SQLite.

2) shared-pwa's manifest references pwa-192x192.png, pwa-512x512.png
   and apple-touch-icon.png. None existed → Chrome's manifest-icon
   validator failed and there was no usable icon for A2HS. Generated
   minimal indigo-stacked-cards PNGs at the three required sizes.

Note: the sync 402 reports in the same console output are a separate,
intentional behaviour — mana-sync's billing middleware blocks pull/
push when the user has no active sync subscription. No code change
needed; handled at the mana-credits layer.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 14:42:47 +02:00
Till JS
82db4eb794 feat(cards-web): Anki import carries images + audio along
Closes the gap from the first Anki-import pass: media files are now
uploaded alongside the cards instead of stripped.

Pipeline:
  • parse.ts: read the .apkg's `media` JSON manifest, build a
    filename → ZIP-entry map (Anki names files numerically; the
    manifest is the original-name lookup table). Returned alongside
    decks/cards as parsed.mediaByFilename.
  • import.ts: collectMediaRefs() walks every card field, gathers
    distinct <img src=…> and [sound:…] references — orphan media
    bundled in the .apkg are ignored. Referenced files upload to
    mana-media in 4 parallel workers, returning a filename → URL map.
  • parse.sanitizeAnkiHtml() now takes that map: <img src="X"> →
    <img src="<url>" alt="" />, [sound:Y] → <audio controls
    preload="metadata" src="<url>"/>. The remaining-tag stripper has
    a negative lookahead for img/audio/video/source so the new tags
    survive.
  • CardFace already renders <img>/<audio> via @mana/cards-core's
    DOMPurify config (the image/audio attachments commit added the
    allowlist), so the freshly-imported cards just work in the
    learn session.

UI:
  • AnkiImport gains an "uploading-media" stage with X / N progress
    bar between preview and card creation.
  • Preview now shows the media count, copy promise updated from
    "Bilder/Audio bleiben raus" to "Bilder + Audio werden mit
    übernommen".
  • Result block reports `N Medien übernommen · M fehlgeschlagen`.

Phase-2 ideas: per-user media scoping in mana-media; verify-then-
upload via /media/hash/:sha256 to skip duplicates from re-imports.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 14:25:43 +02:00
Till JS
daa1ef0513 feat(cards): image / audio attachments on cards via mana-media
Cards can now carry image, audio, and video attachments uploaded to
mana-media (the existing CAS service that already powers picture,
photos, wardrobe, etc.).

Pipeline:
  • lib/media/upload.ts wraps POST /api/v1/media/upload (multipart,
    app=cards). Returns { id, url, kind } with the right variant URL
    per kind (medium for images, full file for audio/video). 25 MB
    cap matches the website-upload pattern.
  • mediaToFieldSnippet(): drops Markdown ![]() for images; raw
    <audio>/<video controls> for the others — the user can later
    tweak attributes by hand.
  • Deck-detail card editor gains a "📎 Anhang" button next to every
    text field (front/back/cloze). Pick → upload → snippet appended
    to the field's content. Loading + error states surfaced inline.

Render:
  • @mana/cards-core/render.ts whitelists `audio`, `source`, `video`
    plus the `controls`/`preload`/`src`/`type` attrs in DOMPurify so
    inline media survives sanitization. Markdown's <img> already
    passed through the default policy.

Wiring:
  • hooks.server.ts injects __PUBLIC_MANA_MEDIA_URL__.
  • compose adds PUBLIC_MANA_MEDIA_URL_CLIENT=https://media.mana.how
    to cards-web.

Phase 2 ideas: drag-drop directly into the textarea, paste-from-
clipboard for screenshots, mana-media auth scoping per user, Anki
import bringing media files along.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 13:52:53 +02:00
Till JS
1f2206f10b feat(cards-web): PDF input for AI generator + study activity heatmap
PDF input:
  • lib/ai/pdf.ts wraps pdfjs-dist (Apache-2.0). Worker is bound via
    Vite's `?worker` suffix so the heavy parsing runs off-main-thread.
  • AiCardGen gains a "📄 PDF laden" button that pipes extracted text
    into the same textarea — the user can review/trim before
    generation. Reading state shows file name + page count + chars.

Heatmap:
  • queries.useStudyHeatmap(weeks=12) fills gaps with count=0 so the
    grid renders without holes.
  • StudyHeatmap.svelte: 7 rows × N columns (Monday-anchored), 5
    intensity buckets (neutral → emerald-300), tooltip per cell with
    date + count, legend strip.
  • Mounted on the dashboard between the deck list and the Anki import
    so the user lands on a quick visual progress receipt every visit.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 13:37:01 +02:00
Till JS
e3cca9e271 feat(cards-web): PWA installability + AI card generation from text
PWA:
  • SvelteKitPWA in vite.config via @mana/shared-pwa preset (name +
    theme color). Layout injects pwaInfo.webManifest.linkTag so
    Chrome's manifest pickup works → install icon + A2HS.
  • Service worker registers automatically (workbox auto-update); the
    cards-already-cached path now keeps working offline as long as the
    user has visited a deck once.

AI generation:
  • lib/ai/generate.ts — direct fetch to mana-llm /v1/chat/completions
    (OpenAI-compatible, mirrors playground module). System prompt
    constrains the model to a JSON array of {front, back}. Code-fence
    stripping handles models that wrap JSON in ```json blocks despite
    the prompt.
  • AiCardGen.svelte — text in, list of generated cards out, per-card
    checkbox preview, "X übernehmen" creates them via cardStore.
    Phase-1 lands them as basic cards.
  • Mounted on the deck-detail page next to "Neue Karte".

Wiring:
  • hooks.server.ts injects __PUBLIC_MANA_LLM_URL__.
  • compose adds PUBLIC_MANA_LLM_URL_CLIENT=https://llm.mana.how to the
    cards-web service.
  • app.d.ts gets vite-plugin-pwa virtual-module references so
    svelte-check can resolve `virtual:pwa-info`.

Phase 2: PDF/image input, mana-credits gating, model selector,
streaming preview as cards arrive.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 13:24:42 +02:00
Till JS
778e5a2ad7 chore(infra): drop status-page-gen from Mini, status.mana.how → GPU-Box tunnel
Phase 2e cleanup. status-page-gen + a dedicated nginx now run on the
GPU-Box (sparse repo clone provides the generator script + mana-apps.ts,
hourly git-pull via systemd timer). Container queries VictoriaMetrics
locally over docker-network ('http://victoriametrics:9090'), no public
vm.mana.how endpoint required — that hostname is also gone from the
GPU tunnel config (v25 → v26 effectively, removed in same PUT that
added status.mana.how).

DNS for status.mana.how now points at the mana-gpu-server tunnel.
Mini-tunnel ingress for it is removed; the previous 'mana-status-gen'
container on the Mini was stopped + rm'd.

Side benefit: closes the inode-stale-bind-mount bug that took status.
mana.how down for a few hours — single-file bind mounts on the Mini
break whenever the CD git-checkout rewrites the source file. The
GPU-Box mounts the same files but the systemd timer git-pulls in-
place, preserving the inode.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 13:22:20 +02:00
Till JS
22cce59c3a feat(cards-web): Anki .apkg import — first acquisition lever
Anki users have decks they hate the UI for but can't migrate. This
gives them a one-drop path: drop a .apkg on the homepage, see a
preview, confirm, the cards land in our DB and start syncing.

Pipeline (lib/anki/):
  • parse.ts — JSZip → sql.js (WASM SQLite) → walk Anki's three core
    tables (col, notes, cards). Models (col.models JSON) classify each
    note: type=0 → basic / basic-reverse, type=1 → cloze. Anki cards
    table has one row per generated learnable unit (basic-reverse = 2,
    cloze = N) — we dedupe at the note level since our model
    regenerates those automatically via reviewStore.ensureReviewsForCard.
  • import.ts — every Anki deck becomes one of ours (1:1, "::" → " / ");
    fields go through sanitizeAnkiHtml (drops <img>, [sound:], maps
    <b>/<i> to Markdown). Orphans land in a fallback "Anki-Import"
    deck.

UI: AnkiImport.svelte on the decks list — drag-drop or click,
parse → preview ("X Decks, Y Karten"), confirm → import. No images,
no audio, no review history (cards are FSRS-new on import) — those
are Phase 2.

Deps: sql.js 1.14, jszip 3.10, @types/sql.js. WASM blob copied into
static/ so SvelteKit serves it at /sql-wasm.wasm.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 13:02:29 +02:00
Till JS
009fb3589e feat(cards-web): streak indicator + per-deck due counts
Two small UI surfaces over data the backend already computes:
  • Header shows current streak (🔥 N) — useStreak() walks back through
    cardStudyBlocks until it finds a gap.
  • Decks list shows a "fällig"-pill per deck and a total in the header
    subline — useDueCountByDeck() joins cardReviews→cards once and
    groups by deckId.

Both queries live in lib/queries.ts and use Dexie liveQuery, so the
header refreshes automatically the moment a learn session ticks the
study block forward.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 12:45:04 +02:00
Till JS
96c06162e6 fix(cards-web): inject __PUBLIC_MANA_AUTH_URL__ on SSR — login was 404
createManaAuthStore from @mana/shared-auth-ui reads the auth backend
URL from window.__PUBLIC_MANA_AUTH_URL__ at runtime. Without the
injection it falls back to a relative URL, so signIn POSTs land at
cards.mana.how/api/v1/auth/login (SvelteKit 404, HTML body) instead
of auth.mana.how/api/v1/auth/login.

Adds a hooks.server.ts modeled after the mana-web one, but trimmed
to the two URLs the standalone app actually consumes today (auth +
sync). The values come from PUBLIC_MANA_AUTH_URL_CLIENT and
PUBLIC_MANA_SYNC_URL_CLIENT in docker-compose.macmini.yml.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 02:02:29 +02:00
Till JS
dd2e609545 fix(docker): COPY packages/cards-core in SvelteKit Dockerfiles
The cards-spinoff commit (0a544ac41) added @mana/cards-core as a
workspace dependency for apps/mana/apps/web but didn't update the
two Dockerfiles that COPY-and-pnpm-install the workspace into the
image. CD's --no-cache build for mana-web therefore failed at
`pnpm install` with ERR_PNPM_WORKSPACE_PKG_NOT_FOUND, leaving the
container on a stale pre-cleanup image whose ListView28 chunk still
referenced the dropped contextSpaces Dexie table — every mana.how
route 500'd.

Adding the COPY line to both files (the shared sveltekit-base layer
and the per-app layer that does a second pnpm install) makes the
package available to the workspace resolver and lets the build go
through.

Plus the Phase 2c-d doc updates that piled up today (Glitchtip
on dedicated GPU-box stack, gitignore for *_CREDENTIALS.md files).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 01:47:07 +02:00
Till JS
86f14bcc19 fix(cards-web): drop unused @mana/shared-crypto dep — not in sveltekit-base image
The Phase-1 crypto wrapper is a no-op stub; it never actually imports
@mana/shared-crypto. The dep was forward-looking for the Phase-2 vault
wiring, but it broke `pnpm install` inside the cards-web Dockerfile
because the sveltekit-base image only ships a curated subset of
@mana/* packages and shared-crypto isn't one of them.

The wiring will come back when the vault roundtrip is on, together
with a base-image bump.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 01:46:50 +02:00
Till JS
f94c047daa chore: silence pre-existing svelte-check a11y warnings
Pre-push hook runs svelte-check with --fail-on-warnings; nine
long-standing warnings in unrelated files (forms / website-blocks)
were blocking otherwise-clean pushes.

Each <label> here is a visual label whose control follows on the next
line — accessible to a screen reader through proximity but not through
a `for=`/`id` association. The state_referenced_locally cases capture
a prop on first render by design (re-running the hook on prop change
would be a different feature). The <nav role=tablist> is the existing
tab-strip semantic.

All seven sites get scoped svelte-ignore comments rather than functional
rewrites — the goal is to unblock CI, not redesign these components.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 01:34:36 +02:00
Till JS
0a544ac410 feat(cards): Phase-1 Spinoff — standalone cards.mana.how + cards-core extraction
Builds out the Cards spinoff end-to-end so the standalone app at
cards.mana.how shares its data layer with the in-mana cards module
through a single pure-utility package.

Why a spinoff and not just a deeper module: per the GUIDELINES, Cards
gets its own brand + URL while reusing mana-auth, mana-sync, and the
mana-credits/billing stack. The in-mana module under mana.how/cards
stays untouched as the integrated experience.

Phase 0 — mana-modul foundation
  • New tables cardReviews + cardStudyBlocks (Dexie v61) + plaintext
    classification in the crypto registry.
  • LocalCard learns a {type, fields} shape; legacy front/back columns
    kept as a back-compat mirror so older builds keep rendering.
  • FSRS v6 scheduler + Cloze parser + Markdown render pipeline.
  • UI in apps/mana/.../routes/(app)/cards/ gets a learn session
    (learn/[deckId]), 4-type card editor, due-counter, markdown lists.

Phase 1 — standalone (apps/cards/apps/web)
  • SvelteKit 2 + Svelte 5 + Tailwind 4, port 5180.
  • Own Dexie 'cards' DB with a slim 5-table schema.
  • Own sync engine: pending-changes hooks, 1 s push / 5 s pull against
    POST /sync/cards, server-apply with suppression to avoid ping-pong.
  • Auth-Gate via @mana/shared-auth-ui (LoginPage / RegisterPage).
  • Encryption hooks at every write/read/apply path, currently no-op
    stubs — flipping to real vault-backed AES-GCM is a single-file
    change in src/lib/data/crypto.ts.

Shared package — @mana/cards-core
  • Pulls types, cloze, card-reviews, FSRS wrapper, and Markdown
    renderer out of the mana module so both frontends import from one
    source. mana-modul keeps thin re-export shims so consumers don't
    need to change imports.
  • 19 vitest tests carried over from the mana module.

Server-side wiring
  • cards.mana.how added to mana-auth PRODUCTION_TRUSTED_ORIGINS and
    its CORS_ORIGINS env (sso-config.spec.ts stays green).
  • New cards-web container in docker-compose.macmini.yml (mirrors
    manavoxel-web pattern, 128m, depends on mana-auth healthy).
  • cloudflared-config.yml repoints cards.mana.how from :5000 (the
    unified mana-web container) to :5180. mana.how/cards is unchanged.

Cleanup
  • Removed an unrelated 2026-03/04 NestJS+Supabase+Expo experiment
    that was lingering under apps/cards/ (apps/landing, supabase/,
    .github/workflows, MANA_CORE_*.md, etc.). It predated this plan
    and would have confused future readers.

Validation
  • svelte-check on mana-web: 0 errors over 7697 files
  • svelte-check on cards-web: 0 errors over 3481 files
  • vitest on cards-core: 19/19 pass
  • pnpm check:crypto: 214 tables classified
  • bun test sso-config.spec.ts: 8/8 pass
  • vite build on cards-web: green

Not done in this commit (deliberate)
  • Real encryption (vault roundtrip) — Phase 2.
  • WebSocket-driven pull (5 s polling for now).
  • Mobile/landing standalone surfaces — Phase 2/3.
  • The actual production cutover on the Mac mini (build, deploy,
    cloudflared sync) — config is staged, deploy is a user action.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 01:20:43 +02:00
Till JS
950b822070 docs(cards): Phase-1 Spinoff-Guidelines — Core-Gameloop, Stack, Datenpfad
Verbindliche Leitlinien für den Cards-Spinoff (Karteikarten-App mit
Spaced Repetition). Status: Planungsphase, noch kein Code. Doc dient
als nicht-verhandelbarer Kontext für PRs sobald gebaut wird.

Wichtigste Festlegungen:
- Game-Dev-Prinzip: Phase 1 baut NUR den Core-Gameloop (Lernsession).
  KI-Generierung, Voice, Sharing, Stripe, Mobile, Dashboards = Phase 2+.
- Open-Source-only: jede Dep braucht OSI-konforme Lizenz.
- Zentrale Mana-Bausteine sind Pflicht, kein Eigen-Auth/Sync/Analytics.
- Daten-Contract mit dem bestehenden mana-Modul: gleiche Postgres-
  Tabellen (cardDecks/cards + neu cardReviews/cardStudyBlocks),
  appId='cards'. Schema-Änderungen rolled-out gemeinsam, nicht einseitig.
- FSRS v6 via ts-fsrs für Spaced-Repetition-Algorithmik.
- Phase 1 hat keinen eigenen Service — Lese-/Schreibpfad geht
  ausschließlich über IndexedDB → mana-sync → Postgres.

Definition of Done in §7 ist die Acceptance-Liste fürs MVP.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 20:39:26 +02:00
Till JS
1b579ab0b0 chore(mana-events): move from port 3065 to 3115 — collision with platform mana-media
Platform-Repo (Code/mana/) reserviert 3065 für mana-media; um Doppel-
Belegung zu vermeiden wandert mana-events (Public-RSVP / Event-Sharing)
auf 3115. Neuer Port-Block 311x ist unbenutzt und gehört strukturell
neben mana-mail (3042) bzw. die anderen 30xx Service-Ports.

Berührt jeden harden-coded 3065-Default — Server-Config, Webapp-Config,
SSR-Routes (rsvp/[token], status), Playwright-Webserver-Setup, e2e-Spec.
PUBLIC_MANA_EVENTS_URL in .env.development zieht beide Variablen mit.

PORT_SCHEMA.md trägt jetzt den Wechsel mit Datum + Begründung —
zukünftiges Ich soll nicht raten warum der Port aus der 30xx-Reihe
ausschert.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 20:38:46 +02:00
Till JS
546b94d472 feat(personas): move admin + internal endpoints from mana-auth to apps/api
Schließt die platform/product-split-Lücke: HEAD's apps/api/src/index.ts
referenziert seit dem Forms-M10d-Commit personasInternalRoutes /
personasAdminRoutes — die Implementierung lag aber noch nicht im Repo.
Build war strukturell broken bis hierhin.

Was wandert von mana-auth nach apps/api:

  apps/api/src/modules/personas/
    ├── schema.ts          — pgSchema('personas') mit personas /
    │                        persona_actions / persona_feedback;
    │                        userId ist plain text (Cross-DB-FK auf
    │                        mana-auth's auth.users geht nach Split nicht).
    ├── internal-routes.ts — service-key gated GET /due, POST /:id/actions
    │                        und POST /:id/feedback. Append-only +
    │                        idempotent über deterministische row-ids
    │                        (tickId-i-tool / tickId-module).
    └── admin-routes.ts    — admin-JWT gated CRUD; ruft mana-auth via
                             /api/v1/admin/users + /api/v1/auth/register
                             + /api/v1/internal/users/:id/persona-stamp
                             für den User-Lifecycle.

Persona-runner-Client zeigt jetzt auf apps/api:

  - config.ts: neues apiUrl-Feld (default http://localhost:3060,
    Env MANA_API_URL); authUrl bleibt für /api/v1/auth/login + spaces.
  - clients/mana-auth-internal.ts: drei Calls treffen jetzt
    /api/v1/personas/internal/* statt mana-auth's
    /api/v1/internal/personas/* — Datei-Name bleibt um Call-Site-Diff
    klein zu halten.
  - index.ts: ManaAuthInternalClient bekommt config.apiUrl statt authUrl.

Seed/Cleanup-Skripte:

  - --api= als bevorzugter Flag, --auth= als Legacy-Alias (cached
    Shell-History würde sonst hart brechen).
  - default http://localhost:3060, Env MANA_API_URL.
  - Endpoint-Pfade umgeschrieben:
      POST   /api/v1/admin/personas        → /api/v1/personas/admin
      DELETE /api/v1/admin/personas/:id    → /api/v1/personas/admin/:id

drizzle.config.ts: schema-Array + schemaFilter um 'personas' erweitert.
DB-push ist Pflicht-Schritt vor erstem Boot, sonst 42P01 auf /due.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 20:38:29 +02:00
Till JS
795b39e065 feat(forms): M10d headless wave-cron — server-worker + private internal_meta
Echter Server-Cron für recurring forms — wave-send läuft jetzt
unabhängig von Owner-Tab-State. Bisheriger M10c webapp-side scheduler
bleibt als Belt-and-suspenders aktiv (idempotent).

Architektur:
1. **Owner-private internal_meta auf unlisted snapshots**
   - Drizzle: neue jsonb-column `internal_meta` (Drizzle migration
     0001_internal_meta.sql).
   - public-routes.ts strippt sie strukturell — die explicit select()-
     projection enthält sie nicht (recipients + sender würden sonst
     via share-link leaken).
   - publish-route akzeptiert sie im Body, persistiert auf insert +
     update.
   - ALLOWED_COLLECTIONS um 'lasts' und 'forms' erweitert (war ein
     latenter Bug — formsStore.setVisibility('unlisted') hätte ohne
     diese Ergänzung 400 zurückbekommen; M4b lief vermutlich nie
     end-to-end durch).

2. **shared-privacy publishUnlistedSnapshot**
   - PublishUnlistedOptions erweitert um optionales `internalMeta`.
     Forwarded an /api/v1/unlisted/:collection/:recordId body.

3. **Webapp formsStore**
   - lib/wave-mail.ts: buildFormInternalMeta(form, broadcastSettings)
     baut den Owner-Private-Blob: { kind, recurrence: {frequency,
     recipientEmails, lastSentAt}, sender: {fromEmail, fromName,
     replyTo, legalAddress}, formMeta: {title, description} }.
     Returns null wenn Voraussetzungen fehlen (kein recurrence, keine
     recipients, fehlende broadcast-settings).
   - stores/forms.svelte.ts: setVisibility / regenerateUnlistedToken /
     setUnlistedExpiry laden broadcastSettings via Dexie + decrypt,
     bauen internalMeta, übergeben an publishUnlistedSnapshot. Form
     wird vor dem buildFormInternalMeta-Call dekrypted.

4. **mana-mail internal bulk-send route**
   - createInternalRoutes(accountService, broadcastOrchestrator,
     maxRecipients) — Signature erweitert.
   - Neue POST /api/v1/internal/mail/bulk-send: gleicher Payload-shape
     wie user-facing /v1/mail/bulk-send aber userId aus Body statt
     JWT. X-Service-Key-gate sitzt bei /api/v1/internal/* prefix.
     Audit-trail trägt principalId aus Body. Cap = 5000 (gleicher
     Wert wie user-facing).

5. **apps/api forms wave-worker**
   - 5-min setInterval, advisory-lock-gated (key 0x464f5257 'FORW').
   - Tick: select snapshots WHERE collection='forms' AND
     internal_meta IS NOT NULL AND revoked_at IS NULL. Filter auf
     kind='forms-recurrence' + isWaveDue (lastSentAt + period <= now,
     never-sent fires sofort). Pro fälligem snapshot: build HTML/text
     mailbody (mirror webapp wave-mail-render), POST an mana-mail
     internal-bulk-send mit X-Service-Key + userId, dann jsonb_set
     auf internal_meta.recurrence.lastSentAt. Per-snapshot errors
     werden als console.warn geloggt, Tick läuft weiter.
   - Disable via FORMS_WAVE_WORKER_DISABLED=true (tests / multi-
     replica deployments).
   - Wired in apps/api/src/index.ts neben startArticleImportWorker().

Trade-offs:
- internal_meta wird beim setVisibility/regenerate/setExpiry frisch
  aus broadcast-settings gebaut — wenn der User später broadcast-
  settings ändert (zB neuer fromEmail) muss er das Form re-publishen
  damit die snapshot-internal_meta aktualisiert wird. Doc-it: zukünftiger
  Patch könnte ein "settings drift"-Warning ins UI surfacen.
- Worker-Update von lastSentAt geht NICHT zurück in den webapp-form
  (settings.recurrence.lastSentAt ist verschlüsselt, server kann
  nicht schreiben). Owner-UI zeigt ältere lastSentAt von manuellen
  Sends; auto-cron-sends sind in den Server-Logs sichtbar. Future
  patch: GET /api/v1/forms/:id/recurrence-status (auth) gibt das
  snapshot.internal_meta zurück, UI rendert Auto-Cron-State.
- Webapp-side wave-scheduler (M10c) läuft parallel weiter — wenn
  Owner-Tab offen ist, kann beides feuern. Idempotent durch
  lastSentAt-check (weekly/monthly buckets), aber theoretisch könnte
  double-fire passieren wenn die Calls innerhalb 1ms versetzt sind.
  Real-world ignorierbar; future patch: scheduler liest jetzt
  internal_meta.lastSentAt vom server-side state.

apps/api buildet (1776 modules). mana-mail buildet (523 modules).
svelte-check 0 errors in forms/. Forms-Tests 70/70 unverändert.

DB-Migration 0001_internal_meta.sql muss manuell appliziert werden
(siehe feedback memory: hand-authored SQL migrations sind nicht in
pnpm setup:db).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 17:18:05 +02:00
Till JS
82dbfe6ee7 feat(forms): M7c auto-sync zu library + space_member
Schließt M7 ab: Form-Antworten erzeugen jetzt zusätzlich zu Kontakten
(M7a) und Event-RSVPs (M7b) auch Library-Einträge und Space-
Einladungen. feedback bleibt bewusst aus dem UI raus —
Architektur-Mismatch.

- types.ts:
  - AutoSyncConfig erweitert um optional `libraryKind`
    ('book'|'movie'|'series'|'comic') und `spaceMemberRole`
    ('member'|'admin', default 'member').
  - Form domain-type bekommt `spaceId: string` (war intern auf
    LocalForm vorhanden, wird jetzt durch toForm exposed). Brauchen
    wir, weil space_member-Invite den organizationId der Form-Owner-
    Space schicken muss.
- queries.ts toForm: spaceId aus LocalForm.spaceId mappen, Fallback ''.
- lib/auto-sync.ts:
  - buildLibraryEntryFromAnswers (pure): mappt title / creators /
    year / review. creators-strings werden auf , ; \n gesplittet
    (multi-author-mapping). year bounds-checked 1900..2100.
  - buildSpaceInviteFromAnswers (pure): findet das erste Form-Feld
    mit mapping='email', validiert per Loose-Regex, gibt
    {email}-payload zurück.
  - dispatchTarget('library'): wirft wenn libraryKind fehlt; ruft
    libraryEntriesStore.createEntry mit kind+title+creators+year+
    review.
  - dispatchTarget('space_member'): wirft wenn form.spaceId fehlt;
    POSTet an /api/auth/organization/invite-member über authFetch
    mit role aus cfg.spaceMemberRole. Returns invitation.id oder
    Fallback `invite:<email>` (better-auth response-shape kann je
    nach Version variieren).
  - dispatchTarget('feedback') wirft jetzt mit klarem Kommentar:
    architektur-Mismatch — feedback ist zentraler Public-Hub,
    nicht per-Owner-Daten. UI filtert die Option raus.
  - applyAutoSync reicht `form` durch zu dispatchTarget (statt nur
    cfg/answers), damit Space-Invite die spaceId hat.
- lib/auto-sync.spec.ts: 9 weitere Tests (4 library: title/creators/
  year-bounds/empty, 5 space: extract/malformed/non-mapped/no-mapping/
  non-string). Total Forms-Tests jetzt 70/70.
- SettingsPanel:
  - SUPPORTED_TARGETS auf [contacts, events, library, space_member]
    erweitert. feedback erscheint NICHT — Type bleibt für Legacy-
    Daten erhalten, aber UI bietet ihn nicht an.
  - Library-Block: kind-picker (book/movie/series/comic) +
    LIBRARY_KEYS-Mapping (title, creators, year, review).
  - Space-Member-Block: role-picker (member/admin) +
    SPACE_KEYS-Mapping (nur 'email'). Hint "mappe genau ein Feld".
  - setMappingFor preserved jetzt alle target-spezifischen Felder
    (targetId, libraryKind, spaceMemberRole) damit ein Mapping-Edit
    nicht den Rest droppt.
- 25 neue i18n-Keys × 5 Locales (autoSync.targetLibrary/SpaceMember,
  libraryKindPicker/libraryKind.*, libraryKey.*, libraryHint,
  spaceMemberRolePicker/RoleMember/RoleAdmin/Hint/MappingHint).
  Parity 6515 keys aligned.

Trade-offs:
- Library-Auto-Sync erzeugt einen Eintrag pro Antwort. Deduplizierung
  (gleicher Titel kommt schon vor) bleibt manueller User-Workflow —
  Autosync hat kein Wissen über die existierenden Bibliothek.
- Space-Invite-Flow läuft asynchron: Submitter kriegt Mana-Mail mit
  Invite-Link, klickt → wird Member. Bei nicht-Mana-Identitäten muss
  der Submitter erst registrieren. Owner sieht den Pending-State unter
  /spaces.
- feedback: bewusst nicht implementiert. Form-Antworten als public-
  feedback einzukippen wäre semantisch falsch (Owner sammelt für
  sich, nicht zur Veröffentlichung).

Forms-Tests 70/70. svelte-check 0 errors. apps/api unverändert.
i18n-parity 6515 keys × 5 locales aligned.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 16:27:40 +02:00
Till JS
6d67db48d5 feat(forms): M9b conversation LLM-extract — free-text → typed Antwort
Killer-Feature für den Conversation-Mode (M9): User kann auf
choice/yes_no/rating-Feldern in eigenen Worten antworten ("ich nehme
den zweiten Vorschlag" / "klar bin ich dabei" / "so 4 von 5"), ein
LLM mappt das auf die strikte Option-ID / boolean / Integer.

- apps/api/modules/forms/public-routes.ts: neuer
  POST /api/v1/forms/public/:token/conversation/extract Endpoint.
  Rate-limited (30/min/token + 60/min/IP — Owner-Side-Costs für haiku
  trotz unauthenticated-Pfad). freeText hard-cap 1000 Zeichen.
  Token-resolve via unlistedSnapshots, fieldId muss im publish-Schema
  existieren. Dispatch:
    - text/email/number/date: passthrough (free-text IST die Antwort)
    - single_choice/multi_choice/yes_no/rating: mana-llm haiku-Call
      mit field-spezifischem System-Prompt + JSON-only-Output, Parser
      validiert Option-IDs gegen das Schema (Hallucination-Schutz).
  Response { extracted, confidence: 'high' | 'low', alternatives? }.
  confidence='low' wenn LLM unsicher → Client zeigt Warnung im
  Preview-Block, User kann manuell auswählen.

- ConversationFormView: collapsible <details>"Lieber in eigenen
  Worten antworten?"-Block unter den quick-reply-Buttons aller
  choice/yes_no/rating-Felder. User tippt Free-Text → "Verstehen"
  ruft endpoint → Preview-Karte mit der erkannten Antwort
  (teal=high-confidence, amber=low-confidence) → "Übernehmen" oder
  "Abbrechen". commitExtract löst setAnswerAndAdvance aus, läuft
  über den selben Pfad wie quick-reply-Klick.

Schema-Validierung im Parser:
  - single_choice: optionId muss in field.options sein, sonst null
  - multi_choice: filtert nur valide IDs raus, Array kann leer sein
  - yes_no: nur true/false/null erlaubt
  - rating: round(value), bounds-check 1..ratingScale

LLM-Call:
  - model claude-haiku-4-5 (cheapest)
  - temperature 0 (deterministisch)
  - maxTokens 200 (JSON-Output ist klein)
  - Markdown-code-fence-Strip für robustes JSON-Parsing

Trade-offs:
  - Public-Endpoint = ungated LLM-Spend für Form-Owner. Rate-Limits
    + freeText-Cap mitigaten Spam, aber 30 Calls/min × 200 tokens =
    moderate Kosten pro Form. Owner sollte das im Hinterkopf haben.
  - Confidence='low' eskaliert zur User-Sichtbarkeit, bricht aber
    nicht den Flow — User kann übernehmen oder abbrechen.

Forms-Tests 61/61 unverändert (extract braucht Live-LLM für E2E,
absichtlich kein vitest-Mock). svelte-check 0 errors. apps/api
buildet (1772 modules).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 15:31:25 +02:00
Till JS
c1ed45e574 feat(forms): M9 form-as-conversation — Typeform-Chat-Render
Public-Form-Variante als linearer Chat-Flow (M9, KF4 aus dem Plan).
Owner wählt im Builder zwischen "klassisch" (alle Felder
gleichzeitig, M4b-View) und "conversation" (eine Frage nach der
anderen, mobile-friendly).

LLM-gestützte free-text → typed-Antwort-Extraktion (z.B. "Ich nehme
den zweiten Vorschlag" → option-id) bleibt M9b — die jetzige
Implementierung nutzt typed widgets pro Field-Type für einen
deterministischen ersten Wurf.

- types.ts: FormSettings.experience: 'classic' | 'conversation'
  (default 'classic'). Reist im Settings-Blob mitverschlüsselt.
- data/unlisted/resolvers.ts: buildFormBlob whitelistet experience
  ins public-snapshot — nur ein Enum, kein PII.
- SharedFormView (M4b) bleibt der classic-Renderer.
- ConversationFormView (neu, ~600 Zeilen):
  - Linear: stepIndex zeigt durch das Visible-Subset von
    resolveVisibleFields (gleicher branching-resolver wie classic).
  - Pro Step: question-bubble + Field-Type-spezifischer Widget:
    short_text/long_text/email/number → Free-Text-Input mit
    Enter-Submit, date → datepicker, yes_no → 2 Quick-Reply-Buttons,
    rating → Skala-Buttons, single_choice → vertikale
    Quick-Reply-Liste, multi_choice → Toggle-Chips + "Weiter",
    section → "Verstanden"-Step, consent → Yes(/Nein optional).
  - Answer-Bubble nach Submit; "← Vorherige" droppt das letzte
    Q/A-Pair und löscht die Antwort, damit der branching-resolver
    den nächsten Step neu berechnet.
  - Final-Step: Submitter-Name+Email (optional) + bestehender
    POST /api/v1/forms/public/:token/submit.
  - Progress-Bar oben, "via Mana Forms"-Footer.
- routes/share/[token]/+page.svelte: dispatched bei
  collection='forms' auf experience-Wert — 'conversation' →
  ConversationFormView, sonst SharedFormView.
- SettingsPanel: dropdown unter den Anonymous-Toggle, dt./eng./es./
  fr./it. (15 neue i18n-Keys × 5 Locales = 6498 keys aligned).

Trade-offs:
- Branching reagiert pro-step: wenn der User auf einer späteren Frage
  zurückgeht und die Quelle einer Hide-Regel ändert, fällt der
  zwischenzeitlich gerenderte Pfad weg — eventuell taucht eine neue
  Frage als "next" auf. Dokumentiert als linearer "tree-walk" statt
  WYSIWYG-Snapshot, üblich für Typeform-Klone.
- Ohne LLM-Extraction (M9b) sind die Quick-Replies nicht fluide; das
  ist intent: deterministic > magical for first ship.

Forms-Tests 61/61. svelte-check 0 errors.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 15:22:56 +02:00
Till JS
7d8e562091 feat(forms): M10c auto-scheduler + mana-mail bulk-send
Headless wave-send während die Mana-Tab offen ist (M10c). Echter
Server-Cron (mana-ai oder mana-notify) bleibt M10d.

- lib/wave-mail.ts: sendWaveViaBulkMail POSTet an
  /api/v1/mail/bulk-send mit form-derived payload (subject =
  "{title} — {cohort}", htmlBody mit Inline-CSS + share-link-Button +
  Impressum + Abmelden-Footer, textBody plain). campaignId =
  form-{formId}-{cohort} (idempotent über Retries). Wirft
  WavePreconditionError wenn fromEmail/fromName/legalAddress fehlen
  oder Empfänger leer sind — Caller fällt auf mailto-Bridge zurück.
- lib/wave-scheduler.ts: singleton setInterval (5 min,
  Page-visibility-aware — pausiert bei hidden), Tick scant formTable,
  dekrypt-aware, filtert published+token+recurrence+recipients+due,
  ruft sendWaveViaBulkMail + markWaveSent. Wirft nicht — per-form
  errors werden als console.warn geloggt, Schedule läuft weiter.
  Initial-tick 30s nach start damit Montag-Morgen-Welle nicht
  5 Minuten warten muss. start/stop idempotent.
- BuilderView.sendWave: versucht erst bulk-send (wenn
  broadcasts-Settings configured = defaultFromEmail + legalAddress),
  fällt auf mailto-Bridge zurück (M10b) wenn precondition fehlt.
  waveError-state für non-precondition-Fehler. Confirm-Dialog hat
  jetzt zwei Texte (confirmBulk vs confirmSend).
- (app)/+layout.svelte: startWaveScheduler() neben startMissionTick()
  beim Auth-Ready, stopWaveScheduler() im onDestroy.
- 5 neue i18n-Keys × 5 Locales (forms.builder.recurrence.confirmBulk).
  Parity 6495.

Trade-offs:
- Auto-Tick nur während Tab offen — headless Cron via mana-ai-Mission
  oder mana-notify-Worker bleibt M10d.
- Bulk-send bypasst die Campaign-Pipeline der broadcasts (kein
  Audience-Filter, kein Rich-Editor) — ist Absicht für Forms-Wellen
  als kurze transactional notifications.
- DSGVO: Impressum + Abmelden-Footer ({{unsubscribe_url}} wird vom
  Orchestrator pro Empfänger ersetzt) sind Pflicht via
  WavePreconditionError; Mailto-Fallback hat das nicht — User-Risk.

Forms-Tests 61/61 unverändert. svelte-check 0 errors.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 15:15:12 +02:00
Till JS
664b8241d0 feat(forms): M10b wave-send — Empfänger + Manueller Trigger + Due-Banner
Erste nutzbare Versand-Schicht für wiederkehrende Forms (M10b).
Vollautomatisches Cron via mana-ai/mana-notify bleibt M10c — die
Owner-action-Pipeline funktioniert standalone und nutzt den
existierenden mailto:-Pfad als Bridge.

- types.ts: RecurrenceConfig erweitert um `recipientEmails?: string[]`
  (max 50, mailto-URL-realistisch) und `lastSentAt?: string`.
- lib/wave.ts (pure):
  - nextWaveDueAt(recurrence): lastSentAt + 7d (weekly) oder
    +1 month UTC (monthly). Never-sent → startedAt oder Epoch.
  - isWaveDue(recurrence, now): boundary-inclusive (= ist auch fällig).
  - buildWaveMailto({recipients, subject, body}): URL-encoded
    mailto:?bcc=...&subject=...&body=... Keine BCC wenn empty.
  - parseRecipientEmails(raw): newline/comma/semicolon-getrennt,
    Email-Regex-validiert, case-insensitive deduped (erste Casing
    bleibt). Drops invalid silent.
- lib/wave.spec.ts: 20/20 grün — month-end-overflow, boundary-instant,
  never-sent, dedup, mixed-separators.
- formsStore.markWaveSent(id, sentAt?): liest current settings,
  patch-tt lastSentAt, encrypted-aware update (settings ist
  encrypted-blob).
- SettingsPanel: bei aktiver recurrence Empfänger-Textarea (commit on
  blur via parseRecipientEmails, slice 50, count-feedback) +
  lastSent-Hint.
- BuilderView (visibility-section): wave-block mit fällig-Banner
  (orange wenn isWaveDue) oder nextWaveAt-Hint, "Welle jetzt
  senden"-Button (disabled bis recurrence + unlistedToken +
  recipients alle stimmen). Click → confirm → buildWaveMailto +
  window.open + markWaveSent. Subject + Body via i18n-Keys.
- 13 neue i18n-Keys × 5 Locales (recipientsLabel/Count, lastSent,
  waveDue, nextWaveAt, sendNow, needsUnlisted/Recipients,
  mailSubject/Body, confirmSend). Parity 6494.

Total Forms-Tests jetzt 61/61 (5 csv + 11 branching + 10 auto-sync +
15 cohort + 20 wave). svelte-check 0 errors.

Use-Case: Wöchentlicher Team-Pulse-Check. Recurrence='weekly' setzen,
3 Team-Emails ins Textarea, am Montag-Morgen das fällig-Banner
sehen, "Welle jetzt senden" → Mail-Programm öffnet sich mit
BCC-Liste + Share-Link. Antworten kommen mit cohort='2026-W19' rein,
ResponsesView gruppiert sie.

M10c open: Cron-Worker für headless wave-send via mana-mail
bulk-send. Owner-tab muss heute offen sein, damit der Send-Klick
fällt.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 14:56:28 +02:00
Till JS
38e0ae2ff8 feat(forms): M10a wiederkehrende Forms — cohort-tagging + UI
Plan: docs/plans/forms-module.md M10. Erste Hälfte (Datenshape +
Submit-Stamping + ResponsesView-Filter); cron-basierter
Broadcast-Versand bleibt M10b.

- types.ts:
  - FormSettings.recurrence?: { frequency: 'weekly'|'monthly',
    startedAt?, sendVia? }. Im Settings-Blob mitverschlüsselt.
  - LocalFormResponse.cohort?: string. Plaintext (sortier-/filter-
    Feld, kein PII). FormResponse.cohort: string|null.
  - queries.toFormResponse maps null durch.
- lib/cohort.ts (pure):
  - computeCohort(iso, frequency) → "YYYY-WNN" (ISO-Woche, padded für
    lex-sort) | "YYYY-MM". Behandelt Year-Boundary korrekt
    (2027-01-01 → 2026-W53).
  - cohortLabel(cohort, frequency, now=new Date()) → "Diese Woche" /
    "Letzte Woche" / "KW NN / YYYY" für weekly; "Dieser Monat" /
    "Letzter Monat" / "Monatsname YYYY" für monthly. Falsche Keys
    fallen auf Roh-Wert zurück (inkl. Bounds-Check 1-12 für month).
  - sortCohortsDesc(): immutable, lex-sort newest-first.
  - 15/15 Vitest-Cases (compute, label-current/prev/older, malformed,
    sort, no-mutation).
- buildFormBlob (data/unlisted/resolvers.ts) zieht recurrence.frequency
  in den Public-Snapshot. Frequenz ist nicht-sensitive Metadaten —
  kann öffentlich diskoverabel sein, der Server braucht es zum
  cohort-stamping.
- apps/api/src/modules/forms/public-routes.ts:
  - Inline computeCohort (Mirror der pure-Funktion — apps/api kann
    apps/mana nicht importieren).
  - Bei jeder Submission: wenn snapshot.recurrence.frequency gesetzt,
    stamp data.cohort = computeCohort(submittedAt, freq). Cohort
    landet im sync_changes-Insert und reist zum Owner-Client.
  - errorResponse-Aufrufe: { code, field } → { code, details: { field } }
    weil der Hono-helper die details-Struktur erzwingt (M3b
    type-safety-fix mitgenommen).
- SettingsPanel: Recurrence-Block mit Dropdown (Einmalig / Wöchentlich
  / Monatlich) + Hint-Erklärung. Setzt startedAt automatisch beim
  Aktivieren.
- ResponsesView: cohort-chip-bar oberhalb der Status-Tabs. Zeigt
  "Alle Wellen" + ein Chip pro distincter cohort (newest-first), je
  mit Anzahl. Klick filtert die responses-Liste — alle bestehenden
  Status-Counts berechnen sich auf den cohort-gefilterten Set, also
  CSV-Export + Detail-Modal-Status-Pills bleiben konsistent.
- 8 neue i18n-Keys × 5 Locales (forms.builder.recurrence.* +
  forms.responses.cohort.all). i18n-parity 6483 keys aligned.

Tests: 41/41 forms-tests grün (5 csv + 11 branching + 10 auto-sync +
15 cohort). svelte-check 0 errors. apps/api buildet.

M10b open: cron-worker in mana-ai oder mana-notify, der
recurrence-Forms scant + Share-Link via broadcasts an die
Recipients-Liste sendet. Schemata stehen, Pipeline fehlt noch.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 14:09:55 +02:00
Till JS
cb9d79d2c9 feat(articles): finale polish — Help-Eintrag + intentional-console + 5-Locale i18n (#16,#10,#17)
#16 — Help-content entry for articles
   The articles module had no entry in `app-registry/help-content.ts`
   so the (?) icon in ModuleShell rendered an empty body. Added
   description + 9 features + 4 tips covering Reader-View, Highlights,
   Bookmarklet, Share-Target, and the new bulk-import flow with the
   "Erneut speichern" rescue path for cookie-walled hits.

#10 — Console statements marked intentional
   The 5 console.log/warn/error calls in import-worker.ts (boot,
   tick errors, GC summary, stale-recovery sweep) were ESLint
   warnings. They're intentional operational logs — same pattern as
   services/mana-ai/src/cron/tick.ts. Added file-level
   `/* eslint-disable no-console */` with a comment explaining the
   pattern + that structured signal lives in Prometheus counters.

#17 — Full 5-locale i18n for the bulk-import UI
   New `articles.import` namespace with 50 keys covering the
   BulkImportForm, JobsList, JobDetailView, and AddUrlForm bulk-link.
   All five locales translated by hand:
     - de.json (canonical, mirrors the original hardcoded German)
     - en.json (English)
     - fr.json (French — bookmarklet → "bookmarklet HTML du navigateur")
     - it.json (Italian — bookmarklet → "bookmarklet HTML del browser")
     - es.json (Spanish — bookmarklet → "bookmarklet HTML del navegador")
   Plural-aware `consent_hint_body` uses ICU plural format
   (`{n, plural, one {…} other {…}}`) so single-vs-multiple article
   counts read naturally in each language.
   The consent-hint sentence is split into 3 keys (body/link/after-link)
   so the link text appears mid-sentence rather than tacked on after.
   Components converted to `$_('articles.import.*')` everywhere — no
   remaining hardcoded strings in the bulk-import UI.

i18n parity validator: 76 namespaces × 5 locales — 6477 canonical
keys, all aligned. validate:i18n-hardcoded baseline unchanged for
articles files (broadcasts/notes/timeline failures are user-WIP).

Plan: docs/plans/articles-bulk-import.md.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 13:39:50 +02:00
Till JS
48bd09188c feat(forms): FormsWidget — Workbench-Karte mit Stats + letzte Forms
Heimstart-Karte für das Forms-Modul, parallele zu BroadcastsWidget /
InvoicesOpenWidget:

- modules/forms/widgets/FormsWidget.svelte: 3-Spalten-Stats
  (veröffentlicht / Entwurf / Antworten total + "+N/7T" delta für
  letzte 7 Tage), bis zu 2 zuletzt aktualisierte Forms mit
  Status-Punkt (grün=published, grau=sonst) + Response-Count +
  relative-Zeit, "+N weitere"-Link wenn mehr als 2 Forms existieren.
  Empty-State mit "+ Erstes Formular bauen". Live aus Dexie via 2
  parallele liveQuery-Subs (forms + formResponses).
- types/dashboard.ts: WidgetType-Union erweitert um 'forms';
  WIDGET_REGISTRY-Eintrag mit defaultSize 'medium' + 📋-Icon.
- components/dashboard/widget-registry.ts: FormsWidget importiert +
  in widgetComponents map registriert.
- 5 Locales × 2 dashboard-Keys (forms.title + forms.description).

App-Registry-Eintrag für /forms in app-registry/apps.ts existiert
bereits (Parallel-Session). FormsWidget ist die _aggregierte_
Heimstart-Variante; der app-registry-Eintrag mountet die ListView
direkt als Modul-Card.

i18n-parity 6417 keys aligned. svelte-check 0 errors in
modules/forms/widgets/.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 02:50:16 +02:00
Till JS
e37c008a7a chore(articles): polish pass — schema cleanup, MAX cap, filters, docs (#8,#9,#13,#15,#18,#20)
Polish-pass on top of the bulk-import rollout. Five contained items.

#8 + #9 — Dexie v60 schema cleanup
   - Drop articleImportJobs.leasedBy + .leasedUntil. They were defined
     on the original v57 schema as a soft-lease handshake, but the
     worker uses pg_try_advisory_xact_lock and never wrote them.
     Local-* type + projection row stripped.
   - Drop the standalone `state` index on articleImportItems.
     [jobId+state] covers the worker's hot query; the state-solo
     index had no call site.
   Both changes lossless — Dexie just removes the column declarations
   from new rows; existing rows still carry the dead nulls (zombies)
   until the next full row-rewrite. Not worth a hard migration for
   two never-written columns.

#15 — MAX_URLS_PER_JOB hard cap (200)
   articleImportsStore.createJob() throws if the URL list exceeds the
   cap. BulkImportForm surfaces the limit in the live counter chip
   and disables the submit when over. The worker can chew through any
   N, but at high counts the UI gets unwieldy (no virtualisation) and
   wall-clock duration climbs into multi-hour. 200 is a pragmatic
   ceiling — Pocket-export dumps average 50–150.

#13 — Filter-Tabs in JobsList
   Pill-style tabs above the list: Alle / Aktiv / Fertig / Mit Fehlern,
   each with the row count. Disabled when the bucket is empty so the
   user only sees actionable filters. The "Mit Fehlern" filter
   (errorCount > 0) is the most valuable for triage.

#18 — apps/mana/CLAUDE.md
   - Articles row added to the Tool Coverage table (5 propose +
     1 auto, including the new auto-policy import_articles_from_urls).
   - New "Articles bulk-import" section after the AI Workbench part:
     pipeline diagram, table list, actor + metrics + cap pointers.

#20 — ARTICLES_IMPORT_WORKER_DISABLED env var documented
   New row under "Mana API — Articles Bulk-Import Worker" in
   docs/ENVIRONMENT_VARIABLES.md.

Plan: docs/plans/articles-bulk-import.md.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 02:42:46 +02:00