@jjlmoya/utils-performing-arts 1.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.github/workflows/npm-publish.yml +40 -0
- package/.gitignore +6 -0
- package/.stylelintrc.json +98 -0
- package/astro.config.mjs +19 -0
- package/eslint.config.js +201 -0
- package/package.json +79 -0
- package/prompts/create_tool.md +98 -0
- package/prompts/i18n/de.md +16 -0
- package/prompts/i18n/en.md +16 -0
- package/prompts/i18n/es.md +16 -0
- package/prompts/i18n/fr.md +16 -0
- package/prompts/i18n/id.md +16 -0
- package/prompts/i18n/it.md +16 -0
- package/prompts/i18n/ja.md +16 -0
- package/prompts/i18n/ko.md +16 -0
- package/prompts/i18n/nl.md +16 -0
- package/prompts/i18n/pl.md +16 -0
- package/prompts/i18n/pt.md +16 -0
- package/prompts/i18n/ru.md +16 -0
- package/prompts/i18n/sv.md +16 -0
- package/prompts/i18n/tr.md +16 -0
- package/prompts/i18n/zh.md +16 -0
- package/prompts/seo.md +58 -0
- package/prompts/translations/french.md +33 -0
- package/scripts/postinstall.mjs +27 -0
- package/src/category/PerformingArtsCategorySEO.astro +9 -0
- package/src/category/i18n/de.ts +27 -0
- package/src/category/i18n/en.ts +27 -0
- package/src/category/i18n/es.ts +27 -0
- package/src/category/i18n/fr.ts +27 -0
- package/src/category/i18n/id.ts +27 -0
- package/src/category/i18n/it.ts +27 -0
- package/src/category/i18n/ja.ts +27 -0
- package/src/category/i18n/ko.ts +27 -0
- package/src/category/i18n/nl.ts +27 -0
- package/src/category/i18n/pl.ts +27 -0
- package/src/category/i18n/pt.ts +27 -0
- package/src/category/i18n/ru.ts +27 -0
- package/src/category/i18n/sv.ts +27 -0
- package/src/category/i18n/tr.ts +27 -0
- package/src/category/i18n/zh.ts +27 -0
- package/src/category/index.ts +10 -0
- package/src/components/PreviewNavSidebar.astro +116 -0
- package/src/components/PreviewToolbar.astro +143 -0
- package/src/data.ts +10 -0
- package/src/entries.ts +3 -0
- package/src/env.d.ts +5 -0
- package/src/index.ts +20 -0
- package/src/layouts/PreviewLayout.astro +118 -0
- package/src/pages/[locale]/[slug].astro +165 -0
- package/src/pages/[locale].astro +251 -0
- package/src/pages/index.astro +4 -0
- package/src/tests/bibliography_wellformed_export.test.ts +46 -0
- package/src/tests/category_seo_quality.test.ts +74 -0
- package/src/tests/diacritics_density.test.ts +118 -0
- package/src/tests/faq_count.test.ts +18 -0
- package/src/tests/i18n_coverage.test.ts +34 -0
- package/src/tests/inverted_punctuation.test.ts +84 -0
- package/src/tests/locale_completeness.test.ts +23 -0
- package/src/tests/mocks/astro_mock.js +2 -0
- package/src/tests/no_em_dash.test.ts +47 -0
- package/src/tests/no_en_dash.test.ts +70 -0
- package/src/tests/no_h1_in_components.test.ts +48 -0
- package/src/tests/pagespeed_best_practices.test.ts +198 -0
- package/src/tests/qa-test-helpers.ts +32 -0
- package/src/tests/qa_bibliography_links.test.ts +43 -0
- package/src/tests/qa_claim_evidence.test.ts +69 -0
- package/src/tests/qa_logic_reference_coverage.test.ts +46 -0
- package/src/tests/qa_runtime_i18n.test.ts +100 -0
- package/src/tests/schemas_fulfillment.test.ts +23 -0
- package/src/tests/script_density.test.ts +94 -0
- package/src/tests/seo_length.test.ts +22 -0
- package/src/tests/seo_parity.test.ts +60 -0
- package/src/tests/seo_translation_completeness.test.ts +69 -0
- package/src/tests/seo_wellformed_export.test.ts +65 -0
- package/src/tests/shared-test-helpers.ts +56 -0
- package/src/tests/slug_language_code_format.test.ts +23 -0
- package/src/tests/slug_uniqueness.test.ts +81 -0
- package/src/tests/spanish_leakage.test.ts +175 -0
- package/src/tests/title_quality.test.ts +55 -0
- package/src/tests/tool_exports.test.ts +34 -0
- package/src/tests/tool_validation.test.ts +16 -0
- package/src/tests/translation_copy.test.ts +115 -0
- package/src/tool/rehearsal-call-sheet-planner/bibliography.astro +6 -0
- package/src/tool/rehearsal-call-sheet-planner/bibliography.ts +12 -0
- package/src/tool/rehearsal-call-sheet-planner/component.astro +74 -0
- package/src/tool/rehearsal-call-sheet-planner/controller.ts +98 -0
- package/src/tool/rehearsal-call-sheet-planner/dom-views.ts +86 -0
- package/src/tool/rehearsal-call-sheet-planner/entry.ts +27 -0
- package/src/tool/rehearsal-call-sheet-planner/evaluator.ts +27 -0
- package/src/tool/rehearsal-call-sheet-planner/i18n/de.ts +60 -0
- package/src/tool/rehearsal-call-sheet-planner/i18n/en.ts +158 -0
- package/src/tool/rehearsal-call-sheet-planner/i18n/es.ts +60 -0
- package/src/tool/rehearsal-call-sheet-planner/i18n/fr.ts +60 -0
- package/src/tool/rehearsal-call-sheet-planner/i18n/id.ts +47 -0
- package/src/tool/rehearsal-call-sheet-planner/i18n/it.ts +47 -0
- package/src/tool/rehearsal-call-sheet-planner/i18n/ja.ts +47 -0
- package/src/tool/rehearsal-call-sheet-planner/i18n/ko.ts +47 -0
- package/src/tool/rehearsal-call-sheet-planner/i18n/nl.ts +47 -0
- package/src/tool/rehearsal-call-sheet-planner/i18n/pl.ts +47 -0
- package/src/tool/rehearsal-call-sheet-planner/i18n/pt.ts +47 -0
- package/src/tool/rehearsal-call-sheet-planner/i18n/ru.ts +48 -0
- package/src/tool/rehearsal-call-sheet-planner/i18n/sv.ts +48 -0
- package/src/tool/rehearsal-call-sheet-planner/i18n/tr.ts +48 -0
- package/src/tool/rehearsal-call-sheet-planner/i18n/zh.ts +48 -0
- package/src/tool/rehearsal-call-sheet-planner/index.ts +11 -0
- package/src/tool/rehearsal-call-sheet-planner/logic.test.ts +68 -0
- package/src/tool/rehearsal-call-sheet-planner/logic.ts +229 -0
- package/src/tool/rehearsal-call-sheet-planner/rehearsal-call-sheet-planner.css +593 -0
- package/src/tool/rehearsal-call-sheet-planner/seo.astro +16 -0
- package/src/tool/rehearsal-call-sheet-planner/storage.ts +33 -0
- package/src/tool/rehearsal-call-sheet-planner/types.ts +84 -0
- package/src/tool/rehearsal-call-sheet-planner/ui.ts +70 -0
- package/src/tools.ts +8 -0
- package/src/types.ts +68 -0
- package/tsconfig.json +15 -0
- package/vitest.config.ts +20 -0
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import type { FAQPage, HowTo, SoftwareApplication, WithContext } from 'schema-dts';
|
|
2
|
+
import { bibliography } from '../bibliography';
|
|
3
|
+
import type { RehearsalCallSheetLocaleContent, RehearsalCallSheetUI } from '../entry';
|
|
4
|
+
import type { FAQItem, HowToStep, SEOSection } from '../../../types';
|
|
5
|
+
|
|
6
|
+
const ui: RehearsalCallSheetUI = {
|
|
7
|
+
productionSection: '制作信息', showName: '剧目名称', showNamePlaceholder: '例如 樱桃园', date: '排练日期', timingSection: '时间与休息', dayStart: '工作日开始', dayEnd: '工作日结束', breakMinutes: '场次之间的休息时间(分钟)', sceneSection: '场次与工作顺序', scenesLabel: '场次', scenesHint: '每行一个场次:名称 | 分钟数 | 参与人员 | 房间', availabilitySection: '可用时间', participantAvailability: '参与人员的可用时间', participantHint: '每人一行:姓名 | 开始 | 结束。请加入场次所需的所有人员。', roomAvailability: '房间可用时间', roomHint: '每个房间一行:房间 | 开始 | 结束。', notes: '团队备注', notesPlaceholder: '添加目标、道具或重要提醒。', presets: '示例方案', presetDay: '普通工作日', presetTight: '紧凑日程', presetShowcase: '演出展示', resultSection: '排练计划草稿', scheduled: '已安排', conflicts: '冲突', unscheduled: '未安排', minutes: '分钟', timelineLabel: '时间线', callSheetLabel: '排练召集表', statusScheduled: '已安排', statusConflict: '需要检查', statusUnscheduled: '未安排', issueDayOverflow: '场次超出了工作日范围。', issueParticipantAvailability: '场次超出了一个或多个参与人员的可用时间。', issueRoomAvailability: '场次超出了房间的可用时间。', issueMissingParticipantAvailability: '没有填写该参与人员的可用时间。', issueMissingRoomAvailability: '没有填写该房间的可用时间。', participantSummary: '参与人员', roomSummary: '房间', scene: '场次', room: '房间', participants: '参与人员', time: '时间', status: '状态', detail: '详情', dateNotSet: '未设置日期', untitledProduction: '未命名制作', noRoom: '未填写房间', noPeople: '未填写参与人员', readyDetail: '所有场次都位于工作日和已填写的可用时间内。', scenes: '个场次', firstCall: '首次召集', lastCall: '最后召集', inputIssuesTitle: '请检查输入内容', inputIssuesHint: '部分行无法识别,因此没有被纳入计算。', scenesInput: '场次', peopleInput: '参与人员', roomsInput: '房间', line: '行', summaryReady: '这份草稿已经可以交给制作团队审核并打印。', summaryAttention: '发送排练召集表前,请先检查标记出的场次。', summaryInputIssues: '请修正有问题的行,让计划包含你输入的全部信息。', noScenes: '请至少添加一个场次来生成排练召集表。', exportData: '导出数据', print: '打印', reset: '重置', rulesNote: '计算会考虑场次时长、休息时间、参与人员和房间的可用时间。最终决定由制作团队负责。', rulesLinkLabel: '了解更多工作时间规则',
|
|
8
|
+
};
|
|
9
|
+
|
|
10
|
+
const faq: FAQItem[] = [
|
|
11
|
+
{ question: '什么是排练召集表?', answer: '排练召集表是一天的工作计划,列出场次、召集时间、参与人员、房间和团队备注。' },
|
|
12
|
+
{ question: '如何制作排练召集表?', answer: '填写场次时长、参与人员、房间、工作时间和休息时间,然后检查时间线与冲突。' },
|
|
13
|
+
{ question: '为什么某个场次标记为需要检查?', answer: '该场次可能与参与人员或房间的不可用时间冲突,也可能超出工作日范围。' },
|
|
14
|
+
{ question: '这个工具能生成完美的计划吗?', answer: '工具会生成连续的草稿并清楚显示限制,但制作团队仍需要检查优先级并作出最终决定。' },
|
|
15
|
+
{ question: '我的数据会被保存吗?', answer: '不会。数据在浏览器中处理,本工具不会把数据发送到服务器。' },
|
|
16
|
+
{ question: '工具会考虑工作时间规定吗?', answer: '工具可以帮助你查看工作时间和可用时间,但不能替代对适用法律、合同或机构规则的检查。' },
|
|
17
|
+
];
|
|
18
|
+
|
|
19
|
+
const howTo: HowToStep[] = [
|
|
20
|
+
{ name: '填写制作信息和日期', text: '输入剧目名称和排练日期。' },
|
|
21
|
+
{ name: '添加场次', text: '为每个场次填写名称、时长、参与人员和房间。' },
|
|
22
|
+
{ name: '添加可用时间', text: '填写所需参与人员和房间的可用时间。' },
|
|
23
|
+
{ name: '检查时间线', text: '查看召集时间、休息时间、冲突和无法安排的场次。' },
|
|
24
|
+
{ name: '打印草稿', text: '添加团队备注,完成最终检查后打印召集表。' },
|
|
25
|
+
];
|
|
26
|
+
|
|
27
|
+
const faqSchema: WithContext<FAQPage> = { '@context': 'https://schema.org', '@type': 'FAQPage', mainEntity: faq.map((item) => ({ '@type': 'Question', name: item.question, acceptedAnswer: { '@type': 'Answer', text: item.answer } })) };
|
|
28
|
+
const appSchema: WithContext<SoftwareApplication> = { '@context': 'https://schema.org', '@type': 'SoftwareApplication', name: '排练召集表', operatingSystem: 'All', applicationCategory: 'BusinessApplication', description: '根据场次、参与人员、房间和可用时间制作清晰的戏剧排练召集表。' };
|
|
29
|
+
const howToSchema: WithContext<HowTo> = { '@context': 'https://schema.org', '@type': 'HowTo', name: '如何制作排练召集表', step: howTo.map((step) => ({ '@type': 'HowToStep', name: step.name, text: step.text })) };
|
|
30
|
+
|
|
31
|
+
const seo: SEOSection[] = [
|
|
32
|
+
{ type: 'title', text: '如何制作排练召集表', level: 2 },
|
|
33
|
+
{ type: 'paragraph', html: '好的召集表会把排练计划变成团队都能理解的共同安排。本计划器会按时间放置场次,计算休息时间,并在排练开始前显示可用时间问题,让导演、舞台管理和演员都能提前发现需要协调的地方。' },
|
|
34
|
+
{ type: 'title', text: '一份有用的召集表应包含什么', level: 2 },
|
|
35
|
+
{ type: 'paragraph', html: '填写剧目名称、日期、场次时长、参与人员、房间和工作时间。输入信息越准确,团队审核草稿就越容易,也越能避免排练当天临时调整。' },
|
|
36
|
+
{ type: 'table', headers: ['字段', '用途'], rows: [['场次', '确定工作顺序和时长'], ['参与人员', '显示每个场次需要哪些人'], ['房间', '帮助避免不同空间之间的冲突'], ['休息时间', '在场次之间留出实际可行的时间']] },
|
|
37
|
+
{ type: 'title', text: '如何阅读时间线', level: 2 },
|
|
38
|
+
{ type: 'paragraph', html: '首次召集表示第一个已安排场次的开始时间,最后召集帮助你看到工作计划的结束时间。需要检查表示草稿需要团队作出决定,不能直接当作最终通知。' },
|
|
39
|
+
{ type: 'title', text: '发送前检查草稿', level: 2 },
|
|
40
|
+
{ type: 'list', items: ['检查日期、工作时间和休息时长。', '确认参与人员和房间的名称前后一致。', '与制作团队一起处理所有可用时间冲突。', '补充道具、目标和当天优先事项等实际备注。'] },
|
|
41
|
+
{ type: 'title', text: '透明的草稿把判断留给人', level: 2 },
|
|
42
|
+
{ type: 'paragraph', html: '自动生成的计划适合作为第一版,但它不了解艺术优先级、团队疲劳程度或某个场次的紧迫性。请把它用于提高透明度,而不是替代制作团队的判断。' },
|
|
43
|
+
{ type: 'tip', title: '把可用时间作为沟通的起点', html: '如果某一行无法识别,或某个场次超出了可用时间,请先修正输入,再把召集表发送给团队。' },
|
|
44
|
+
{ type: 'title', text: '打印一份真正好用的排练召集表', level: 2 },
|
|
45
|
+
{ type: 'paragraph', html: '完成审核后打印简明计划,并把它放在排练笔记旁边。召集表应该让团队快速知道何时以及在哪里集合。' },
|
|
46
|
+
];
|
|
47
|
+
|
|
48
|
+
export const content: RehearsalCallSheetLocaleContent = { slug: 'rehearsal-call-sheet-planner', title: '戏剧排练召集表', description: '根据场次、演员、房间、可用时间和休息安排制作戏剧排练召集表。打印清晰的日程,并在召集前发现冲突。', ui, faq, howTo, seo, bibliography, schemas: [faqSchema, appSchema, howToSchema] as unknown as Record<string, unknown>[] };
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import type { ToolDefinition } from '../../types';
|
|
2
|
+
import { rehearsalCallSheetPlanner } from './entry';
|
|
3
|
+
|
|
4
|
+
export * from './entry';
|
|
5
|
+
|
|
6
|
+
export const REHEARSAL_CALL_SHEET_PLANNER_TOOL: ToolDefinition = {
|
|
7
|
+
entry: rehearsalCallSheetPlanner,
|
|
8
|
+
Component: () => import('./component.astro'),
|
|
9
|
+
SEOComponent: () => import('./seo.astro'),
|
|
10
|
+
BibliographyComponent: () => import('./bibliography.astro'),
|
|
11
|
+
};
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { calculateCallSheet, formatTime, parseAvailability, parseScenes, parseTime } from './logic';
|
|
3
|
+
import { DEFAULT_SETTINGS } from './logic';
|
|
4
|
+
|
|
5
|
+
describe('rehearsal call sheet logic', () => {
|
|
6
|
+
it('parses and formats clock values', () => {
|
|
7
|
+
expect(parseTime('09:30')).toBe(570);
|
|
8
|
+
expect(parseTime('25:00')).toBeNull();
|
|
9
|
+
expect(formatTime(1439)).toBe('23:59');
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
it('parses scene and availability rows', () => {
|
|
13
|
+
expect(parseScenes('Warm up | 30 | Studio | Alex, Bea | Notes')).toEqual([{
|
|
14
|
+
id: 'scene-1', name: 'Warm up', duration: 30, room: 'Studio', participants: ['Alex', 'Bea'], notes: 'Notes',
|
|
15
|
+
}]);
|
|
16
|
+
expect(Object.keys(parseAvailability('Alex | 09:00-12:00'))).toEqual(['alex']);
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
it('surfaces malformed input instead of dropping it silently', () => {
|
|
20
|
+
const result = calculateCallSheet({
|
|
21
|
+
...DEFAULT_SETTINGS,
|
|
22
|
+
scenesText: 'Warm up | 30 | Studio | Alex | Notes\nBroken scene',
|
|
23
|
+
participantAvailabilityText: 'Alex | not-a-time',
|
|
24
|
+
roomAvailabilityText: 'Studio | 09:00-18:00\nBroken room',
|
|
25
|
+
});
|
|
26
|
+
expect(result.scenes).toHaveLength(1);
|
|
27
|
+
expect(result.inputIssues).toEqual([
|
|
28
|
+
{ section: 'scenes', line: 2, value: 'Broken scene', detail: 'Add a scene name and a positive duration in minutes.' },
|
|
29
|
+
{ section: 'people', line: 1, value: 'Alex | not-a-time', detail: 'Use times like 09:00-18:00 with the end after the start.' },
|
|
30
|
+
{ section: 'rooms', line: 2, value: 'Broken room', detail: 'Use Name | first call-last call.' },
|
|
31
|
+
]);
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it('keeps the starter draft conflict free', () => {
|
|
35
|
+
const result = calculateCallSheet(DEFAULT_SETTINGS);
|
|
36
|
+
expect(result.conflictCount).toBe(0);
|
|
37
|
+
expect(result.unscheduledCount).toBe(0);
|
|
38
|
+
expect(result.inputIssues).toEqual([]);
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it('marks availability conflicts and keeps the schedule readable', () => {
|
|
42
|
+
const result = calculateCallSheet({
|
|
43
|
+
...DEFAULT_SETTINGS,
|
|
44
|
+
dayStart: '09:00',
|
|
45
|
+
dayEnd: '11:00',
|
|
46
|
+
breakMinutes: 0,
|
|
47
|
+
scenesText: 'Scene one | 60 | Studio | Alex | ',
|
|
48
|
+
participantAvailabilityText: 'Alex | 10:00-18:00',
|
|
49
|
+
roomAvailabilityText: 'Studio | 09:00-18:00',
|
|
50
|
+
});
|
|
51
|
+
expect(result.scenes[0]?.status).toBe('conflict');
|
|
52
|
+
expect(result.scenes[0]?.issues[0]?.key).toBe('participantAvailability');
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it('marks scenes beyond the working day as unscheduled', () => {
|
|
56
|
+
const result = calculateCallSheet({
|
|
57
|
+
...DEFAULT_SETTINGS,
|
|
58
|
+
dayStart: '09:00',
|
|
59
|
+
dayEnd: '10:00',
|
|
60
|
+
breakMinutes: 0,
|
|
61
|
+
scenesText: 'Scene one | 90 | Studio | Alex | ',
|
|
62
|
+
participantAvailabilityText: 'Alex | 09:00-18:00',
|
|
63
|
+
roomAvailabilityText: 'Studio | 09:00-18:00',
|
|
64
|
+
});
|
|
65
|
+
expect(result.unscheduledCount).toBe(1);
|
|
66
|
+
expect(result.scenes[0]?.status).toBe('unscheduled');
|
|
67
|
+
});
|
|
68
|
+
});
|
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
AvailabilityMap,
|
|
3
|
+
AvailabilityWindow,
|
|
4
|
+
CallSheetResult,
|
|
5
|
+
InputIssue,
|
|
6
|
+
PersonSummary,
|
|
7
|
+
PlannerSettings,
|
|
8
|
+
RoomSummary,
|
|
9
|
+
SceneInput,
|
|
10
|
+
SceneIssue,
|
|
11
|
+
ScheduledScene,
|
|
12
|
+
} from './types';
|
|
13
|
+
|
|
14
|
+
export const DEFAULT_SETTINGS: PlannerSettings = {
|
|
15
|
+
showName: 'The Glass Menagerie',
|
|
16
|
+
date: '',
|
|
17
|
+
dayStart: '09:00',
|
|
18
|
+
dayEnd: '18:00',
|
|
19
|
+
breakMinutes: 15,
|
|
20
|
+
scenesText: 'Table read | 60 | Studio | Director, Alex, Bea | Pages 1 to 18\nBlocking scene 1 | 90 | Studio | Alex, Bea, Chris | Bring rehearsal props\nFight call | 45 | Black box | Alex, Chris | Slow work only\nRun scene 2 | 75 | Studio | Bea, Chris | Notes after the run',
|
|
21
|
+
participantAvailabilityText: 'Director | 09:00-18:00\nAlex | 09:00-17:00\nBea | 09:00-18:00\nChris | 09:00-16:30',
|
|
22
|
+
roomAvailabilityText: 'Studio | 09:00-18:00\nBlack box | 09:00-13:00',
|
|
23
|
+
notes: 'Wear rehearsal clothes. Bring the scene folders and water bottles.',
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
function clamp(value: number, min: number, max: number): number {
|
|
27
|
+
return Math.min(Math.max(value, min), max);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function parseTime(value: string): number | null {
|
|
31
|
+
const match = value.trim().match(/^(\d{1,2}):([0-5]\d)$/);
|
|
32
|
+
if (!match) return null;
|
|
33
|
+
const hour = Number(match[1]);
|
|
34
|
+
const minute = Number(match[2]);
|
|
35
|
+
if (hour > 23) return null;
|
|
36
|
+
return hour * 60 + minute;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function formatTime(totalMinutes: number): string {
|
|
40
|
+
const safeMinutes = ((Math.round(totalMinutes) % 1440) + 1440) % 1440;
|
|
41
|
+
const hour = Math.floor(safeMinutes / 60).toString().padStart(2, '0');
|
|
42
|
+
const minute = (safeMinutes % 60).toString().padStart(2, '0');
|
|
43
|
+
return `${hour}:${minute}`;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function parseRange(value: string): AvailabilityWindow | null {
|
|
47
|
+
const parts = value.split('-').map((part) => part.trim());
|
|
48
|
+
if (parts.length !== 2) return null;
|
|
49
|
+
const start = parseTime(parts[0] ?? '');
|
|
50
|
+
const end = parseTime(parts[1] ?? '');
|
|
51
|
+
if (start === null || end === null || end <= start) return null;
|
|
52
|
+
return { start, end };
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function normalizeName(value: string): string {
|
|
56
|
+
return value.trim().toLocaleLowerCase();
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function parseAvailability(text: string): AvailabilityMap {
|
|
60
|
+
return parseAvailabilityDetailed(text, 'people').map;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
interface AvailabilityParseResult {
|
|
64
|
+
map: AvailabilityMap;
|
|
65
|
+
issues: InputIssue[];
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function parseAvailabilityDetailed(text: string, section: 'people' | 'rooms'): AvailabilityParseResult {
|
|
69
|
+
const result: AvailabilityMap = {};
|
|
70
|
+
const issues: InputIssue[] = [];
|
|
71
|
+
text.split('\n').forEach((line, index) => {
|
|
72
|
+
if (!line.trim()) return;
|
|
73
|
+
const [rawName, rawRange] = line.split('|').map((part) => part.trim());
|
|
74
|
+
if (!rawName || !rawRange) {
|
|
75
|
+
issues.push({ section, line: index + 1, value: line.trim(), detail: 'Use Name | first call-last call.' });
|
|
76
|
+
return;
|
|
77
|
+
}
|
|
78
|
+
const range = parseRange(rawRange);
|
|
79
|
+
if (!range) {
|
|
80
|
+
issues.push({ section, line: index + 1, value: line.trim(), detail: 'Use times like 09:00-18:00 with the end after the start.' });
|
|
81
|
+
return;
|
|
82
|
+
}
|
|
83
|
+
const key = normalizeName(rawName);
|
|
84
|
+
result[key] = [...(result[key] ?? []), range];
|
|
85
|
+
});
|
|
86
|
+
return { map: result, issues };
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function parseParticipants(value: string): string[] {
|
|
90
|
+
return value.split(',').map((part) => part.trim()).filter(Boolean);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export function parseScenes(text: string): SceneInput[] {
|
|
94
|
+
return parseScenesDetailed(text).scenes;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
interface SceneParseResult {
|
|
98
|
+
scenes: SceneInput[];
|
|
99
|
+
issues: InputIssue[];
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function parseScenesDetailed(text: string): SceneParseResult {
|
|
103
|
+
const issues: InputIssue[] = [];
|
|
104
|
+
const scenes = text.split('\n').map((line, index) => {
|
|
105
|
+
if (!line.trim()) return null;
|
|
106
|
+
const [name, durationText, room, participantsText, notes] = line.split('|').map((part) => part.trim());
|
|
107
|
+
const duration = Number(durationText);
|
|
108
|
+
if (!name || !Number.isFinite(duration) || duration <= 0) {
|
|
109
|
+
issues.push({ section: 'scenes', line: index + 1, value: line.trim(), detail: 'Add a scene name and a positive duration in minutes.' });
|
|
110
|
+
return null;
|
|
111
|
+
}
|
|
112
|
+
return {
|
|
113
|
+
id: `scene-${index + 1}`,
|
|
114
|
+
name,
|
|
115
|
+
duration: clamp(Math.round(duration), 5, 720),
|
|
116
|
+
room: room ?? '',
|
|
117
|
+
participants: parseParticipants(participantsText ?? ''),
|
|
118
|
+
notes: notes ?? '',
|
|
119
|
+
};
|
|
120
|
+
}).filter((scene): scene is SceneInput => scene !== null);
|
|
121
|
+
return { scenes, issues };
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function getWindows(map: AvailabilityMap, name: string): AvailabilityWindow[] {
|
|
125
|
+
return map[normalizeName(name)] ?? [];
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function hasWindow(windows: AvailabilityWindow[], start: number, end: number): boolean {
|
|
129
|
+
return windows.some((window) => window.start <= start && window.end >= end);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function describeWindows(windows: AvailabilityWindow[]): string {
|
|
133
|
+
return windows.map((window) => `${formatTime(window.start)} to ${formatTime(window.end)}`).join(', ');
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
interface SchedulingContext {
|
|
137
|
+
settings: PlannerSettings;
|
|
138
|
+
people: AvailabilityMap;
|
|
139
|
+
rooms: AvailabilityMap;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function collectSceneIssues(scene: SceneInput, start: number, end: number, context: SchedulingContext): SceneIssue[] {
|
|
143
|
+
const { settings, people, rooms } = context;
|
|
144
|
+
const issues: SceneIssue[] = [];
|
|
145
|
+
if (end > parseTime(settings.dayEnd)!) issues.push({ key: 'dayOverflow', detail: settings.dayEnd });
|
|
146
|
+
scene.participants.forEach((person) => {
|
|
147
|
+
const key = normalizeName(person);
|
|
148
|
+
const windows = getWindows(people, person);
|
|
149
|
+
if (!people[key]) issues.push({ key: 'missingParticipantAvailability', detail: person });
|
|
150
|
+
else if (!hasWindow(windows, start, end)) issues.push({ key: 'participantAvailability', detail: `${person} (entered: ${describeWindows(windows)})` });
|
|
151
|
+
});
|
|
152
|
+
if (scene.room) {
|
|
153
|
+
const key = normalizeName(scene.room);
|
|
154
|
+
const windows = getWindows(rooms, scene.room);
|
|
155
|
+
if (!rooms[key]) issues.push({ key: 'missingRoomAvailability', detail: scene.room });
|
|
156
|
+
else if (!hasWindow(windows, start, end)) issues.push({ key: 'roomAvailability', detail: `${scene.room} (entered: ${describeWindows(windows)})` });
|
|
157
|
+
}
|
|
158
|
+
return issues;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function scheduleScenes(settings: PlannerSettings, scenes: SceneInput[], people: AvailabilityMap, rooms: AvailabilityMap): ScheduledScene[] {
|
|
162
|
+
const dayEnd = parseTime(settings.dayEnd)!;
|
|
163
|
+
let cursor = parseTime(settings.dayStart)!;
|
|
164
|
+
return scenes.map((scene) => {
|
|
165
|
+
const start = cursor;
|
|
166
|
+
const end = start + scene.duration;
|
|
167
|
+
const issues = collectSceneIssues(scene, start, end, { settings, people, rooms });
|
|
168
|
+
const isUnscheduled = start >= dayEnd || issues.some((issue) => issue.key === 'dayOverflow');
|
|
169
|
+
const status = getSceneStatus(isUnscheduled, issues);
|
|
170
|
+
cursor = end + clamp(Math.round(settings.breakMinutes), 0, 180);
|
|
171
|
+
return { ...scene, start, end, status, issues };
|
|
172
|
+
});
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
function getSceneStatus(isUnscheduled: boolean, issues: SceneIssue[]): ScheduledScene['status'] {
|
|
176
|
+
if (isUnscheduled) return 'unscheduled';
|
|
177
|
+
if (issues.length > 0) return 'conflict';
|
|
178
|
+
return 'scheduled';
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
function makePeopleSummary(scenes: ScheduledScene[]): PersonSummary[] {
|
|
182
|
+
const map = new Map<string, PersonSummary>();
|
|
183
|
+
scenes.filter((scene) => scene.status !== 'unscheduled').forEach((scene) => scene.participants.forEach((name) => {
|
|
184
|
+
const current = map.get(normalizeName(name));
|
|
185
|
+
if (current) {
|
|
186
|
+
current.sceneCount += 1;
|
|
187
|
+
current.firstCall = Math.min(current.firstCall, scene.start);
|
|
188
|
+
current.lastCall = Math.max(current.lastCall, scene.end);
|
|
189
|
+
} else map.set(normalizeName(name), { name, sceneCount: 1, firstCall: scene.start, lastCall: scene.end });
|
|
190
|
+
}));
|
|
191
|
+
return [...map.values()].sort((left, right) => left.firstCall - right.firstCall);
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
function makeRoomSummary(scenes: ScheduledScene[]): RoomSummary[] {
|
|
195
|
+
const map = new Map<string, RoomSummary>();
|
|
196
|
+
scenes.filter((scene) => scene.status !== 'unscheduled' && scene.room).forEach((scene) => {
|
|
197
|
+
const key = normalizeName(scene.room);
|
|
198
|
+
const current = map.get(key);
|
|
199
|
+
if (current) {
|
|
200
|
+
current.sceneCount += 1;
|
|
201
|
+
current.firstCall = Math.min(current.firstCall, scene.start);
|
|
202
|
+
current.lastCall = Math.max(current.lastCall, scene.end);
|
|
203
|
+
} else map.set(key, { name: scene.room, sceneCount: 1, firstCall: scene.start, lastCall: scene.end });
|
|
204
|
+
});
|
|
205
|
+
return [...map.values()].sort((left, right) => left.firstCall - right.firstCall);
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
export function calculateCallSheet(rawSettings: PlannerSettings): CallSheetResult {
|
|
209
|
+
const dayStart = parseTime(rawSettings.dayStart) ?? 540;
|
|
210
|
+
const parsedEnd = parseTime(rawSettings.dayEnd) ?? 1080;
|
|
211
|
+
const dayEnd = parsedEnd > dayStart ? parsedEnd : dayStart + 60;
|
|
212
|
+
const settings = { ...rawSettings, dayStart: formatTime(dayStart), dayEnd: formatTime(dayEnd) };
|
|
213
|
+
const parsedScenes = parseScenesDetailed(settings.scenesText);
|
|
214
|
+
const parsedPeople = parseAvailabilityDetailed(settings.participantAvailabilityText, 'people');
|
|
215
|
+
const parsedRooms = parseAvailabilityDetailed(settings.roomAvailabilityText, 'rooms');
|
|
216
|
+
const scenes = scheduleScenes(settings, parsedScenes.scenes, parsedPeople.map, parsedRooms.map);
|
|
217
|
+
return {
|
|
218
|
+
settings,
|
|
219
|
+
scenes,
|
|
220
|
+
dayStart,
|
|
221
|
+
dayEnd,
|
|
222
|
+
scheduledMinutes: scenes.filter((scene) => scene.status !== 'unscheduled').reduce((total, scene) => total + scene.duration, 0),
|
|
223
|
+
conflictCount: scenes.filter((scene) => scene.status === 'conflict').length,
|
|
224
|
+
unscheduledCount: scenes.filter((scene) => scene.status === 'unscheduled').length,
|
|
225
|
+
inputIssues: [...parsedScenes.issues, ...parsedPeople.issues, ...parsedRooms.issues],
|
|
226
|
+
people: makePeopleSummary(scenes),
|
|
227
|
+
rooms: makeRoomSummary(scenes),
|
|
228
|
+
};
|
|
229
|
+
}
|