Split the person card into overview, clothes, carry and now tabs.

Today's history is a paged HTTP log on the worker, not on the card or in people.json.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Leonid Pershin
2026-08-20 04:00:38 +03:00
co-authored by Cursor
parent 69106cb48b
commit 6be4fa75ab
27 changed files with 1551 additions and 109 deletions
+30
View File
@@ -136,6 +136,21 @@ const ru = {
peopleChildren: 'Дети',
peopleSiblings: 'Братья и сёстры',
peoplePartners: 'Супруг(а)',
peopleTabOverview: 'Обзор',
peopleTabApparel: 'Одежда',
peopleTabCarry: 'Ноша',
peopleTabNow: 'Сейчас',
peopleAtHome: 'дома',
peopleApparelFit: 'Уместность: {value}',
peopleLockerYes: 'Есть шкафчик',
peopleLockerNone: 'Шкафчика нет',
peopleHomeCount: 'ещё {n} дома',
peopleLogSearch: 'Поиск',
peopleLogNewest: 'Сначала новые',
peopleLogOldest: 'Сначала старые',
peopleLogTime: 'Время',
peopleLogEvent: 'Событие',
peopleLogEmpty: 'Сегодня записей нет.',
modeOverview: 'Обзор',
modeManage: 'Управление',
@@ -356,6 +371,21 @@ const en: Messages = {
peopleChildren: 'Children',
peopleSiblings: 'Siblings',
peoplePartners: 'Spouse',
peopleTabOverview: 'Overview',
peopleTabApparel: 'Clothes',
peopleTabCarry: 'Carried',
peopleTabNow: 'Now',
peopleAtHome: 'home',
peopleApparelFit: 'Fit: {value}',
peopleLockerYes: 'Has a locker',
peopleLockerNone: 'No locker',
peopleHomeCount: '{n} more at home',
peopleLogSearch: 'Search',
peopleLogNewest: 'Newest first',
peopleLogOldest: 'Oldest first',
peopleLogTime: 'Time',
peopleLogEvent: 'Event',
peopleLogEmpty: 'Nothing logged today.',
modeOverview: 'Overview',
modeManage: 'Management',
+48
View File
@@ -295,6 +295,8 @@ export interface PersonCard {
readonly carried: readonly CarriedItem[];
readonly carryMass: number;
readonly carryCapacity: number;
readonly hasLocker: boolean;
readonly homeCount: number;
}
export interface WornItem {
@@ -303,6 +305,8 @@ export interface WornItem {
readonly color: string | null;
readonly colorLabel: string | null;
readonly layers: readonly DefLabel[];
readonly condition?: number;
readonly conditionLabel?: string | null;
}
export interface CarriedItem {
@@ -335,6 +339,50 @@ export async function fetchPerson(schoolId: number, personId: string, lang: stri
);
}
export type PersonLogDir = 'asc' | 'desc';
export interface PersonLogQuery {
readonly q?: string;
readonly sort?: 'time';
readonly dir?: PersonLogDir;
readonly page?: number;
readonly pageSize?: number;
}
export interface PersonLogEntry {
readonly time: string;
readonly type: string;
readonly label: string;
readonly thingDef: string | null;
}
export interface PersonLogPage {
readonly total: number;
readonly page: number;
readonly pageSize: number;
readonly entries: readonly PersonLogEntry[];
}
export async function fetchPersonLog(
schoolId: number,
personId: string,
lang: string,
query: PersonLogQuery,
): Promise<PersonLogPage> {
const params = new URLSearchParams({ lang });
for (const [key, value] of Object.entries(query)) {
if (value === undefined || value === '') {
continue;
}
params.set(key, String(value));
}
return request<PersonLogPage>(
`/api/schools/${schoolId}/people/${encodeURIComponent(personId)}/log?${params.toString()}`,
);
}
export interface DirectoryPerson {
readonly id: string;
readonly fullName: string;
+85 -3
View File
@@ -593,11 +593,93 @@ body {
}
.people__card-place,
.people__card-activity {
.people__card-activity,
.people__now-activity {
margin: 4px 0 0;
color: var(--text-muted);
}
.people__tabs {
display: flex;
flex-wrap: wrap;
gap: 4px;
margin: 10px 0 8px;
}
.people__tab {
border: 1px solid var(--border);
border-radius: 6px;
background: transparent;
color: var(--text-muted);
cursor: pointer;
font: inherit;
padding: 3px 8px;
}
.people__tab:hover {
color: var(--text);
}
.people__tab--active {
background: var(--surface);
color: var(--text);
border-color: var(--text-muted);
}
.people__tab-panel[hidden] {
display: none;
}
.people__fit,
.people__locker,
.people__home-count {
margin: 0 0 6px;
font-size: 12px;
}
.people__garments {
display: flex;
flex-direction: column;
gap: 8px;
}
.people__wear {
display: grid;
grid-template-columns: minmax(4em, auto) 1fr;
gap: 6px 8px;
align-items: center;
margin-top: 4px;
font-size: 12px;
}
.people__wear-label {
color: var(--text-muted);
}
.people__wear .people__need-track {
margin: 0;
}
.people__log-tools {
display: flex;
flex-wrap: wrap;
gap: 6px;
margin: 8px 0;
}
.people__log {
width: 100%;
border-collapse: collapse;
font-size: 12px;
}
.people__log th,
.people__log td {
padding: 4px 6px;
text-align: left;
border-bottom: 1px solid var(--border);
}
.people__section {
margin-top: 10px;
}
@@ -625,8 +707,8 @@ body {
border-top: 1px solid var(--border);
}
/* The first block sits right under the name, where a divider would only add noise. */
.people__card > .people__card-meta + .people__section > .people__section-title {
/* The first block sits right under the tabs, where a divider would only add noise. */
.people__tab-panel > .people__section:first-child > .people__section-title {
margin-top: 0;
padding-top: 0;
border-top: 0;
+68 -2
View File
@@ -1,7 +1,10 @@
import {
fetchPerson,
fetchPersonLog,
hireStaff,
type PersonCard,
type PersonLogPage,
type PersonLogQuery,
type Staffing,
type StaffingApplicant,
} from '../net/api.ts';
@@ -9,7 +12,7 @@ import { getLocale } from '../i18n/locale.ts';
import { t, type MessageKey } from '../i18n/strings.ts';
import { clear, el } from './dom.ts';
import { Modal } from './modal.ts';
import { formatPersonPlace, renderPersonCard } from './personCard.ts';
import { formatPersonPlace, renderPersonCard, type PersonCardTab } from './personCard.ts';
import {
actionError,
fillSelect,
@@ -69,6 +72,10 @@ export class ApplicantsDialog {
private cardToken = 0;
private busy = false;
private painted: PersonCard | null = null;
private cardTab: PersonCardTab = 'overview';
private logQuery: PersonLogQuery = { page: 1, pageSize: 20, dir: 'desc' };
private logPage: PersonLogPage | null = null;
private logToken = 0;
constructor(private readonly options: ApplicantsDialogOptions) {
this.staffing = options.staffing;
@@ -292,6 +299,9 @@ export class ApplicantsDialog {
private async openCard(personId: string): Promise<void> {
const token = ++this.cardToken;
this.cardTab = 'overview';
this.logPage = null;
this.logQuery = { page: 1, pageSize: 20, dir: 'desc' };
try {
const card = await fetchPerson(this.options.schoolId, personId, getLocale());
if (token !== this.cardToken) {
@@ -317,10 +327,66 @@ export class ApplicantsDialog {
return;
}
renderPersonCard(this.card, card, (id) => this.openRelative(id), this.placeOf(card.id));
renderPersonCard(this.card, card, (id) => this.openRelative(id), this.cardOptions(card));
this.appendHireIfNeeded();
}
private cardOptions(card: PersonCard) {
const place = this.placeOf(card.id);
return {
place,
away: place === formatPersonPlace('away'),
tab: this.cardTab,
log: this.cardTab === 'now' ? this.logPage : null,
logQuery: this.logQuery,
onTab: (tab: PersonCardTab) => {
this.cardTab = tab;
if (tab === 'now') {
void this.loadLog();
} else {
this.paintCard(this.painted);
}
},
onLogSearch: (q: string) => {
this.logQuery = { ...this.logQuery, q, page: 1 };
void this.loadLog();
},
onLogDir: (dir: 'asc' | 'desc') => {
this.logQuery = { ...this.logQuery, dir, page: 1 };
void this.loadLog();
},
onLogPage: (page: number) => {
this.logQuery = { ...this.logQuery, page };
void this.loadLog();
},
};
}
private async loadLog(): Promise<void> {
const painted = this.painted;
if (painted === null || this.cardTab !== 'now') {
this.paintCard(painted);
return;
}
const token = ++this.logToken;
try {
this.logPage = await fetchPersonLog(this.options.schoolId, painted.id, getLocale(), this.logQuery);
if (token !== this.logToken) {
return;
}
this.paintCard(this.painted);
} catch {
if (token !== this.logToken) {
return;
}
this.logPage = { total: 0, page: this.logQuery.page ?? 1, pageSize: this.logQuery.pageSize ?? 20, entries: [] };
this.paintCard(this.painted);
}
}
private appendHireIfNeeded(): void {
const applicant = this.staffing.applicants.find((row) => row.id === this.painted?.id);
if (applicant === undefined) {
@@ -24,6 +24,7 @@ vi.mock('../net/api.ts', async (importOriginal) => {
fetchStaffing: vi.fn(),
fetchTimetable: vi.fn(),
fetchPerson: vi.fn(),
fetchPersonLog: vi.fn(),
hireStaff: vi.fn(),
assignSubject: vi.fn(),
unassignSubject: vi.fn(),
@@ -59,6 +60,8 @@ function personCard(): PersonCard {
carried: [],
carryMass: 0,
carryCapacity: 0,
hasLocker: false,
homeCount: 0,
};
}
+78 -5
View File
@@ -1,10 +1,13 @@
import {
assignSubject,
fetchPerson,
fetchPersonLog,
fetchStaffing,
fetchTimetable,
unassignSubject,
type PersonCard,
type PersonLogPage,
type PersonLogQuery,
type Staffing,
type StaffMember,
type Timetable,
@@ -13,7 +16,7 @@ import { getLocale } from '../i18n/locale.ts';
import { t } from '../i18n/strings.ts';
import { ApplicantsDialog } from './applicantsDialog.ts';
import { clear, el } from './dom.ts';
import { formatPersonPlace, renderPersonCard } from './personCard.ts';
import { formatPersonPlace, nowActivityText, renderPersonCard, type PersonCardTab } from './personCard.ts';
import { actionError, fillSelect, formatHours, formatMoney } from './staffingUi.ts';
import { personTimetableQuery, TimetableGrid } from './timetableGrid.ts';
@@ -64,6 +67,10 @@ export class ManagementPanel {
private locate: ((id: string) => string) | null = null;
private painted: PersonCard | null = null;
private poolDialog: ApplicantsDialog | null = null;
private cardTab: PersonCardTab = 'overview';
private logQuery: PersonLogQuery = { page: 1, pageSize: 20, dir: 'desc' };
private logPage: PersonLogPage | null = null;
private logToken = 0;
constructor() {
this.error.hidden = true;
@@ -381,6 +388,9 @@ export class ManagementPanel {
}
const token = ++this.cardToken;
this.cardTab = 'overview';
this.logPage = null;
this.logQuery = { page: 1, pageSize: 20, dir: 'desc' };
try {
const card = await fetchPerson(schoolId, personId, getLocale());
if (token !== this.cardToken) {
@@ -406,18 +416,80 @@ export class ManagementPanel {
return;
}
renderPersonCard(this.card, card, (id) => void this.openRelative(id), this.placeOf(card.id));
renderPersonCard(this.card, card, (id) => void this.openRelative(id), this.cardOptions(card));
this.mountPersonTimetable(card);
this.appendActions(card.id);
}
private cardOptions(card: PersonCard) {
const place = this.placeOf(card.id);
return {
place,
away: place === formatPersonPlace('away'),
tab: this.cardTab,
log: this.cardTab === 'now' ? this.logPage : null,
logQuery: this.logQuery,
onTab: (tab: PersonCardTab) => {
this.cardTab = tab;
if (tab === 'now') {
void this.loadLog();
} else {
this.paintCard(this.painted);
}
},
onLogSearch: (q: string) => {
this.logQuery = { ...this.logQuery, q, page: 1 };
void this.loadLog();
},
onLogDir: (dir: 'asc' | 'desc') => {
this.logQuery = { ...this.logQuery, dir, page: 1 };
void this.loadLog();
},
onLogPage: (page: number) => {
this.logQuery = { ...this.logQuery, page };
void this.loadLog();
},
};
}
private async loadLog(): Promise<void> {
const schoolId = this.schoolId;
const painted = this.painted;
if (schoolId === null || painted === null || this.cardTab !== 'now') {
this.paintCard(painted);
return;
}
const token = ++this.logToken;
try {
this.logPage = await fetchPersonLog(schoolId, painted.id, getLocale(), this.logQuery);
if (token !== this.logToken) {
return;
}
this.paintCard(this.painted);
} catch {
if (token !== this.logToken) {
return;
}
this.logPage = { total: 0, page: this.logQuery.page ?? 1, pageSize: this.logQuery.pageSize ?? 20, entries: [] };
this.paintCard(this.painted);
}
}
private relocate(): void {
const line = this.card.querySelector('.people__card-place');
if (!(line instanceof HTMLElement) || this.painted === null) {
return;
}
line.textContent = this.placeOf(this.painted.id);
const place = this.placeOf(this.painted.id);
line.textContent = place;
const now = this.card.querySelector('.people__now-activity');
if (now instanceof HTMLElement) {
now.textContent = nowActivityText(this.painted, place === formatPersonPlace('away'));
}
}
private placeOf(id: string): string {
@@ -427,11 +499,12 @@ export class ManagementPanel {
private mountPersonTimetable(card: PersonCard): void {
const schoolId = this.schoolId;
const query = personTimetableQuery(card);
if (schoolId === null || query === null) {
const overview = this.card.querySelector('[data-card-tab="overview"]');
if (!(overview instanceof HTMLElement) || schoolId === null || query === null) {
return;
}
this.card.append(this.personTimetableTitle, this.personGrid.element);
overview.append(this.personTimetableTitle, this.personGrid.element);
this.personGrid.attach(schoolId);
const token = this.cardToken;
void fetchTimetable(schoolId, getLocale(), query)
@@ -20,6 +20,7 @@ vi.mock('../net/api.ts', async (importOriginal) => {
...actual,
fetchPeople: vi.fn(),
fetchPerson: vi.fn(),
fetchPersonLog: vi.fn(),
fetchTimetable: vi.fn(),
};
});
+76 -6
View File
@@ -1,8 +1,8 @@
import { fetchPeople, fetchPerson, fetchTimetable, type PeoplePage, type PersonCard, type PersonRole, type PersonSort } from '../net/api.ts';
import { fetchPeople, fetchPerson, fetchPersonLog, fetchTimetable, type PeoplePage, type PersonCard, type PersonLogPage, type PersonLogQuery, type PersonRole, type PersonSort } from '../net/api.ts';
import { getLocale } from '../i18n/locale.ts';
import { t, type MessageKey } from '../i18n/strings.ts';
import { clear, el } from './dom.ts';
import { formatPersonPlace, placement, renderPersonCard, roleLabels } from './personCard.ts';
import { formatPersonPlace, nowActivityText, placement, renderPersonCard, roleLabels, type PersonCardTab } from './personCard.ts';
import { personTimetableQuery, TimetableGrid } from './timetableGrid.ts';
const COLUMNS: readonly { sort: PersonSort; label: MessageKey }[] = [
@@ -62,6 +62,10 @@ export class PeoplePanel {
private cardToken = 0;
private locate: ((id: string) => string) | null = null;
private painted: PersonCard | null = null;
private cardTab: PersonCardTab = 'overview';
private logQuery: PersonLogQuery = { page: 1, pageSize: 20, dir: 'desc' };
private logPage: PersonLogPage | null = null;
private logToken = 0;
constructor(private readonly options: PeoplePanelOptions) {
this.ageMinInput.min = '0';
@@ -340,6 +344,9 @@ export class PeoplePanel {
// Also covers the links inside a card: opening a relative is picking somebody too.
this.options.onSelect();
this.cardTab = 'overview';
this.logPage = null;
this.logQuery = { page: 1, pageSize: 20, dir: 'desc' };
const token = ++this.cardToken;
try {
const card = await fetchPerson(schoolId, personId, getLocale());
@@ -368,17 +375,79 @@ export class PeoplePanel {
return;
}
renderPersonCard(this.card, card, (id) => void this.openCard(id), this.placeOf(card.id));
renderPersonCard(this.card, card, (id) => void this.openCard(id), this.cardOptions(card));
this.mountPersonTimetable(card);
}
private cardOptions(card: PersonCard) {
const place = this.placeOf(card.id);
return {
place,
away: place === formatPersonPlace('away'),
tab: this.cardTab,
log: this.cardTab === 'now' ? this.logPage : null,
logQuery: this.logQuery,
onTab: (tab: PersonCardTab) => {
this.cardTab = tab;
if (tab === 'now') {
void this.loadLog();
} else {
this.paintCard(this.painted);
}
},
onLogSearch: (q: string) => {
this.logQuery = { ...this.logQuery, q, page: 1 };
void this.loadLog();
},
onLogDir: (dir: 'asc' | 'desc') => {
this.logQuery = { ...this.logQuery, dir, page: 1 };
void this.loadLog();
},
onLogPage: (page: number) => {
this.logQuery = { ...this.logQuery, page };
void this.loadLog();
},
};
}
private async loadLog(): Promise<void> {
const schoolId = this.schoolId;
const painted = this.painted;
if (schoolId === null || painted === null || this.cardTab !== 'now') {
this.paintCard(painted);
return;
}
const token = ++this.logToken;
try {
this.logPage = await fetchPersonLog(schoolId, painted.id, getLocale(), this.logQuery);
if (token !== this.logToken) {
return;
}
this.paintCard(this.painted);
} catch {
if (token !== this.logToken) {
return;
}
this.logPage = { total: 0, page: this.logQuery.page ?? 1, pageSize: this.logQuery.pageSize ?? 20, entries: [] };
this.paintCard(this.painted);
}
}
private relocate(): void {
const line = this.card.querySelector('.people__card-place');
if (!(line instanceof HTMLElement) || this.painted === null) {
return;
}
line.textContent = this.placeOf(this.painted.id);
const place = this.placeOf(this.painted.id);
line.textContent = place;
const now = this.card.querySelector('.people__now-activity');
if (now instanceof HTMLElement) {
now.textContent = nowActivityText(this.painted, place === formatPersonPlace('away'));
}
}
private placeOf(id: string): string {
@@ -388,11 +457,12 @@ export class PeoplePanel {
private mountPersonTimetable(card: PersonCard): void {
const schoolId = this.schoolId;
const query = personTimetableQuery(card);
if (schoolId === null || query === null) {
const overview = this.card.querySelector('[data-card-tab="overview"]');
if (!(overview instanceof HTMLElement) || schoolId === null || query === null) {
return;
}
this.card.append(this.personTimetableTitle, this.personGrid.element);
overview.append(this.personTimetableTitle, this.personGrid.element);
this.personGrid.attach(schoolId);
const token = this.cardToken;
void fetchTimetable(schoolId, getLocale(), query)
+88 -9
View File
@@ -1,11 +1,11 @@
/**
* @vitest-environment happy-dom
*/
import { afterEach, describe, expect, it } from 'vitest';
import type { PersonCard } from '../net/api.ts';
import { afterEach, describe, expect, it, vi } from 'vitest';
import type { PersonCard, PersonLogPage } from '../net/api.ts';
import { getLocale, setLocale } from '../i18n/locale.ts';
import { t } from '../i18n/strings.ts';
import { renderPersonCard } from './personCard.ts';
import { renderPersonCard, type PersonCardTab } from './personCard.ts';
const initial = getLocale();
@@ -27,7 +27,7 @@ function card(overrides: Partial<PersonCard> = {}): PersonCard {
classId: 'class-1',
position: null,
positionLabel: null,
body: [],
body: [{ id: 'Height', label: 'Рост', value: '164' }],
skills: [],
traits: [],
needs: [],
@@ -41,6 +41,7 @@ function card(overrides: Partial<PersonCard> = {}): PersonCard {
color: 'White',
colorLabel: 'Белый',
layers: [{ defName: 'Top', label: 'Верх' }],
condition: 1,
},
],
carried: [
@@ -56,22 +57,100 @@ function card(overrides: Partial<PersonCard> = {}): PersonCard {
],
carryMass: 1.2,
carryCapacity: 14,
hasLocker: false,
homeCount: 2,
...overrides,
};
}
function logPage(overrides: Partial<PersonLogPage> = {}): PersonLogPage {
return {
total: 3,
page: 1,
pageSize: 2,
entries: [
{ time: '2012-04-03T12:00:00Z', type: 'action-started', label: 'начал: Обед', thingDef: 'EatLunch' },
{ time: '2012-04-03T12:15:00Z', type: 'action-ended', label: 'закончил: Обед', thingDef: 'EatLunch' },
],
...overrides,
};
}
function tabButton(root: HTMLElement, tab: PersonCardTab): HTMLButtonElement {
const button = [...root.querySelectorAll('button')].find((node) => node.dataset.tab === tab);
if (!(button instanceof HTMLButtonElement)) {
throw new Error(`missing tab ${tab}`);
}
return button;
}
describe('renderPersonCard', () => {
it('shows worn layers with colour and carried mass in the same scroll', () => {
it('switches overview, clothes, carried and now', () => {
setLocale('ru');
const root = document.createElement('div');
renderPersonCard(root, card(), () => {});
expect(root.textContent).toContain(t('peopleApparel'));
expect(root.textContent).toContain('Верх');
expect(root.querySelector('.people__tabs')).not.toBeNull();
expect(root.querySelector('[data-card-tab="overview"]')?.hasAttribute('hidden')).toBe(false);
expect(root.textContent).toContain('Рост');
expect(root.querySelector('.people__log')).toBeNull();
tabButton(root, 'apparel').click();
expect(root.querySelector('[data-card-tab="apparel"]')?.hasAttribute('hidden')).toBe(false);
expect(root.querySelector('[data-card-tab="overview"]')?.hasAttribute('hidden')).toBe(true);
expect(root.textContent).toContain('Рубашка · Белый');
expect(root.textContent).toContain(t('peopleCarry'));
expect(root.querySelector('.people__log')).toBeNull();
tabButton(root, 'carry').click();
expect(root.textContent).toContain(t('peopleCarryMass', { held: '1.2', cap: '14' }));
expect(root.textContent).toContain(t('peopleLockerNone'));
expect(root.textContent).toContain(t('peopleHomeCount', { n: 2 }));
expect(root.textContent).toContain('Учебник · Математика');
expect(root.querySelector('.people__tabs')).toBeNull();
tabButton(root, 'now').click();
expect(root.querySelector('[data-card-tab="now"]')?.hasAttribute('hidden')).toBe(false);
});
it('does not mount the log table on the clothes tab', () => {
setLocale('ru');
const root = document.createElement('div');
renderPersonCard(root, card(), () => {}, { tab: 'apparel', log: logPage() });
expect(root.querySelector('[data-card-tab="apparel"]')?.hasAttribute('hidden')).toBe(false);
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();
const root = document.createElement('div');
renderPersonCard(root, card(), () => {}, {
tab: 'now',
log: logPage(),
onLogPage,
});
expect(root.querySelector('.people__log')).not.toBeNull();
expect(root.textContent).toContain('начал: Обед');
expect(root.textContent).toContain(t('peoplePager', { page: 1, pages: 2, total: 3 }));
const next = [...root.querySelectorAll('.people__pager button')].at(-1);
expect(next).toBeInstanceOf(HTMLButtonElement);
(next as HTMLButtonElement).click();
expect(onLogPage).toHaveBeenCalledWith(2);
});
it('shows home on now when activity is empty and the person is away', () => {
setLocale('ru');
const root = document.createElement('div');
renderPersonCard(root, card({ activity: null, activityLabel: null }), () => {}, {
tab: 'now',
away: true,
place: t('presenceAway'),
});
expect(root.querySelector('.people__now-activity')?.textContent).toBe(t('peopleAtHome'));
expect(root.querySelector('.people__now-activity')?.textContent).not.toBe('');
});
});
+266 -56
View File
@@ -1,4 +1,13 @@
import type { PersonCard, PersonListItem, PersonRel, PersonRole } from '../net/api.ts';
import type {
PersonCard,
PersonListItem,
PersonLogDir,
PersonLogPage,
PersonLogQuery,
PersonRel,
PersonRole,
} from '../net/api.ts';
import { formatGameTimeOfDay } from '../format/gameTime.ts';
import { t, type MessageKey } from '../i18n/strings.ts';
import { el } from './dom.ts';
@@ -8,6 +17,20 @@ const ROLE_KEYS: Record<PersonRole, MessageKey> = {
parent: 'peopleRoleParent',
};
export type PersonCardTab = 'overview' | 'apparel' | 'carry' | 'now';
export interface RenderPersonCardOptions {
readonly place?: string;
readonly away?: boolean;
readonly tab?: PersonCardTab;
readonly log?: PersonLogPage | null;
readonly logQuery?: PersonLogQuery;
readonly onTab?: (tab: PersonCardTab) => void;
readonly onLogSearch?: (query: string) => void;
readonly onLogDir?: (dir: PersonLogDir) => void;
readonly onLogPage?: (page: number) => void;
}
export function roleLabels(roles: readonly string[]): string {
return roles
.map((role) => (role in ROLE_KEYS ? t(ROLE_KEYS[role as PersonRole]) : role))
@@ -27,28 +50,90 @@ export function placement(person: Pick<PersonListItem, 'classYear' | 'classLette
return parts.length > 0 ? parts.join(' · ') : '—';
}
export function nowActivityText(card: PersonCard, away: boolean): string {
if (away && (card.activityLabel === null || card.activityLabel.length === 0)) {
return t('peopleAtHome');
}
return card.activityLabel ?? '';
}
export function renderPersonCard(
parent: HTMLElement,
card: PersonCard,
onRelative: (id: string) => void,
place?: string,
options: RenderPersonCardOptions = {},
): void {
const tab = options.tab ?? 'overview';
parent.append(
el('h3', { class: 'people__card-name', text: card.fullName }),
el('p', { class: 'people__card-meta', text: cardMeta(card) }),
);
if (place !== undefined && place.length > 0) {
parent.append(el('p', { class: 'people__card-place', text: place }));
if (options.place !== undefined && options.place.length > 0) {
parent.append(el('p', { class: 'people__card-place', text: options.place }));
}
if (card.activityLabel !== null && card.activityLabel.length > 0) {
parent.append(el('p', { class: 'people__card-activity', text: card.activityLabel }));
const tabs = el('div', { class: 'people__tabs' });
const panels: Record<PersonCardTab, HTMLElement> = {
overview: el('div', { class: 'people__tab-panel', dataset: { cardTab: 'overview' } }),
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' } }),
};
const labels: Record<PersonCardTab, MessageKey> = {
overview: 'peopleTabOverview',
apparel: 'peopleTabApparel',
carry: 'peopleTabCarry',
now: 'peopleTabNow',
};
const buttons: Partial<Record<PersonCardTab, HTMLButtonElement>> = {};
for (const id of Object.keys(labels) as PersonCardTab[]) {
const button = el('button', {
class: 'people__tab',
type: 'button',
text: t(labels[id]),
dataset: { tab: id },
onClick: () => {
showTab(id);
options.onTab?.(id);
},
});
button.classList.toggle('people__tab--active', id === tab);
buttons[id] = button;
tabs.append(button);
}
fillOverview(panels.overview, card, onRelative);
fillApparel(panels.apparel, card);
fillCarry(panels.carry, card);
fillNow(panels.now, card, options.away === true, tab === 'now' ? options.log : null, options);
const showTab = (next: PersonCardTab): void => {
for (const id of Object.keys(panels) as PersonCardTab[]) {
panels[id].hidden = id !== next;
buttons[id]?.classList.toggle('people__tab--active', id === next);
}
const log = panels.now.querySelector('.people__log, .people__log-tools');
if (next !== 'now' && log !== null) {
for (const node of [...panels.now.querySelectorAll('.people__log, .people__log-tools, .people__pager, .panel__empty')]) {
node.remove();
}
}
};
showTab(tab);
parent.append(tabs, panels.overview, panels.apparel, panels.carry, panels.now);
}
function fillOverview(parent: HTMLElement, card: PersonCard, onRelative: (id: string) => void): void {
appendPairs(parent, t('peopleBody'), card.body);
appendPairs(parent, t('peopleSkills'), card.skills);
appendTags(parent, t('peopleTraits'), card.traits.map((row) => row.label));
appendNeeds(parent, t('peopleNeeds'), card.needs);
appendApparel(parent, card.worn);
appendCarry(parent, card);
const family = section(t('peopleFamily'));
appendRelatives(family, t('peopleParents'), card.family.parents, onRelative);
@@ -60,6 +145,179 @@ export function renderPersonCard(
}
}
function fillApparel(parent: HTMLElement, card: PersonCard): void {
parent.append(el('p', { class: 'people__fit', text: t('peopleApparelFit', { value: '—' }) }));
if (card.worn.length === 0) {
return;
}
const list = el('div', { class: 'people__garments' });
for (const row of card.worn) {
const layers = row.layers.map((layer) => layer.label).join(', ');
const color = row.colorLabel ?? row.color;
const value = color !== null && color.length > 0 ? `${row.label} · ${color}` : row.label;
const garment = el(
'div',
{ class: 'people__garment' },
el(
'div',
{ class: 'people__pair' },
el('dt', { text: layers.length > 0 ? layers : row.label }),
el('dd', { text: value }),
),
);
if (row.condition !== undefined) {
const share = Math.max(0, Math.min(1, row.condition));
const fill = el('span', { class: 'people__need-fill' });
fill.style.width = `${Math.round(share * 100)}%`;
fill.classList.toggle('people__need-fill--low', share < 0.25);
const caption = row.conditionLabel !== undefined && row.conditionLabel !== null && row.conditionLabel.length > 0
? row.conditionLabel
: '—';
garment.append(
el(
'div',
{ class: 'people__wear' },
el('span', { class: 'people__wear-label', text: caption }),
el('span', { class: 'people__need-track' }, fill),
),
);
}
list.append(garment);
}
parent.append(list);
}
function fillCarry(parent: HTMLElement, card: PersonCard): void {
parent.append(
el('p', {
class: 'people__carry-mass',
text: t('peopleCarryMass', { held: formatMass(card.carryMass), cap: formatMass(card.carryCapacity) }),
}),
el('p', {
class: 'people__locker',
text: card.hasLocker ? t('peopleLockerYes') : t('peopleLockerNone'),
}),
el('p', {
class: 'people__home-count',
text: t('peopleHomeCount', { n: card.homeCount }),
}),
);
if (card.carried.length === 0) {
return;
}
const grid = el('dl', { class: 'people__pairs' });
for (const row of card.carried) {
const extra = row.subjectLabel ?? row.colorLabel;
const value = extra !== null && extra.length > 0 ? `${row.label} · ${extra}` : row.label;
grid.append(
el(
'div',
{ class: 'people__pair' },
el('dt', { text: value }),
el('dd', { text: formatMass(row.mass) }),
),
);
}
parent.append(grid);
}
function fillNow(
parent: HTMLElement,
card: PersonCard,
away: boolean,
log: PersonLogPage | null | undefined,
options: RenderPersonCardOptions,
): void {
const activity = nowActivityText(card, away);
if (activity.length > 0) {
parent.append(el('p', { class: 'people__now-activity', text: activity }));
}
if (log === null || log === undefined) {
return;
}
const search = el('input', { class: 'input people__input', type: 'search' });
search.placeholder = t('peopleLogSearch');
search.value = options.logQuery?.q ?? '';
search.addEventListener('change', () => options.onLogSearch?.(search.value));
const dir = el('select', { class: 'input people__input' });
const desc = el('option', { text: t('peopleLogNewest') });
desc.value = 'desc';
const asc = el('option', { text: t('peopleLogOldest') });
asc.value = 'asc';
dir.append(desc, asc);
dir.value = options.logQuery?.dir ?? 'desc';
dir.addEventListener('change', () => options.onLogDir?.(dir.value === 'asc' ? 'asc' : 'desc'));
const table = el('table', { class: 'people__log' });
const body = el('tbody');
table.append(
el(
'thead',
{},
el(
'tr',
{},
el('th', { text: t('peopleLogTime') }),
el('th', { text: t('peopleLogEvent') }),
),
),
body,
);
if (log.entries.length === 0) {
parent.append(
el('div', { class: 'people__log-tools' }, search, dir),
el('p', { class: 'panel__empty', text: t('peopleLogEmpty') }),
);
return;
}
for (const row of log.entries) {
body.append(
el(
'tr',
{},
el('td', { text: formatGameTimeOfDay(new Date(row.time)) }),
el('td', { text: row.label }),
),
);
}
const pages = Math.max(1, Math.ceil(log.total / log.pageSize));
const prev = el('button', {
class: 'button button--small',
type: 'button',
text: t('peoplePrev'),
disabled: log.page <= 1,
onClick: () => options.onLogPage?.(log.page - 1),
});
const next = el('button', {
class: 'button button--small',
type: 'button',
text: t('peopleNext'),
disabled: log.page >= pages,
onClick: () => options.onLogPage?.(log.page + 1),
});
parent.append(
el('div', { class: 'people__log-tools' }, search, dir),
table,
el(
'div',
{ class: 'people__pager' },
prev,
el('span', { class: 'people__pager-label', text: t('peoplePager', { page: log.page, pages, total: log.total }) }),
next,
),
);
}
function cardMeta(card: PersonCard): string {
const bits = [
roleLabels(card.roles),
@@ -114,54 +372,6 @@ function appendPairs(
parent.append(section(title), grid);
}
function appendApparel(parent: HTMLElement, rows: PersonCard['worn']): void {
if (rows.length === 0) {
return;
}
const grid = el('dl', { class: 'people__pairs' });
for (const row of rows) {
const layers = row.layers.map((layer) => layer.label).join(', ');
const color = row.colorLabel ?? row.color;
const value = color !== null && color.length > 0 ? `${row.label} · ${color}` : row.label;
grid.append(
el(
'div',
{ class: 'people__pair' },
el('dt', { text: layers.length > 0 ? layers : row.label }),
el('dd', { text: value }),
),
);
}
parent.append(section(t('peopleApparel')), grid);
}
function appendCarry(parent: HTMLElement, card: PersonCard): void {
const mass = el('p', {
class: 'people__carry-mass',
text: t('peopleCarryMass', { held: formatMass(card.carryMass), cap: formatMass(card.carryCapacity) }),
});
const grid = el('dl', { class: 'people__pairs' });
for (const row of card.carried) {
const extra = row.subjectLabel ?? row.colorLabel;
const value = extra !== null && extra.length > 0 ? `${row.label} · ${extra}` : row.label;
grid.append(
el(
'div',
{ class: 'people__pair' },
el('dt', { text: value }),
el('dd', { text: formatMass(row.mass) }),
),
);
}
parent.append(section(t('peopleCarry')), mass);
if (card.carried.length > 0) {
parent.append(grid);
}
}
function formatMass(value: number): string {
return String(Math.round(value * 100) / 100);
}