@things-factory/work-shift 10.1.248 → 10.1.250

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.
Files changed (87) hide show
  1. package/dist-client/bootstrap.d.ts +1 -0
  2. package/dist-client/bootstrap.js +6 -0
  3. package/dist-client/bootstrap.js.map +1 -0
  4. package/dist-client/index.d.ts +0 -0
  5. package/dist-client/index.js +2 -0
  6. package/dist-client/index.js.map +1 -0
  7. package/dist-client/pages/work-shift.d.ts +105 -0
  8. package/dist-client/pages/work-shift.js +1144 -0
  9. package/dist-client/pages/work-shift.js.map +1 -0
  10. package/dist-client/route.d.ts +1 -0
  11. package/dist-client/route.js +8 -0
  12. package/dist-client/route.js.map +1 -0
  13. package/dist-client/shift-coverage.d.ts +102 -0
  14. package/dist-client/shift-coverage.js +196 -0
  15. package/dist-client/shift-coverage.js.map +1 -0
  16. package/dist-client/shift-editing.d.ts +80 -0
  17. package/dist-client/shift-editing.js +212 -0
  18. package/dist-client/shift-editing.js.map +1 -0
  19. package/dist-client/shift-revisions.d.ts +73 -0
  20. package/dist-client/shift-revisions.js +64 -0
  21. package/dist-client/shift-revisions.js.map +1 -0
  22. package/dist-client/tsconfig.tsbuildinfo +1 -0
  23. package/dist-server/controllers/index.d.ts +2 -0
  24. package/dist-server/controllers/index.js +6 -0
  25. package/dist-server/controllers/index.js.map +1 -0
  26. package/dist-server/controllers/work-shift-range.d.ts +28 -0
  27. package/dist-server/controllers/work-shift-range.js +65 -0
  28. package/dist-server/controllers/work-shift-range.js.map +1 -0
  29. package/dist-server/controllers/work-shift-schedule.d.ts +5 -0
  30. package/dist-server/controllers/work-shift-schedule.js +60 -0
  31. package/dist-server/controllers/work-shift-schedule.js.map +1 -0
  32. package/dist-server/index.d.ts +2 -0
  33. package/dist-server/index.js +19 -0
  34. package/dist-server/index.js.map +1 -0
  35. package/dist-server/service/index.d.ts +13 -0
  36. package/dist-server/service/index.js +33 -0
  37. package/dist-server/service/index.js.map +1 -0
  38. package/dist-server/service/work-shift/days-of-week.d.ts +30 -0
  39. package/dist-server/service/work-shift/days-of-week.js +74 -0
  40. package/dist-server/service/work-shift/days-of-week.js.map +1 -0
  41. package/dist-server/service/work-shift/index.d.ts +9 -0
  42. package/dist-server/service/work-shift/index.js +20 -0
  43. package/dist-server/service/work-shift/index.js.map +1 -0
  44. package/dist-server/service/work-shift/shift-occurrence.d.ts +19 -0
  45. package/dist-server/service/work-shift/shift-occurrence.js +34 -0
  46. package/dist-server/service/work-shift/shift-occurrence.js.map +1 -0
  47. package/dist-server/service/work-shift/work-shift-arrangement.d.ts +10 -0
  48. package/dist-server/service/work-shift/work-shift-arrangement.js +40 -0
  49. package/dist-server/service/work-shift/work-shift-arrangement.js.map +1 -0
  50. package/dist-server/service/work-shift/work-shift-baseline.d.ts +23 -0
  51. package/dist-server/service/work-shift/work-shift-baseline.js +55 -0
  52. package/dist-server/service/work-shift/work-shift-baseline.js.map +1 -0
  53. package/dist-server/service/work-shift/work-shift-change-approval.d.ts +14 -0
  54. package/dist-server/service/work-shift/work-shift-change-approval.js +64 -0
  55. package/dist-server/service/work-shift/work-shift-change-approval.js.map +1 -0
  56. package/dist-server/service/work-shift/work-shift-content.d.ts +18 -0
  57. package/dist-server/service/work-shift/work-shift-content.js +62 -0
  58. package/dist-server/service/work-shift/work-shift-content.js.map +1 -0
  59. package/dist-server/service/work-shift/work-shift-lifecycle-resolver.d.ts +68 -0
  60. package/dist-server/service/work-shift/work-shift-lifecycle-resolver.js +89 -0
  61. package/dist-server/service/work-shift/work-shift-lifecycle-resolver.js.map +1 -0
  62. package/dist-server/service/work-shift/work-shift-lifecycle.d.ts +229 -0
  63. package/dist-server/service/work-shift/work-shift-lifecycle.js +672 -0
  64. package/dist-server/service/work-shift/work-shift-lifecycle.js.map +1 -0
  65. package/dist-server/service/work-shift/work-shift-mutation.d.ts +16 -0
  66. package/dist-server/service/work-shift/work-shift-mutation.js +77 -0
  67. package/dist-server/service/work-shift/work-shift-mutation.js.map +1 -0
  68. package/dist-server/service/work-shift/work-shift-query.d.ts +14 -0
  69. package/dist-server/service/work-shift/work-shift-query.js +116 -0
  70. package/dist-server/service/work-shift/work-shift-query.js.map +1 -0
  71. package/dist-server/service/work-shift/work-shift-revision-service.d.ts +32 -0
  72. package/dist-server/service/work-shift/work-shift-revision-service.js +89 -0
  73. package/dist-server/service/work-shift/work-shift-revision-service.js.map +1 -0
  74. package/dist-server/service/work-shift/work-shift-revision.d.ts +85 -0
  75. package/dist-server/service/work-shift/work-shift-revision.js +337 -0
  76. package/dist-server/service/work-shift/work-shift-revision.js.map +1 -0
  77. package/dist-server/service/work-shift/work-shift-selection.d.ts +50 -0
  78. package/dist-server/service/work-shift/work-shift-selection.js +65 -0
  79. package/dist-server/service/work-shift/work-shift-selection.js.map +1 -0
  80. package/dist-server/service/work-shift/work-shift-type.d.ts +26 -0
  81. package/dist-server/service/work-shift/work-shift-type.js +77 -0
  82. package/dist-server/service/work-shift/work-shift-type.js.map +1 -0
  83. package/dist-server/service/work-shift/work-shift.d.ts +56 -0
  84. package/dist-server/service/work-shift/work-shift.js +109 -0
  85. package/dist-server/service/work-shift/work-shift.js.map +1 -0
  86. package/dist-server/tsconfig.tsbuildinfo +1 -0
  87. package/package.json +4 -4
