Phase 0+1: Repo-Skelett für Cards-Greenfield
Strategie B (beschlossen 2026-05-08): Cards wird als eigenständige
föderierte App neu gebaut, ohne Code-Übernahme aus mana-monorepo.
Skelett enthält:
- apps/api: Hono+Bun mit /healthz, /version, Manifest-Endpoint, leere
pgSchema('cards'), Drizzle-Config, erstem Vitest
- apps/web: SvelteKit 2 + Svelte 5 (runes), Vite auf 3082
- packages/cards-domain: Pure-TS, CardType-Discriminated-Union,
SubIndex-Granularität für Reviews, Future-CardType-Set vorbereitet
- infrastructure/docker-compose.yml: Postgres 16 auf 5435
- app-manifest.json: v1.0.0, Verein-owned, beta-tier
- .github/workflows/ci.yml
- docs/LESSONS_FROM_MANA_MONOREPO.md (Read-Day-Output, 15 Lehren)
Pre-Flight für Phase 2 (Auth-Föderation): DNS cardecky.mana.how,
GitHub-Repo mana-ev/cards, Cards-App-Registrierung in mana-auth,
NPM_AUTH_TOKEN für Verdaccio.
Plan: mana/docs/playbooks/CARDS_GREENFIELD.md
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
commit
8605b1b517
37 changed files with 1197 additions and 0 deletions
36
.env.example
Normal file
36
.env.example
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
# Cards-Greenfield — Local Dev Defaults
|
||||
# Copy to .env (git-ignored) and adjust as needed.
|
||||
|
||||
# === API + DB ===
|
||||
NODE_ENV=development
|
||||
DATABASE_URL=postgresql://cards:cards@localhost:5435/cards
|
||||
CARDS_API_PORT=3081
|
||||
CARDS_WEB_PORT=3082
|
||||
|
||||
# === Plattform-Endpunkte ===
|
||||
# Lokal mit Mana-Plattform-Stack auf Default-Ports laufend
|
||||
MANA_AUTH_URL=http://localhost:3001
|
||||
MANA_CREDITS_URL=http://localhost:3061
|
||||
MANA_MEDIA_URL=http://localhost:3065
|
||||
MANA_SHARE_URL=http://localhost:3072
|
||||
MANA_LINKS_URL=http://localhost:3073
|
||||
MANA_EVENTS_URL=http://localhost:3074
|
||||
MANA_MCP_URL=http://localhost:3069
|
||||
MANA_SEARCH_URL=http://localhost:3076
|
||||
|
||||
# === App-Identität (von mana-auth ausgegeben) ===
|
||||
# Cards-Service-Key für interne Cross-Service-Calls.
|
||||
# Production: aus mana-auth.apps-Tabelle bei App-Onboarding.
|
||||
CARDS_APP_SERVICE_KEY=msk_dev_change_me
|
||||
|
||||
# === JWT-Validation ===
|
||||
# JWKS-URL für User-JWT-Verification (5-Min-Cache).
|
||||
MANA_AUTH_JWKS_URL=http://localhost:3001/.well-known/jwks.json
|
||||
|
||||
# === Verdaccio (für pnpm install) ===
|
||||
# Lokal: npm login --registry=https://pkg.mana.how
|
||||
# Production: GitHub Secret
|
||||
NPM_AUTH_TOKEN=
|
||||
|
||||
# === MinIO (über mana-media) ===
|
||||
CARDS_MEDIA_BUCKET=cards-storage
|
||||
46
.github/workflows/ci.yml
vendored
Normal file
46
.github/workflows/ci.yml
vendored
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
name: CI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
branches: [main]
|
||||
|
||||
jobs:
|
||||
validate:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: 9.15.9
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20
|
||||
cache: 'pnpm'
|
||||
|
||||
- name: Setup Bun (für apps/api)
|
||||
uses: oven-sh/setup-bun@v2
|
||||
with:
|
||||
bun-version: latest
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
env:
|
||||
NPM_AUTH_TOKEN: ${{ secrets.NPM_AUTH_TOKEN }}
|
||||
|
||||
- name: Type-check
|
||||
run: pnpm run type-check
|
||||
|
||||
- name: Test
|
||||
run: pnpm run test
|
||||
|
||||
- name: Format check
|
||||
run: pnpm run format:check
|
||||
|
||||
- name: Validate manifest
|
||||
run: pnpm run validate:manifest
|
||||
65
.gitignore
vendored
Normal file
65
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,65 @@
|
|||
# Dependencies
|
||||
node_modules/
|
||||
.pnpm-store/
|
||||
|
||||
# Build outputs
|
||||
dist/
|
||||
build/
|
||||
.turbo/
|
||||
.svelte-kit/
|
||||
|
||||
# Environment files
|
||||
.env
|
||||
.env.local
|
||||
.env.*.local
|
||||
.env.secrets
|
||||
!.env.development
|
||||
!.env.example
|
||||
!.env.secrets.example
|
||||
|
||||
# IDE
|
||||
.idea/
|
||||
.vscode/
|
||||
*.swp
|
||||
*.swo
|
||||
*~
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# Logs
|
||||
logs/
|
||||
*.log
|
||||
npm-debug.log*
|
||||
pnpm-debug.log*
|
||||
|
||||
# Testing
|
||||
coverage/
|
||||
.nyc_output/
|
||||
playwright-report/
|
||||
test-results/
|
||||
|
||||
# TypeScript
|
||||
*.tsbuildinfo
|
||||
|
||||
# Drizzle compiled config
|
||||
drizzle.config.js
|
||||
drizzle.config.d.ts
|
||||
|
||||
# Cache
|
||||
.cache/
|
||||
.eslintcache
|
||||
.prettiercache
|
||||
|
||||
# Package manager locks (keep only pnpm)
|
||||
package-lock.json
|
||||
yarn.lock
|
||||
|
||||
# Mac Mini deploy
|
||||
.env.macmini
|
||||
ssh-key-command.txt
|
||||
|
||||
# Volumes for local docker-compose
|
||||
.volumes/
|
||||
.local/
|
||||
7
.npmrc
Normal file
7
.npmrc
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
# Cards consumes @mana/* shared packages from the Verein's private
|
||||
# Verdaccio registry (pkg.mana.how on Mac Mini).
|
||||
# Local dev: run `npm login --registry=https://pkg.mana.how` once;
|
||||
# the resulting ~/.npmrc token is read via $NPM_AUTH_TOKEN substitution.
|
||||
# Production CI: set NPM_AUTH_TOKEN in workflow secrets.
|
||||
@mana:registry=https://pkg.mana.how/
|
||||
//pkg.mana.how/:_authToken=${NPM_AUTH_TOKEN}
|
||||
15
.prettierrc.json
Normal file
15
.prettierrc.json
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
{
|
||||
"useTabs": true,
|
||||
"singleQuote": true,
|
||||
"trailingComma": "es5",
|
||||
"printWidth": 100,
|
||||
"plugins": ["prettier-plugin-svelte"],
|
||||
"overrides": [
|
||||
{
|
||||
"files": "*.svelte",
|
||||
"options": {
|
||||
"parser": "svelte"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
133
CLAUDE.md
Normal file
133
CLAUDE.md
Normal file
|
|
@ -0,0 +1,133 @@
|
|||
# CLAUDE.md — cards repo
|
||||
|
||||
Guidance for Claude Code when working in this repository.
|
||||
|
||||
## Was dieses Repo ist
|
||||
|
||||
**Cards** — eigenständige föderierte Spaced-Repetition-App des
|
||||
Vereins **mana e.V.** Greenfield-Build (Strategie B, beschlossen
|
||||
2026-05-08). Cards ist eine **Peer-App** im mana-Ökosystem: redet über
|
||||
mana-share/-mcp/-search/-events/-credits mit Memoro, Who und der
|
||||
Mana-Successor-App, hat aber eigene DB, eigenes Frontend, eigene
|
||||
Deployment-Pipeline.
|
||||
|
||||
```
|
||||
HTTP/JWT ┌──────────────┐
|
||||
mana e.V. ◄─────────────── │ cards/ │ Postgres `cards`
|
||||
Plattform │ (this repo) │ cardecky.mana.how
|
||||
(auth/credits/share/etc.) └──────────────┘
|
||||
```
|
||||
|
||||
## Status
|
||||
|
||||
**Phase 0 — Repo-Skeleton.** Vollständiger Plan in
|
||||
`mana/docs/playbooks/CARDS_GREENFIELD.md` (im Plattform-Repo).
|
||||
Nächste konkrete Schritte: Read-Day, dann Phase 1 (Auth-Föderation).
|
||||
|
||||
## Konventionen
|
||||
|
||||
- **pnpm 9.15.x**, Node 20+, Turborepo 2.x
|
||||
- **Tabs** für Indent, **single quotes**, 100-col Prettier
|
||||
(`.prettierrc.json` ist die Source of Truth)
|
||||
- **Stack:** SvelteKit 2 + Svelte 5 (runes-only) für Web,
|
||||
Hono + Bun + Drizzle für API, Postgres mit `pgSchema('cards')`
|
||||
- **Drizzle 0.38 / drizzle-kit 0.30 / zod 3** — bewusst gleich wie
|
||||
mana-Plattform; Migration auf 0.45/0.31/zod-4 läuft mit Plattform-
|
||||
Migrations-Pfad mit (`mana/docs/MIGRATION_DRIZZLE_ZOD.md`)
|
||||
- **JWT-Validation** lokal via JWKS-Cache (5 min), niemals Live-Call
|
||||
zu mana-auth pro Request
|
||||
- **Service-to-Service**-Calls via `X-Service-Key` (`CARDS_APP_SERVICE_KEY`)
|
||||
oder mana-auth-issued Bearer-Tokens
|
||||
- **Tests:** Vitest für Unit/Integration, Playwright für e2e
|
||||
- **Formatierung:** `pnpm run format` vor jedem Commit
|
||||
|
||||
## Architektonische Invarianten
|
||||
|
||||
Diese sind beschlossen. Nicht ohne explizite Diskussion antasten:
|
||||
|
||||
1. **Server-authoritative MVP.** Keine Dexie, keine IndexedDB, keine
|
||||
eigene Sync-Engine. Frontend = HTTP-Client zu cards-api. Local-First
|
||||
später via mana-sync-Federation, nicht durch eigenen Stack.
|
||||
2. **Kein Code aus mana-monorepo kopiert.** Code dort wird gelesen
|
||||
(Lessons-Doc), nicht übernommen. Sauber neu ab Tag 0.
|
||||
3. **Eigene Postgres-DB `cards`** im geteilten Mana-Cluster, Schema-
|
||||
Isolation via `pgSchema('cards')`.
|
||||
4. **Föderation über `@mana/shared-app-tpl`.** Pflicht-Endpoints
|
||||
(`/.well-known/mana-app.json`, `/healthz`, `/api/v1/share/receive`,
|
||||
`/api/v1/tools/:name`, `/api/v1/search`, `/api/v1/dsgvo/...`)
|
||||
kommen aus dem geteilten Boilerplate.
|
||||
5. **Keine externe SaaS, wo vermeidbar.** Erlaubte Ausnahmen kommen
|
||||
über mana-Plattform: Stripe (mana-credits), Anthropic/OpenAI
|
||||
(mana-llm), Cloudflare-Tunnel (Edge-Routing).
|
||||
6. **Encryption initial AUS.** Nachrüstbar, wenn sensibles Feld auftaucht.
|
||||
|
||||
## Repo-Struktur
|
||||
|
||||
```
|
||||
cards/
|
||||
├── apps/
|
||||
│ ├── web/ SvelteKit-Frontend (cardecky.mana.how)
|
||||
│ └── api/ Hono+Bun-Backend
|
||||
├── packages/
|
||||
│ └── cards-domain/ Pure-TS: FSRS, Card-Types, Schemas, Anki-Parser
|
||||
├── infrastructure/ docker-compose, tunnel-config
|
||||
├── docs/ DOMAIN.md, ANKI_IMPORT.md, LESSONS_FROM_MANA_MONOREPO.md, …
|
||||
├── app-manifest.json Source of Truth für Föderation
|
||||
└── .github/workflows/ ci.yml, deploy.yml
|
||||
```
|
||||
|
||||
## @mana/* Konsumation
|
||||
|
||||
Cards zieht alle Shared-Pakete aus Verdaccio (`pkg.mana.how`):
|
||||
|
||||
- `@mana/shared-share-protocol` — Manifest, Envelope-Schemas
|
||||
- `@mana/shared-app-tpl` — Pflicht-Endpoint-Boilerplate
|
||||
- `@mana/shared-hono` — Auth-Middleware, Health-Routes, Error-Handler
|
||||
- `@mana/shared-auth` — Client-SDK für Login-Flow
|
||||
- `@mana/shared-types` — Common Types
|
||||
- `@mana/shared-icons`, `@mana/shared-i18n`, `@mana/shared-theme`,
|
||||
`@mana/shared-tailwind` — UI-Building-Blocks (kein Code-Duplikat
|
||||
aus mana-monorepo)
|
||||
|
||||
Alle als reguläre `dependencies`. Versions-Disziplin ist Klasse-A
|
||||
(siehe `mana/docs/SHARED_PACKAGES.md`).
|
||||
|
||||
## Wichtige Cross-Repo-Doks
|
||||
|
||||
- `mana/docs/playbooks/CARDS_GREENFIELD.md` — der vollständige Plan
|
||||
- `mana/docs/FEDERATION.md` — Architektur-Grundlagen
|
||||
- `mana/docs/SHARE_PROTOCOL.md` — Manifest- und Envelope-Schemas
|
||||
- `mana/docs/MANA_AUTH_FEDERATION.md` — App-Identitäts-Modell
|
||||
- `mana/docs/SHARED_PACKAGES.md` — Versionierungs-Disziplin
|
||||
- `mana/docs/PORTS.md` — Port-Allokation (cards-api: TBD, cards-web: TBD)
|
||||
|
||||
## Wenn ein neuer Pflicht-Endpoint dazukommt
|
||||
|
||||
1. `app-manifest.json` aktualisieren (Endpoint-Pfad, Auth-Mode)
|
||||
2. Handler in `apps/api/src/routes/` schreiben
|
||||
3. Tests in `apps/api/tests/` (Vitest)
|
||||
4. `pnpm run validate:manifest` lokal grün
|
||||
5. CI-Workflow zieht Manifest-Validation automatisch
|
||||
|
||||
## Wenn ein neues Tool/Share/Accept dazukommt
|
||||
|
||||
1. Schema in `packages/cards-domain/src/schemas/` definieren (zod)
|
||||
2. JSON-Schema-Export für mana-mcp/mana-share generieren
|
||||
3. Handler im Manifest registrieren (`tools[].name` oder `accepts[].handler`)
|
||||
4. Implementierung in `apps/api/src/routes/tools.ts` bzw.
|
||||
`apps/api/src/share-handlers/`
|
||||
5. Test-Coverage: zod-parse + Handler-Logic getrennt
|
||||
|
||||
## Lokal entwickeln
|
||||
|
||||
```bash
|
||||
pnpm install # @mana/* aus pkg.mana.how holen
|
||||
pnpm docker:up # Lokales Postgres
|
||||
pnpm db:push # Drizzle-Schema aktualisieren
|
||||
pnpm dev # api + web parallel
|
||||
```
|
||||
|
||||
Voraussetzungen:
|
||||
- mana-Plattform-Stack lokal laufend (mana-auth, optional die
|
||||
Föderations-Services für E2E-Tests)
|
||||
- Verdaccio-Token in `~/.npmrc` (`npm login --registry=https://pkg.mana.how`)
|
||||
45
README.md
Normal file
45
README.md
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
# Cards
|
||||
|
||||
**Eigenständige Spaced-Repetition-App des Vereins mana e.V.**
|
||||
|
||||
Cards ist eine föderierte Peer-App im mana-Ökosystem. Sie verwaltet
|
||||
Karteikarten, plant Wiederholungen mit dem FSRS-Algorithmus und
|
||||
empfängt Inhalte aus anderen Verein-Apps (z.B. Zitate aus Memoro,
|
||||
Notizen aus Mana, Web-Schnipsel aus dem Browser-Plugin).
|
||||
|
||||
→ Live (geplant): <https://cardecky.mana.how>
|
||||
|
||||
## Stack
|
||||
|
||||
- **Frontend:** SvelteKit 2 + Svelte 5 (runes-only)
|
||||
- **Backend:** Hono + Bun + Drizzle ORM
|
||||
- **Datenbank:** Postgres mit Schema-Isolation (`pgSchema('cards')`)
|
||||
- **Auth:** föderiert über mana-auth (EdDSA JWT, JWKS-Cache)
|
||||
- **Subscriptions:** mana-credits (zentral pro Verein-Account)
|
||||
- **AI-Tools:** über mana-mcp Claude Desktop / persona-runner verfügbar
|
||||
- **i18n:** DE / EN / FR / ES / IT
|
||||
- **Build:** Turborepo + pnpm 9
|
||||
|
||||
## Status
|
||||
|
||||
Phase 0 (Repo-Skeleton) — siehe `mana/docs/playbooks/CARDS_GREENFIELD.md`
|
||||
für den vollständigen Plan.
|
||||
|
||||
## Lokal entwickeln
|
||||
|
||||
```bash
|
||||
pnpm install
|
||||
pnpm docker:up # Postgres in Docker
|
||||
pnpm db:push # Drizzle-Schema
|
||||
pnpm dev # api + web parallel
|
||||
```
|
||||
|
||||
→ API auf `http://localhost:3081`, Web auf `http://localhost:3082` (oder Vite-Dev-Default `5173`).
|
||||
|
||||
Voraussetzung: Mana-Plattform-Stack (mana-auth, evtl. Föderations-Services)
|
||||
muss lokal laufen, sonst greift Auth-Login nicht.
|
||||
|
||||
## Lizenz
|
||||
|
||||
Mana-Verein-intern, MIT (siehe `mana/docs/COMPLIANCE.md` für Details
|
||||
zur Verein-Lizenzpolitik).
|
||||
65
app-manifest.json
Normal file
65
app-manifest.json
Normal file
|
|
@ -0,0 +1,65 @@
|
|||
{
|
||||
"$schema": "https://pkg.mana.how/@mana/shared-share-protocol/0.1/manifest.json",
|
||||
"schema_version": "0.1",
|
||||
"id": "cards",
|
||||
"name": "Cardecky",
|
||||
"version": "1.0.0",
|
||||
"homepage": "https://cardecky.mana.how",
|
||||
"icon": "https://cardecky.mana.how/icon-512.png",
|
||||
"description": "Spaced-repetition flashcards with FSRS scheduling.",
|
||||
"description_de": "Lernkarten mit Spaced-Repetition (FSRS).",
|
||||
"ownership": { "kind": "verein" },
|
||||
"tier_required": "beta",
|
||||
"endpoints": {
|
||||
"base_url": "https://cardecky.mana.how",
|
||||
"health": "/healthz",
|
||||
"dsgvo_export": "/api/v1/dsgvo/export",
|
||||
"search": "/api/v1/search",
|
||||
"share_receive": "/api/v1/share/receive",
|
||||
"tool_invoke": "/api/v1/tools/:name",
|
||||
"deep_link_scheme": "cards://",
|
||||
"link_patterns": [
|
||||
{
|
||||
"pattern": "card/(?<id>[a-z0-9_-]+)",
|
||||
"template": "https://cardecky.mana.how/c/{id}"
|
||||
},
|
||||
{
|
||||
"pattern": "deck/(?<id>[a-z0-9_-]+)",
|
||||
"template": "https://cardecky.mana.how/d/{id}"
|
||||
}
|
||||
]
|
||||
},
|
||||
"shares": [{ "type": "mana/card", "schema_ref": "/payload-schemas/card.json" }],
|
||||
"accepts": [
|
||||
{ "type": "mana/quote", "handler": "create_card_from_quote" },
|
||||
{ "type": "mana/url", "handler": "save_link_as_card" },
|
||||
{ "type": "mana/text", "handler": "create_card_from_text" }
|
||||
],
|
||||
"tools": [
|
||||
{
|
||||
"name": "cards.create",
|
||||
"description": "Erzeugt eine Lernkarte für den authentifizierten User.",
|
||||
"input_schema": { "$ref": "/payload-schemas/create-card-input.json" },
|
||||
"output_schema": { "$ref": "/payload-schemas/card.json" },
|
||||
"auth": "user_token"
|
||||
},
|
||||
{
|
||||
"name": "cards.search",
|
||||
"description": "Sucht in den Lernkarten des authentifizierten Users.",
|
||||
"input_schema": { "$ref": "/payload-schemas/search-input.json" },
|
||||
"output_schema": { "$ref": "/payload-schemas/search-output.json" },
|
||||
"auth": "user_token"
|
||||
}
|
||||
],
|
||||
"search": {
|
||||
"types": ["card", "deck"],
|
||||
"languages": ["de", "en", "fr", "es", "it"],
|
||||
"max_results": 30,
|
||||
"timeout_ms": 3000
|
||||
},
|
||||
"data": {
|
||||
"personal_data_categories": ["card_content", "review_history"],
|
||||
"encrypted_at_rest": false,
|
||||
"storage_location": "EU"
|
||||
}
|
||||
}
|
||||
13
apps/api/drizzle.config.ts
Normal file
13
apps/api/drizzle.config.ts
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
import type { Config } from 'drizzle-kit';
|
||||
|
||||
export default {
|
||||
schema: './src/db/schema/*.ts',
|
||||
out: './src/db/migrations',
|
||||
dialect: 'postgresql',
|
||||
dbCredentials: {
|
||||
url: process.env.DATABASE_URL ?? 'postgresql://cards:cards@localhost:5435/cards',
|
||||
},
|
||||
schemaFilter: ['cards'],
|
||||
verbose: true,
|
||||
strict: true,
|
||||
} satisfies Config;
|
||||
32
apps/api/package.json
Normal file
32
apps/api/package.json
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
{
|
||||
"name": "@cards/api",
|
||||
"version": "0.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"description": "Cards-API — Hono+Bun-Backend für die Greenfield-Cards-App. Spricht mana-Plattform-Services über HTTP.",
|
||||
"scripts": {
|
||||
"dev": "bun run --hot src/index.ts",
|
||||
"start": "bun run src/index.ts",
|
||||
"build": "tsc -p tsconfig.json --noEmit",
|
||||
"type-check": "tsc -p tsconfig.json --noEmit",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest",
|
||||
"lint": "echo 'lint configured later (eslint flat-config)'",
|
||||
"clean": "rm -rf dist .turbo coverage",
|
||||
"drizzle:generate": "drizzle-kit generate",
|
||||
"drizzle:push": "drizzle-kit push --force",
|
||||
"drizzle:studio": "drizzle-kit studio"
|
||||
},
|
||||
"dependencies": {
|
||||
"@cards/domain": "workspace:*",
|
||||
"hono": "^4.6.0",
|
||||
"drizzle-orm": "0.38",
|
||||
"postgres": "^3.4.0",
|
||||
"zod": "3",
|
||||
"zod-to-json-schema": "^3.23.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"drizzle-kit": "0.30",
|
||||
"vitest": "^2.1.0"
|
||||
}
|
||||
}
|
||||
13
apps/api/src/db/schema/index.ts
Normal file
13
apps/api/src/db/schema/index.ts
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
// Drizzle-Schemas für die `cards`-Datenbank.
|
||||
//
|
||||
// Phase-3-Aufgabe (siehe CARDS_GREENFIELD.md): hier landen
|
||||
// `decks`, `cards`, `reviews`, `study_sessions`, `tags`, `media_refs`,
|
||||
// `import_jobs` als pgSchema('cards').table(...) Definitionen.
|
||||
//
|
||||
// Schema-Skizze in mana/docs/playbooks/CARDS_GREENFIELD.md §"Drizzle-Schema-Skizze".
|
||||
// Card-Type-Granularität (subIndex pro Karte) aus
|
||||
// docs/LESSONS_FROM_MANA_MONOREPO.md mitnehmen.
|
||||
|
||||
import { pgSchema } from 'drizzle-orm/pg-core';
|
||||
|
||||
export const cardsSchema = pgSchema('cards');
|
||||
26
apps/api/src/index.ts
Normal file
26
apps/api/src/index.ts
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
import { Hono } from 'hono';
|
||||
|
||||
import { manifestRoute } from './routes/manifest.ts';
|
||||
import { healthRoute } from './routes/health.ts';
|
||||
|
||||
const app = new Hono();
|
||||
|
||||
app.route('/', healthRoute);
|
||||
app.route('/.well-known/mana-app.json', manifestRoute);
|
||||
|
||||
app.get('/', (c) =>
|
||||
c.json({
|
||||
app: 'cards',
|
||||
version: process.env.CARDS_API_VERSION ?? '0.0.0',
|
||||
see: '/.well-known/mana-app.json',
|
||||
})
|
||||
);
|
||||
|
||||
const port = Number(process.env.CARDS_API_PORT ?? 3081);
|
||||
|
||||
console.log(`[cards-api] listening on http://localhost:${port}`);
|
||||
|
||||
export default {
|
||||
port,
|
||||
fetch: app.fetch,
|
||||
};
|
||||
27
apps/api/src/routes/health.ts
Normal file
27
apps/api/src/routes/health.ts
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
import { Hono } from 'hono';
|
||||
|
||||
export const healthRoute = new Hono();
|
||||
|
||||
healthRoute.get('/healthz', (c) => c.json({ status: 'ok' }));
|
||||
|
||||
healthRoute.get('/healthz/details', (c) =>
|
||||
c.json({
|
||||
status: 'ok',
|
||||
app: 'cards',
|
||||
version: process.env.CARDS_API_VERSION ?? '0.0.0',
|
||||
uptime_s: Math.floor(process.uptime()),
|
||||
mana_packages: {
|
||||
// In Phase 5 mit @mana/shared-app-tpl ersetzen.
|
||||
'@mana/shared-share-protocol': 'TBD',
|
||||
'@mana/shared-app-tpl': 'TBD',
|
||||
},
|
||||
})
|
||||
);
|
||||
|
||||
healthRoute.get('/version', (c) =>
|
||||
c.json({
|
||||
app: 'cards',
|
||||
version: process.env.CARDS_API_VERSION ?? '0.0.0',
|
||||
build: process.env.CARDS_BUILD_SHA ?? 'dev',
|
||||
})
|
||||
);
|
||||
7
apps/api/src/routes/manifest.ts
Normal file
7
apps/api/src/routes/manifest.ts
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
import { Hono } from 'hono';
|
||||
|
||||
import manifest from '../../../../app-manifest.json' with { type: 'json' };
|
||||
|
||||
export const manifestRoute = new Hono();
|
||||
|
||||
manifestRoute.get('/', (c) => c.json(manifest));
|
||||
24
apps/api/tests/health.test.ts
Normal file
24
apps/api/tests/health.test.ts
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
import { describe, it, expect } from 'vitest';
|
||||
import { Hono } from 'hono';
|
||||
import { healthRoute } from '../src/routes/health.ts';
|
||||
|
||||
describe('health routes', () => {
|
||||
const app = new Hono();
|
||||
app.route('/', healthRoute);
|
||||
|
||||
it('GET /healthz returns ok', async () => {
|
||||
const res = await app.request('/healthz');
|
||||
expect(res.status).toBe(200);
|
||||
const body = (await res.json()) as { status: string };
|
||||
expect(body.status).toBe('ok');
|
||||
});
|
||||
|
||||
it('GET /version returns app + version + build', async () => {
|
||||
const res = await app.request('/version');
|
||||
expect(res.status).toBe(200);
|
||||
const body = (await res.json()) as { app: string; version: string; build: string };
|
||||
expect(body.app).toBe('cards');
|
||||
expect(body.version).toBeTruthy();
|
||||
expect(body.build).toBeTruthy();
|
||||
});
|
||||
});
|
||||
14
apps/api/tsconfig.json
Normal file
14
apps/api/tsconfig.json
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "./dist",
|
||||
"rootDir": "./src",
|
||||
"types": ["bun-types"],
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": ["src/*"]
|
||||
}
|
||||
},
|
||||
"include": ["src/**/*", "tests/**/*", "drizzle.config.ts"],
|
||||
"exclude": ["node_modules", "dist", ".turbo"]
|
||||
}
|
||||
29
apps/web/package.json
Normal file
29
apps/web/package.json
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
{
|
||||
"name": "@cards/web",
|
||||
"version": "0.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"description": "Cards-Web — SvelteKit 2 + Svelte 5 Frontend für cardecky.mana.how.",
|
||||
"scripts": {
|
||||
"dev": "vite dev --port 3082 --host",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview",
|
||||
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
|
||||
"type-check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
|
||||
"test": "vitest run",
|
||||
"lint": "echo 'lint configured later (eslint flat-config)'",
|
||||
"clean": "rm -rf .svelte-kit build .turbo"
|
||||
},
|
||||
"dependencies": {
|
||||
"@cards/domain": "workspace:*"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@sveltejs/adapter-node": "^5.2.0",
|
||||
"@sveltejs/kit": "^2.8.0",
|
||||
"@sveltejs/vite-plugin-svelte": "^4.0.0",
|
||||
"svelte": "^5.0.0",
|
||||
"svelte-check": "^4.0.0",
|
||||
"vite": "^5.4.0",
|
||||
"vitest": "^2.1.0"
|
||||
}
|
||||
}
|
||||
13
apps/web/src/app.d.ts
vendored
Normal file
13
apps/web/src/app.d.ts
vendored
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
// See https://kit.svelte.dev/docs/types#app
|
||||
// for information about these interfaces
|
||||
declare global {
|
||||
namespace App {
|
||||
// interface Error {}
|
||||
// interface Locals {}
|
||||
// interface PageData {}
|
||||
// interface PageState {}
|
||||
// interface Platform {}
|
||||
}
|
||||
}
|
||||
|
||||
export {};
|
||||
13
apps/web/src/app.html
Normal file
13
apps/web/src/app.html
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
<!doctype html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<meta name="description" content="Cards — Karteikarten-App des Vereins mana e.V." />
|
||||
<title>Cards</title>
|
||||
%sveltekit.head%
|
||||
</head>
|
||||
<body data-sveltekit-preload-data="hover">
|
||||
<div style="display: contents">%sveltekit.body%</div>
|
||||
</body>
|
||||
</html>
|
||||
6
apps/web/src/lib/index.ts
Normal file
6
apps/web/src/lib/index.ts
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
// Re-exports for $lib/...
|
||||
//
|
||||
// Phase 4: api-clients, auth, components, stores, i18n.
|
||||
// Heute: Stub.
|
||||
|
||||
export const APP_NAME = 'cards';
|
||||
20
apps/web/src/routes/+layout.svelte
Normal file
20
apps/web/src/routes/+layout.svelte
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
<script lang="ts">
|
||||
let { children } = $props();
|
||||
</script>
|
||||
|
||||
<main>
|
||||
{@render children?.()}
|
||||
</main>
|
||||
|
||||
<style>
|
||||
main {
|
||||
font-family:
|
||||
-apple-system,
|
||||
BlinkMacSystemFont,
|
||||
'Segoe UI',
|
||||
sans-serif;
|
||||
max-width: 64rem;
|
||||
margin: 0 auto;
|
||||
padding: 2rem 1rem;
|
||||
}
|
||||
</style>
|
||||
16
apps/web/src/routes/+page.svelte
Normal file
16
apps/web/src/routes/+page.svelte
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
<script lang="ts">
|
||||
// Phase-0-Skelett. Sobald Auth-Föderation steht (Phase 2),
|
||||
// redirected diese Seite zu /(app) für eingeloggte User
|
||||
// und zu auth.mana.how/login für anonyme.
|
||||
</script>
|
||||
|
||||
<h1>Cards</h1>
|
||||
<p>
|
||||
Karteikarten-App des Vereins <strong>mana e.V.</strong>
|
||||
— Phase 0, Repo-Skelett.
|
||||
</p>
|
||||
<p>
|
||||
Plan: <a href="https://github.com/mana-ev/mana/blob/main/docs/playbooks/CARDS_GREENFIELD.md">
|
||||
CARDS_GREENFIELD.md
|
||||
</a>
|
||||
</p>
|
||||
4
apps/web/static/robots.txt
Normal file
4
apps/web/static/robots.txt
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
User-agent: *
|
||||
Disallow: /
|
||||
|
||||
# Cards ist eine private Verein-App, kein Index für Suchmaschinen.
|
||||
15
apps/web/svelte.config.js
Normal file
15
apps/web/svelte.config.js
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
import adapter from '@sveltejs/adapter-node';
|
||||
import { vitePreprocess } from '@sveltejs/vite-plugin-svelte';
|
||||
|
||||
/** @type {import('@sveltejs/kit').Config} */
|
||||
const config = {
|
||||
preprocess: vitePreprocess(),
|
||||
kit: {
|
||||
adapter: adapter(),
|
||||
alias: {
|
||||
$lib: 'src/lib',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export default config;
|
||||
21
apps/web/tsconfig.json
Normal file
21
apps/web/tsconfig.json
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"allowJs": true,
|
||||
"checkJs": true,
|
||||
"sourceMap": true,
|
||||
"strict": true,
|
||||
"moduleResolution": "bundler"
|
||||
},
|
||||
"include": [
|
||||
".svelte-kit/ambient.d.ts",
|
||||
".svelte-kit/non-ambient.d.ts",
|
||||
".svelte-kit/types/**/$types.d.ts",
|
||||
"vite.config.ts",
|
||||
"src/**/*.js",
|
||||
"src/**/*.ts",
|
||||
"src/**/*.svelte",
|
||||
"tests/**/*"
|
||||
],
|
||||
"exclude": ["node_modules", ".svelte-kit/output", "build"]
|
||||
}
|
||||
10
apps/web/vite.config.ts
Normal file
10
apps/web/vite.config.ts
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
import { sveltekit } from '@sveltejs/kit/vite';
|
||||
import { defineConfig } from 'vite';
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [sveltekit()],
|
||||
server: {
|
||||
port: Number(process.env.CARDS_WEB_PORT ?? 3082),
|
||||
host: true,
|
||||
},
|
||||
});
|
||||
203
docs/LESSONS_FROM_MANA_MONOREPO.md
Normal file
203
docs/LESSONS_FROM_MANA_MONOREPO.md
Normal file
|
|
@ -0,0 +1,203 @@
|
|||
# Lessons aus mana-monorepo für Cards-Greenfield
|
||||
|
||||
**Stand:** 2026-05-08, Read-Day-Output
|
||||
**Quelle:** `mana-monorepo/{packages/cards-core,apps/mana/.../modules/cards,services/cards-server}`
|
||||
**Verwendung:** Architektonische Lehren für den Greenfield-Build. Kein Code wird übernommen, nur Designs.
|
||||
|
||||
## TL;DR
|
||||
|
||||
- **Domain-Library separieren** (`@cards/domain`, framework-agnostic)
|
||||
- **Card-Type als discriminated union** mit `fields: Record<string,string>` + `subIndex`-Granularität
|
||||
- **FSRS via `ts-fsrs` v5.3.2** in dünnem Adapter, Reviews bleiben PLAINTEXT
|
||||
- **Markdown-Editor**, kein Rich-Text/WYSIWYG
|
||||
- **Keyboard-driven Study-View** (Space=Reveal, 1–4=Grade)
|
||||
- **Marketplace ist separate Concern** (eigener Server in mana-monorepo, NICHT im Cards-MVP)
|
||||
- **Encryption-Registry zentral planen**, MVP `enabled: false`
|
||||
- **AI-Tools sind dünn** in mana-monorepo (nur Draft `create_card`); wir können sauber neu definieren
|
||||
|
||||
---
|
||||
|
||||
## 1. Domain-Library als eigener Workspace-Package
|
||||
|
||||
mana-monorepo hat `packages/cards-core/` mit Pure-TS:
|
||||
- Card-Types, Cloze-Parser, FSRS-Wrapper, Markdown-Render-Helpers
|
||||
- Keine Dexie-, Sync-, oder UI-Abhängigkeiten
|
||||
- Konsumiert von App-UI-Modul UND von `services/cards-server`
|
||||
|
||||
→ **Bei uns:** `packages/cards-domain/` analog. Bewusst kein `@cards/core` (Name-Konflikt), sondern `@cards/domain`. Steht.
|
||||
|
||||
## 2. Card-Type-Modell
|
||||
|
||||
mana-monorepo unterstützt: `'basic' | 'basic-reverse' | 'cloze' | 'type-in' | 'image-occlusion' | 'audio' | 'multiple-choice'`.
|
||||
|
||||
Datenstruktur:
|
||||
- `fields: Record<string, string>` als generischer Slot (statt `front`/`back`-Spalten)
|
||||
- Pro Type unterschiedliche Field-Sets:
|
||||
- `basic`/`basic-reverse`: `{ front, back }`
|
||||
- `cloze`: `{ text, extra? }`
|
||||
- `type-in`: `{ question, expected }`
|
||||
|
||||
**SubIndex-Granularität:** Pro Karte gibt es N Reviews (mit `subIndex`):
|
||||
- `basic`: 1 Review (subIndex 0)
|
||||
- `basic-reverse`: 2 Reviews (front→back UND back→front)
|
||||
- `cloze`: 1 Review pro Cluster-Index (`{{c1::...}}`, `{{c2::...}}`)
|
||||
|
||||
→ **Bei uns:** MVP nur `basic` + `basic-reverse`. Aber `Card`-Schema gleich für die volle CardType-Future-Union vorgesehen, damit Schema-Migration klein bleibt. SubIndex-Pattern in Reviews-Tabelle übernehmen.
|
||||
|
||||
## 3. FSRS-Library + Adapter-Pattern
|
||||
|
||||
- `ts-fsrs` v5.3.2 — dependency-free, Date-basierte API
|
||||
- mana-monorepo wraps in `LocalCardReview` ↔ `ts-fsrs.Card` Adapter (ISO-Strings ↔ Date)
|
||||
- Funktionen: `newReview()` (init), `gradeReview(review, rating)` (next state)
|
||||
|
||||
→ **Bei uns:** Genau gleicher Wrapper. Phase 3.
|
||||
|
||||
**Wichtige Regel:** Reviews bleiben **PLAINTEXT** in der DB, weil der Scheduler täglich auf `due <= now` quert. Encryption müsste täglich N Reviews entschlüsseln — geht nicht. mana-monorepos `crypto/registry.ts` listet `cardReviews` explizit als plaintext-allowlisted.
|
||||
|
||||
→ **Bei uns:** Schema-Doku wird das erwähnen. MVP-Encryption-OFF macht das ohnehin moot, aber Future-Proofing wichtig.
|
||||
|
||||
## 4. Cloze-Parser: Token-basiert, nicht Regex
|
||||
|
||||
mana-monorepo hat `packages/cards-core/src/cloze.ts` mit:
|
||||
- Anki-kompatible Syntax: `{{c1::answer}}` oder `{{c1::answer::hint}}`
|
||||
- `tokenize()` → `Array<{ kind: 'text' | 'cluster', ... }>`
|
||||
- `clusters()` gruppiert pro Cluster-Index
|
||||
- `renderCloze(source, hideIndex)` → `{ front, back, answer }`
|
||||
|
||||
**Warum Token-basiert:** Ein Cluster kann mehrfach im Text vorkommen (`{{c1::Berlin}} … {{c1::Berlin}}`). Beide müssen synchron geblankt werden. Token-Parser macht das trivial; Regex-Replace nicht.
|
||||
|
||||
→ **Bei uns:** Cloze ist Phase 8+. Wenn implementiert: Token-basiert, Anki-kompatible Syntax. Library-Wahl: gleich wie ts-fsrs eigene mini-Library schreiben (klein), oder `@anki/cloze-parser` falls existent (TBD).
|
||||
|
||||
## 5. Local-First-Stack ist nicht trivial
|
||||
|
||||
mana-monorepos Cards-Modul nutzt:
|
||||
- Dexie + 5 Tabellen (`cardDecks`, `cards`, `cardReviews`, `cardStudyBlocks`, `deckTags`)
|
||||
- mana-sync (Go) für Server-Sync mit Field-Level-LWW
|
||||
- `__fieldMeta` pro Record für Konflikt-Detection
|
||||
- `_updatedAtIndex` Shadow-Column für orderBy
|
||||
- Encryption-Layer mit Master-Key aus mana-auth
|
||||
|
||||
**Lessons:**
|
||||
- Sync-Engine ist nicht trivial (siehe `apps/mana/.../data/DATA_LAYER_AUDIT.md`)
|
||||
- Quota-Recovery, Backoff, RLS, Encryption-Rollout sind ihre eigenen Features
|
||||
- "Schnell mal was Local-First bauen" ist eine Illusion
|
||||
|
||||
→ **Bei uns:** **Server-authoritative MVP, Local-First erst via mana-sync-Federation** (Variante III in CARDS_GREENFIELD.md). Damit überspringen wir das gesamte Komplexitäts-Paket. Wenn später Local-First nötig: Cards bekommt `appId=cards` in mana-sync, statt eigenen Stack zu bauen.
|
||||
|
||||
## 6. Encryption-Registry zentral
|
||||
|
||||
mana-monorepo hat `apps/mana/.../data/crypto/registry.ts`:
|
||||
- Cards: `{ enabled: true, fields: ['front', 'back', 'fields'] }` (ein-Tabelle)
|
||||
- CardDecks: `{ enabled: true, fields: ['name', 'description'] }`
|
||||
- cardReviews + cardStudyBlocks: explizit plaintext (Performance-Gründe oben)
|
||||
|
||||
Regel: Plaintext = IDs/Timestamps/Enums/Sortkeys/Indizes. Encrypt = User-Typed-Content.
|
||||
|
||||
→ **Bei uns:** Encryption initial **AUS** (CARDS_GREENFIELD.md). Wenn nachgerüstet: gleiche Aufteilung. Felder-Allowlist in `packages/cards-domain/src/crypto/registry.ts` zentral, von `apps/api/src/db/...` konsumiert.
|
||||
|
||||
## 7. Card-Editor: Markdown statt Rich-Text
|
||||
|
||||
mana-monorepo nutzt:
|
||||
- Stateless `CardFace.svelte` (`card`, `subIndex`, `showBack`, Callbacks)
|
||||
- Render-Logik:
|
||||
- Basic: `renderMarkdown(card.fields.front/back)`
|
||||
- Cloze: `renderCloze(card.fields.text, subIndex)` + extra-hint
|
||||
- Type-In: Input-Feld + case-insensitive Vergleich gegen `expected`
|
||||
|
||||
**Anti-Pattern (vermieden):** Rich-Text-Editor mit Toolbar/Undo/Bold-Hotkeys.
|
||||
|
||||
→ **Bei uns:** Phase 4: Markdown-Editor. Library: vermutlich `marked` + `DOMPurify`. CodeMirror oder Monaco wäre Overkill für Karten — wir brauchen kein Syntax-Highlighting.
|
||||
|
||||
## 8. Study-View: Keyboard-driven
|
||||
|
||||
mana-monorepos `/cards/learn/[deckId]/+page.svelte`:
|
||||
- **Space/Enter:** Antwort aufdecken
|
||||
- **1/2/3/4:** Grade (Again/Hard/Good/Easy → ts-fsrs Rating-Enum)
|
||||
- **Queue snapshot am Session-Start** — verhindert Mid-Session-Verschiebung neu fälliger Karten
|
||||
- **Skip-Logic:** Wenn `INPUT` focused, ignoriere Hotkeys
|
||||
|
||||
→ **Bei uns:** Phase 4. Dasselbe Pattern. Hotkey-Handler-Helper `<KeyboardListener>`-Component oder Effect mit `addEventListener`.
|
||||
|
||||
## 9. Marketplace: separater Service, NICHT im MVP
|
||||
|
||||
mana-monorepo hat **`services/cards-server/`** (Hono+Bun, Port 3072 — Konflikt mit unserer mana-share-Plattform!) als Marketplace-Backend:
|
||||
- Tabellen: `decks` (public, slug-indexed), `deck_versions` (immutable snapshots), `deck_cards` (versionId, type, fields JSON, contentHash)
|
||||
- Smart-Merge via per-card SHA-256-Hashes: Subscriber pullen neue Versionen ohne FSRS-State zu verlieren
|
||||
- Routes: POST `/decks`, GET `/:slug`, POST `/:slug/publish`
|
||||
|
||||
**Wichtig:** Das ist ein **eigenes Feature**, kein Teil des Cards-Frontend-Moduls. Cards-Web bringt Decks lokal, Marketplace ist read-only-Browse + Publish.
|
||||
|
||||
→ **Bei uns:** **Nicht im MVP.** Phase 12+. Wenn nachgerüstet: eigenes Backend (oder dedizierter Endpoint in cards-api), nicht in MVP-Schema. Smart-Merge-Pattern (content-hash) ist ein gutes Design — übernehmen, wenn es so weit ist.
|
||||
|
||||
**Decommission-Konsequenz:** Wenn Cards-Greenfield live ist, muss `mana-monorepo/services/cards-server/` ebenfalls weg (siehe CARDS_GREENFIELD.md → Decommission). Steht.
|
||||
|
||||
## 10. AI-Tools sind dünn
|
||||
|
||||
mana-monorepos `lib/modules/cards/tools.ts` hat:
|
||||
- 1 Tool: `create_card` (name, deckId, front, back) — als Draft, NICHT in `AI_TOOL_CATALOG` registriert
|
||||
- Keine `list_decks`, `get_deck_stats`, `update_card`-Tools
|
||||
|
||||
→ **Bei uns:** Phase 7 entscheidet Scope. Vermutlich `cards.create` + `cards.search` für MVP. Mehr Tools wenn Persona-Runner-Use-Cases erscheinen.
|
||||
|
||||
## 11. Routing-Pattern
|
||||
|
||||
mana-monorepos `(app)/cards/`-Routen:
|
||||
- `/cards/decks` — ListView
|
||||
- `/cards/decks/[id]` — DetailView (EditName, EditDescription, EditColor, VisibilityPicker)
|
||||
- `/cards/learn/[deckId]` — Study-Session-View
|
||||
- `/cards/explore` — Marketplace-Browse
|
||||
- `/cards/progress` — Stats, Streak, Heatmap
|
||||
|
||||
→ **Bei uns:** Cards ist Standalone, nicht `(app)/cards/...`-Sub-Tree. Routing flacht aus zu `/decks`, `/decks/:id`, `/study/:deckId`. `/explore` und `/progress` sind Polish (Phase 9).
|
||||
|
||||
## 12. Visibility: einfacher Enum reicht
|
||||
|
||||
mana-monorepo nutzt `@mana/shared-privacy` mit `VisibilityLevel = 'private' | 'space' | 'public'`. Bei Änderungen wird `visibilityChangedAt` + `visibilityChangedBy` getrackt.
|
||||
|
||||
→ **Bei uns:** Gleiches Enum als TypeScript-Type in `@cards/domain`. Audit-Felder optional ab Phase 9 (DSGVO-Polish).
|
||||
|
||||
## 13. Tests mit fake-indexeddb
|
||||
|
||||
mana-monorepo nutzt `fake-indexeddb` für Unit-Tests gegen Dexie ohne echte DB.
|
||||
|
||||
→ **Bei uns:** **Nicht relevant** — wir sind server-authoritative, keine Dexie. Vitest + Hono `app.request()` (wie in mana-Plattform) für API-Tests, Playwright für e2e.
|
||||
|
||||
## 14. Domain-Events + Analytics früh
|
||||
|
||||
mana-monorepo emittiert `CardCreated` u.ä. an einen domain-event-Bus + ruft `CardsEvents.cardCreated()` für Analytics.
|
||||
|
||||
→ **Bei uns:** Domain-Events gehen über mana-events (`card.created`, `card.studied`, `deck.completed`). Analytics ggf. später. Phase 5 macht die Event-Anbindung.
|
||||
|
||||
## 15. Hash-Everything-Early
|
||||
|
||||
mana-monorepos Marketplace hat content-hash auf jeder Karte + jedem Deck-Version-Snapshot. Ermöglicht Smart-Merge ohne Diffing.
|
||||
|
||||
→ **Bei uns:** Hash-Spalte auf `cards` und `decks` schon im MVP-Schema einplanen, auch wenn Marketplace nicht da ist. Cheap to add, expensive to retrofit.
|
||||
|
||||
---
|
||||
|
||||
## Anti-Patterns aus mana-monorepo, die wir vermeiden
|
||||
|
||||
- **Rich-Text-Editor mit Toolbar:** Markdown reicht für Karten-Inhalte
|
||||
- **Real-time-Collaboration:** Cards sind privat, async-Sync genügt
|
||||
- **Geteilte Dexie für 27 Module:** entfällt, Cards hat eigene Postgres
|
||||
- **`updatedAt` als synced data field:** mana-monorepo musste das nachträglich auf `__fieldMeta`-deriviert umbauen (siehe `mana-monorepo/CLAUDE.md` §"Conflict-Detection 2026-04-26"). **Wir machen es ab Tag 1 richtig:** `updatedAt` wird beim Read aus `max(__fieldMeta[*].at)` deriviert, falls wir je eine ähnliche Architektur brauchen — oder bleibt einfach eine normale Column im Server-Modell. (MVP: Column reicht.)
|
||||
|
||||
## 5 Kern-Entscheidungen für unser Greenfield
|
||||
|
||||
1. **`@cards/domain` als Pure-TS-Workspace-Package** — keine Framework-Bindings
|
||||
2. **Card-Type discriminated union mit `fields`-Slot + `subIndex`-Granularität** — auch wenn MVP nur basic-Karten hat
|
||||
3. **`ts-fsrs` v5.3.2 hinter dünnem Adapter** — Reviews plaintext, indiziert auf `due`
|
||||
4. **Cloze als Token-Parser, wenn implementiert** — Anki-kompatible Syntax
|
||||
5. **Encryption planen, aber MVP-OFF** — Felder-Allowlist von Tag 1 vorsehen
|
||||
|
||||
## Was nicht aus mana-monorepo kommt
|
||||
|
||||
- Marketplace-Backend (eigenständige Phase 12+)
|
||||
- Local-First-Sync-Stack (mana-sync-Federation oder gar nicht)
|
||||
- Mobile-App (PWA reicht)
|
||||
- Komplexe AI-Workbench-Integration
|
||||
|
||||
---
|
||||
|
||||
**Verbindlich:** Phase 1 (Repo-Skelett) ist abgeschlossen. Phase 0 (dieser Read-Day) ist mit diesem Dokument erledigt. Phase 2 (Auth-Föderation) ist als Nächstes dran.
|
||||
28
infrastructure/docker-compose.yml
Normal file
28
infrastructure/docker-compose.yml
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
# Lokales Cards-Dev-Setup. Postgres-Container auf 5435,
|
||||
# damit der mana-Plattform-Stack auf 5432 ungestört weiterläuft.
|
||||
#
|
||||
# Start: pnpm docker:up (vom Repo-Root)
|
||||
# Logs: pnpm docker:logs
|
||||
# Stop: pnpm docker:down
|
||||
#
|
||||
# Daten persistieren in `infrastructure/.volumes/cards-postgres`,
|
||||
# git-ignored.
|
||||
|
||||
services:
|
||||
cards-postgres:
|
||||
image: postgres:16-alpine
|
||||
container_name: cards-postgres
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
POSTGRES_USER: cards
|
||||
POSTGRES_PASSWORD: cards
|
||||
POSTGRES_DB: cards
|
||||
ports:
|
||||
- '5435:5432'
|
||||
volumes:
|
||||
- ./.volumes/cards-postgres:/var/lib/postgresql/data
|
||||
healthcheck:
|
||||
test: ['CMD-SHELL', 'pg_isready -U cards -d cards']
|
||||
interval: 5s
|
||||
timeout: 3s
|
||||
retries: 10
|
||||
37
package.json
Normal file
37
package.json
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
{
|
||||
"name": "cards",
|
||||
"version": "0.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"description": "Cards — eigenständige föderierte Spaced-Repetition-App des mana e.V. Greenfield-Build (Strategie B), redet über mana-share/-mcp/-search/-events mit den anderen Verein-Apps.",
|
||||
"packageManager": "pnpm@9.15.9",
|
||||
"engines": {
|
||||
"node": ">=20",
|
||||
"pnpm": "^9"
|
||||
},
|
||||
"scripts": {
|
||||
"dev": "turbo run dev",
|
||||
"build": "turbo run build",
|
||||
"test": "turbo run test",
|
||||
"lint": "turbo run lint",
|
||||
"type-check": "turbo run type-check",
|
||||
"clean": "turbo run clean",
|
||||
"format": "prettier --config .prettierrc.json --write \"**/*.{ts,tsx,svelte,js,jsx,json,md}\"",
|
||||
"format:check": "prettier --config .prettierrc.json --check \"**/*.{ts,tsx,svelte,js,jsx,json,md}\"",
|
||||
"validate:manifest": "pnpm exec validate-manifest app-manifest.json",
|
||||
"validate:fast": "pnpm run type-check && pnpm run validate:manifest",
|
||||
"validate:all": "pnpm run type-check && pnpm run validate:manifest && pnpm run test",
|
||||
"docker:up": "docker compose -f infrastructure/docker-compose.yml up -d",
|
||||
"docker:down": "docker compose -f infrastructure/docker-compose.yml down",
|
||||
"docker:logs": "docker compose -f infrastructure/docker-compose.yml logs -f",
|
||||
"db:push": "pnpm --filter @cards/api drizzle:push"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/bun": "latest",
|
||||
"@types/node": "^22.10.2",
|
||||
"prettier": "^3.3.3",
|
||||
"prettier-plugin-svelte": "^3.2.6",
|
||||
"turbo": "^2.3.0",
|
||||
"typescript": "^5.6.3"
|
||||
}
|
||||
}
|
||||
31
packages/cards-domain/package.json
Normal file
31
packages/cards-domain/package.json
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
{
|
||||
"name": "@cards/domain",
|
||||
"version": "0.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"description": "Cards-Domain — Pure-TS-Modell: Card-Types, FSRS-Adapter, Cloze-Parser, Anki-Import-Helpers, zod-Schemas. Keine DB-, Framework- oder Hono/SvelteKit-Abhängigkeiten.",
|
||||
"main": "./src/index.ts",
|
||||
"types": "./src/index.ts",
|
||||
"exports": {
|
||||
".": "./src/index.ts",
|
||||
"./types": "./src/types.ts",
|
||||
"./fsrs": "./src/fsrs.ts",
|
||||
"./cloze": "./src/cloze.ts",
|
||||
"./schemas": "./src/schemas/index.ts"
|
||||
},
|
||||
"scripts": {
|
||||
"build": "tsc -p tsconfig.json --noEmit",
|
||||
"type-check": "tsc -p tsconfig.json --noEmit",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest",
|
||||
"lint": "echo 'lint configured later'",
|
||||
"clean": "rm -rf dist .turbo coverage"
|
||||
},
|
||||
"dependencies": {
|
||||
"ts-fsrs": "^5.3.2",
|
||||
"zod": "3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"vitest": "^2.1.0"
|
||||
}
|
||||
}
|
||||
8
packages/cards-domain/src/index.ts
Normal file
8
packages/cards-domain/src/index.ts
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
// @cards/domain — public API
|
||||
//
|
||||
// Pure-TS, keine DB/Framework-Abhängigkeiten. Wird vom apps/api
|
||||
// (Drizzle-Schemas) und apps/web (UI) gleichermaßen konsumiert.
|
||||
|
||||
export * from './types.ts';
|
||||
// export * from './fsrs.ts'; // Phase 3: FSRS-Adapter (ts-fsrs)
|
||||
// export * from './cloze.ts'; // Phase 8 oder später: Cloze-Parser
|
||||
7
packages/cards-domain/src/schemas/index.ts
Normal file
7
packages/cards-domain/src/schemas/index.ts
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
// Phase-3-Aufgabe: zod-Schemas für API-Inputs/Outputs.
|
||||
//
|
||||
// Konvention: ein zod-Schema pro Domain-Type (DeckSchema, CardSchema,
|
||||
// ReviewSchema). API-Routen rufen `Schema.parse()` für Input-Validation,
|
||||
// und `zod-to-json-schema` generiert die JSON-Schemas für mana-mcp/-share.
|
||||
|
||||
export {};
|
||||
87
packages/cards-domain/src/types.ts
Normal file
87
packages/cards-domain/src/types.ts
Normal file
|
|
@ -0,0 +1,87 @@
|
|||
// Domain-Typen für Cards.
|
||||
//
|
||||
// Modellierung folgt den Lessons aus mana-monorepo
|
||||
// (siehe docs/LESSONS_FROM_MANA_MONOREPO.md):
|
||||
//
|
||||
// - CardType ist eine discriminated union.
|
||||
// - Card hat `fields: Record<string, string>` als generischen Slot.
|
||||
// - Pro Karte gibt es N Reviews mit `subIndex` (basic = 1, basic-reverse = 2,
|
||||
// cloze = 1 pro Cluster).
|
||||
// - cardReviews bleiben PLAINTEXT, weil der Scheduler täglich auf `due`
|
||||
// filtert.
|
||||
|
||||
/** Phase-1-MVP-Set; Cloze + Image-Occlusion in Phase 8+. */
|
||||
export type CardType = 'basic' | 'basic-reverse';
|
||||
|
||||
/** Voll geplantes Set (für Schemas vorbereitet, MVP nicht alle implementiert). */
|
||||
export type CardTypeFuture =
|
||||
| 'basic'
|
||||
| 'basic-reverse'
|
||||
| 'cloze'
|
||||
| 'type-in'
|
||||
| 'image-occlusion'
|
||||
| 'audio'
|
||||
| 'multiple-choice';
|
||||
|
||||
export type CardFields = Record<string, string>;
|
||||
|
||||
export type Deck = {
|
||||
id: string;
|
||||
user_id: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
color?: string;
|
||||
visibility: 'private' | 'space' | 'public';
|
||||
fsrs_settings: FsrsSettings;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
};
|
||||
|
||||
export type Card = {
|
||||
id: string;
|
||||
deck_id: string;
|
||||
user_id: string;
|
||||
type: CardType;
|
||||
fields: CardFields;
|
||||
tags: string[];
|
||||
media_refs: string[];
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Pro `(card_id, sub_index)` ein Review-Eintrag. Der Scheduler quert auf
|
||||
* `due <= now` täglich — das Feld bleibt deshalb plaintext und ist indiziert.
|
||||
*/
|
||||
export type Review = {
|
||||
card_id: string;
|
||||
sub_index: number;
|
||||
user_id: string;
|
||||
due: string;
|
||||
stability: number;
|
||||
difficulty: number;
|
||||
elapsed_days: number;
|
||||
scheduled_days: number;
|
||||
reps: number;
|
||||
lapses: number;
|
||||
state: 'new' | 'learning' | 'review' | 'relearning';
|
||||
last_review: string | null;
|
||||
};
|
||||
|
||||
export type StudySession = {
|
||||
id: string;
|
||||
user_id: string;
|
||||
deck_id: string;
|
||||
started_at: string;
|
||||
finished_at: string | null;
|
||||
cards_reviewed: number;
|
||||
cards_correct: number;
|
||||
};
|
||||
|
||||
/** Default-Konstanten aus ts-fsrs; per-Deck-Overrides möglich. */
|
||||
export type FsrsSettings = {
|
||||
requestRetention?: number;
|
||||
maximumInterval?: number;
|
||||
w?: number[];
|
||||
enableFuzz?: boolean;
|
||||
};
|
||||
12
packages/cards-domain/tsconfig.json
Normal file
12
packages/cards-domain/tsconfig.json
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "./dist",
|
||||
"rootDir": "./src",
|
||||
"declaration": true,
|
||||
"declarationMap": true,
|
||||
"sourceMap": true
|
||||
},
|
||||
"include": ["src/**/*", "tests/**/*"],
|
||||
"exclude": ["node_modules", "dist", ".turbo"]
|
||||
}
|
||||
11
pnpm-workspace.yaml
Normal file
11
pnpm-workspace.yaml
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
packages:
|
||||
- 'apps/*'
|
||||
- 'packages/*'
|
||||
|
||||
# Akzeptierte Peer-Drift mit Mana-Plattform-Stack (siehe mana/pnpm-workspace.yaml).
|
||||
# Cards startet auf gleichem Stand und migriert mit der Plattform mit.
|
||||
peerDependencyRules:
|
||||
allowedVersions:
|
||||
drizzle-orm: '0.38'
|
||||
drizzle-kit: '0.30'
|
||||
zod: '3'
|
||||
15
tsconfig.base.json
Normal file
15
tsconfig.base.json
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
"target": "ESNext",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"lib": ["ESNext"],
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"verbatimModuleSyntax": false
|
||||
}
|
||||
}
|
||||
43
turbo.json
Normal file
43
turbo.json
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
{
|
||||
"$schema": "https://turbo.build/schema.json",
|
||||
"concurrency": "5",
|
||||
"globalEnv": [
|
||||
"NODE_ENV",
|
||||
"DATABASE_URL",
|
||||
"MANA_AUTH_URL",
|
||||
"MANA_SHARE_URL",
|
||||
"MANA_LINKS_URL",
|
||||
"MANA_MCP_URL",
|
||||
"MANA_SEARCH_URL",
|
||||
"MANA_EVENTS_URL",
|
||||
"MANA_CREDITS_URL",
|
||||
"MANA_MEDIA_URL",
|
||||
"MANA_SERVICE_KEY",
|
||||
"CARDS_APP_SERVICE_KEY"
|
||||
],
|
||||
"tasks": {
|
||||
"dev": {
|
||||
"cache": false,
|
||||
"persistent": true
|
||||
},
|
||||
"build": {
|
||||
"dependsOn": ["^build"],
|
||||
"outputs": ["dist/**", "build/**", ".svelte-kit/**"]
|
||||
},
|
||||
"lint": {
|
||||
"dependsOn": ["^lint"],
|
||||
"outputs": []
|
||||
},
|
||||
"type-check": {
|
||||
"dependsOn": ["^type-check", "^build"],
|
||||
"outputs": []
|
||||
},
|
||||
"test": {
|
||||
"dependsOn": ["^build"],
|
||||
"outputs": ["coverage/**"]
|
||||
},
|
||||
"clean": {
|
||||
"cache": false
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue