diff --git a/apps/mana/apps/web/src/routes/(app)/onboarding/templates/+page.svelte b/apps/mana/apps/web/src/routes/(app)/onboarding/templates/+page.svelte new file mode 100644 index 000000000..eddd18e75 --- /dev/null +++ b/apps/mana/apps/web/src/routes/(app)/onboarding/templates/+page.svelte @@ -0,0 +1,351 @@ + + + +
+
+

Wofür willst du Mana nutzen?

+

+ Wähl aus, was zu dir passt. Wir stellen dir dazu passende Module auf deinen Startbildschirm — + weitere kannst du jederzeit hinzufügen. +

+
+ +
+ {#each ONBOARDING_TEMPLATES as tpl (tpl.id)} + {@const Icon = ICONS[tpl.iconName]} + {@const isSelected = selected.has(tpl.id)} + + {/each} +
+ + {#if error} + + {/if} + +
+ +
+ + +
+
+
+ + diff --git a/packages/shared-branding/src/index.ts b/packages/shared-branding/src/index.ts index c5a70f917..4f12a27b1 100644 --- a/packages/shared-branding/src/index.ts +++ b/packages/shared-branding/src/index.ts @@ -84,3 +84,11 @@ export { type SpaceModuleId, type SpaceMetadata, } from './spaces'; + +// Onboarding templates (see docs/plans/onboarding-flow.md) +export { + ONBOARDING_TEMPLATES, + resolveModulesForTemplates, + type OnboardingTemplate, + type OnboardingTemplateId, +} from './onboarding-templates'; diff --git a/packages/shared-branding/src/onboarding-templates.spec.ts b/packages/shared-branding/src/onboarding-templates.spec.ts new file mode 100644 index 000000000..7db8bc6f6 --- /dev/null +++ b/packages/shared-branding/src/onboarding-templates.spec.ts @@ -0,0 +1,100 @@ +import { describe, it, expect } from 'vitest'; +import { + ONBOARDING_TEMPLATES, + resolveModulesForTemplates, + type OnboardingTemplateId, +} from './onboarding-templates'; + +describe('ONBOARDING_TEMPLATES', () => { + it('has exactly 7 templates in the expected order', () => { + expect(ONBOARDING_TEMPLATES.map((t) => t.id)).toEqual([ + 'alltag', + 'arbeit', + 'health', + 'sport', + 'lernen', + 'entdecken', + 'erinnern', + ]); + }); + + it('every template has a name, description, icon, and at least 3 modules', () => { + for (const tpl of ONBOARDING_TEMPLATES) { + expect(tpl.name.length).toBeGreaterThan(0); + expect(tpl.shortDescription.length).toBeGreaterThan(0); + expect(tpl.iconName.length).toBeGreaterThan(0); + expect(tpl.moduleIds.length).toBeGreaterThanOrEqual(3); + } + }); + + it('no template references duplicate module ids within itself', () => { + for (const tpl of ONBOARDING_TEMPLATES) { + expect(new Set(tpl.moduleIds).size).toBe(tpl.moduleIds.length); + } + }); +}); + +describe('resolveModulesForTemplates', () => { + it('returns empty for empty selection', () => { + expect(resolveModulesForTemplates([])).toEqual([]); + }); + + it('returns a single templates modules verbatim (under cap)', () => { + expect(resolveModulesForTemplates(['alltag'])).toEqual([ + 'todo', + 'calendar', + 'notes', + 'contacts', + ]); + }); + + it('dedupes across templates in priority order', () => { + // Alltag first: todo/calendar/notes/contacts + // Arbeit second: todo/calendar/mail/chat/times/notes — only mail/chat/times are new + const result = resolveModulesForTemplates(['alltag', 'arbeit']); + expect(result).toEqual(['todo', 'calendar', 'notes', 'contacts', 'mail', 'chat', 'times']); + }); + + it('preserves selection order (arbeit-first vs alltag-first)', () => { + const a = resolveModulesForTemplates(['arbeit', 'alltag']); + const b = resolveModulesForTemplates(['alltag', 'arbeit']); + // Different first module confirms priority is the selection order, + // not a fixed template order. + expect(a[0]).toBe('todo'); // both start with todo, but arbeit's "mail" outranks alltag's "contacts" + expect(a.indexOf('mail')).toBeLessThan(a.indexOf('contacts')); + expect(b.indexOf('contacts')).toBeLessThan(b.indexOf('mail')); + }); + + it('honours the cap and drops overflow modules (default cap = 8)', () => { + // All 7 templates picked — union pre-dedup is ~27 modules. + const allIds: OnboardingTemplateId[] = [ + 'alltag', + 'arbeit', + 'health', + 'sport', + 'lernen', + 'entdecken', + 'erinnern', + ]; + const result = resolveModulesForTemplates(allIds); + expect(result.length).toBe(8); + // Alltag's four modules must all make it — they're in the first template. + expect(result.slice(0, 4)).toEqual(['todo', 'calendar', 'notes', 'contacts']); + }); + + it('respects a custom cap', () => { + const result = resolveModulesForTemplates(['alltag', 'health'], 5); + expect(result.length).toBe(5); + expect(result).toEqual(['todo', 'calendar', 'notes', 'contacts', 'habits']); + }); + + it('silently ignores unknown template ids', () => { + const result = resolveModulesForTemplates([ + 'alltag', + 'nonexistent' as OnboardingTemplateId, + 'arbeit', + ]); + // Same as 'alltag' + 'arbeit' without the unknown one. + expect(result).toEqual(['todo', 'calendar', 'notes', 'contacts', 'mail', 'chat', 'times']); + }); +}); diff --git a/packages/shared-branding/src/onboarding-templates.ts b/packages/shared-branding/src/onboarding-templates.ts new file mode 100644 index 000000000..3617d1a49 --- /dev/null +++ b/packages/shared-branding/src/onboarding-templates.ts @@ -0,0 +1,120 @@ +/** + * Onboarding templates — starter-packs a user picks on Screen 3 of the + * first-login flow (/onboarding/templates). Each template is a named + * use-case with an ordered list of module IDs. The flow's finish + * handler deduplicates the union of picked templates' modules (keeping + * the first occurrence in priority order), caps the total at 8, and + * writes the result as the user's Home scene. + * + * All module IDs are verified against + * apps/mana/apps/web/src/lib/app-registry/apps.ts — if a template + * references a module that was removed, catch it in the test below + * before the user sees an empty tile. + * + * See docs/plans/onboarding-flow.md for rationale (why these templates, + * why multi-select, why a cap). + */ + +export type OnboardingTemplateId = + | 'alltag' + | 'arbeit' + | 'health' + | 'sport' + | 'lernen' + | 'entdecken' + | 'erinnern'; + +export type OnboardingTemplate = { + id: OnboardingTemplateId; + /** German name rendered on the tile. */ + name: string; + /** One-line description under the tile name. */ + shortDescription: string; + /** Phosphor icon name — resolved by the consuming page. */ + iconName: 'House' | 'Briefcase' | 'Heart' | 'Barbell' | 'GraduationCap' | 'Compass' | 'Camera'; + /** + * Module IDs in priority order (first = most important). Dedup + * across templates keeps the earliest occurrence; cap = 8. + */ + moduleIds: string[]; +}; + +export const ONBOARDING_TEMPLATES: readonly OnboardingTemplate[] = [ + { + id: 'alltag', + name: 'Alltag', + shortDescription: 'Aufgaben, Termine, Notizen, Kontakte', + iconName: 'House', + moduleIds: ['todo', 'calendar', 'notes', 'contacts'], + }, + { + id: 'arbeit', + name: 'Arbeit', + shortDescription: 'Produktivität für den Job', + iconName: 'Briefcase', + moduleIds: ['todo', 'calendar', 'mail', 'chat', 'times', 'notes'], + }, + { + id: 'health', + name: 'Health', + shortDescription: 'Gesundheit, Stimmung, Ernährung, Zyklus', + iconName: 'Heart', + moduleIds: ['habits', 'body', 'mood', 'food', 'period'], + }, + { + id: 'sport', + name: 'Sport', + shortDescription: 'Training, Ziele, Körper, Ernährung', + iconName: 'Barbell', + moduleIds: ['habits', 'body', 'food', 'goals', 'stretch'], + }, + { + id: 'lernen', + name: 'Lernen', + shortDescription: 'Skills, Quizzes, Notizen, Kontext', + iconName: 'GraduationCap', + moduleIds: ['skilltree', 'quiz', 'notes', 'library', 'kontext'], + }, + { + id: 'entdecken', + name: 'Entdecken', + shortDescription: 'Orte, Fotos, Musik, Wetter', + iconName: 'Compass', + moduleIds: ['places', 'citycorners', 'photos', 'music', 'wetter'], + }, + { + id: 'erinnern', + name: 'Erinnern', + shortDescription: 'Memoro, Journal, Fotos, Zitate', + iconName: 'Camera', + moduleIds: ['memoro', 'journal', 'photos', 'moodlit', 'quotes'], + }, +]; + +/** + * Pick modules for the user's Home scene based on a multi-selection of + * templates. Iterates templates in the order provided, collecting their + * modules while skipping dups and stopping at the cap. + * + * Exported so the onboarding page and its test can share the same + * logic — the page only has to worry about UI state. + */ +export function resolveModulesForTemplates( + selectedIds: readonly OnboardingTemplateId[], + cap = 8 +): string[] { + const byId = new Map(ONBOARDING_TEMPLATES.map((t) => [t.id, t])); + const seen = new Set(); + const out: string[] = []; + for (const id of selectedIds) { + const tpl = byId.get(id); + if (!tpl) continue; + for (const moduleId of tpl.moduleIds) { + if (seen.has(moduleId)) continue; + seen.add(moduleId); + out.push(moduleId); + if (out.length >= cap) return out; + } + } + return out; +}