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:
parent
751a50347c
commit
11e4389774
14 changed files with 187 additions and 1 deletions
12
apps/api/src/db/migrations/0006_decks_grade_levels.sql
Normal file
12
apps/api/src/db/migrations/0006_decks_grade_levels.sql
Normal 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[];
|
||||
|
|
@ -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 7–9).
|
||||
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).
|
||||
|
|
@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -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'),
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 }),
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue