feat(marketplace): grade_levels + geography/civics categories + history cleanup

Discovery-Verbesserung für Phase 2 (CONTENT_PLAN_PHASE2.md §5/§8):

- Schema: neue Spalte marketplace.decks.grade_levels (text[]) +
  Kategorien 'geography' und 'civics' (decks.ts, @wordeck/domain).
- Migration 0006: ADD COLUMN grade_levels (idempotent).
- Migration 0007: einmalige Daten-Korrektur — Geografie-/Orts-Decks
  history → geography, Factfulness → civics; grade_levels-Backfill
  (zyklus2/sek1/sek2/erwachsene) für die curricularen Decks. Slug-
  gezielt + idempotent (0 Zeilen auf fremden DBs).
- API: create/patch akzeptieren gradeLevels; browse/explore/me geben
  grade_levels zurück; ?grade= Discovery-Filter.
- Web: Kategorie-Icons/Farben für geography (MapPin) + civics (BookOpen),
  Schulstufen-Filter auf /explore, Stufen-Badge auf Deck-Karten.

Aktiviert sich auf prod beim nächsten Deploy (WORDECK_RUN_MIGRATIONS).
type-check grün (api/web/domain), 51 Domain-Tests grün.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-05-25 15:31:43 +02:00
parent 751a50347c
commit 11e4389774
14 changed files with 187 additions and 1 deletions

View file

@ -0,0 +1,12 @@
-- Phase-2-Discovery: Schulstufen-Tags pro Marketplace-Deck.
--
-- Bisher unterscheidet die Discovery Sek-1- und Sek-2-Decks nicht
-- (gleicher Fachbereich, gleiche Kategorie). Das Feld `grade_levels`
-- (text[]) erlaubt Filter wie „nur Sek 2" oder „nur Erwachsene".
-- Konvention der Werte: 'zyklus2', 'sek1', 'sek2', 'erwachsene'.
-- Siehe docs/marketplace/CONTENT_PLAN_PHASE2.md §5/§8.
--
-- Idempotent (ADD COLUMN IF NOT EXISTS).
ALTER TABLE "marketplace"."decks"
ADD COLUMN IF NOT EXISTS "grade_levels" text[];

View file