@@ -0,0 +1,212 @@
1
+ /**
2
+ * The rules of the work-shift editor, kept out of the page so they can be tested.
3
+ *
4
+ * A shift is a name, a start (day offset + clock), an end and the work weekdays it runs on. The editor asks
5
+ * for the start day and both clocks; the end day follows from them: an end at or before the start clock is
6
+ * on the next day. Asking for it was the most common mistake on the old screen - a night shift saved with
7
+ * both ends on the same day is not a span at all.
8
+ *
9
+ * Weekdays are work dates, numbered like `Date.getDay()` (0 Sunday … 6 Saturday). The stored form is a sorted
10
+ * CSV, and the empty string means every day.
11
+ */
12
+ import { minutesOf } from './shift-coverage';
13
+ /** Display order of the week: Monday first. */
14
+ export const WEEK = ['1', '2', '3', '4', '5', '6', '0'];
15
+ const DAY = 24 * 60;
16
+ const WEEK_MINUTES = 7 * DAY;
17
+ /** The weekdays a stored value names, in display order. Empty or unreadable means every day. */
18
+ export function daysOf(daysOfWeek) {
19
+ const named = new Set(String(daysOfWeek ?? '')
20
+ .split(',')
21
+ .map(day => day.trim())
22
+ .filter(day => WEEK.includes(day)));
23
+ return named.size ? WEEK.filter(day => named.has(day)) : [...WEEK];
24
+ }
25
+ /**
26
+ * The stored value for a chosen set of weekdays: '' for all seven, a sorted CSV otherwise.
27
+ * `undefined` when nothing is chosen - a shift that runs on no day is not a shift, and '' would read as every day.
28
+ */
29
+ export function daysValue(chosen) {
30
+ const days = WEEK.filter(day => chosen.includes(day));
31
+ if (!days.length)
32
+ return undefined;
33
+ if (days.length === WEEK.length)
34
+ return '';
35
+ return [...days].sort().join(',');
36
+ }
37
+ /**
38
+ * A short label for a set of weekdays: runs of three or more consecutive days (Monday first) collapse to a
39
+ * range, the rest are listed. `월–금`, `월·수·금`, `토·일`, and the every-day word for all seven.
40
+ */
41
+ export function daysLabel(days, name, everyDay) {
42
+ const ordered = WEEK.filter(day => days.includes(day));
43
+ if (ordered.length === WEEK.length)
44
+ return everyDay;
45
+ const runs = [];
46
+ for (const day of ordered) {
47
+ const run = runs[runs.length - 1];
48
+ if (run && WEEK.indexOf(run[run.length - 1]) + 1 === WEEK.indexOf(day))
49
+ run.push(day);
50
+ else
51
+ runs.push([day]);
52
+ }
53
+ return runs
54
+ .map(run => (run.length >= 3 ? `${name(run[0])}–${name(run[run.length - 1])}` : run.map(name).join('·')))
55
+ .join('·');
56
+ }
57
+ /** The end day that follows from the start: an end at or before the start clock is on the next day. */
58
+ export function endDateOf(shift) {
59
+ const from = minutesOf(shift.fromTime);
60
+ const to = minutesOf(shift.toTime);
61
+ const fromDate = Number(shift.fromDate ?? 0);
62
+ if (from === undefined || to === undefined)
63
+ return fromDate;
64
+ return to <= from ? fromDate + 1 : fromDate;
65
+ }
66
+ /** The shift with its end day put right. Call after every change to a start day or a clock. */
67
+ export function settle(shift) {
68
+ return { ...shift, toDate: endDateOf(shift) };
69
+ }
70
+ /** Length of a shift in minutes, or undefined when its clocks cannot be read. */
71
+ export function lengthOf(shift) {
72
+ const from = minutesOf(shift.fromTime);
73
+ const to = minutesOf(shift.toTime);
74
+ if (from === undefined || to === undefined)
75
+ return undefined;
76
+ return Number(shift.toDate ?? 0) * DAY + to - (Number(shift.fromDate ?? 0) * DAY + from);
77
+ }
78
+ const clock = (minute) => {
79
+ const m = ((minute % DAY) + DAY) % DAY;
80
+ return `${String(Math.floor(m / 60)).padStart(2, '0')}:${String(m % 60).padStart(2, '0')}`;
81
+ };
82
+ /**
83
+ * The row "add shift" puts in: it starts where the last shift ends and lasts eight hours, on the same days.
84
+ * The work day is usually split into back-to-back shifts, so this is the next one more often than not.
85
+ */
86
+ export function nextShift(shifts) {
87
+ const last = shifts[shifts.length - 1];
88
+ const lastEnd = last && minutesOf(last.toTime);
89
+ if (!last || lastEnd === undefined) {
90
+ return settle({ name: '', description: '', fromDate: 0, fromTime: '08:00', toDate: 0, toTime: '16:00', daysOfWeek: '' });
91
+ }
92
+ /* A shift that started on the next day cannot start a further one: offsets stop at the next day. */
93
+ const fromDate = Math.min(Number(last.toDate ?? 0), 1);
94
+ return settle({
95
+ name: '',
96
+ description: '',
97
+ fromDate,
98
+ fromTime: clock(lastEnd),
99
+ toDate: fromDate,
100
+ toTime: clock(lastEnd + 8 * 60),
101
+ daysOfWeek: last.daysOfWeek ?? ''
102
+ });
103
+ }
104
+ /**
105
+ * Weekdays grouped by the shifts that run on them, Monday first: one group per distinct set.
106
+ * A plant that runs the same shifts every day gets one group; days with no shift form a group with none.
107
+ */
108
+ export function weekGroups(shifts) {
109
+ const groups = new Map();
110
+ for (const day of WEEK) {
111
+ const active = shifts.filter(shift => daysOf(shift.daysOfWeek).includes(day));
112
+ const key = active.map(shift => shifts.indexOf(shift)).join(',');
113
+ const group = groups.get(key) ?? { days: [], shifts: active };
114
+ group.days.push(day);
115
+ groups.set(key, group);
116
+ }
117
+ return [...groups.values()];
118
+ }
119
+ /**
120
+ * What stops a draft from saving, found before the server refuses it.
121
+ *
122
+ * The same rules as the server's content check: a non-empty unique name, readable clocks, a start day the
123
+ * end can follow (a shift that starts on the next day must end on it), a reason, and no two shifts running
124
+ * at the same time anywhere in the week - including a Friday night shift running into Saturday's first one.
125
+ */
126
+ export function draftIssues(draft) {
127
+ const rows = new Map();
128
+ const flag = (index, issue) => rows.set(index, [...(rows.get(index) ?? []), issue]);
129
+ const names = draft.shifts.map(shift => String(shift.name ?? '').trim());
130
+ const windows = [];
131
+ draft.shifts.forEach((shift, index) => {
132
+ const name = names[index];
133
+ if (!name)
134
+ flag(index, 'name-missing');
135
+ else if (names.indexOf(name) !== index)
136
+ flag(index, 'name-duplicate');
137
+ const from = minutesOf(shift.fromTime);
138
+ const to = minutesOf(shift.toTime);
139
+ if (from === undefined || to === undefined) {
140
+ flag(index, 'time-missing');
141
+ return;
142
+ }
143
+ const toDate = endDateOf(shift);
144
+ if (toDate > 1) {
145
+ flag(index, 'ends-too-late');
146
+ return;
147
+ }
148
+ const start = Number(shift.fromDate ?? 0) * DAY + from;
149
+ const end = toDate * DAY + to;
150
+ /* One week and the start of the next: a shift running past Saturday night meets Sunday's in the next copy. */
151
+ for (const day of daysOf(shift.daysOfWeek)) {
152
+ for (const week of [0, 1]) {
153
+ const base = Number(day) * DAY + week * WEEK_MINUTES;
154
+ windows.push({ from: base + start, to: base + end, name: name || `#${index + 1}`, day, base });
155
+ }
156
+ }
157
+ });
158
+ windows.sort((a, b) => a.from - b.from || a.to - b.to);
159
+ const overlaps = [];
160
+ const seen = new Set();
161
+ let reach;
162
+ for (const window of windows) {
163
+ if (reach && window.from < reach.to) {
164
+ const fromMinute = window.from - window.base;
165
+ const key = `${reach.name}|${window.name}|${window.day}|${fromMinute}`;
166
+ /* The same overlap shows up in both week copies; report it once. */
167
+ if (!seen.has(key)) {
168
+ seen.add(key);
169
+ overlaps.push({
170
+ names: [reach.name, window.name],
171
+ day: window.day,
172
+ fromMinute,
173
+ toMinute: fromMinute + Math.min(reach.to, window.to) - window.from
174
+ });
175
+ }
176
+ }
177
+ if (!reach || window.to > reach.to)
178
+ reach = window;
179
+ }
180
+ const reasonMissing = draft.reason !== undefined && !String(draft.reason).trim();
181
+ return { rows, overlaps, reasonMissing, blocking: rows.size > 0 || overlaps.length > 0 || reasonMissing };
182
+ }
183
+ /** The content the server takes: trimmed names, end days put right, nothing the editor added. */
184
+ export function shiftsContent(shifts) {
185
+ return shifts.map(shift => {
186
+ const settled = settle(shift);
187
+ return {
188
+ name: String(settled.name ?? '').trim(),
189
+ description: String(settled.description ?? ''),
190
+ fromDate: Number(settled.fromDate ?? 0),
191
+ fromTime: settled.fromTime,
192
+ toDate: settled.toDate,
193
+ toTime: settled.toTime,
194
+ daysOfWeek: settled.daysOfWeek ?? ''
195
+ };
196
+ });
197
+ }
198
+ /**
199
+ * Week overlaps folded into one line per pair and clock range: two every-day shifts that collide make one
200
+ * finding on seven days, not seven findings.
201
+ */
202
+ export function groupOverlaps(overlaps) {
203
+ const groups = new Map();
204
+ for (const overlap of overlaps) {
205
+ const key = `${overlap.names.join('|')}|${overlap.fromMinute}|${overlap.toMinute}`;
206
+ const group = groups.get(key) ?? { names: overlap.names, days: [], fromMinute: overlap.fromMinute, toMinute: overlap.toMinute };
207
+ group.days.push(overlap.day);
208
+ groups.set(key, group);
209
+ }
210
+ return [...groups.values()].map(group => ({ ...group, days: WEEK.filter(day => group.days.includes(day)) }));
211
+ }
212
+ //# sourceMappingURL=shift-editing.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"shift-editing.js","sourceRoot":"","sources":["../client/shift-editing.ts"],"names":[],"mappings":"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","sourcesContent":["/**\n * The rules of the work-shift editor, kept out of the page so they can be tested.\n *\n * A shift is a name, a start (day offset + clock), an end and the work weekdays it runs on. The editor asks\n * for the start day and both clocks; the end day follows from them: an end at or before the start clock is\n * on the next day. Asking for it was the most common mistake on the old screen - a night shift saved with\n * both ends on the same day is not a span at all.\n *\n * Weekdays are work dates, numbered like `Date.getDay()` (0 Sunday … 6 Saturday). The stored form is a sorted\n * CSV, and the empty string means every day.\n */\nimport { minutesOf } from './shift-coverage'\nimport type { ShiftLike } from './shift-coverage'\n\nexport interface EditableShift extends ShiftLike {\n description?: string\n}\n\n/** Display order of the week: Monday first. */\nexport const WEEK = ['1', '2', '3', '4', '5', '6', '0'] as const\n\nconst DAY = 24 * 60\nconst WEEK_MINUTES = 7 * DAY\n\n/** The weekdays a stored value names, in display order. Empty or unreadable means every day. */\nexport function daysOf(daysOfWeek?: string | null): string[] {\n const named = new Set(\n String(daysOfWeek ?? '')\n .split(',')\n .map(day => day.trim())\n .filter(day => (WEEK as readonly string[]).includes(day))\n )\n return named.size ? WEEK.filter(day => named.has(day)) : [...WEEK]\n}\n\n/**\n * The stored value for a chosen set of weekdays: '' for all seven, a sorted CSV otherwise.\n * `undefined` when nothing is chosen - a shift that runs on no day is not a shift, and '' would read as every day.\n */\nexport function daysValue(chosen: readonly string[]): string | undefined {\n const days = WEEK.filter(day => chosen.includes(day))\n if (!days.length) return undefined\n if (days.length === WEEK.length) return ''\n return [...days].sort().join(',')\n}\n\n/**\n * A short label for a set of weekdays: runs of three or more consecutive days (Monday first) collapse to a\n * range, the rest are listed. `월–금`, `월·수·금`, `토·일`, and the every-day word for all seven.\n */\nexport function daysLabel(days: readonly string[], name: (day: string) => string, everyDay: string): string {\n const ordered = WEEK.filter(day => days.includes(day))\n if (ordered.length === WEEK.length) return everyDay\n\n const runs: string[][] = []\n for (const day of ordered) {\n const run = runs[runs.length - 1]\n if (run && WEEK.indexOf(run[run.length - 1] as any) + 1 === WEEK.indexOf(day as any)) run.push(day)\n else runs.push([day])\n }\n return runs\n .map(run => (run.length >= 3 ? `${name(run[0])}–${name(run[run.length - 1])}` : run.map(name).join('·')))\n .join('·')\n}\n\n/** The end day that follows from the start: an end at or before the start clock is on the next day. */\nexport function endDateOf(shift: Pick<ShiftLike, 'fromDate' | 'fromTime' | 'toTime'>): number {\n const from = minutesOf(shift.fromTime)\n const to = minutesOf(shift.toTime)\n const fromDate = Number(shift.fromDate ?? 0)\n if (from === undefined || to === undefined) return fromDate\n return to <= from ? fromDate + 1 : fromDate\n}\n\n/** The shift with its end day put right. Call after every change to a start day or a clock. */\nexport function settle<T extends EditableShift>(shift: T): T {\n return { ...shift, toDate: endDateOf(shift) }\n}\n\n/** Length of a shift in minutes, or undefined when its clocks cannot be read. */\nexport function lengthOf(shift: ShiftLike): number | undefined {\n const from = minutesOf(shift.fromTime)\n const to = minutesOf(shift.toTime)\n if (from === undefined || to === undefined) return undefined\n return Number(shift.toDate ?? 0) * DAY + to - (Number(shift.fromDate ?? 0) * DAY + from)\n}\n\nconst clock = (minute: number) => {\n const m = ((minute % DAY) + DAY) % DAY\n return `${String(Math.floor(m / 60)).padStart(2, '0')}:${String(m % 60).padStart(2, '0')}`\n}\n\n/**\n * The row \"add shift\" puts in: it starts where the last shift ends and lasts eight hours, on the same days.\n * The work day is usually split into back-to-back shifts, so this is the next one more often than not.\n */\nexport function nextShift(shifts: readonly EditableShift[]): EditableShift {\n const last = shifts[shifts.length - 1]\n const lastEnd = last && minutesOf(last.toTime)\n if (!last || lastEnd === undefined) {\n return settle({ name: '', description: '', fromDate: 0, fromTime: '08:00', toDate: 0, toTime: '16:00', daysOfWeek: '' })\n }\n /* A shift that started on the next day cannot start a further one: offsets stop at the next day. */\n const fromDate = Math.min(Number(last.toDate ?? 0), 1)\n return settle({\n name: '',\n description: '',\n fromDate,\n fromTime: clock(lastEnd),\n toDate: fromDate,\n toTime: clock(lastEnd + 8 * 60),\n daysOfWeek: last.daysOfWeek ?? ''\n })\n}\n\n/**\n * Weekdays grouped by the shifts that run on them, Monday first: one group per distinct set.\n * A plant that runs the same shifts every day gets one group; days with no shift form a group with none.\n */\nexport function weekGroups<T extends ShiftLike>(shifts: readonly T[]): { days: string[]; shifts: T[] }[] {\n const groups = new Map<string, { days: string[]; shifts: T[] }>()\n for (const day of WEEK) {\n const active = shifts.filter(shift => daysOf(shift.daysOfWeek).includes(day))\n const key = active.map(shift => shifts.indexOf(shift)).join(',')\n const group = groups.get(key) ?? { days: [], shifts: active }\n group.days.push(day)\n groups.set(key, group)\n }\n return [...groups.values()]\n}\n\nexport type RowIssue = 'name-missing' | 'name-duplicate' | 'time-missing' | 'ends-too-late'\n\nexport interface WeekOverlap {\n names: [string, string]\n /** Work weekday of the later shift - the day the overlap is read on. */\n day: string\n /** Minutes from that work date's midnight. */\n fromMinute: number\n toMinute: number\n}\n\nexport interface DraftIssues {\n /** Problems of one row, by its index. Rows without problems are absent. */\n rows: Map<number, RowIssue[]>\n /** Shifts that run at the same time somewhere in the week - the server refuses these. */\n overlaps: WeekOverlap[]\n reasonMissing: boolean\n /** True when saving would be refused. Gaps are not here: an arrangement with gaps is allowed. */\n blocking: boolean\n}\n\n/**\n * What stops a draft from saving, found before the server refuses it.\n *\n * The same rules as the server's content check: a non-empty unique name, readable clocks, a start day the\n * end can follow (a shift that starts on the next day must end on it), a reason, and no two shifts running\n * at the same time anywhere in the week - including a Friday night shift running into Saturday's first one.\n */\nexport function draftIssues(draft: { shifts: readonly EditableShift[]; reason?: string }): DraftIssues {\n const rows = new Map<number, RowIssue[]>()\n const flag = (index: number, issue: RowIssue) => rows.set(index, [...(rows.get(index) ?? []), issue])\n const names = draft.shifts.map(shift => String(shift.name ?? '').trim())\n\n type Window = { from: number; to: number; name: string; day: string; base: number }\n const windows: Window[] = []\n\n draft.shifts.forEach((shift, index) => {\n const name = names[index]\n if (!name) flag(index, 'name-missing')\n else if (names.indexOf(name) !== index) flag(index, 'name-duplicate')\n\n const from = minutesOf(shift.fromTime)\n const to = minutesOf(shift.toTime)\n if (from === undefined || to === undefined) {\n flag(index, 'time-missing')\n return\n }\n const toDate = endDateOf(shift)\n if (toDate > 1) {\n flag(index, 'ends-too-late')\n return\n }\n const start = Number(shift.fromDate ?? 0) * DAY + from\n const end = toDate * DAY + to\n /* One week and the start of the next: a shift running past Saturday night meets Sunday's in the next copy. */\n for (const day of daysOf(shift.daysOfWeek)) {\n for (const week of [0, 1]) {\n const base = Number(day) * DAY + week * WEEK_MINUTES\n windows.push({ from: base + start, to: base + end, name: name || `#${index + 1}`, day, base })\n }\n }\n })\n\n windows.sort((a, b) => a.from - b.from || a.to - b.to)\n const overlaps: WeekOverlap[] = []\n const seen = new Set<string>()\n let reach: Window | undefined\n for (const window of windows) {\n if (reach && window.from < reach.to) {\n const fromMinute = window.from - window.base\n const key = `${reach.name}|${window.name}|${window.day}|${fromMinute}`\n /* The same overlap shows up in both week copies; report it once. */\n if (!seen.has(key)) {\n seen.add(key)\n overlaps.push({\n names: [reach.name, window.name],\n day: window.day,\n fromMinute,\n toMinute: fromMinute + Math.min(reach.to, window.to) - window.from\n })\n }\n }\n if (!reach || window.to > reach.to) reach = window\n }\n\n const reasonMissing = draft.reason !== undefined && !String(draft.reason).trim()\n return { rows, overlaps, reasonMissing, blocking: rows.size > 0 || overlaps.length > 0 || reasonMissing }\n}\n\n/** The content the server takes: trimmed names, end days put right, nothing the editor added. */\nexport function shiftsContent(shifts: readonly EditableShift[]): EditableShift[] {\n return shifts.map(shift => {\n const settled = settle(shift)\n return {\n name: String(settled.name ?? '').trim(),\n description: String(settled.description ?? ''),\n fromDate: Number(settled.fromDate ?? 0),\n fromTime: settled.fromTime,\n toDate: settled.toDate,\n toTime: settled.toTime,\n daysOfWeek: settled.daysOfWeek ?? ''\n }\n })\n}\n\nexport interface OverlapGroup {\n names: [string, string]\n /** Work weekdays it happens on, in display order. */\n days: string[]\n fromMinute: number\n toMinute: number\n}\n\n/**\n * Week overlaps folded into one line per pair and clock range: two every-day shifts that collide make one\n * finding on seven days, not seven findings.\n */\nexport function groupOverlaps(overlaps: readonly WeekOverlap[]): OverlapGroup[] {\n const groups = new Map<string, OverlapGroup>()\n for (const overlap of overlaps) {\n const key = `${overlap.names.join('|')}|${overlap.fromMinute}|${overlap.toMinute}`\n const group = groups.get(key) ?? { names: overlap.names, days: [], fromMinute: overlap.fromMinute, toMinute: overlap.toMinute }\n group.days.push(overlap.day)\n groups.set(key, group)\n }\n return [...groups.values()].map(group => ({ ...group, days: WEEK.filter(day => group.days.includes(day)) }))\n}\n"]}
@@ -0,0 +1,73 @@
1
+ /**
2
+ * Where each revision of the shift arrangement belongs on the screen, kept out of the page so it can be tested.
3
+ *
4
+ * A revision is one whole arrangement with the moment it takes effect. Once published it has a segment in the
5
+ * plan: `current` (in force now), `scheduled` (takes effect later) or `expired` (replaced). Before that it is a
6
+ * draft, then - where changes need approval - pending, approved, rejected or withdrawn. The approval state
7
+ * comes from ops-master's approval flow, not from the revision row.
8
+ */
9
+ import type { ShiftLike } from './shift-coverage';
10
+ export type RevisionState = 'current' | 'scheduled' | 'expired' | 'draft' | 'pending' | 'approved' | 'rejected' | 'aborted' | 'unknown';
11
+ export interface RevisionRow {
12
+ id: string;
13
+ revisionNumber?: number;
14
+ frozenAt?: string | null;
15
+ effectiveFrom?: string;
16
+ reason?: string;
17
+ /** How it was published, from the plan that published it; null until then. */
18
+ releaseKind?: 'approved' | 'policy' | 'baseline' | null;
19
+ shifts?: ShiftLike[];
20
+ [key: string]: any;
21
+ }
22
+ export interface Segment {
23
+ revisionId: string;
24
+ validFrom: string;
25
+ validTo?: string | null;
26
+ state: 'current' | 'scheduled' | 'expired';
27
+ }
28
+ export interface Overview {
29
+ revisions: RevisionRow[];
30
+ schedule: Segment[];
31
+ }
32
+ export interface PlacedRevision {
33
+ row: RevisionRow;
34
+ state: RevisionState;
35
+ /** When it is or was in force. Only published revisions have one. */
36
+ period?: {
37
+ from: string;
38
+ to?: string | null;
39
+ };
40
+ }
41
+ /**
42
+ * The state of one revision. A published one is what its segment says; an unpublished one is what its approval
43
+ * says, and without an approval it is a draft until it is frozen (submitted), then pending.
44
+ */
45
+ export declare function revisionState(row: RevisionRow, schedule: readonly Segment[], approval?: {
46
+ state?: string;
47
+ } | null): RevisionState;
48
+ /** Revisions someone is still working on: they appear in the "changes in progress" list, not in the history. */
49
+ export declare const OPEN_STATES: readonly RevisionState[];
50
+ export interface PlacedOverview {
51
+ current?: PlacedRevision;
52
+ /** Takes effect later, earliest first. */
53
+ scheduled: PlacedRevision[];
54
+ /** Drafts and changes waiting for a decision, newest first. */
55
+ open: PlacedRevision[];
56
+ /** Everything decided: in force, scheduled, replaced, rejected, withdrawn - newest first. */
57
+ history: PlacedRevision[];
58
+ }
59
+ export declare function placeRevisions(overview: Overview, approvals?: Record<string, {
60
+ state?: string;
61
+ } | undefined>): PlacedOverview;
62
+ /**
63
+ * The first arrangement the server recorded from the shifts already in use, when revisions began. Nobody wrote
64
+ * its reason - the server did, for engineers - so the screen names it in the reader's words instead.
65
+ */
66
+ export declare function isBaseline(row: RevisionRow): boolean;
67
+ /** The reason as a person reads it: what its author wrote, or `baseline` for the recorded first arrangement. */
68
+ export declare function reasonOf(row: RevisionRow, baseline: string): string;
69
+ /**
70
+ * The clock range of a shift as a person reads it: `08:00 – 16:00`, `22:00 – 익일 06:00`, `전일 22:00 – 06:00`.
71
+ * Only a day other than the work date is named; `day(offset)` gives the word for -1 and 1.
72
+ */
73
+ export declare function timeRange(shift: ShiftLike, day: (offset: number) => string): string;
@@ -0,0 +1,64 @@
1
+ const SEGMENT_ORDER = { current: 0, scheduled: 1, expired: 2 };
2
+ /** The segment that speaks for a revision: the one in force, else the next one, else the latest. */
3
+ function segmentOf(schedule, revisionId) {
4
+ return schedule
5
+ .filter(segment => segment.revisionId === revisionId)
6
+ .sort((a, b) => SEGMENT_ORDER[a.state] - SEGMENT_ORDER[b.state] || b.validFrom.localeCompare(a.validFrom))[0];
7
+ }
8
+ /**
9
+ * The state of one revision. A published one is what its segment says; an unpublished one is what its approval
10
+ * says, and without an approval it is a draft until it is frozen (submitted), then pending.
11
+ */
12
+ export function revisionState(row, schedule, approval) {
13
+ const segment = segmentOf(schedule, row.id);
14
+ if (segment)
15
+ return segment.state;
16
+ const said = approval?.state;
17
+ if (said && ['pending', 'approved', 'rejected', 'aborted', 'unknown'].includes(said))
18
+ return said;
19
+ return row.frozenAt ? 'pending' : 'draft';
20
+ }
21
+ /** Revisions someone is still working on: they appear in the "changes in progress" list, not in the history. */
22
+ export const OPEN_STATES = ['draft', 'pending', 'approved', 'unknown'];
23
+ const newestFirst = (a, b) => Number(b.row.revisionNumber ?? 0) - Number(a.row.revisionNumber ?? 0);
24
+ export function placeRevisions(overview, approvals = {}) {
25
+ const placed = overview.revisions.map(row => {
26
+ const segment = segmentOf(overview.schedule, row.id);
27
+ return {
28
+ row,
29
+ state: revisionState(row, overview.schedule, approvals[row.id]),
30
+ ...(segment ? { period: { from: segment.validFrom, to: segment.validTo ?? null } } : {})
31
+ };
32
+ });
33
+ return {
34
+ current: placed.find(item => item.state === 'current'),
35
+ scheduled: placed
36
+ .filter(item => item.state === 'scheduled')
37
+ .sort((a, b) => a.period.from.localeCompare(b.period.from)),
38
+ open: placed.filter(item => OPEN_STATES.includes(item.state)).sort(newestFirst),
39
+ history: placed.filter(item => !OPEN_STATES.includes(item.state)).sort(newestFirst)
40
+ };
41
+ }
42
+ /**
43
+ * The first arrangement the server recorded from the shifts already in use, when revisions began. Nobody wrote
44
+ * its reason - the server did, for engineers - so the screen names it in the reader's words instead.
45
+ */
46
+ export function isBaseline(row) {
47
+ return row.releaseKind === 'baseline';
48
+ }
49
+ /** The reason as a person reads it: what its author wrote, or `baseline` for the recorded first arrangement. */
50
+ export function reasonOf(row, baseline) {
51
+ return isBaseline(row) ? baseline : row.reason ?? '';
52
+ }
53
+ /**
54
+ * The clock range of a shift as a person reads it: `08:00 – 16:00`, `22:00 – 익일 06:00`, `전일 22:00 – 06:00`.
55
+ * Only a day other than the work date is named; `day(offset)` gives the word for -1 and 1.
56
+ */
57
+ export function timeRange(shift, day) {
58
+ const fromDate = Number(shift.fromDate ?? 0);
59
+ const toDate = Number(shift.toDate ?? 0);
60
+ const from = (fromDate ? `${day(fromDate)} ` : '') + String(shift.fromTime ?? '').slice(0, 5);
61
+ const to = (toDate ? `${day(toDate)} ` : '') + String(shift.toTime ?? '').slice(0, 5);
62
+ return `${from} – ${to}`;
63
+ }
64
+ //# sourceMappingURL=shift-revisions.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"shift-revisions.js","sourceRoot":"","sources":["../client/shift-revisions.ts"],"names":[],"mappings":"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","sourcesContent":["/**\n * Where each revision of the shift arrangement belongs on the screen, kept out of the page so it can be tested.\n *\n * A revision is one whole arrangement with the moment it takes effect. Once published it has a segment in the\n * plan: `current` (in force now), `scheduled` (takes effect later) or `expired` (replaced). Before that it is a\n * draft, then - where changes need approval - pending, approved, rejected or withdrawn. The approval state\n * comes from ops-master's approval flow, not from the revision row.\n */\nimport type { ShiftLike } from './shift-coverage'\n\nexport type RevisionState =\n | 'current'\n | 'scheduled'\n | 'expired'\n | 'draft'\n | 'pending'\n | 'approved'\n | 'rejected'\n | 'aborted'\n | 'unknown'\n\nexport interface RevisionRow {\n id: string\n revisionNumber?: number\n frozenAt?: string | null\n effectiveFrom?: string\n reason?: string\n /** How it was published, from the plan that published it; null until then. */\n releaseKind?: 'approved' | 'policy' | 'baseline' | null\n shifts?: ShiftLike[]\n [key: string]: any\n}\n\nexport interface Segment {\n revisionId: string\n validFrom: string\n validTo?: string | null\n state: 'current' | 'scheduled' | 'expired'\n}\n\nexport interface Overview {\n revisions: RevisionRow[]\n schedule: Segment[]\n}\n\nexport interface PlacedRevision {\n row: RevisionRow\n state: RevisionState\n /** When it is or was in force. Only published revisions have one. */\n period?: { from: string; to?: string | null }\n}\n\nconst SEGMENT_ORDER = { current: 0, scheduled: 1, expired: 2 }\n\n/** The segment that speaks for a revision: the one in force, else the next one, else the latest. */\nfunction segmentOf(schedule: readonly Segment[], revisionId: string): Segment | undefined {\n return schedule\n .filter(segment => segment.revisionId === revisionId)\n .sort((a, b) => SEGMENT_ORDER[a.state] - SEGMENT_ORDER[b.state] || b.validFrom.localeCompare(a.validFrom))[0]\n}\n\n/**\n * The state of one revision. A published one is what its segment says; an unpublished one is what its approval\n * says, and without an approval it is a draft until it is frozen (submitted), then pending.\n */\nexport function revisionState(\n row: RevisionRow,\n schedule: readonly Segment[],\n approval?: { state?: string } | null\n): RevisionState {\n const segment = segmentOf(schedule, row.id)\n if (segment) return segment.state\n const said = approval?.state\n if (said && ['pending', 'approved', 'rejected', 'aborted', 'unknown'].includes(said)) return said as RevisionState\n return row.frozenAt ? 'pending' : 'draft'\n}\n\n/** Revisions someone is still working on: they appear in the \"changes in progress\" list, not in the history. */\nexport const OPEN_STATES: readonly RevisionState[] = ['draft', 'pending', 'approved', 'unknown']\n\nexport interface PlacedOverview {\n current?: PlacedRevision\n /** Takes effect later, earliest first. */\n scheduled: PlacedRevision[]\n /** Drafts and changes waiting for a decision, newest first. */\n open: PlacedRevision[]\n /** Everything decided: in force, scheduled, replaced, rejected, withdrawn - newest first. */\n history: PlacedRevision[]\n}\n\nconst newestFirst = (a: PlacedRevision, b: PlacedRevision) =>\n Number(b.row.revisionNumber ?? 0) - Number(a.row.revisionNumber ?? 0)\n\nexport function placeRevisions(overview: Overview, approvals: Record<string, { state?: string } | undefined> = {}): PlacedOverview {\n const placed = overview.revisions.map(row => {\n const segment = segmentOf(overview.schedule, row.id)\n return {\n row,\n state: revisionState(row, overview.schedule, approvals[row.id]),\n ...(segment ? { period: { from: segment.validFrom, to: segment.validTo ?? null } } : {})\n } as PlacedRevision\n })\n\n return {\n current: placed.find(item => item.state === 'current'),\n scheduled: placed\n .filter(item => item.state === 'scheduled')\n .sort((a, b) => a.period!.from.localeCompare(b.period!.from)),\n open: placed.filter(item => OPEN_STATES.includes(item.state)).sort(newestFirst),\n history: placed.filter(item => !OPEN_STATES.includes(item.state)).sort(newestFirst)\n }\n}\n\n/**\n * The first arrangement the server recorded from the shifts already in use, when revisions began. Nobody wrote\n * its reason - the server did, for engineers - so the screen names it in the reader's words instead.\n */\nexport function isBaseline(row: RevisionRow): boolean {\n return row.releaseKind === 'baseline'\n}\n\n/** The reason as a person reads it: what its author wrote, or `baseline` for the recorded first arrangement. */\nexport function reasonOf(row: RevisionRow, baseline: string): string {\n return isBaseline(row) ? baseline : row.reason ?? ''\n}\n\n/**\n * The clock range of a shift as a person reads it: `08:00 – 16:00`, `22:00 – 익일 06:00`, `전일 22:00 – 06:00`.\n * Only a day other than the work date is named; `day(offset)` gives the word for -1 and 1.\n */\nexport function timeRange(shift: ShiftLike, day: (offset: number) => string): string {\n const fromDate = Number(shift.fromDate ?? 0)\n const toDate = Number(shift.toDate ?? 0)\n const from = (fromDate ? `${day(fromDate)} ` : '') + String(shift.fromTime ?? '').slice(0, 5)\n const to = (toDate ? `${day(toDate)} ` : '') + String(shift.toTime ?? '').slice(0, 5)\n return `${from} – ${to}`\n}\n"]}