evOo-local/apps/web/src/lib/user-profile-view.ts
Claude ddf13adf08
feat(profile): edit facts in place in About you
About you could only confirm or delete what the bots know about the user, so a
fact that was slightly wrong or filed under the wrong category had to be
deleted and taught again, and a fact could not be limited to some spaces from
the app even though the profile supports it.

- Each fact gets an Edit button that turns its row into an inline editor:
  wording, category, and (when the user has more than one space, or the fact
  is already limited) which spaces it shows in. Escape or Cancel leaves it
  unchanged; only changed fields are sent through profile.update editFacts.
- A limited fact names its spaces next to its source.
- The repository validates edits like new facts: a wording whose normalized
  form is empty or equals another fact of the user is refused, all edits of
  one update apply atomically, and the stored embedding is only dropped when
  the wording really changes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MRtGG9ARfqqhma3t85Ro2u
2026-10-03 13:27:38 +00:00

133 lines
4.3 KiB
TypeScript

import type { UpdateUserProfile, UserFact, UserFactCategory, UserProfile } from "@rakazo/contracts";
import { USER_FACT_CATEGORIES } from "@rakazo/contracts";
/** What the fact editor holds: null spaces means every space. */
export interface UserFactDraft {
value: string;
category: UserFactCategory;
spaceIds: string[] | null;
}
type UserFactEdit = NonNullable<UpdateUserProfile["editFacts"]>[number];
export type ProfileField = "timeZone" | "language" | "locale" | "communicationStyle" | "summary";
const PROFILE_FIELDS: ProfileField[] = [
"timeZone",
"language",
"locale",
"communicationStyle",
"summary",
];
type ClearedProfileField = {
timeZone?: null;
language?: null;
locale?: null;
communicationStyle?: "";
summary?: "";
};
/** The `profile.update` input that removes one profile field. */
export const CLEARED_PROFILE_FIELD: Record<ProfileField, ClearedProfileField> = {
timeZone: { timeZone: null },
language: { language: null },
locale: { locale: null },
communicationStyle: { communicationStyle: "" },
summary: { summary: "" },
};
/** Profile fields that hold a value, in display order. */
export function filledProfileFields(
profile: UserProfile,
): Array<{ field: ProfileField; value: string }> {
return PROFILE_FIELDS.flatMap((field) => {
const value = profile[field];
return value ? [{ field, value }] : [];
});
}
/** Facts grouped in category order; pending facts lead each group, then the newest. */
export function groupUserFacts(
facts: UserFact[],
): Array<{ category: UserFactCategory; facts: UserFact[] }> {
return USER_FACT_CATEGORIES.flatMap((category) => {
const inCategory = facts
.filter((fact) => fact.category === category)
.sort((a, b) =>
a.status === b.status
? b.createdAt.localeCompare(a.createdAt)
: a.status === "pending"
? -1
: 1,
);
return inCategory.length > 0 ? [{ category, facts: inCategory }] : [];
});
}
/** A language tag in the reader's language, e.g. "de" → "German"; the tag itself if unknown. */
export function displayLanguageTag(tag: string, uiLocale: string): string {
try {
return new Intl.DisplayNames([uiLocale], { type: "language" }).of(tag) ?? tag;
} catch {
return tag;
}
}
/** The region of a locale tag in the reader's language, e.g. "de-AT" → "Austria". */
export function displayRegionTag(tag: string, uiLocale: string): string {
try {
const region = new Intl.Locale(tag).region;
if (!region) return displayLanguageTag(tag, uiLocale);
return new Intl.DisplayNames([uiLocale], { type: "region" }).of(region) ?? tag;
} catch {
return tag;
}
}
export function userFactDraft(fact: UserFact): UserFactDraft {
return {
value: fact.value,
category: fact.category,
spaceIds: fact.allSpaces ? null : fact.spaceIds,
};
}
/** The `editFacts` entry for what changed in the draft, or null when nothing did. */
export function userFactEdit(fact: UserFact, draft: UserFactDraft): UserFactEdit | null {
const edit: UserFactEdit = { id: fact.id };
const value = draft.value.trim();
if (value !== fact.value) edit.value = value;
if (draft.category !== fact.category) edit.category = draft.category;
const before = userFactDraft(fact).spaceIds;
const sameSpaces =
before === null || draft.spaceIds === null
? before === draft.spaceIds
: before.length === draft.spaceIds.length &&
draft.spaceIds.every((spaceId) => before.includes(spaceId));
if (!sameSpaces) edit.spaceIds = draft.spaceIds;
return Object.keys(edit).length > 1 ? edit : null;
}
/** Checks or unchecks one space; unchecking the last one shows the fact everywhere again. */
export function toggleFactSpace(
spaceIds: string[] | null,
spaceId: string,
checked: boolean,
): string[] | null {
const current = spaceIds ?? [];
const next = checked
? [...current.filter((id) => id !== spaceId), spaceId]
: current.filter((id) => id !== spaceId);
return next.length > 0 ? next : null;
}
/** Names of the spaces a limited fact shows in, in the order of `spaces`; unknown ids are skipped. */
export function factSpaceNames(
spaceIds: string[] | null,
spaces: ReadonlyArray<{ id: string; name: string }>,
): string[] {
if (!spaceIds) return [];
return spaces.filter((space) => spaceIds.includes(space.id)).map((space) => space.name);
}