feat(onboarding): add app-specific mini-onboarding system

- Create @manacore/shared-app-onboarding package with:
  - createAppOnboardingStore factory function (Svelte 5 runes)
  - MiniOnboardingModal component for select/toggle/info steps
  - TypeScript types for flexible step configuration
- Integrate into Calendar app with questions for:
  - Week start (Monday/Sunday)
  - Default view (Day/Week/Month)
  - Timezone preference (Auto/Manual)
  - Welcome tips

The mini-onboarding stores completion state in deviceSettings,
allowing per-device, per-app onboarding experiences.
This commit is contained in:
Till-JS 2026-02-16 12:50:04 +01:00
parent 5fe16b5eec
commit b92b9bd2b5
9 changed files with 809 additions and 0 deletions

View file

@ -52,6 +52,7 @@
"@manacore/shared-types": "workspace:*",
"@manacore/shared-ui": "workspace:*",
"@manacore/shared-utils": "workspace:*",
"@manacore/shared-app-onboarding": "workspace:*",
"@neodrag/svelte": "^2.3.3",
"d3-force": "^3.0.0",
"date-fns": "^4.1.0",

View file

@ -0,0 +1,144 @@
import { createAppOnboardingStore, type AppOnboardingStep } from '@manacore/shared-app-onboarding';
import { userSettings } from './user-settings.svelte';
/**
* Calendar-specific onboarding steps
*/
const calendarOnboardingSteps: AppOnboardingStep[] = [
{
id: 'weekStart',
type: 'select',
question: 'Wann beginnt deine Woche?',
description: 'Diese Einstellung bestimmt die Anordnung deiner Kalenderansicht.',
emoji: '📅',
gradient: { from: 'blue-500', to: 'blue-700' },
options: [
{
id: 'monday',
label: 'Montag',
description: 'Europäischer Standard',
emoji: '1⃣',
},
{
id: 'sunday',
label: 'Sonntag',
description: 'Amerikanischer Standard',
emoji: '7⃣',
},
],
defaultValue: 'monday',
},
{
id: 'defaultView',
type: 'select',
question: 'Welche Ansicht bevorzugst du?',
description: 'Du kannst die Ansicht jederzeit in der App wechseln.',
emoji: '👁️',
gradient: { from: 'indigo-500', to: 'indigo-700' },
options: [
{
id: 'day',
label: 'Tagesansicht',
description: 'Detaillierte 24-Stunden-Timeline',
emoji: '📆',
},
{
id: 'week',
label: 'Wochenansicht',
description: '7-Tage-Übersicht (Empfohlen)',
emoji: '🗓️',
},
{
id: 'month',
label: 'Monatsansicht',
description: 'Kompakte Monatsübersicht',
emoji: '📅',
},
],
defaultValue: 'week',
},
{
id: 'timezone',
type: 'select',
question: 'Welche Zeitzone verwendest du?',
description: 'Termine werden in dieser Zeitzone angezeigt.',
emoji: '🌍',
gradient: { from: 'emerald-500', to: 'emerald-700' },
options: [
{
id: 'auto',
label: 'Automatisch erkennen',
description: 'Basierend auf deinem Standort',
emoji: '📍',
},
{
id: 'Europe/Berlin',
label: 'Berlin (MEZ/MESZ)',
description: 'Deutschland, Österreich, Schweiz',
emoji: '🇩🇪',
},
{
id: 'Europe/London',
label: 'London (GMT/BST)',
description: 'Großbritannien',
emoji: '🇬🇧',
},
{
id: 'America/New_York',
label: 'New York (EST/EDT)',
description: 'US-Ostküste',
emoji: '🇺🇸',
},
],
defaultValue: 'auto',
},
{
id: 'welcome',
type: 'info',
question: 'Dein Kalender ist bereit!',
description: 'Hier sind einige Tipps für den Start:',
emoji: '🎉',
gradient: { from: 'primary', to: 'primary/70' },
bullets: [
'Nutze die Schnelleingabe unten, um Termine per Text zu erstellen',
'Drücke "F" für den Fokus-Modus ohne Ablenkungen',
'Pfeiltasten navigieren zwischen Tagen/Wochen',
'Ziehe Termine per Drag & Drop auf neue Zeiten',
],
},
];
/**
* Calendar app onboarding store
*
* Usage in components:
* ```svelte
* <script>
* import { calendarOnboarding } from '$lib/stores/app-onboarding.svelte';
* import { MiniOnboardingModal } from '@manacore/shared-app-onboarding';
* </script>
*
* {#if calendarOnboarding.shouldShow}
* <MiniOnboardingModal
* store={calendarOnboarding}
* appName="Kalender"
* appEmoji="📅"
* />
* {/if}
* ```
*/
export const calendarOnboarding = createAppOnboardingStore({
appId: 'calendar',
steps: calendarOnboardingSteps,
userSettings,
onComplete: async (preferences) => {
console.log('[Calendar] Onboarding completed with preferences:', preferences);
// Apply preferences to the app
// The preferences are automatically saved to deviceSettings by the store
// Additional app-specific logic can go here (e.g., applying timezone, view settings)
},
onSkip: async () => {
console.log('[Calendar] Onboarding skipped');
},
});

