Merge branch 'phase/41-opinions'

# Conflicts:
#	docs/phases/README.md
This commit is contained in:
Leonid Pershin
2026-08-20 07:58:05 +03:00
25 changed files with 1021 additions and 28 deletions
+12
View File
@@ -191,7 +191,13 @@ const ru = {
peopleTabApparel: 'Одежда',
peopleTabCarry: 'Ноша',
peopleTabNow: 'Сейчас',
peopleTabConnections: 'Связи',
peopleTabPortrait: 'Портрет',
peopleConnectionsFriends: 'Друзья',
peopleConnectionsEnemies: 'Враги',
peopleConnectionsSearch: 'Поиск по связям',
peopleConnectionsEmpty: 'Нет других связей.',
peopleOpinionValue: '{label} ({value})',
peopleAtHome: 'дома',
peopleApparelFit: 'Уместность: {value}',
peopleLockerYes: 'Есть шкафчик',
@@ -533,7 +539,13 @@ const en: Messages = {
peopleTabApparel: 'Clothes',
peopleTabCarry: 'Carried',
peopleTabNow: 'Now',
peopleTabConnections: 'Connections',
peopleTabPortrait: 'Portrait',
peopleConnectionsFriends: 'Friends',
peopleConnectionsEnemies: 'Enemies',
peopleConnectionsSearch: 'Search connections',
peopleConnectionsEmpty: 'No other connections.',
peopleOpinionValue: '{label} ({value})',
peopleAtHome: 'home',
peopleApparelFit: 'Fit: {value}',
peopleLockerYes: 'Has a locker',
+23
View File
@@ -262,6 +262,28 @@ export interface PersonRel {
readonly id: string;
readonly fullName: string;
readonly female: boolean;
readonly opinion?: number | null;
readonly opinionLabel?: string | null;
}
export interface PersonOpinionLink {
readonly id: string;
readonly fullName: string;
readonly female: boolean;
readonly opinion: number;
readonly opinionLabel: string;
}
export interface PersonConnections {
readonly family: {
readonly parents: readonly PersonRel[];
readonly children: readonly PersonRel[];
readonly siblings: readonly PersonRel[];
readonly partners: readonly PersonRel[];
};
readonly friends: readonly PersonOpinionLink[];
readonly enemies: readonly PersonOpinionLink[];
readonly others: readonly PersonOpinionLink[];
}
export interface PersonCard {
@@ -301,6 +323,7 @@ export interface PersonCard {
readonly hasCustom: boolean;
readonly hasFullBody: boolean;
readonly customPortraitPrompt: string | null;
readonly connections: PersonConnections | null;
}
export interface WornItem {
@@ -78,6 +78,7 @@ function personCard(): PersonCard {
hasCustom: false,
hasFullBody: false,
customPortraitPrompt: null,
connections: null,
};
}
@@ -64,6 +64,25 @@ function card(overrides: Partial<PersonCard> = {}): PersonCard {
hasCustom: false,
hasFullBody: false,
customPortraitPrompt: null,
connections: {
family: {
parents: [
{
id: 'f0.p0',
fullName: 'Иванова Ольга',
female: true,
opinion: 75,
opinionLabel: 'близкие друзья',
},
],
children: [],
siblings: [],
partners: [],
},
friends: [],
enemies: [],
others: [],
},
...overrides,
};
}
@@ -261,6 +280,17 @@ describe('renderPersonCard', () => {
expect(root.querySelector('.people__log-tools')).toBeNull();
});
it('does not mount the log table on the connections tab', () => {
setLocale('ru');
const root = document.createElement('div');
renderPersonCard(root, card(), () => {}, { tab: 'connections', log: logPage() });
expect(root.querySelector('[data-card-tab="connections"]')?.hasAttribute('hidden')).toBe(false);
expect(root.textContent).toContain('близкие друзья');
expect(root.querySelector('.people__log')).toBeNull();
expect(root.querySelector('.people__log-tools')).toBeNull();
});
it('pages the log on the now tab', () => {
setLocale('ru');
const onLogPage = vi.fn();
+131 -4
View File
@@ -4,13 +4,14 @@ import type {
PersonLogDir,
PersonLogPage,
PersonLogQuery,
PersonOpinionLink,
PersonRel,
PersonRole,
} from '../net/api.ts';
import { portraitUrl, type PortraitKind, type PortraitPrompt } from '../net/api.ts';
import { formatGameTimeOfDay } from '../format/gameTime.ts';
import { t, type MessageKey } from '../i18n/strings.ts';
import { el } from './dom.ts';
import { clear, el } from './dom.ts';
const ROLE_KEYS: Record<PersonRole, MessageKey> = {
student: 'peopleRoleStudent',
@@ -18,7 +19,7 @@ const ROLE_KEYS: Record<PersonRole, MessageKey> = {
parent: 'peopleRoleParent',
};
export type PersonCardTab = 'overview' | 'apparel' | 'carry' | 'now' | 'portrait';
export type PersonCardTab = 'overview' | 'apparel' | 'carry' | 'now' | 'connections' | 'portrait';
export interface RenderPersonCardOptions {
readonly place?: string;
@@ -110,6 +111,7 @@ export function renderPersonCard(
apparel: el('div', { class: 'people__tab-panel', dataset: { cardTab: 'apparel' } }),
carry: el('div', { class: 'people__tab-panel', dataset: { cardTab: 'carry' } }),
now: el('div', { class: 'people__tab-panel', dataset: { cardTab: 'now' } }),
connections: el('div', { class: 'people__tab-panel', dataset: { cardTab: 'connections' } }),
portrait: el('div', { class: 'people__tab-panel people__portrait-panel', dataset: { cardTab: 'portrait' } }),
};
@@ -118,6 +120,7 @@ export function renderPersonCard(
apparel: 'peopleTabApparel',
carry: 'peopleTabCarry',
now: 'peopleTabNow',
connections: 'peopleTabConnections',
portrait: 'peopleTabPortrait',
};
@@ -142,6 +145,7 @@ export function renderPersonCard(
fillApparel(panels.apparel, card);
fillCarry(panels.carry, card);
fillNow(panels.now, card, options.away === true, tab === 'now' ? options.log : null, options);
fillConnections(panels.connections, card, onRelative);
fillPortrait(panels.portrait, card, options);
const showTab = (next: PersonCardTab): void => {
@@ -160,7 +164,7 @@ export function renderPersonCard(
showTab(tab);
parent.append(tabs, panels.overview, panels.apparel, panels.carry, panels.now, panels.portrait);
parent.append(tabs, panels.overview, panels.apparel, panels.carry, panels.now, panels.connections, panels.portrait);
}
function fillOverview(parent: HTMLElement, card: PersonCard, onRelative: (id: string) => void): void {
@@ -253,7 +257,47 @@ function fillCarry(parent: HTMLElement, card: PersonCard): void {
);
}
parent.append(grid);
parent.append(grid );
}
function fillConnections(parent: HTMLElement, card: PersonCard, onRelative: (id: string) => void): void {
const connections = card.connections;
if (connections === null) {
return;
}
const family = section(t('peopleFamily'));
appendRelativesWithOpinion(family, t('peopleParents'), connections.family.parents, onRelative);
appendRelativesWithOpinion(family, t('peopleChildren'), connections.family.children, onRelative);
appendRelativesWithOpinion(family, t('peopleSiblings'), connections.family.siblings, onRelative);
appendRelativesWithOpinion(family, t('peoplePartners'), connections.family.partners, onRelative);
if (family.childElementCount > 1) {
parent.append(family);
}
appendOpinionLinks(parent, t('peopleConnectionsFriends'), connections.friends, onRelative);
appendOpinionLinks(parent, t('peopleConnectionsEnemies'), connections.enemies, onRelative);
const search = el('input', { class: 'input people__input', type: 'search' });
search.placeholder = t('peopleConnectionsSearch');
const list = el('div', { class: 'people__connections-list' });
const renderMatches = (): void => {
clear(list);
const query = search.value.trim().toLocaleLowerCase();
const matches = connections.others.filter((row) =>
query.length === 0 ? true : row.fullName.toLocaleLowerCase().includes(query),
);
if (matches.length === 0) {
list.append(el('p', { class: 'panel__empty', text: t('peopleConnectionsEmpty') }));
return;
}
appendOpinionLinks(list, '', matches, onRelative, false);
};
search.addEventListener('input', renderMatches);
parent.append(search, list);
renderMatches();
}
function fillNow(
@@ -682,3 +726,86 @@ function appendRelatives(
),
);
}
function appendRelativesWithOpinion(
parent: HTMLElement,
title: string,
relatives: readonly PersonRel[],
open: (id: string) => void,
): void {
if (relatives.length === 0) {
return;
}
const list = el('span', { class: 'people__rel-list' });
for (const relative of relatives) {
const caption =
relative.opinion !== null &&
relative.opinion !== undefined &&
relative.opinionLabel !== null &&
relative.opinionLabel !== undefined
? t('peopleOpinionValue', { label: relative.opinionLabel, value: relative.opinion })
: relative.fullName;
list.append(
el('button', {
class: 'people__link',
type: 'button',
text: caption,
title: relative.fullName,
onClick: () => open(relative.id),
}),
);
}
parent.append(
el(
'div',
{ class: 'people__rel' },
el('span', { class: 'people__rel-title', text: title }),
list,
),
);
}
function appendOpinionLinks(
parent: HTMLElement,
title: string,
rows: readonly PersonOpinionLink[],
open: (id: string) => void,
titled = true,
): void {
if (rows.length === 0) {
return;
}
const block = el('div', { class: 'people__connections-block' });
if (titled && title.length > 0) {
block.append(el('h4', { class: 'people__section-title', text: title }));
}
const list = el('dl', { class: 'people__pairs' });
for (const row of rows) {
list.append(
el(
'div',
{ class: 'people__pair' },
el(
'dt',
{},
el('button', {
class: 'people__link',
type: 'button',
text: row.fullName,
onClick: () => open(row.id),
}),
),
el('dd', {
text: t('peopleOpinionValue', { label: row.opinionLabel, value: row.opinion }),
}),
),
);
}
block.append(list);
parent.append(block);
}