mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-14 22:21:10 +02:00
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:
parent
a1bb703086
commit
4b41549d20
8 changed files with 389 additions and 57 deletions
|
|
@ -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 -->
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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)}
|
||||
>×</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)}>×</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)}>×</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%;
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
},
|
||||
];
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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: '',
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue