Add speech-topic policy from tomorrow and a morning family talk.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Leonid Pershin
2026-08-20 13:53:12 +03:00
co-authored by Cursor
parent 5e147a8e47
commit 92c2143329
31 changed files with 909 additions and 35 deletions
+14 -2
View File
@@ -315,11 +315,17 @@ const ru = {
rulesStudentsColor: 'Цвет учеников',
rulesStaffForm: 'Форма сотрудников',
rulesStaffColor: 'Цвет сотрудников',
rulesRoleSummary: '{role}: {form}, {color}',
rulesRoleSummary: '{role}: {form}, {color}; {speech}',
rulesApply: 'Применить',
rulesLoadFailed: 'Не удалось загрузить правила одежды.',
rulesSaveFailed: 'Не удалось сохранить правила.',
rulesPickHint: 'Правила действуют на всю школу.',
rulesStudentsSpeech: 'Речь учеников',
rulesStaffSpeech: 'Речь сотрудников',
speechPolicyFree: 'свободно',
speechPolicyNoRude: 'без грубого',
speechPolicyStudyOnly: 'только учёба',
speechErrorUnknownPolicy: 'Такой политики речи нет.',
dressFormRegular: 'обычная',
dressFormShort: 'короткая',
dressFormStrict: 'строгая',
@@ -688,11 +694,17 @@ const en: Messages = {
rulesStudentsColor: 'Student colours',
rulesStaffForm: 'Staff form',
rulesStaffColor: 'Staff colours',
rulesRoleSummary: '{role}: {form}, {color}',
rulesRoleSummary: '{role}: {form}, {color}; {speech}',
rulesApply: 'Apply',
rulesLoadFailed: 'Could not load dress rules.',
rulesSaveFailed: 'Could not save the rules.',
rulesPickHint: 'Rules apply to the whole school.',
rulesStudentsSpeech: 'Student speech',
rulesStaffSpeech: 'Staff speech',
speechPolicyFree: 'free',
speechPolicyNoRude: 'no rude talk',
speechPolicyStudyOnly: 'study only',
speechErrorUnknownPolicy: 'That speech policy is not in the catalog.',
dressFormRegular: 'regular',
dressFormShort: 'short',
dressFormStrict: 'strict',
+22
View File
@@ -762,6 +762,28 @@ export async function setDressRules(
});
}
export interface SpeechRules {
readonly students: string;
readonly staff: string;
readonly pendingStudents: string | null;
readonly pendingStaff: string | null;
}
export async function fetchSpeechRules(schoolId: number): Promise<SpeechRules> {
return request<SpeechRules>(`/api/schools/${schoolId}/speech-rules`);
}
export async function setSpeechRules(
schoolId: number,
body: { readonly students?: string; readonly staff?: string },
): Promise<SpeechRules> {
return request<SpeechRules>(`/api/schools/${schoolId}/speech-rules`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify(body),
});
}
export interface TimetableLesson {
readonly classId: string;
readonly classYear: number;
@@ -5,8 +5,11 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import {
ApiError,
fetchDressRules,
fetchSpeechRules,
setDressRules,
setSpeechRules,
type DressRules,
type SpeechRules,
} from '../net/api.ts';
import { getLocale, setLocale } from '../i18n/locale.ts';
import { t } from '../i18n/strings.ts';
@@ -17,7 +20,9 @@ vi.mock('../net/api.ts', async (importOriginal) => {
return {
...actual,
fetchDressRules: vi.fn(),
fetchSpeechRules: vi.fn(),
setDressRules: vi.fn(),
setSpeechRules: vi.fn(),
};
});
@@ -32,12 +37,24 @@ function rules(): DressRules {
};
}
function speech(): SpeechRules {
return {
students: 'free',
staff: 'free',
pendingStudents: null,
pendingStaff: null,
};
}
describe('DressRulesPanel', () => {
beforeEach(() => {
setLocale('en');
vi.mocked(fetchDressRules).mockReset();
vi.mocked(fetchSpeechRules).mockReset();
vi.mocked(setDressRules).mockReset();
vi.mocked(setSpeechRules).mockReset();
vi.mocked(fetchDressRules).mockResolvedValue(rules());
vi.mocked(fetchSpeechRules).mockResolvedValue(speech());
});
afterEach(() => {
@@ -49,7 +66,7 @@ describe('DressRulesPanel', () => {
const panel = new DressRulesPanel();
document.body.append(panel.element);
panel.show(3);
await vi.waitFor(() => expect(fetchDressRules).toHaveBeenCalled());
await vi.waitFor(() => expect(fetchSpeechRules).toHaveBeenCalled());
const colorSelect = panel.element.querySelector('select.input');
if (!(colorSelect instanceof HTMLSelectElement)) {
@@ -66,7 +83,13 @@ describe('DressRulesPanel', () => {
const panel = new DressRulesPanel();
document.body.append(panel.element);
panel.show(3);
await vi.waitFor(() => expect(fetchDressRules).toHaveBeenCalled());
await vi.waitFor(() => expect(fetchSpeechRules).toHaveBeenCalled());
const apply = panel.element.querySelector('button.button');
if (!(apply instanceof HTMLButtonElement)) {
throw new Error('apply button is missing');
}
await vi.waitFor(() => expect(apply.disabled).toBe(false));
const formSelect = panel.element.querySelector('fieldset select.input');
if (!(formSelect instanceof HTMLSelectElement)) {
@@ -74,11 +97,6 @@ describe('DressRulesPanel', () => {
}
formSelect.value = 'strict';
const apply = panel.element.querySelector('button.button');
if (!(apply instanceof HTMLButtonElement)) {
throw new Error('apply button is missing');
}
apply.click();
const banner = panel.element.querySelector('.staffing__error');
if (!(banner instanceof HTMLElement)) {
@@ -88,4 +106,40 @@ describe('DressRulesPanel', () => {
await vi.waitFor(() => expect(banner.hidden).toBe(false));
expect(banner.textContent).toBe(t('dressErrorUnknownForm'));
});
it('places speech comboboxes next to dress with a from-tomorrow caption', async () => {
const panel = new DressRulesPanel();
document.body.append(panel.element);
panel.reveal();
panel.show(3);
await vi.waitFor(() => expect(fetchSpeechRules).toHaveBeenCalled());
expect(panel.element.textContent).toContain(t('rulesPendingTitle'));
const studentsFieldset = panel.element.querySelector('fieldset.rules__fieldset');
if (!(studentsFieldset instanceof HTMLFieldSetElement)) {
throw new Error('students fieldset is missing');
}
const selects = [...studentsFieldset.querySelectorAll('select.input')];
expect(selects).toHaveLength(3);
const speechSelect = studentsFieldset.querySelector('select[data-speech="students"]');
if (!(speechSelect instanceof HTMLSelectElement)) {
throw new Error('student speech select is missing');
}
expect(selects[2]).toBe(speechSelect);
expect([...speechSelect.options].map((option) => option.value)).toEqual([
'free',
'noRude',
'studyOnly',
]);
expect(studentsFieldset.textContent).toContain(t('rulesStudentsSpeech'));
const staffSpeech = panel.element.querySelector('select[data-speech="staff"]');
if (!(staffSpeech instanceof HTMLSelectElement)) {
throw new Error('staff speech select is missing');
}
expect(staffSpeech.closest('fieldset.rules__fieldset')?.querySelectorAll('select.input')).toHaveLength(3);
});
});
+64 -7
View File
@@ -1,4 +1,4 @@
import { fetchDressRules, setDressRules, type DressRulePair, type DressRules } from '../net/api.ts';
import { fetchDressRules, fetchSpeechRules, setDressRules, setSpeechRules, type DressRulePair, type DressRules, type SpeechRules } from '../net/api.ts';
import { t } from '../i18n/strings.ts';
import { el } from './dom.ts';
import {
@@ -7,6 +7,8 @@ import {
dressFormLabel,
dressFormOptions,
dressRulesError,
speechPolicyLabel,
speechPolicyOptions,
} from './dressRulesUi.ts';
import { fillSelect } from './staffingUi.ts';
@@ -26,10 +28,20 @@ export class DressRulesPanel {
private readonly studentsFormSelect = el('select', { class: 'input people__input' });
private readonly studentsColorLabel = el('span', { class: 'people__label' });
private readonly studentsColorSelect = el('select', { class: 'input people__input' });
private readonly studentsSpeechLabel = el('span', { class: 'people__label' });
private readonly studentsSpeechSelect = el('select', {
class: 'input people__input',
dataset: { speech: 'students' },
});
private readonly staffFormLabel = el('span', { class: 'people__label' });
private readonly staffFormSelect = el('select', { class: 'input people__input' });
private readonly staffColorLabel = el('span', { class: 'people__label' });
private readonly staffColorSelect = el('select', { class: 'input people__input' });
private readonly staffSpeechLabel = el('span', { class: 'people__label' });
private readonly staffSpeechSelect = el('select', {
class: 'input people__input',
dataset: { speech: 'staff' },
});
private readonly applyButton = el('button', { class: 'button', type: 'button' });
private readonly studentsLegend: HTMLElement;
@@ -37,6 +49,7 @@ export class DressRulesPanel {
private schoolId: number | null = null;
private rules: DressRules | null = null;
private speech: SpeechRules | null = null;
private loadToken = 0;
private busy = false;
@@ -70,6 +83,12 @@ export class DressRulesPanel {
this.studentsColorLabel,
this.studentsColorSelect,
),
el(
'label',
{ class: 'people__field' },
this.studentsSpeechLabel,
this.studentsSpeechSelect,
),
),
el(
'fieldset',
@@ -77,6 +96,7 @@ export class DressRulesPanel {
el('legend', { class: 'people__section-title', text: '' }),
el('label', { class: 'people__field' }, this.staffFormLabel, this.staffFormSelect),
el('label', { class: 'people__field' }, this.staffColorLabel, this.staffColorSelect),
el('label', { class: 'people__field' }, this.staffSpeechLabel, this.staffSpeechSelect),
),
this.applyButton,
),
@@ -100,15 +120,19 @@ export class DressRulesPanel {
this.pendingHint.textContent = t('rulesPendingHint');
this.studentsFormLabel.textContent = t('rulesStudentsForm');
this.studentsColorLabel.textContent = t('rulesStudentsColor');
this.studentsSpeechLabel.textContent = t('rulesStudentsSpeech');
this.staffFormLabel.textContent = t('rulesStaffForm');
this.staffColorLabel.textContent = t('rulesStaffColor');
this.staffSpeechLabel.textContent = t('rulesStaffSpeech');
this.applyButton.textContent = t('rulesApply');
this.studentsLegend.textContent = t('rulesStudentsRole');
this.staffLegend.textContent = t('rulesStaffRole');
fillSelect(this.studentsFormSelect, dressFormOptions(), this.studentsFormSelect.value);
fillSelect(this.studentsColorSelect, dressColorOptions(), this.studentsColorSelect.value);
fillSelect(this.studentsSpeechSelect, speechPolicyOptions(), this.studentsSpeechSelect.value);
fillSelect(this.staffFormSelect, dressFormOptions(), this.staffFormSelect.value);
fillSelect(this.staffColorSelect, dressColorOptions(), this.staffColorSelect.value);
fillSelect(this.staffSpeechSelect, speechPolicyOptions(), this.staffSpeechSelect.value);
this.paint();
}
@@ -116,6 +140,7 @@ export class DressRulesPanel {
if (this.schoolId !== schoolId) {
this.schoolId = schoolId;
this.rules = null;
this.speech = null;
this.clearError();
}
@@ -138,12 +163,13 @@ export class DressRulesPanel {
const token = ++this.loadToken;
try {
const rules = await fetchDressRules(schoolId);
const [rules, speech] = await Promise.all([fetchDressRules(schoolId), fetchSpeechRules(schoolId)]);
if (token !== this.loadToken) {
return;
}
this.rules = rules;
this.speech = speech;
this.paint();
} catch {
if (token !== this.loadToken) {
@@ -156,9 +182,10 @@ export class DressRulesPanel {
private paint(): void {
const rules = this.rules;
this.applyButton.disabled = this.busy || rules === null;
const speech = this.speech;
this.applyButton.disabled = this.busy || rules === null || speech === null;
if (rules === null) {
if (rules === null || speech === null) {
this.currentStudents.textContent = '—';
this.currentStaff.textContent = '—';
return;
@@ -168,11 +195,13 @@ export class DressRulesPanel {
role: t('rulesStudentsRole'),
form: dressFormLabel(rules.students.form),
color: dressColorLabel(rules.students.color),
speech: speechPolicyLabel(speech.students),
});
this.currentStaff.textContent = t('rulesRoleSummary', {
role: t('rulesStaffRole'),
form: dressFormLabel(rules.staff.form),
color: dressColorLabel(rules.staff.color),
speech: speechPolicyLabel(speech.staff),
});
const pendingStudents = rules.pendingStudents ?? rules.students;
@@ -181,12 +210,19 @@ export class DressRulesPanel {
fillSelect(this.studentsColorSelect, dressColorOptions(), pendingStudents.color);
fillSelect(this.staffFormSelect, dressFormOptions(), pendingStaff.form);
fillSelect(this.staffColorSelect, dressColorOptions(), pendingStaff.color);
fillSelect(
this.studentsSpeechSelect,
speechPolicyOptions(),
speech.pendingStudents ?? speech.students,
);
fillSelect(this.staffSpeechSelect, speechPolicyOptions(), speech.pendingStaff ?? speech.staff);
}
private async apply(): Promise<void> {
const schoolId = this.schoolId;
const rules = this.rules;
if (schoolId === null || rules === null || this.busy) {
const speech = this.speech;
if (schoolId === null || rules === null || speech === null || this.busy) {
return;
}
@@ -201,7 +237,21 @@ export class DressRulesPanel {
body.staff = staff;
}
if (body.students === undefined && body.staff === undefined) {
const speechBody: { students?: string; staff?: string } = {};
if (this.studentsSpeechSelect.value !== (speech.pendingStudents ?? speech.students)) {
speechBody.students = this.studentsSpeechSelect.value;
}
if (this.staffSpeechSelect.value !== (speech.pendingStaff ?? speech.staff)) {
speechBody.staff = this.staffSpeechSelect.value;
}
if (
body.students === undefined
&& body.staff === undefined
&& speechBody.students === undefined
&& speechBody.staff === undefined
) {
return;
}
@@ -209,7 +259,14 @@ export class DressRulesPanel {
this.clearError();
this.paint();
try {
this.rules = await setDressRules(schoolId, body);
if (body.students !== undefined || body.staff !== undefined) {
this.rules = await setDressRules(schoolId, body);
}
if (speechBody.students !== undefined || speechBody.staff !== undefined) {
this.speech = await setSpeechRules(schoolId, speechBody);
}
this.paint();
} catch (error) {
this.showError(dressRulesError(error));
+23
View File
@@ -51,7 +51,30 @@ export function dressRulesError(error: unknown): string {
return t('dressErrorUnknownForm');
case 'unknown-color':
return t('dressErrorUnknownColor');
case 'unknown-speech':
return t('speechErrorUnknownPolicy');
default:
return error.message.length > 0 ? error.message : t('rulesSaveFailed');
}
}
export const SPEECH_POLICIES = ['free', 'noRude', 'studyOnly'] as const;
export type SpeechPolicy = (typeof SPEECH_POLICIES)[number];
export function speechPolicyOptions(): readonly { value: SpeechPolicy; label: string }[] {
return SPEECH_POLICIES.map((value) => ({ value, label: speechPolicyLabel(value) }));
}
export function speechPolicyLabel(policy: string): string {
switch (policy) {
case 'free':
return t('speechPolicyFree');
case 'noRude':
return t('speechPolicyNoRude');
case 'studyOnly':
return t('speechPolicyStudyOnly');
default:
return policy;
}
}
@@ -7,6 +7,7 @@ import {
fetchPerson,
fetchGameStatus,
fetchDressRules,
fetchSpeechRules,
fetchStaffing,
fetchTimetable,
hireStaff,
@@ -27,6 +28,7 @@ vi.mock('../net/api.ts', async (importOriginal) => {
fetchStaffing: vi.fn(),
fetchTimetable: vi.fn(),
fetchDressRules: vi.fn(),
fetchSpeechRules: vi.fn(),
fetchPerson: vi.fn(),
fetchGameStatus: vi.fn().mockResolvedValue({
tick: 0,
@@ -224,6 +226,7 @@ describe('ManagementPanel uncovered', () => {
vi.mocked(fetchStaffing).mockReset();
vi.mocked(fetchTimetable).mockReset();
vi.mocked(fetchDressRules).mockReset();
vi.mocked(fetchSpeechRules).mockReset();
vi.mocked(fetchStaffing).mockResolvedValue({
...staffing(),
applicants: [],
@@ -245,6 +248,12 @@ describe('ManagementPanel uncovered', () => {
pendingStudents: null,
pendingStaff: null,
});
vi.mocked(fetchSpeechRules).mockResolvedValue({
students: 'free',
staff: 'free',
pendingStudents: null,
pendingStaff: null,
});
});
afterEach(() => {
@@ -271,6 +280,7 @@ describe('ManagementPanel rules tab', () => {
vi.mocked(fetchStaffing).mockReset();
vi.mocked(fetchTimetable).mockReset();
vi.mocked(fetchDressRules).mockReset();
vi.mocked(fetchSpeechRules).mockReset();
vi.mocked(fetchStaffing).mockResolvedValue(staffing());
vi.mocked(fetchTimetable).mockResolvedValue(timetable());
vi.mocked(fetchDressRules).mockResolvedValue({
@@ -279,6 +289,12 @@ describe('ManagementPanel rules tab', () => {
pendingStudents: null,
pendingStaff: null,
});
vi.mocked(fetchSpeechRules).mockResolvedValue({
students: 'free',
staff: 'free',
pendingStudents: null,
pendingStaff: null,
});
});
afterEach(() => {