fix(profile): expand data model + fix interview field mappings

- 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) <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-04-16 15:24:11 +02:00
parent a1bb703086
commit 4b41549d20
8 changed files with 389 additions and 57 deletions

View file

@ -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<string | null>(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 @@
<p class="mt-2 text-xs text-muted-foreground">JPG, PNG, GIF oder WebP. Max. 5MB.</p>
</div>
<!-- Email (readonly) -->
<!-- Email -->
<div>
<label for="profile-email" class="block text-sm font-medium mb-2">E-Mail</label>
<input
id="profile-email"
type="email"
value={user?.email || ''}
disabled
class="w-full px-3 py-2 border rounded-lg bg-muted text-muted-foreground cursor-not-allowed"
/>
<p class="mt-1 text-xs text-muted-foreground">E-Mail kann nicht geändert werden</p>
{#if editingEmail}
{#if emailSent}
<div
class="p-3 bg-green-50 dark:bg-green-900/20 border border-green-200 dark:border-green-800 rounded-lg"
>
<p class="text-sm text-green-700 dark:text-green-300">
Bestätigungs-E-Mail an <strong>{newEmail}</strong> gesendet. Bitte klicke den Link
in der E-Mail, um die Änderung abzuschließen.
</p>
</div>
<button
type="button"
onclick={() => {
editingEmail = false;
emailSent = false;
}}
class="mt-2 text-sm text-primary hover:underline"
>
Zurück
</button>
{:else}
<div class="flex gap-2">
<input
id="profile-email"
type="email"
bind:value={newEmail}
disabled={saving}
placeholder="Neue E-Mail-Adresse"
class="flex-1 px-3 py-2 border rounded-lg bg-background focus:outline-none focus:ring-2 focus:ring-primary/50 disabled:opacity-50"
/>
<button
type="button"
onclick={handleChangeEmail}
disabled={saving || !newEmail.trim()}
class="px-3 py-2 text-sm bg-primary text-primary-foreground rounded-lg hover:bg-primary/90 disabled:opacity-50 transition-colors whitespace-nowrap"
>
{saving ? 'Senden...' : 'Bestätigen'}
</button>
</div>
<button
type="button"
onclick={() => {
editingEmail = false;
error = null;
}}
class="mt-1 text-xs text-muted-foreground hover:text-foreground"
>
Abbrechen
</button>
{/if}
{:else}
<div class="flex gap-2">
<input
id="profile-email"
type="email"
value={user?.email || ''}
disabled
class="flex-1 px-3 py-2 border rounded-lg bg-muted text-muted-foreground cursor-not-allowed"
/>
<button
type="button"
onclick={() => {
editingEmail = true;
newEmail = '';
error = null;
}}
disabled={saving || uploadingAvatar}
class="px-3 py-2 text-sm border rounded-lg hover:bg-muted transition-colors disabled:opacity-50 whitespace-nowrap"
>
Ändern
</button>
</div>
<p class="mt-1 text-xs text-muted-foreground">
Eine Bestätigungs-E-Mail wird an die neue Adresse gesendet
</p>
{/if}
</div>
<!-- Name -->

View file

@ -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');
}

View file

@ -475,7 +475,16 @@ export const ENCRYPTION_REGISTRY: Record<string, EncryptionConfig> = {
// 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

View file

@ -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 {

View file

@ -158,10 +158,11 @@
{/if}
</section>
{#if ctx?.routine && (ctx.routine.wakeUp || ctx.routine.workStart || ctx.routine.bedtime || ctx.routine.workDays?.length)}
<section class="section-card">
<h3 class="section-title">Tagesablauf</h3>
<div class="routine-grid">
<!-- Routine -->
<section class="section-card">
<h3 class="section-title">Tagesablauf</h3>
{#if ctx?.routine && (ctx.routine.wakeUp || ctx.routine.workStart || ctx.routine.bedtime)}
<div class="routine-grid" onclick={() => onStartInterview()}>
{#if ctx.routine.wakeUp}<div class="routine-item">
<span class="routine-label">Aufstehen</span><span class="routine-value"
>{ctx.routine.wakeUp}</span
@ -183,35 +184,159 @@
>
</div>{/if}
</div>
{:else}
<button class="empty-hint" onclick={onStartInterview}
>Tagesablauf im Interview ausfüllen</button
>
{/if}
</section>
<!-- Nutrition -->
<section class="section-card">
<h3 class="section-title">Ernährung</h3>
{#if ctx?.nutrition && (ctx.nutrition.diet || ctx.nutrition.allergies?.length)}
<div>
{#if ctx.nutrition.diet}<p class="section-text" onclick={() => onStartInterview()}>
{ctx.nutrition.diet}
</p>{/if}
{#if ctx.nutrition.allergies?.length}
{#if editingField === 'nutrition.allergies'}
<div class="tags-edit">
<div class="tags-list">
{#each editValue as string[] as tag (tag)}<span class="tag warning"
>{tag}<button class="tag-remove" onclick={() => removeEditTag(tag)}
>&times;</button
></span
>{/each}
</div>
<input
type="text"
class="edit-input"
bind:value={tagInput}
placeholder="Allergie + Enter"
onkeydown={(e) => {
if (e.key === 'Enter' || e.key === ',') {
e.preventDefault();
addEditTag();
}
}}
onblur={addEditTag}
/>
<div class="edit-actions">
<button class="edit-btn" onclick={cancelEdit}>Abbrechen</button>
<button class="edit-btn primary" onclick={() => saveEdit('nutrition.allergies')}
>Speichern</button
>
</div>
</div>
{:else}
<div
class="tags-list"
onclick={() => startEdit('nutrition.allergies', ctx?.nutrition?.allergies ?? [])}
>
{#each ctx.nutrition.allergies as a (a)}<span class="tag warning">{a}</span>{/each}
</div>
{/if}
{/if}
{#if ctx.nutrition.preferences}<p class="section-detail">
{ctx.nutrition.preferences}
</p>{/if}
</div>
{:else}
<button class="empty-hint" onclick={onStartInterview}
>Ernährung im Interview ausfüllen</button
>
{/if}
</section>
<!-- Leisure -->
{#if ctx?.leisure && (ctx.leisure.media?.length || ctx.leisure.sports?.length || ctx.leisure.pets)}
<section class="section-card">
<h3 class="section-title">Freizeit</h3>
{#if ctx.leisure.sports?.length}
<div class="sub-section">
<span class="routine-label">Sport</span>
<div
class="tags-list"
onclick={() => startEdit('leisure.sports', ctx?.leisure?.sports ?? [])}
>
{#each ctx.leisure.sports as s (s)}<span class="tag">{s}</span>{/each}
</div>
</div>
{/if}
{#if ctx.leisure.media?.length}
<div class="sub-section">
<span class="routine-label">Medien</span>
<div
class="tags-list"
onclick={() => startEdit('leisure.media', ctx?.leisure?.media ?? [])}
>
{#each ctx.leisure.media as m (m)}<span class="tag">{m}</span>{/each}
</div>
</div>
{/if}
{#if ctx.leisure.pets}
<div class="sub-section">
<span class="routine-label">Haustiere</span>
<span
class="section-text"
onclick={() => startEdit('leisure.pets', ctx?.leisure?.pets ?? '')}
>{ctx.leisure.pets}</span
>
</div>
{/if}
</section>
{/if}
{#if ctx?.nutrition && (ctx.nutrition.diet || ctx.nutrition.allergies?.length)}
<section class="section-card">
<h3 class="section-title">Ernährung</h3>
{#if ctx.nutrition.diet}<p class="section-text">{ctx.nutrition.diet}</p>{/if}
{#if ctx.nutrition.allergies?.length}<div class="tags-list">
{#each ctx.nutrition.allergies as a (a)}<span class="tag warning">{a}</span>{/each}
</div>{/if}
{#if ctx.nutrition.preferences}<p class="section-detail">
{ctx.nutrition.preferences}
</p>{/if}
</section>
{/if}
{#if ctx?.goals?.length}
<section class="section-card">
<h3 class="section-title">Ziele</h3>
<!-- Goals -->
<section class="section-card">
<h3 class="section-title">Ziele</h3>
{#if editingField === 'goals'}
<div class="tags-edit">
<div class="tags-list">
{#each editValue as string[] as tag (tag)}<span class="tag accent"
>{tag}<button class="tag-remove" onclick={() => removeEditTag(tag)}>&times;</button
></span
>{/each}
</div>
<input
type="text"
class="edit-input"
bind:value={tagInput}
placeholder="Neues Ziel + Enter"
onkeydown={(e) => {
if (e.key === 'Enter' || e.key === ',') {
e.preventDefault();
addEditTag();
}
}}
onblur={addEditTag}
/>
<div class="edit-actions">
<button class="edit-btn" onclick={cancelEdit}>Abbrechen</button>
<button class="edit-btn primary" onclick={() => saveEdit('goals')}>Speichern</button>
</div>
</div>
{:else if ctx?.goals?.length}
<div class="tags-list" onclick={() => startEdit('goals', ctx?.goals ?? [])}>
{#each ctx.goals as goal (goal)}<span class="tag accent">{goal}</span>{/each}
</div>
</section>
{/if}
{:else}
<button
class="empty-hint"
onclick={() => {
editValue = [];
editingField = 'goals';
}}>Ziele hinzufügen</button
>
{/if}
</section>
{#if ctx?.social && (ctx.social.workStyle || ctx.social.communication)}
<section class="section-card">
<h3 class="section-title">Arbeitsstil</h3>
<div class="routine-grid">
<!-- Social / Work style -->
<section class="section-card">
<h3 class="section-title">Arbeitsstil</h3>
{#if ctx?.social && (ctx.social.workStyle || ctx.social.communication || ctx.social.livingSetup)}
<div class="routine-grid" onclick={() => onStartInterview()}>
{#if ctx.social.workStyle}<div class="routine-item">
<span class="routine-label">Arbeitsweise</span><span class="routine-value"
>{ctx.social.workStyle}</span
@ -222,18 +347,67 @@
>{ctx.social.communication}</span
>
</div>{/if}
{#if ctx.social.livingSetup}<div class="routine-item">
<span class="routine-label">Wohnsituation</span><span class="routine-value"
>{ctx.social.livingSetup}</span
>
</div>{/if}
</div>
</section>
{/if}
{:else}
<button class="empty-hint" onclick={onStartInterview}
>Arbeitsstil im Interview ausfüllen</button
>
{/if}
</section>
{#if ctx?.about?.languages?.length}
<section class="section-card">
<h3 class="section-title">Sprachen</h3>
<div class="tags-list">
<!-- Languages -->
<section class="section-card">
<h3 class="section-title">Sprachen</h3>
{#if editingField === 'about.languages'}
<div class="tags-edit">
<div class="tags-list">
{#each editValue as string[] as tag (tag)}<span class="tag"
>{tag}<button class="tag-remove" onclick={() => removeEditTag(tag)}>&times;</button
></span
>{/each}
</div>
<input
type="text"
class="edit-input"
bind:value={tagInput}
placeholder="Sprache + Enter"
onkeydown={(e) => {
if (e.key === 'Enter' || e.key === ',') {
e.preventDefault();
addEditTag();
}
}}
onblur={addEditTag}
/>
<div class="edit-actions">
<button class="edit-btn" onclick={cancelEdit}>Abbrechen</button>
<button class="edit-btn primary" onclick={() => saveEdit('about.languages')}
>Speichern</button
>
</div>
</div>
{:else if ctx?.about?.languages?.length}
<div
class="tags-list"
onclick={() => startEdit('about.languages', ctx?.about?.languages ?? [])}
>
{#each ctx.about.languages as lang (lang)}<span class="tag">{lang}</span>{/each}
</div>
</section>
{/if}
{:else}
<button
class="empty-hint"
onclick={() => {
editValue = [];
editingField = 'about.languages';
}}>Sprachen hinzufügen</button
>
{/if}
</section>
</div>
</div>
@ -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%;

View file

@ -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,
},
];

View file

@ -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<LocalUserContext> {
export const userContextStore = {
ensureDoc,
/** Replace a full section (about, routine, nutrition, social). */
async updateSection<K extends 'about' | 'routine' | 'nutrition' | 'social'>(
/** Replace a full section (about, routine, nutrition, leisure, social). */
async updateSection<K extends 'about' | 'routine' | 'nutrition' | 'leisure' | 'social'>(
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<void> {
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<void> {
/** 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<void> {
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<string, unknown>)[field] as unknown[]) ?? [];
} else {
existing = (current[section] as unknown[]) ?? [];
}
if (Array.isArray(existing)) {
const set = new Set([...existing, ...value]);
finalValue = [...set];
}
}
let diff: Partial<LocalUserContext>;
if (field && typeof current[section] === 'object' && !Array.isArray(current[section])) {
// Nested field: e.g. 'about.occupation'
const sectionObj = { ...(current[section] as Record<string, unknown>) };
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<LocalUserContext>;
diff = {
[section]: finalValue,
updatedAt: new Date().toISOString(),
} as Partial<LocalUserContext>;
}
await encryptRecord('userContext', diff);

View file

@ -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: '',