From 4b41549d20c3d9c975ebfbce95459fb52f37874b Mon Sep 17 00:00:00 2001 From: Till JS Date: Thu, 16 Apr 2026 15:24:11 +0200 Subject: [PATCH] fix(profile): expand data model + fix interview field mappings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add leisure section (media, sports, pets) + social.livingSetup to userContext - Fix interview questions: each maps to its own field, no more bio overwrites (leisure.favoriteMedia → leisure.media, leisure.sports → leisure.sports, social.living → social.livingSetup, social.pets → leisure.pets) - Add merge flag for array fields (goals.learn merges into goals, not replaces) - Store.setField() supports merge=true for deduped array merging - All ContextOverview sections now always visible with empty-state hints - All tag-based sections (languages, goals, allergies) are inline-editable - Routine/nutrition/social link to interview for editing - EditProfileModal: restore email change UI (Ändern button + verification flow) - Update AI resolver with leisure + livingSetup fields - Add leisure to crypto registry encryption fields Co-Authored-By: Claude Opus 4.6 (1M context) --- .../profile/EditProfileModal.svelte | 113 +++++++- .../lib/data/ai/missions/default-resolvers.ts | 9 +- .../apps/web/src/lib/data/crypto/registry.ts | 11 +- .../modules/profile/ContextInterview.svelte | 2 +- .../modules/profile/ContextOverview.svelte | 252 +++++++++++++++--- .../web/src/lib/modules/profile/questions.ts | 11 +- .../profile/stores/user-context.svelte.ts | 37 ++- .../apps/web/src/lib/modules/profile/types.ts | 11 + 8 files changed, 389 insertions(+), 57 deletions(-) diff --git a/apps/mana/apps/web/src/lib/components/profile/EditProfileModal.svelte b/apps/mana/apps/web/src/lib/components/profile/EditProfileModal.svelte index 82663c547..51d8754f1 100644 --- a/apps/mana/apps/web/src/lib/components/profile/EditProfileModal.svelte +++ b/apps/mana/apps/web/src/lib/components/profile/EditProfileModal.svelte @@ -13,6 +13,9 @@ let { show, user, onClose, onSuccess }: Props = $props(); let name = $state(''); + let newEmail = $state(''); + let editingEmail = $state(false); + let emailSent = $state(false); let saving = $state(false); let uploadingAvatar = $state(false); let error = $state(null); @@ -26,6 +29,9 @@ $effect(() => { if (show && user) { name = user.name || ''; + newEmail = ''; + editingEmail = false; + emailSent = false; avatarPreview = user.image || null; selectedFile = null; error = null; @@ -127,6 +133,27 @@ } } + async function handleChangeEmail() { + if (!newEmail.trim()) { + error = 'Bitte gib eine neue E-Mail-Adresse ein'; + return; + } + if (newEmail.trim() === user?.email) { + error = 'Die neue E-Mail-Adresse muss sich von der aktuellen unterscheiden'; + return; + } + saving = true; + error = null; + try { + await profileService.changeEmail({ newEmail: newEmail.trim() }); + emailSent = true; + } catch (e) { + error = e instanceof Error ? e.message : 'Fehler beim Ändern der E-Mail'; + } finally { + saving = false; + } + } + // Get initials for avatar placeholder function getInitials(name: string): string { return name @@ -236,17 +263,85 @@

JPG, PNG, GIF oder WebP. Max. 5MB.

- +
- -

E-Mail kann nicht geändert werden

+ {#if editingEmail} + {#if emailSent} +
+

+ Bestätigungs-E-Mail an {newEmail} gesendet. Bitte klicke den Link + in der E-Mail, um die Änderung abzuschließen. +

+
+ + {:else} +
+ + +
+ + {/if} + {:else} +
+ + +
+

+ Eine Bestätigungs-E-Mail wird an die neue Adresse gesendet +

+ {/if}
diff --git a/apps/mana/apps/web/src/lib/data/ai/missions/default-resolvers.ts b/apps/mana/apps/web/src/lib/data/ai/missions/default-resolvers.ts index 6bcb84a67..1b45b4874 100644 --- a/apps/mana/apps/web/src/lib/data/ai/missions/default-resolvers.ts +++ b/apps/mana/apps/web/src/lib/data/ai/missions/default-resolvers.ts @@ -60,8 +60,9 @@ interface UserContextLike { interests?: string[]; routine?: { wakeUp?: string; workStart?: string; workEnd?: string; bedtime?: string }; nutrition?: { diet?: string; allergies?: string[]; preferences?: string }; + leisure?: { media?: string[]; sports?: string[]; pets?: string }; goals?: string[]; - social?: { communication?: string; workStyle?: string }; + social?: { communication?: string; workStyle?: string; livingSetup?: string }; freeform?: string; } @@ -98,8 +99,14 @@ function buildUserContextText(ctx: UserContextLike): string { if (ctx.nutrition.allergies?.length) lines.push(`Allergien: ${ctx.nutrition.allergies.join(', ')}`); } + if (ctx.leisure) { + if (ctx.leisure.media?.length) lines.push(`Medien: ${ctx.leisure.media.join(', ')}`); + if (ctx.leisure.sports?.length) lines.push(`Sport: ${ctx.leisure.sports.join(', ')}`); + if (ctx.leisure.pets) lines.push(`Haustiere: ${ctx.leisure.pets}`); + } if (ctx.goals?.length) lines.push(`\nZiele: ${ctx.goals.join(', ')}`); if (ctx.social?.workStyle) lines.push(`Arbeitsweise: ${ctx.social.workStyle}`); + if (ctx.social?.livingSetup) lines.push(`Wohnsituation: ${ctx.social.livingSetup}`); if (ctx.freeform?.trim()) lines.push(`\n---\n${ctx.freeform.trim()}`); return lines.join('\n'); } diff --git a/apps/mana/apps/web/src/lib/data/crypto/registry.ts b/apps/mana/apps/web/src/lib/data/crypto/registry.ts index 512f477de..e217c7a2a 100644 --- a/apps/mana/apps/web/src/lib/data/crypto/registry.ts +++ b/apps/mana/apps/web/src/lib/data/crypto/registry.ts @@ -475,7 +475,16 @@ export const ENCRYPTION_REGISTRY: Record = { // except the fixed id and interview progress is user-typed content. userContext: { enabled: true, - fields: ['about', 'interests', 'routine', 'nutrition', 'goals', 'social', 'freeform'], + fields: [ + 'about', + 'interests', + 'routine', + 'nutrition', + 'leisure', + 'goals', + 'social', + 'freeform', + ], }, // Per-agent kontext documents — same schema as kontextDoc but keyed diff --git a/apps/mana/apps/web/src/lib/modules/profile/ContextInterview.svelte b/apps/mana/apps/web/src/lib/modules/profile/ContextInterview.svelte index 6acafadc8..0694c2b64 100644 --- a/apps/mana/apps/web/src/lib/modules/profile/ContextInterview.svelte +++ b/apps/mana/apps/web/src/lib/modules/profile/ContextInterview.svelte @@ -79,7 +79,7 @@ if (!currentQuestion) return; saving = true; try { - await userContextStore.setField(currentQuestion.field, inputValue); + await userContextStore.setField(currentQuestion.field, inputValue, currentQuestion.merge); await userContextStore.markAnswered(currentQuestion.id); advanceQuestion(); } finally { diff --git a/apps/mana/apps/web/src/lib/modules/profile/ContextOverview.svelte b/apps/mana/apps/web/src/lib/modules/profile/ContextOverview.svelte index 133c79dfa..8f80e9a41 100644 --- a/apps/mana/apps/web/src/lib/modules/profile/ContextOverview.svelte +++ b/apps/mana/apps/web/src/lib/modules/profile/ContextOverview.svelte @@ -158,10 +158,11 @@ {/if} - {#if ctx?.routine && (ctx.routine.wakeUp || ctx.routine.workStart || ctx.routine.bedtime || ctx.routine.workDays?.length)} -
-

Tagesablauf

-
+ +
+

Tagesablauf

+ {#if ctx?.routine && (ctx.routine.wakeUp || ctx.routine.workStart || ctx.routine.bedtime)} +
onStartInterview()}> {#if ctx.routine.wakeUp}
Aufstehen{ctx.routine.wakeUp}
{/if}
+ {:else} + + {/if} +
+ + +
+

Ernährung

+ {#if ctx?.nutrition && (ctx.nutrition.diet || ctx.nutrition.allergies?.length)} +
+ {#if ctx.nutrition.diet}

onStartInterview()}> + {ctx.nutrition.diet} +

{/if} + {#if ctx.nutrition.allergies?.length} + {#if editingField === 'nutrition.allergies'} +
+
+ {#each editValue as string[] as tag (tag)}{tag}{/each} +
+ { + if (e.key === 'Enter' || e.key === ',') { + e.preventDefault(); + addEditTag(); + } + }} + onblur={addEditTag} + /> +
+ + +
+
+ {:else} +
startEdit('nutrition.allergies', ctx?.nutrition?.allergies ?? [])} + > + {#each ctx.nutrition.allergies as a (a)}{a}{/each} +
+ {/if} + {/if} + {#if ctx.nutrition.preferences}

+ {ctx.nutrition.preferences} +

{/if} +
+ {:else} + + {/if} +
+ + + {#if ctx?.leisure && (ctx.leisure.media?.length || ctx.leisure.sports?.length || ctx.leisure.pets)} +
+

Freizeit

+ {#if ctx.leisure.sports?.length} +
+ Sport +
startEdit('leisure.sports', ctx?.leisure?.sports ?? [])} + > + {#each ctx.leisure.sports as s (s)}{s}{/each} +
+
+ {/if} + {#if ctx.leisure.media?.length} +
+ Medien +
startEdit('leisure.media', ctx?.leisure?.media ?? [])} + > + {#each ctx.leisure.media as m (m)}{m}{/each} +
+
+ {/if} + {#if ctx.leisure.pets} +
+ Haustiere + startEdit('leisure.pets', ctx?.leisure?.pets ?? '')} + >{ctx.leisure.pets} +
+ {/if}
{/if} - {#if ctx?.nutrition && (ctx.nutrition.diet || ctx.nutrition.allergies?.length)} -
-

Ernährung

- {#if ctx.nutrition.diet}

{ctx.nutrition.diet}

{/if} - {#if ctx.nutrition.allergies?.length}
- {#each ctx.nutrition.allergies as a (a)}{a}{/each} -
{/if} - {#if ctx.nutrition.preferences}

- {ctx.nutrition.preferences} -

{/if} -
- {/if} - - {#if ctx?.goals?.length} -
-

Ziele

+ +
+

Ziele

+ {#if editingField === 'goals'} +
+
+ {#each editValue as string[] as tag (tag)}{tag}{/each} +
+ { + if (e.key === 'Enter' || e.key === ',') { + e.preventDefault(); + addEditTag(); + } + }} + onblur={addEditTag} + /> +
+ + +
+
+ {:else if ctx?.goals?.length}
startEdit('goals', ctx?.goals ?? [])}> {#each ctx.goals as goal (goal)}{goal}{/each}
-
- {/if} + {:else} + + {/if} +
- {#if ctx?.social && (ctx.social.workStyle || ctx.social.communication)} -
-

Arbeitsstil

-
+ +
+

Arbeitsstil

+ {#if ctx?.social && (ctx.social.workStyle || ctx.social.communication || ctx.social.livingSetup)} +
onStartInterview()}> {#if ctx.social.workStyle}
Arbeitsweise{ctx.social.workStyle}{ctx.social.communication}
{/if} + {#if ctx.social.livingSetup}
+ Wohnsituation{ctx.social.livingSetup} +
{/if}
-
- {/if} + {:else} + + {/if} +
- {#if ctx?.about?.languages?.length} -
-

Sprachen

-
+ +
+

Sprachen

+ {#if editingField === 'about.languages'} +
+
+ {#each editValue as string[] as tag (tag)}{tag}{/each} +
+ { + if (e.key === 'Enter' || e.key === ',') { + e.preventDefault(); + addEditTag(); + } + }} + onblur={addEditTag} + /> +
+ + +
+
+ {:else if ctx?.about?.languages?.length} +
startEdit('about.languages', ctx?.about?.languages ?? [])} + > {#each ctx.about.languages as lang (lang)}{lang}{/each}
-
- {/if} + {:else} + + {/if} +
@@ -413,6 +587,16 @@ font-size: 0.875rem; font-weight: 500; } + .sub-section { + display: flex; + flex-direction: column; + gap: 0.25rem; + padding: 0.25rem 0; + } + .sub-section + .sub-section { + border-top: 1px solid hsl(var(--color-border) / 0.5); + padding-top: 0.5rem; + } .empty-hint { display: block; width: 100%; diff --git a/apps/mana/apps/web/src/lib/modules/profile/questions.ts b/apps/mana/apps/web/src/lib/modules/profile/questions.ts index cbe9383f0..4c0f9970a 100644 --- a/apps/mana/apps/web/src/lib/modules/profile/questions.ts +++ b/apps/mana/apps/web/src/lib/modules/profile/questions.ts @@ -27,6 +27,8 @@ export interface ContextQuestion { field: string; /** Sort priority within category (lower = asked first). */ priority: number; + /** If true, array values are merged with existing values instead of replacing. */ + merge?: boolean; } export interface CategoryMeta { @@ -236,7 +238,7 @@ export const QUESTIONS: ContextQuestion[] = [ question: 'Welche Medien konsumierst du gerne?', hint: 'z.B. Podcasts, Bücher, Serien, YouTube', inputType: 'tags', - field: 'about.bio', + field: 'leisure.media', priority: 2, }, { @@ -245,7 +247,7 @@ export const QUESTIONS: ContextQuestion[] = [ question: 'Treibst du Sport? Wenn ja, welchen?', hint: 'z.B. Laufen, Yoga, Krafttraining, Fußball', inputType: 'tags', - field: 'interests', + field: 'leisure.sports', priority: 3, }, @@ -275,6 +277,7 @@ export const QUESTIONS: ContextQuestion[] = [ hint: 'z.B. eine Sprache, ein Instrument, Programmieren', inputType: 'tags', field: 'goals', + merge: true, priority: 3, }, @@ -285,7 +288,7 @@ export const QUESTIONS: ContextQuestion[] = [ question: 'Wie lebst du?', inputType: 'choice', choices: ['Allein', 'Mit Partner/in', 'WG', 'Familie', 'Anderes'], - field: 'about.bio', + field: 'social.livingSetup', priority: 1, }, { @@ -294,7 +297,7 @@ export const QUESTIONS: ContextQuestion[] = [ question: 'Hast du Haustiere?', hint: 'z.B. Hund, Katze, Fische', inputType: 'text', - field: 'about.bio', + field: 'leisure.pets', priority: 2, }, ]; diff --git a/apps/mana/apps/web/src/lib/modules/profile/stores/user-context.svelte.ts b/apps/mana/apps/web/src/lib/modules/profile/stores/user-context.svelte.ts index b7a19ec14..b76b191b2 100644 --- a/apps/mana/apps/web/src/lib/modules/profile/stores/user-context.svelte.ts +++ b/apps/mana/apps/web/src/lib/modules/profile/stores/user-context.svelte.ts @@ -14,6 +14,7 @@ import { type UserContextAbout, type UserContextRoutine, type UserContextNutrition, + type UserContextLeisure, type UserContextSocial, } from '../types'; @@ -35,8 +36,8 @@ async function readDecrypted(): Promise { export const userContextStore = { ensureDoc, - /** Replace a full section (about, routine, nutrition, social). */ - async updateSection( + /** Replace a full section (about, routine, nutrition, leisure, social). */ + async updateSection( section: K, value: K extends 'about' ? UserContextAbout @@ -44,7 +45,9 @@ export const userContextStore = { ? UserContextRoutine : K extends 'nutrition' ? UserContextNutrition - : UserContextSocial + : K extends 'leisure' + ? UserContextLeisure + : UserContextSocial ): Promise { await ensureDoc(); const current = await readDecrypted(); @@ -57,22 +60,42 @@ export const userContextStore = { await userContextTable.update(USER_CONTEXT_SINGLETON_ID, diff); }, - /** Set a single field within a section. */ - async setField(path: string, value: unknown): Promise { + /** Set a single field within a section. + * When `merge` is true and the value is an array, new items are added + * to the existing array instead of replacing it (deduped). */ + async setField(path: string, value: unknown, merge = false): Promise { await ensureDoc(); const current = await readDecrypted(); const [section, field] = path.split('.') as [keyof LocalUserContext, string]; + let finalValue = value; + if (merge && Array.isArray(value)) { + // Merge arrays: get existing value, dedupe + let existing: unknown[]; + if (field && typeof current[section] === 'object' && !Array.isArray(current[section])) { + existing = ((current[section] as Record)[field] as unknown[]) ?? []; + } else { + existing = (current[section] as unknown[]) ?? []; + } + if (Array.isArray(existing)) { + const set = new Set([...existing, ...value]); + finalValue = [...set]; + } + } + let diff: Partial; if (field && typeof current[section] === 'object' && !Array.isArray(current[section])) { // Nested field: e.g. 'about.occupation' const sectionObj = { ...(current[section] as Record) }; - sectionObj[field] = value; + sectionObj[field] = finalValue; diff = { [section]: sectionObj, updatedAt: new Date().toISOString() }; } else { // Top-level field: e.g. 'interests', 'goals' - diff = { [section]: value, updatedAt: new Date().toISOString() } as Partial; + diff = { + [section]: finalValue, + updatedAt: new Date().toISOString(), + } as Partial; } await encryptRecord('userContext', diff); diff --git a/apps/mana/apps/web/src/lib/modules/profile/types.ts b/apps/mana/apps/web/src/lib/modules/profile/types.ts index 2e20f4ff8..198d818e2 100644 --- a/apps/mana/apps/web/src/lib/modules/profile/types.ts +++ b/apps/mana/apps/web/src/lib/modules/profile/types.ts @@ -33,9 +33,16 @@ export interface UserContextNutrition { preferences?: string; // freeform } +export interface UserContextLeisure { + media?: string[]; // ["Podcasts", "Bücher", "Serien"] + sports?: string[]; // ["Laufen", "Yoga"] + pets?: string; // "Hund, Katze" +} + export interface UserContextSocial { communication?: string; // "Direkt", "Diplomatisch" workStyle?: string; // "Solo", "Team", "Hybrid" + livingSetup?: string; // "Allein", "Mit Partner/in", "WG", "Familie" } export interface InterviewProgress { @@ -53,6 +60,7 @@ export interface LocalUserContext extends BaseRecord { interests: string[]; routine: UserContextRoutine; nutrition: UserContextNutrition; + leisure: UserContextLeisure; goals: string[]; social: UserContextSocial; @@ -69,6 +77,7 @@ export interface UserContext { interests: string[]; routine: UserContextRoutine; nutrition: UserContextNutrition; + leisure: UserContextLeisure; goals: string[]; social: UserContextSocial; freeform: string; @@ -85,6 +94,7 @@ export function toUserContext(local: LocalUserContext): UserContext { interests: local.interests ?? [], routine: local.routine ?? {}, nutrition: local.nutrition ?? {}, + leisure: local.leisure ?? {}, goals: local.goals ?? [], social: local.social ?? {}, freeform: local.freeform ?? '', @@ -102,6 +112,7 @@ export function emptyUserContext(): LocalUserContext { interests: [], routine: {}, nutrition: {}, + leisure: {}, goals: [], social: {}, freeform: '',