From e068335dd42aa2762dc9677dd12ef328c01385a7 Mon Sep 17 00:00:00 2001 From: Till JS Date: Fri, 10 Apr 2026 19:08:42 +0200 Subject: [PATCH] =?UTF-8?q?refactor(credits):=20simplify=20credit=20system?= =?UTF-8?q?=20=E2=80=94=20remove=20productivity=20credits,=20guild=20pools?= =?UTF-8?q?,=20complex=20gift=20types?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The credit system was overengineered for the local-first architecture: - Productivity micro-credits (task/event/contact creation at 0.02 credits) made no sense since these operations happen locally in IndexedDB with zero server cost and were never enforced - Guild pool system (6 DB tables, spending limits, membership checks) had no active users - Gift system had 5 types (simple/personalized/split/first_come/riddle) when 2 suffice Now credits are only charged for operations that actually cost money: AI API calls and premium features (sync, exports). This makes the value proposition clear to users. Changes: - Remove 8 productivity operations + CreditCategory.PRODUCTIVITY from @mana/credits - Delete guild pool service, routes, schema (3 files); remove guild refs from 8 backend files - Simplify gifts to simple + personalized only; remove bcrypt/riddle/portions logic - Update all frontend pages (credits dashboard, gift create/redeem, public gift page) - Update shared-hono consumeCredits() to remove creditSource parameter - Update mana-credits CLAUDE.md Co-Authored-By: Claude Opus 4.6 (1M context) --- .claude/guidelines/sveltekit-web.md | 23 +- apps/mana/apps/web/eslint.config.js | 23 ++ apps/mana/apps/web/src/lib/api/gifts.ts | 27 +- apps/mana/apps/web/src/lib/data/database.ts | 11 + .../modules/calendar/stores/calendars.test.ts | 1 + .../apps/web/src/lib/modules/guides/index.ts | 3 +- .../apps/web/src/lib/modules/memoro/index.ts | 3 +- .../web/src/routes/(app)/credits/+page.svelte | 7 +- .../web/src/routes/(app)/gifts/+page.svelte | 81 +---- .../routes/(app)/gifts/redeem/+page.svelte | 2 +- .../(app)/gifts/redeem/[code]/+page.svelte | 56 +--- .../apps/web/src/routes/g/[code]/+page.svelte | 29 +- packages/credits/src/index.ts | 1 - packages/credits/src/operations.ts | 104 ------ .../credits/src/web/CreditPricingTable.svelte | 12 +- packages/shared-auth/package.json | 1 + packages/shared-hono/src/credits.ts | 4 +- packages/shared-ui/src/dnd/types.ts | 3 +- services/mana-credits/CLAUDE.md | 28 +- .../mana-credits/src/db/schema/credits.ts | 3 - services/mana-credits/src/db/schema/gifts.ts | 20 +- services/mana-credits/src/db/schema/guilds.ts | 79 ----- services/mana-credits/src/db/schema/index.ts | 1 - services/mana-credits/src/index.ts | 13 +- services/mana-credits/src/lib/validation.ts | 30 +- services/mana-credits/src/routes/credits.ts | 2 +- services/mana-credits/src/routes/gifts.ts | 1 - services/mana-credits/src/routes/guild.ts | 69 ---- services/mana-credits/src/routes/internal.ts | 11 +- services/mana-credits/src/services/credits.ts | 13 +- .../mana-credits/src/services/gift-code.ts | 88 ++--- .../mana-credits/src/services/guild-pool.ts | 316 ------------------ 32 files changed, 143 insertions(+), 922 deletions(-) delete mode 100644 services/mana-credits/src/db/schema/guilds.ts delete mode 100644 services/mana-credits/src/routes/guild.ts delete mode 100644 services/mana-credits/src/services/guild-pool.ts diff --git a/.claude/guidelines/sveltekit-web.md b/.claude/guidelines/sveltekit-web.md index 16da4d772..7d953ae16 100644 --- a/.claude/guidelines/sveltekit-web.md +++ b/.claude/guidelines/sveltekit-web.md @@ -65,23 +65,38 @@ apps/{project}/apps/web/ ### Derived Values with $derived +**CRITICAL: `$derived(expr)` vs `$derived.by(fn)`** + +- `$derived(expression)` — takes a **single expression**. The value IS the expression result. +- `$derived.by(() => { ... return value; })` — takes a **function** (thunk). Use this when you need `if`/`switch`/`for` or multiple statements. + +**Common mistake:** writing `$derived(() => { ... })` — this stores the arrow function itself as the value, not its return value. Every `{#if myDerived}` will be truthy (functions are always truthy), and `myDerived()` will fail with "not callable" at the type level. + ```svelte ``` diff --git a/apps/mana/apps/web/eslint.config.js b/apps/mana/apps/web/eslint.config.js index 5347ceaf7..bd1e9b8ef 100644 --- a/apps/mana/apps/web/eslint.config.js +++ b/apps/mana/apps/web/eslint.config.js @@ -9,4 +9,27 @@ export default [ ...typescriptConfig, ...svelteConfig, ...prettierConfig, + // Guard: prevent raw liveQuery imports in module code. All modules + // must use useLiveQueryWithDefault from @mana/local-store/svelte + // instead, which provides a reactive { value, loading, error } shape. + // Raw liveQuery returns an Observable that requires manual .subscribe() + // boilerplate and was the root cause of 38 type errors. + { + files: ['src/lib/modules/**/queries.ts'], + rules: { + 'no-restricted-imports': [ + 'error', + { + paths: [ + { + name: 'dexie', + importNames: ['liveQuery'], + message: + 'Use useLiveQueryWithDefault from @mana/local-store/svelte instead of raw liveQuery. See the Observable migration commit for rationale.', + }, + ], + }, + ], + }, + }, ]; diff --git a/apps/mana/apps/web/src/lib/api/gifts.ts b/apps/mana/apps/web/src/lib/api/gifts.ts index 9be0bd733..32e2dec03 100644 --- a/apps/mana/apps/web/src/lib/api/gifts.ts +++ b/apps/mana/apps/web/src/lib/api/gifts.ts @@ -9,15 +9,11 @@ import { getManaAuthUrl } from './config'; // Types export interface GiftCodeInfo { code: string; - type: 'simple' | 'personalized' | 'split' | 'first_come' | 'riddle'; + type: 'simple' | 'personalized'; status: 'active' | 'depleted' | 'expired' | 'cancelled' | 'refunded'; - creditsPerPortion: number; - totalPortions: number; - claimedPortions: number; - remainingPortions: number; + totalCredits: number; + redeemed: boolean; message?: string; - riddleQuestion?: string; - hasRiddle: boolean; isPersonalized: boolean; expiresAt?: string; creatorName?: string; @@ -38,9 +34,7 @@ export interface GiftListItem { type: string; status: string; totalCredits: number; - creditsPerPortion: number; - totalPortions: number; - claimedPortions: number; + redeemed: number; message?: string; expiresAt?: string; createdAt: string; @@ -60,19 +54,14 @@ export interface CreateGiftResponse { code: string; url: string; totalCredits: number; - creditsPerPortion: number; - totalPortions: number; type: string; expiresAt?: string; } export interface CreateGiftRequest { credits: number; - type?: 'simple' | 'personalized' | 'split' | 'first_come' | 'riddle'; - portions?: number; + type?: 'simple' | 'personalized'; targetEmail?: string; - riddleQuestion?: string; - riddleAnswer?: string; message?: string; expiresAt?: string; sourceAppId?: string; @@ -127,10 +116,10 @@ export const giftsService = { /** * Redeem a gift code */ - async redeemGift(code: string, answer?: string): Promise { + async redeemGift(code: string): Promise { return fetchWithAuth(`/api/v1/gifts/${code.toUpperCase()}/redeem`, { method: 'POST', - body: JSON.stringify({ answer, sourceAppId: 'mana-web' }), + body: JSON.stringify({ sourceAppId: 'mana-web' }), }); }, @@ -159,7 +148,7 @@ export const giftsService = { }, /** - * Cancel a gift code and get refund for unclaimed portions + * Cancel a gift code and get refund */ async cancelGift(id: string): Promise<{ refundedCredits: number }> { return fetchWithAuth<{ refundedCredits: number }>(`/api/v1/gifts/${id}`, { diff --git a/apps/mana/apps/web/src/lib/data/database.ts b/apps/mana/apps/web/src/lib/data/database.ts index f153c9e5b..ebe5d1a1e 100644 --- a/apps/mana/apps/web/src/lib/data/database.ts +++ b/apps/mana/apps/web/src/lib/data/database.ts @@ -228,6 +228,9 @@ db.version(1).stores({ habits: 'id, order, isArchived, color', habitLogs: 'id, habitId, timeBlockId, [habitId+timeBlockId]', + // ─── Journal (appId: 'journal') ─── + journalEntries: 'id, entryDate, mood, isPinned, isArchived, isFavorite, updatedAt', + // ─── Dreams (appId: 'dreams') ─── dreams: 'id, dreamDate, mood, isLucid, isPinned, isArchived, updatedAt', dreamSymbols: 'id, name, count, updatedAt', @@ -356,6 +359,14 @@ db.version(4).stores({ newsCachedFeed: 'id, topic, sourceSlug, language, publishedAt, [topic+publishedAt]', }); +// Schema version 5 — adds timeBlockId index to bodyWorkouts so the +// calendar/timeline integration (createBlock in startWorkout) can +// look up "which workout owns this TimeBlock" via a Dexie index +// instead of a full-table scan + filter. Additive only. +db.version(5).stores({ + bodyWorkouts: 'id, startedAt, endedAt, routineId, timeBlockId, [endedAt+startedAt]', +}); + // v5: Zitare custom quotes — user-created quotes stored locally. db.version(5).stores({ zitareCustomQuotes: 'id, author, category', diff --git a/apps/mana/apps/web/src/lib/modules/calendar/stores/calendars.test.ts b/apps/mana/apps/web/src/lib/modules/calendar/stores/calendars.test.ts index 42190cdd7..6e47914d8 100644 --- a/apps/mana/apps/web/src/lib/modules/calendar/stores/calendars.test.ts +++ b/apps/mana/apps/web/src/lib/modules/calendar/stores/calendars.test.ts @@ -77,6 +77,7 @@ describe('calendarsStore.setAsDefault', () => { isDefault: c.isDefault as boolean, isVisible: c.isVisible as boolean, color: c.color as string, + timezone: (c.timezone as string) ?? null, createdAt: (c.createdAt as string) ?? '', updatedAt: (c.updatedAt as string) ?? '', })); diff --git a/apps/mana/apps/web/src/lib/modules/guides/index.ts b/apps/mana/apps/web/src/lib/modules/guides/index.ts index 8c2bebb45..6d8e41477 100644 --- a/apps/mana/apps/web/src/lib/modules/guides/index.ts +++ b/apps/mana/apps/web/src/lib/modules/guides/index.ts @@ -24,4 +24,5 @@ export { guideTable, sectionTable, stepTable, runTable, GUIDES_GUEST_SEED } from // TODO: GUIDES should be populated from a content source (static JSON, CMS, or DB). // For now export an empty array so the /guides route renders without crashing the build. -export const GUIDES: Guide[] = []; +import type { Guide as GuideType } from './types'; +export const GUIDES: GuideType[] = []; diff --git a/apps/mana/apps/web/src/lib/modules/memoro/index.ts b/apps/mana/apps/web/src/lib/modules/memoro/index.ts index 845b8b3ca..c596251b9 100644 --- a/apps/mana/apps/web/src/lib/modules/memoro/index.ts +++ b/apps/mana/apps/web/src/lib/modules/memoro/index.ts @@ -46,7 +46,8 @@ export type { LocalMemoSpace, Memo, Memory, - Tag, Space, ProcessingStatus, } from './types'; +// Tag type re-exported from @mana/shared-tags (the local memoro Tag was removed) +export type { Tag } from '@mana/shared-tags'; diff --git a/apps/mana/apps/web/src/routes/(app)/credits/+page.svelte b/apps/mana/apps/web/src/routes/(app)/credits/+page.svelte index ea5213a4b..4b16d9cf0 100644 --- a/apps/mana/apps/web/src/routes/(app)/credits/+page.svelte +++ b/apps/mana/apps/web/src/routes/(app)/credits/+page.svelte @@ -24,7 +24,7 @@ let loading = $state(true); let error = $state(null); let activeTab = $state<'overview' | 'transactions' | 'packages' | 'costs'>('overview'); - let costFilter = $state<'all' | 'ai' | 'productivity' | 'premium'>('all'); + let costFilter = $state<'all' | 'ai' | 'premium'>('all'); // Build pricing data grouped by app const allOperations = $derived( @@ -44,7 +44,6 @@ ? allOperations : allOperations.filter((op) => { if (costFilter === 'ai') return op.category === CreditCategory.AI; - if (costFilter === 'productivity') return op.category === CreditCategory.PRODUCTIVITY; if (costFilter === 'premium') return op.category === CreditCategory.PREMIUM; return true; }) @@ -86,8 +85,6 @@ switch (category) { case CreditCategory.AI: return 'KI-Features'; - case CreditCategory.PRODUCTIVITY: - return 'Erstellen'; case CreditCategory.PREMIUM: return 'Premium'; default: @@ -486,7 +483,7 @@ {:else if activeTab === 'costs'}
- {#each [{ key: 'all', label: 'Alle' }, { key: 'ai', label: 'KI-Features' }, { key: 'productivity', label: 'Erstellen' }, { key: 'premium', label: 'Premium' }] as filter} + {#each [{ key: 'all', label: 'Alle' }, { key: 'ai', label: 'KI-Features' }, { key: 'premium', label: 'Premium' }] as filter}

Eingelöst

-

{gift.claimedPortions} / {gift.totalPortions}

+

{gift.redeemed ? 'Ja' : 'Nein'}

Erstellt

@@ -466,63 +454,30 @@ class="w-full rounded-lg border border-border bg-background px-4 py-2 text-foreground focus:border-primary focus:outline-none focus:ring-1 focus:ring-primary" disabled={creating} > - - - + +
- {#if createType === 'split'} + {#if createType === 'personalized'}
-
{/if} - {#if createType === 'riddle'} -
- - -
-
- - -
- {/if} -
-

Credits: {giftInfo.creditsPerPortion}

+

Credits: {giftInfo.totalCredits}

Status: {getStatusLabel(giftInfo.status)}

{#if giftInfo.creatorName}

Von: {giftInfo.creatorName}

diff --git a/apps/mana/apps/web/src/routes/(app)/gifts/redeem/[code]/+page.svelte b/apps/mana/apps/web/src/routes/(app)/gifts/redeem/[code]/+page.svelte index 38c7aae85..ef40d3441 100644 --- a/apps/mana/apps/web/src/routes/(app)/gifts/redeem/[code]/+page.svelte +++ b/apps/mana/apps/web/src/routes/(app)/gifts/redeem/[code]/+page.svelte @@ -11,7 +11,6 @@ let redeeming = $state(false); let success = $state(false); let error = $state(null); - let riddleAnswer = $state(''); let receivedCredits = $state(0); let newBalance = $state(0); @@ -39,19 +38,12 @@ async function handleRedeem() { if (!giftInfo) return; - if (giftInfo.hasRiddle && !riddleAnswer.trim()) { - showToast('Bitte gib die Antwort auf das Rätsel ein', 'error'); - return; - } redeeming = true; error = null; try { - const result = await giftsService.redeemGift( - code, - giftInfo.hasRiddle ? riddleAnswer : undefined - ); + const result = await giftsService.redeemGift(code); if (result.success) { success = true; @@ -110,12 +102,6 @@ return 'Geschenk'; case 'personalized': return 'Persönliches Geschenk'; - case 'split': - return 'Geteiltes Geschenk'; - case 'first_come': - return 'Erste kommen'; - case 'riddle': - return 'Rätsel-Geschenk'; default: return type; } @@ -198,7 +184,7 @@

Du erhältst

-

{giftInfo.creditsPerPortion}

+

{giftInfo.totalCredits}

Credits

@@ -217,14 +203,6 @@ {getStatusLabel(giftInfo.status)}
- {#if giftInfo.totalPortions > 1} -
- Verfügbar - {giftInfo.remainingPortions} / {giftInfo.totalPortions} -
- {/if} {#if giftInfo.expiresAt}
Gültig bis @@ -249,7 +227,7 @@

{#if giftInfo.status === 'depleted'} - Dieses Geschenk wurde bereits vollständig eingelöst + Dieses Geschenk wurde bereits eingelöst {:else if giftInfo.status === 'expired'} Dieses Geschenk ist abgelaufen {:else} @@ -270,32 +248,6 @@

{/if} - {#if giftInfo.hasRiddle} -
-
- 🧩 -
-

Rätsel:

-

{giftInfo.riddleQuestion}

-
-
-
- -
- - -
- {/if} - {#if error}

{error}

@@ -304,7 +256,7 @@