@ -0,0 +1,84 @@
-- Einmalige Daten-Korrektur: Kategorie-Cleanup + grade_levels-Backfill.
--
-- Ausgangslage (Audit 2026-05-25): die `history`-Kategorie war
-- überladen — Geografie- und Orts-/Regional-Decks lagen dort, weil es
-- vor Migration 0007 keine `geography`-Kategorie gab. Dazu wird das in
-- 0006 angelegte `grade_levels`-Feld für die curricularen Decks befüllt.
--
-- Alle Statements sind slug-gezielt und idempotent: auf einer frischen
-- oder lokalen DB ohne diese Decks betreffen sie 0 Zeilen.
--
-- Quelle der Klassifikation: docs/marketplace/CONTENT_PLAN.md (Stufen-
-- Mapping §7) + CONTENT_PLAN_PHASE2.md §5.
-- ── A. Kategorie-Korrektur ────────────────────────────────────────────
-- A1. Echte Geografie-Decks: history → geography.
UPDATE "marketplace"."decks" SET "category" = 'geography'
WHERE "slug" IN (
'geografie-welt-hauptstaedte',
'geografie-welt-top30',
'geografie-dach-gebirge-fluesse',
'schweizer-kantone'
);
-- A2. Orts-/Regional-Decks (Bodensee-Reihe, Thurgau): place-based,
-- gehören in geography statt history.
UPDATE "marketplace"."decks" SET "category" = 'geography'
WHERE "slug" IN (
'allensbach-bodensee', 'bodensee', 'bregenz-vorarlberg',
'friedrichshafen-bodensee', 'gottlieben-seerhein', 'konstanz-bodensee',
'kreuzlingen-thurgau', 'lindau-bodensee', 'meersburg-bodensee',
'muensterlingen-bodensee', 'reichenau-bodensee', 'romanshorn-bodensee',
'taegerwilen-bodensee', 'thurgau', 'ueberlingen-bodensee'
);
-- A3. Factfulness (globales Entwicklungs-/Weltwissen): history → civics.
UPDATE "marketplace"."decks" SET "category" = 'civics'
WHERE "slug" = 'factfulness-welt-quiz';
-- Echte Geschichts-Decks (geschichte-ch/de/welt-*) bleiben history.
-- ── B. grade_levels-Backfill ──────────────────────────────────────────
-- B1. Sek 2 / Gymnasium.
UPDATE "marketplace"."decks" SET "grade_levels" = ARRAY['sek2']::text[]
WHERE "slug" IN (
'mathematik-sek2-analysis', 'mathematik-sek2-stochastik',
'biologie-genetik-molekular', 'geschichte-welt-20-jahrhundert'
);
-- B2. Sek 1 (Kernstufe Zyklus 3 / Klasse 79).
UPDATE "marketplace"."decks" SET "grade_levels" = ARRAY['sek1']::text[]
WHERE "slug" IN (
'mathematik-sek1-grundbegriffe', 'deutsch-grammatik-sek1',
'deutsch-rechtschreibung-regeln', 'englisch-grammatik-tenses',
'franzoesisch-grammatik-konjugation', 'biologie-zelle-grundlagen',
'biologie-genetik-mendel', 'biologie-organe-mensch',
'chemie-organik-grundlagen', 'physik-mechanik-formeln',
'physik-konstanten', 'periodensystem-elemente', 'informatik-sek1',
'geschichte-ch-eckdaten-1291-1848', 'geschichte-de-eckdaten-1789-1989',
'geografie-dach-gebirge-fluesse', 'schweizer-kantone',
'englisch-a2-grundwortschatz', 'english-a2-grundwortschatz',
'franzoesisch-a2-grundwortschatz', 'italienisch-a2-grundwortschatz'
);
-- B3. Zyklus 2 (Primar 3.6.) mit Anschluss Sek 1.
UPDATE "marketplace"."decks" SET "grade_levels" = ARRAY['zyklus2', 'sek1']::text[]
WHERE "slug" IN (
'mathematik-einmaleins', 'geografie-welt-hauptstaedte', 'geografie-welt-top30'
);
-- B4. Stufenübergreifend Sek 1 + Sek 2 (B1-Niveau, Latein, Verb-Drill,
-- Helvetismen).
UPDATE "marketplace"."decks" SET "grade_levels" = ARRAY['sek1', 'sek2']::text[]
WHERE "slug" IN (
'englisch-b1-aufbauwortschatz', 'latein-grundwortschatz',
'englisch-unregelmaessige-verben', 'deutsch-helvetismen'
);
-- B5. Allgemeinbildung (Sek 2 + Erwachsene).
UPDATE "marketplace"."decks" SET "grade_levels" = ARRAY['sek2', 'erwachsene']::text[]
WHERE "slug" = 'factfulness-welt-quiz';
-- Orts-/Regional-Decks bleiben ungetaggt (kein Lehrplan-Bezug).

View file

@ -43,6 +43,20 @@
"when": 1779285060000,
"tag": "0005_wordeck_license_rename",
"breakpoints": true
},
{
"idx": 6,
"version": "7",
"when": 1779667200000,
"tag": "0006_decks_grade_levels",
"breakpoints": true
},
{
"idx": 7,
"version": "7",
"when": 1779667260000,
"tag": "0007_decks_category_cleanup_grades",
"breakpoints": true
}
]
}

View file

@ -58,6 +58,8 @@ export const publicDecks = marketplaceSchema.table(
'science',
'math',
'history',
'geography',
'civics',
'law',
'technology',
'arts',
@ -66,6 +68,10 @@ export const publicDecks = marketplaceSchema.table(
'other',
],
}),
// Schulstufen-Tags für Discovery-Filter (z.B. 'sek1', 'sek2',
// 'zyklus2', 'erwachsene'). Mehrere möglich, nullable/leer = ungetaggt.
// Konvention in docs/marketplace/CONTENT_PLAN_PHASE2.md §5.
gradeLevels: text('grade_levels').array(),
// SPDX-style ID. CC0-1.0, CC-BY-4.0, CC-BY-SA-4.0,
// Wordeck-Personal-Use-1.0 (default für free), Wordeck-Pro-Only-1.0 (paid).
license: text('license').notNull().default('Wordeck-Personal-Use-1.0'),

View file

@ -9,6 +9,7 @@ export function toPublicDeckDto(row: typeof publicDecks.$inferSelect) {
description: row.description,
language: row.language,
category: row.category,
grade_levels: row.gradeLevels ?? [],
license: row.license,
price_credits: row.priceCredits,
owner_user_id: row.ownerUserId,

View file

@ -43,10 +43,14 @@ export type MarketplaceDecksDeps = { db?: CardsDb };
const SEMVER_RE = /^(\d+)\.(\d+)\.(\d+)$/;
const MarketplaceCategorySchema = z.enum([
'language', 'medicine', 'science', 'math', 'history',
'language', 'medicine', 'science', 'math', 'history', 'geography', 'civics',
'law', 'technology', 'arts', 'music', 'sport', 'other',
]);
// Schulstufen-Tags für Discovery-Filter. Werte-Konvention in
// docs/marketplace/CONTENT_PLAN_PHASE2.md §5.
const GradeLevelSchema = z.enum(['zyklus2', 'sek1', 'sek2', 'erwachsene']);
const InitSchema = z.object({
slug: z.string(),
title: z.string().min(1).max(120),
@ -58,6 +62,7 @@ const InitSchema = z.object({
license: z.string().max(60).optional(),
priceCredits: z.number().int().min(0).max(100_000).optional(),
category: MarketplaceCategorySchema.optional(),
gradeLevels: z.array(GradeLevelSchema).max(4).optional(),
});
const PatchSchema = z.object({
@ -67,6 +72,7 @@ const PatchSchema = z.object({
license: z.string().max(60).optional(),
priceCredits: z.number().int().min(0).max(100_000).optional(),
category: MarketplaceCategorySchema.optional(),
gradeLevels: z.array(GradeLevelSchema).max(4).optional(),
});
const CardTypeSchema = z.enum([
@ -217,6 +223,7 @@ export function marketplaceDecksRouter(
description: parsed.data.description,
language: parsed.data.language,
category: parsed.data.category,
gradeLevels: parsed.data.gradeLevels,
license,
priceCredits,
ownerUserId: userId,
@ -257,6 +264,7 @@ export function marketplaceDecksRouter(
...(parsed.data.description !== undefined && { description: parsed.data.description }),
...(parsed.data.language !== undefined && { language: parsed.data.language }),
...(parsed.data.category !== undefined && { category: parsed.data.category }),
...(parsed.data.gradeLevels !== undefined && { gradeLevels: parsed.data.gradeLevels }),
...(parsed.data.license !== undefined && { license }),
...(parsed.data.priceCredits !== undefined && { priceCredits }),
})

View file

@ -44,6 +44,8 @@ const BrowseQuerySchema = z.object({
.regex(/^[a-z]{2}$/)
.optional(),
author: z.string().max(60).optional(),
// Discovery-Filter nach Schulstufe (CONTENT_PLAN_PHASE2.md §5).
grade: z.enum(['zyklus2', 'sek1', 'sek2', 'erwachsene']).optional(),
sort: SortEnum.optional(),
limit: z.coerce.number().int().min(1).max(100).optional(),
offset: z.coerce.number().int().min(0).optional(),
@ -55,6 +57,7 @@ interface DeckListEntry {
description: string | null;
language: string | null;
category: string | null;
grade_levels: string[];
license: string;
price_credits: number;
card_count: number;
@ -99,6 +102,9 @@ async function browseImpl(
sql`EXISTS (SELECT 1 FROM marketplace.deck_tags dt JOIN marketplace.tag_definitions td ON td.id = dt.tag_id WHERE dt.deck_id = ${publicDecks.id} AND td.slug = ${filter.tag})`
);
}
if (filter.grade) {
conditions.push(sql`${publicDecks.gradeLevels} @> ARRAY[${filter.grade}]::text[]`);
}
// Block-Filter: wenn der anfragende User Authors blockiert hat,
// werden deren Decks aus dem Listing geworfen. Reine App-Store-
// Guideline-5.1.1(v)-Compliance — UGC-Block muss in Listings wirken.
@ -128,6 +134,7 @@ async function browseImpl(
description: publicDecks.description,
language: publicDecks.language,
category: publicDecks.category,
gradeLevels: publicDecks.gradeLevels,
license: publicDecks.license,
priceCredits: publicDecks.priceCredits,
cardCount: cardCountExpr,
@ -160,6 +167,7 @@ async function browseImpl(
description: r.description,
language: r.language,
category: r.category,
grade_levels: r.gradeLevels ?? [],
license: r.license,
price_credits: r.priceCredits,
card_count: Number(r.cardCount),

View file

@ -39,6 +39,7 @@ export function marketplaceMeRouter(deps: MarketplaceMeDeps = {}): Hono<{ Variab
description: publicDecks.description,
language: publicDecks.language,
category: publicDecks.category,
gradeLevels: publicDecks.gradeLevels,
license: publicDecks.license,
priceCredits: publicDecks.priceCredits,
isTakedown: publicDecks.isTakedown,
@ -60,6 +61,7 @@ export function marketplaceMeRouter(deps: MarketplaceMeDeps = {}): Hono<{ Variab
description: row.description,
language: row.language,
category: row.category,
grade_levels: row.gradeLevels ?? [],
license: row.license,
price_credits: row.priceCredits,
is_takedown: row.isTakedown,

View file

@ -30,6 +30,7 @@ export interface MarketplaceDeck {
description: string | null;
language: string | null;
category: string | null;
grade_levels: string[];
license: string;
price_credits: number;
owner_user_id: string;
@ -63,6 +64,7 @@ export interface DeckListEntry {
description: string | null;
language: string | null;
category: string | null;
grade_levels: string[];
license: string;
price_credits: number;
card_count: number;
@ -162,6 +164,7 @@ export interface BrowseQuery {
tag?: string;
language?: string;
author?: string;
grade?: 'zyklus2' | 'sek1' | 'sek2' | 'erwachsene';
sort?: 'recent' | 'popular' | 'trending';
limit?: number;
offset?: number;

View file

@ -6,6 +6,8 @@
Leaf,
Brain,
Flag,
MapPin,
BookOpen,
Medal,
Lightning,
Palette,
@ -31,6 +33,8 @@
science: Leaf,
math: Brain,
history: Flag,
geography: MapPin,
civics: BookOpen,
law: Medal,
technology: Lightning,
arts: Palette,

View file

@ -26,6 +26,8 @@
science: '#22C55E',
math: '#6366F1',
history: '#F59E0B',
geography: '#14B8A6',
civics: '#EAB308',
law: '#64748B',
technology: '#06B6D4',
arts: '#A855F7',
@ -42,6 +44,16 @@
const [r] = deterministicRandoms(deck.slug, 1);
return FALLBACK_PALETTE[Math.floor(r * FALLBACK_PALETTE.length)];
});
const GRADE_SHORT: Record<string, string> = {
zyklus2: 'Primar',
sek1: 'Sek 1',
sek2: 'Sek 2',
erwachsene: 'Erwachsene',
};
const gradeLabel = $derived(
deck.grade_levels?.length ? (GRADE_SHORT[deck.grade_levels[0]] ?? null) : null
);
</script>
<div class="stack-wrap">
@ -82,6 +94,9 @@
<div class="cover-meta">
<span class="meta-count">{deck.card_count} Karten</span>
{#if gradeLabel}
<span class="meta-grade">{gradeLabel}</span>
{/if}
{#if deck.star_count > 0}
<span class="meta-stars">
<Star size={11} weight="fill" />{deck.star_count}
@ -186,4 +201,13 @@
color: hsl(var(--color-primary));
font-weight: 500;
}
.meta-grade {
display: inline-flex;
align-items: center;
padding: 0 0.4rem;
border-radius: 9999px;
background: hsl(var(--color-muted) / 0.6);
font-weight: 500;
}
</style>

View file

@ -123,6 +123,7 @@
description: deck.description,
language: deck.language,
category: deck.category,
grade_levels: deck.grade_levels ?? [],
license: deck.license,
price_credits: deck.price_credits,
card_count: latest_version?.card_count ?? 0,

View file

@ -22,6 +22,7 @@
let q = $state('');
let language = $state('');
let grade = $state<'' | 'zyklus2' | 'sek1' | 'sek2' | 'erwachsene'>('');
let sort = $state<'recent' | 'popular' | 'trending'>('recent');
let offset = $state(0);
const limit = 12;
@ -49,6 +50,7 @@
const result = await browseDecks({
q: q || undefined,
language: language || undefined,
grade: grade || undefined,
sort,
limit,
offset,
@ -142,6 +144,19 @@
<option value="en">English</option>
</select>
</label>
<label>
<span class="block text-xs text-[hsl(var(--color-muted-foreground))]">Schulstufe</span>
<select
bind:value={grade}
class="mt-1 block rounded border bg-[hsl(var(--color-card))] border-[hsl(var(--color-border))] px-3 py-2 text-sm"
>
<option value="">Alle</option>
<option value="zyklus2">Primar / Zyklus 2</option>
<option value="sek1">Sek 1</option>
<option value="sek2">Sek 2 / Gymnasium</option>
<option value="erwachsene">Erwachsene</option>
</select>
</label>
<label>
<span class="block text-xs text-[hsl(var(--color-muted-foreground))]">Sortierung</span>
<select

View file

@ -10,6 +10,8 @@ export const DECK_CATEGORY_IDS = [
'science',
'math',
'history',
'geography',
'civics',
'law',
'technology',
'arts',
@ -27,6 +29,8 @@ export const DECK_CATEGORY_LABELS: Record<DeckCategoryId, string> = {
science: 'Wissenschaft',
math: 'Mathematik',
history: 'Geschichte',
geography: 'Geografie',
civics: 'Gesellschaft',
law: 'Recht',
technology: 'Technik',
arts: 'Kunst',