View file

@ -64,6 +64,8 @@
import VoiceRecordingModal from '$lib/components/voice/VoiceRecordingModal.svelte';
import { voiceRecordingStore } from '$lib/stores/voice-recording.svelte';
import { eventContextMenuStore } from '$lib/stores/eventContextMenu.svelte';
import { calendarOnboarding } from '$lib/stores/app-onboarding.svelte';
import { MiniOnboardingModal } from '@manacore/shared-app-onboarding';
// App switcher items
const appItems = getPillAppItems('calendar');
@ -653,6 +655,11 @@
<!-- Settings Modal -->
<SettingsModal visible={showSettingsModal} onClose={() => (showSettingsModal = false)} />
<!-- App Onboarding Modal (shown once on first visit) -->
{#if calendarOnboarding.shouldShow}
<MiniOnboardingModal store={calendarOnboarding} appName="Kalender" appEmoji="📅" />
{/if}
<style>
.layout-container {
display: flex;

View file

@ -0,0 +1,34 @@
{
"name": "@manacore/shared-app-onboarding",
"version": "0.1.0",
"description": "App-specific mini-onboarding for ManaCore apps",
"private": true,
"type": "module",
"main": "./src/index.ts",
"types": "./src/index.ts",
"svelte": "./src/index.ts",
"exports": {
".": {
"types": "./src/index.ts",
"svelte": "./src/index.ts",
"default": "./src/index.ts"
}
},
"scripts": {
"type-check": "svelte-kit sync 2>/dev/null || true && svelte-check --tsconfig ./tsconfig.json"
},
"files": [
"src"
],
"dependencies": {
"@manacore/shared-theme": "workspace:*"
},
"devDependencies": {
"svelte": "^5.0.0",
"svelte-check": "^4.0.0",
"typescript": "^5.0.0"
},
"peerDependencies": {
"svelte": "^5.0.0"
}
}

View file

@ -0,0 +1,196 @@
<script lang="ts">
import type { AppOnboardingStore, AppOnboardingSelectStep, AppOnboardingToggleStep } from './types';
interface Props {
store: AppOnboardingStore;
appName: string;
appEmoji?: string;
}
let { store, appName, appEmoji = '🚀' }: Props = $props();
function handleNext() {
if (store.isLastStep) {
store.complete();
} else {
store.next();
}
}
function handlePrev() {
store.prev();
}
function handleSkip() {
store.skip();
}
function selectOption(stepId: string, optionId: string) {
store.setPreference(stepId, optionId);
}
function toggleOption(stepId: string) {
const currentValue = store.preferences[stepId] ?? false;
store.setPreference(stepId, !currentValue);
}
</script>
<!-- Backdrop -->
<div class="fixed inset-0 z-50 bg-black/60 backdrop-blur-sm flex items-center justify-center p-4">
<!-- Modal Container -->
<div
class="bg-surface-elevated-2 rounded-2xl shadow-2xl w-full max-w-md max-h-[80vh] flex flex-col overflow-hidden animate-in fade-in zoom-in-95 duration-200 border border-border"
>
<!-- Header -->
<header class="border-b px-5 py-4 flex-shrink-0">
<div class="flex items-center justify-between mb-3">
<div class="flex items-center gap-3">
<div
class="h-10 w-10 rounded-xl bg-gradient-to-br from-primary to-primary/70 flex items-center justify-center"
>
<span class="text-xl">{appEmoji}</span>
</div>
<div>
<h1 class="font-semibold text-sm">{appName} einrichten</h1>
<p class="text-xs text-muted-foreground">
Schritt {store.currentStep + 1} von {store.totalSteps}
</p>
</div>
</div>
<button
onclick={handleSkip}
class="text-xs text-muted-foreground hover:text-foreground transition-colors"
>
Überspringen
</button>
</div>
<!-- Progress bar -->
<div class="h-1 bg-muted rounded-full overflow-hidden">
<div
class="h-full bg-primary transition-all duration-300 ease-out rounded-full"
style="width: {store.progress}%"
></div>
</div>
</header>
<!-- Step content -->
<main class="flex-1 overflow-y-auto px-5 py-6">
{#if store.currentStepConfig}
{@const step = store.currentStepConfig}
<div class="text-center">
<!-- Step icon -->
{#if step.emoji}
{@const gradient = step.gradient || { from: 'primary', to: 'primary/70' }}
<div class="mb-5">
<div
class="inline-flex h-14 w-14 rounded-xl bg-gradient-to-br from-{gradient.from} to-{gradient.to} items-center justify-center shadow-lg"
>
<span class="text-2xl">{step.emoji}</span>
</div>
</div>
{/if}
<!-- Question -->
<h2 class="text-lg font-bold mb-2">{step.question}</h2>
{#if step.description}
<p class="text-sm text-muted-foreground mb-5">{step.description}</p>
{/if}
<!-- Step content based on type -->
<div class="max-w-sm mx-auto">
{#if step.type === 'select'}
{@const selectStep = step as AppOnboardingSelectStep}
<div class="space-y-2">
{#each selectStep.options as option}
<button
onclick={() => selectOption(step.id, option.id)}
class="w-full p-3 rounded-xl border-2 transition-all text-left {store.preferences[step.id] === option.id
? 'border-primary bg-primary/10'
: 'border-border hover:border-primary/50 bg-surface-elevated-1'}"
>
<div class="flex items-center gap-3">
{#if option.emoji}
<span class="text-xl">{option.emoji}</span>
{/if}
<div>
<p class="font-medium text-sm">{option.label}</p>
{#if option.description}
<p class="text-xs text-muted-foreground">{option.description}</p>
{/if}
</div>
</div>
</button>
{/each}
</div>
{:else if step.type === 'toggle'}
{@const toggleStep = step as AppOnboardingToggleStep}
{@const isEnabled = store.preferences[step.id] ?? toggleStep.defaultValue ?? false}
<button
onclick={() => toggleOption(step.id)}
class="w-full p-4 rounded-xl border-2 transition-all {isEnabled
? 'border-primary bg-primary/10'
: 'border-border bg-surface-elevated-1'}"
>
<div class="flex items-center justify-between">
<span class="font-medium text-sm">
{isEnabled ? (toggleStep.enabledLabel || 'Aktiviert') : (toggleStep.disabledLabel || 'Deaktiviert')}
</span>
<div
class="relative w-11 h-6 rounded-full transition-colors {isEnabled
? 'bg-primary'
: 'bg-muted'}"
>
<div
class="absolute top-1 left-1 w-4 h-4 rounded-full bg-white shadow transition-transform {isEnabled
? 'translate-x-5'
: 'translate-x-0'}"
></div>
</div>
</div>
</button>
{:else if step.type === 'info'}
{#if step.bullets}
<ul class="text-left space-y-2">
{#each step.bullets as bullet}
<li class="flex items-start gap-2 text-sm">
<span class="text-primary mt-0.5"></span>
<span class="text-muted-foreground">{bullet}</span>
</li>
{/each}
</ul>
{/if}
{/if}
</div>
</div>
{/if}
</main>
<!-- Footer with navigation -->
<footer class="border-t px-5 py-3 flex-shrink-0">
<div class="flex justify-between">
<button
onclick={handlePrev}
disabled={store.isFirstStep}
class="px-4 py-2 text-sm border rounded-lg hover:bg-muted transition-colors disabled:opacity-0 disabled:pointer-events-none"
>
Zurück
</button>
<button
onclick={handleNext}
disabled={store.saving}
class="px-4 py-2 text-sm bg-primary text-primary-foreground rounded-lg hover:bg-primary/90 transition-colors font-medium disabled:opacity-50"
>
{#if store.saving}
Speichern...
{:else if store.isLastStep}
Fertig
{:else}
Weiter
{/if}
</button>
</div>
</footer>
</div>
</div>

View file

@ -0,0 +1,232 @@
import type {
AppOnboardingConfig,
AppOnboardingStore,
AppOnboardingPreferences,
AppOnboardingStep,
} from './types';
const ONBOARDING_COMPLETED_KEY = 'onboarding_completed';
const ONBOARDING_PREFERENCES_KEY = 'onboarding_preferences';
/**
* Create an app-specific onboarding store
*
* This factory creates a store that:
* - Checks if onboarding was already completed (via deviceSettings)
* - Manages step navigation and preference collection
* - Saves completion state and preferences to deviceSettings
*
* @example
* ```typescript
* import { createAppOnboardingStore } from '@manacore/shared-app-onboarding';
*
* const calendarOnboarding = createAppOnboardingStore({
* appId: 'calendar',
* steps: [
* {
* id: 'weekStart',
* type: 'select',
* question: 'Wann beginnt deine Woche?',
* emoji: '📅',
* options: [
* { id: 'monday', label: 'Montag', emoji: '1⃣' },
* { id: 'sunday', label: 'Sonntag', emoji: '7⃣' },
* ],
* defaultValue: 'monday',
* },
* ],
* userSettings,
* });
* ```
*/
export function createAppOnboardingStore(config: AppOnboardingConfig): AppOnboardingStore {
const { appId, steps, userSettings, onComplete, onSkip } = config;
// State
let currentStep = $state(0);
let preferences = $state<AppOnboardingPreferences>({});
let saving = $state(false);
let completed = $state(false);
// Initialize preferences with default values
for (const step of steps) {
if (step.type === 'select' && step.defaultValue !== undefined) {
preferences[step.id] = step.defaultValue;
} else if (step.type === 'toggle' && step.defaultValue !== undefined) {
preferences[step.id] = step.defaultValue;
}
}
// Derived values
const totalSteps = $derived(steps.length);
const isFirstStep = $derived(currentStep === 0);
const isLastStep = $derived(currentStep === steps.length - 1);
const progress = $derived(((currentStep + 1) / steps.length) * 100);
const currentStepConfig = $derived<AppOnboardingStep | undefined>(steps[currentStep]);
/**
* Check if onboarding was already completed
*/
function checkCompleted(): boolean {
const deviceAppSettings = userSettings.getDeviceAppSettings();
return deviceAppSettings[ONBOARDING_COMPLETED_KEY] === true;
}
// Derived: should show modal
const shouldShow = $derived(!completed && !checkCompleted() && userSettings.loaded);
/**
* Go to next step
*/
function next(): void {
if (currentStep < steps.length - 1) {
currentStep++;
}
}
/**
* Go to previous step
*/
function prev(): void {
if (currentStep > 0) {
currentStep--;
}
}
/**
* Go to specific step
*/
function goToStep(index: number): void {
if (index >= 0 && index < steps.length) {
currentStep = index;
}
}
/**
* Set a preference value
*/
function setPreference(key: string, value: unknown): void {
preferences = { ...preferences, [key]: value };
}
/**
* Complete the onboarding and save preferences
*/
async function complete(): Promise<void> {
saving = true;
try {
// Save to deviceSettings
await userSettings.updateDeviceAppSettings({
[ONBOARDING_COMPLETED_KEY]: true,
[ONBOARDING_PREFERENCES_KEY]: preferences,
...preferences, // Also spread preferences at top level for easy access
});
completed = true;
// Call completion callback
if (onComplete) {
await onComplete(preferences);
}
} finally {
saving = false;
}
}
/**
* Skip the onboarding entirely
*/
async function skip(): Promise<void> {
saving = true;
try {
// Mark as completed but don't save preferences
await userSettings.updateDeviceAppSettings({
[ONBOARDING_COMPLETED_KEY]: true,
onboarding_skipped: true,
});
completed = true;
// Call skip callback
if (onSkip) {
await onSkip();
}
} finally {
saving = false;
}
}
/**
* Reset onboarding (for testing/debugging)
*/
async function reset(): Promise<void> {
saving = true;
try {
// Remove onboarding flags from deviceSettings
await userSettings.updateDeviceAppSettings({
[ONBOARDING_COMPLETED_KEY]: false,
onboarding_skipped: false,
});
completed = false;
currentStep = 0;
// Reset preferences to defaults
preferences = {};
for (const step of steps) {
if (step.type === 'select' && step.defaultValue !== undefined) {
preferences[step.id] = step.defaultValue;
} else if (step.type === 'toggle' && step.defaultValue !== undefined) {
preferences[step.id] = step.defaultValue;
}
}
} finally {
saving = false;
}
}
return {
get shouldShow() {
return shouldShow;
},
get currentStep() {
return currentStep;
},
get totalSteps() {
return totalSteps;
},
get isFirstStep() {
return isFirstStep;
},
get isLastStep() {
return isLastStep;
},
get progress() {
return progress;
},
get currentStepConfig() {
return currentStepConfig;
},
get preferences() {
return preferences;
},
get saving() {
return saving;
},
get appId() {
return appId;
},
get steps() {
return steps;
},
next,
prev,
goToStep,
setPreference,
complete,
skip,
reset,
checkCompleted,
};
}

View file

@ -0,0 +1,20 @@
// Types
export type {
AppOnboardingOption,
AppOnboardingStepType,
AppOnboardingStepBase,
AppOnboardingSelectStep,
AppOnboardingToggleStep,
AppOnboardingInfoStep,
AppOnboardingStep,
AppOnboardingConfig,
AppOnboardingPreferences,
AppOnboardingStore,
MiniOnboardingModalProps,
} from './types';
// Factory function
export { createAppOnboardingStore } from './create-app-onboarding.svelte';
// Component
export { default as MiniOnboardingModal } from './MiniOnboardingModal.svelte';

View file

@ -0,0 +1,157 @@
import type { UserSettingsStore } from '@manacore/shared-theme';
/**
* Option for a selection-based onboarding step
*/
export interface AppOnboardingOption {
/** Unique identifier for this option */
id: string;
/** Display label */
label: string;
/** Optional description */
description?: string;
/** Optional emoji icon */
emoji?: string;
}
/**
* Step type determines the UI component used
*/
export type AppOnboardingStepType = 'select' | 'toggle' | 'info';
/**
* Base step configuration
*/
export interface AppOnboardingStepBase {
/** Unique identifier for this step */
id: string;
/** Question or title for this step */
question: string;
/** Optional subtitle/description */
description?: string;
/** Emoji icon for the step header */
emoji?: string;
/** Gradient colors for the icon background */
gradient?: { from: string; to: string };
/** Whether this step can be skipped */
skippable?: boolean;
}
/**
* Selection step - user picks one option
*/
export interface AppOnboardingSelectStep extends AppOnboardingStepBase {
type: 'select';
/** Available options */
options: AppOnboardingOption[];
/** Default selected value */
defaultValue?: string;
}
/**
* Toggle step - user enables/disables something
*/
export interface AppOnboardingToggleStep extends AppOnboardingStepBase {
type: 'toggle';
/** Default toggle state */
defaultValue?: boolean;
/** Label when enabled */
enabledLabel?: string;
/** Label when disabled */
disabledLabel?: string;
}
/**
* Info step - just shows information, no input
*/
export interface AppOnboardingInfoStep extends AppOnboardingStepBase {
type: 'info';
/** Bullet points to display */
bullets?: string[];
}
/**
* Union type for all step types
*/
export type AppOnboardingStep =
| AppOnboardingSelectStep
| AppOnboardingToggleStep
| AppOnboardingInfoStep;
/**
* Configuration for creating an app onboarding store
*/
export interface AppOnboardingConfig {
/** App identifier (e.g., 'calendar', 'todo', 'chat') */
appId: string;
/** Steps to show in the onboarding */
steps: AppOnboardingStep[];
/** User settings store instance (for deviceSettings access) */
userSettings: UserSettingsStore;
/** Optional callback when onboarding completes */
onComplete?: (preferences: Record<string, unknown>) => void | Promise<void>;
/** Optional callback when onboarding is skipped */
onSkip?: () => void | Promise<void>;
}
/**
* Collected preferences from completed steps
*/
export type AppOnboardingPreferences = Record<string, unknown>;
/**
* App onboarding store interface
*/
export interface AppOnboardingStore {
/** Whether the onboarding modal should be shown */
readonly shouldShow: boolean;
/** Current step index (0-based) */
readonly currentStep: number;
/** Total number of steps */
readonly totalSteps: number;
/** Whether on the first step */
readonly isFirstStep: boolean;
/** Whether on the last step */
readonly isLastStep: boolean;
/** Progress percentage (0-100) */
readonly progress: number;
/** Current step configuration */
readonly currentStepConfig: AppOnboardingStep | undefined;
/** Collected preferences so far */
readonly preferences: AppOnboardingPreferences;
/** Whether currently saving */
readonly saving: boolean;
/** App ID */
readonly appId: string;
/** All step configurations */
readonly steps: AppOnboardingStep[];
/** Go to next step */
next: () => void;
/** Go to previous step */
prev: () => void;
/** Go to specific step */
goToStep: (index: number) => void;
/** Set a preference value */
setPreference: (key: string, value: unknown) => void;
/** Complete the onboarding and save preferences */
complete: () => Promise<void>;
/** Skip the onboarding entirely */
skip: () => Promise<void>;
/** Reset onboarding (for testing/debugging) */
reset: () => Promise<void>;
/** Check if onboarding was completed (reads from deviceSettings) */
checkCompleted: () => boolean;
}
/**
* Props for the MiniOnboardingModal component
*/
export interface MiniOnboardingModalProps {
/** The onboarding store instance */
store: AppOnboardingStore;
/** App name for display */
appName: string;
/** Optional app icon/emoji */
appEmoji?: string;
}

View file

@ -0,0 +1,18 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "bundler",
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"isolatedModules": true,
"verbatimModuleSyntax": true,
"noEmit": true,
"types": ["svelte"]
},
"include": ["src/**/*"],
"exclude": ["node_modules"]
}