@nowline/export-msproj 0.8.5 → 0.9.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/src/calendar.ts CHANGED
@@ -1,19 +1,29 @@
1
1
  // Minimal Standard base calendar emitted on every MS Project XML export.
2
2
  //
3
- // Spec: specs/handoffs/m2c.md § 8 + Resolution 6.
4
- // - One base calendar (UID=1, Name=Standard) — Mon–Fri working
5
- // 08:00–12:00 / 13:00–17:00, Sat/Sun non-working. Matches Microsoft's
6
- // default project template; reliably accepted across MSProject versions.
3
+ // Spec: specs/handoffs/m2c.md § 8 + Resolution 6, specs/working-calendar.md §8.
4
+ // - One base calendar (UID=1, Name=Standard). Its working weekdays are the
5
+ // file calendar's open week (`WorkingCalendar.workingWeekdays`), each
6
+ // 08:00–12:00 / 13:00–17:00: Mon–Fri for business, which matches
7
+ // Microsoft's default project template, and all seven days for
8
+ // `calendar:full` and `calendar:custom`.
7
9
  // - One resource calendar (UID=2, Name=Standard, BaseCalendarUID=1).
8
10
  //
11
+ // - `<Exceptions>` in the base calendar (UID=1), right after `</WeekDays>`,
12
+ // one per `CalendarException`: a named closure, a bounded recurrence's
13
+ // closed days, or a working day the open week closes (m2p phase 5b).
14
+ // There is no `<WorkWeeks>`: a bounded recurrence becomes one exception
15
+ // per run of days it changes, and the exceptions never overlap.
16
+ //
9
17
  // Calendar UIDs are fixed (1 / 2). No timestamps in the calendar block →
10
18
  // deterministic across runs.
11
19
 
20
+ import type { CalendarException } from '@nowline/layout';
21
+ import { escapeXml } from './xml.js';
22
+
12
23
  const DAY_NAMES = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday'];
13
24
 
14
- function dayBlock(dayIndex: number): string {
15
- const isWeekend = dayIndex === 0 || dayIndex === 6;
16
- if (isWeekend) {
25
+ function dayBlock(dayIndex: number, isWorking: boolean): string {
26
+ if (!isWorking) {
17
27
  return ` <WeekDay>
18
28
  <DayType>${dayIndex + 1}</DayType>
19
29
  <DayWorking>0</DayWorking>
@@ -35,8 +45,60 @@ function dayBlock(dayIndex: number): string {
35
45
  </WeekDay>`;
36
46
  }
37
47
 
38
- export function buildCalendarsBlock(): string {
39
- const weekDays = [0, 1, 2, 3, 4, 5, 6].map(dayBlock).join('\n');
48
+ function isoDate(date: Date): string {
49
+ return date.toISOString().slice(0, 10);
50
+ }
51
+
52
+ /** One `<Exception>`: element order follows the MSPDI schema. */
53
+ function exceptionBlock(exception: CalendarException): string {
54
+ const lines = [
55
+ ' <Exception>',
56
+ ' <EnteredByOccurrences>0</EnteredByOccurrences>',
57
+ ' <TimePeriod>',
58
+ ` <FromDate>${isoDate(exception.from)}T00:00:00</FromDate>`,
59
+ ` <ToDate>${isoDate(exception.through)}T23:59:00</ToDate>`,
60
+ ' </TimePeriod>',
61
+ ' <Occurrences>1</Occurrences>',
62
+ ];
63
+ if (exception.titles && exception.titles.length > 0) {
64
+ lines.push(` <Name>${escapeXml(exception.titles.join(', '))}</Name>`);
65
+ }
66
+ lines.push(' <Type>1</Type>');
67
+ lines.push(` <DayWorking>${exception.working ? 1 : 0}</DayWorking>`);
68
+ if (exception.working) {
69
+ lines.push(
70
+ ' <WorkingTimes>',
71
+ ' <WorkingTime>',
72
+ ' <FromTime>08:00:00</FromTime>',
73
+ ' <ToTime>12:00:00</ToTime>',
74
+ ' </WorkingTime>',
75
+ ' <WorkingTime>',
76
+ ' <FromTime>13:00:00</FromTime>',
77
+ ' <ToTime>17:00:00</ToTime>',
78
+ ' </WorkingTime>',
79
+ ' </WorkingTimes>',
80
+ );
81
+ }
82
+ lines.push(' </Exception>');
83
+ return lines.join('\n');
84
+ }
85
+
86
+ function exceptionsBlock(exceptions: readonly CalendarException[]): string {
87
+ if (exceptions.length === 0) return '';
88
+ return `\n <Exceptions>\n${exceptions.map(exceptionBlock).join('\n')}\n </Exceptions>`;
89
+ }
90
+
91
+ /**
92
+ * `workingWeekdays`: 0 = Sunday … 6 = Saturday. `exceptions`: the days the
93
+ * file's calendar differs from that week (`calendarExceptions`), ascending.
94
+ */
95
+ export function buildCalendarsBlock(
96
+ workingWeekdays: ReadonlySet<number>,
97
+ exceptions: readonly CalendarException[],
98
+ ): string {
99
+ const weekDays = [0, 1, 2, 3, 4, 5, 6]
100
+ .map((dayIndex) => dayBlock(dayIndex, workingWeekdays.has(dayIndex)))
101
+ .join('\n');
40
102
  return ` <Calendars>
41
103
  <Calendar>
42
104
  <UID>1</UID>
@@ -45,7 +107,7 @@ export function buildCalendarsBlock(): string {
45
107
  <BaseCalendarUID>-1</BaseCalendarUID>
46
108
  <WeekDays>
47
109
  ${weekDays}
48
- </WeekDays>
110
+ </WeekDays>${exceptionsBlock(exceptions)}
49
111
  </Calendar>
50
112
  <Calendar>
51
113
  <UID>2</UID>
package/src/duration.ts CHANGED
@@ -1,45 +1,18 @@
1
- // Convert Nowline duration tokens into MS Project's `PT...H...M...S` form.
1
+ // Task durations in MS Project's `PT<minutes>M0S` form.
2
2
  //
3
- // MS Project uses ISO 8601 duration "PT<minutes>M0S" (e.g. `PT480M0S` = 8h)
4
- // for Standard-calendar working time. We resolve every duration to working
5
- // minutes under the calendar emitted by `calendar.ts` (Mon–Fri, 8h/day).
6
- //
7
- // Size buckets resolve the same way as in @nowline/export-mermaid.
8
-
9
- const SIZE_BUCKET_DAYS: Readonly<Record<string, number>> = {
10
- xs: 1,
11
- sm: 3,
12
- s: 3,
13
- md: 5,
14
- m: 5,
15
- lg: 10,
16
- l: 10,
17
- xl: 15,
18
- };
19
-
20
- const NUMERIC_RE = /^(\d+(?:\.\d+)?)\s*(d|w|m|y)?$/i;
3
+ // The day count is engine C's `ScheduledItem.days` under the file's calendar
4
+ // (specs/working-calendar.md §8), so sizes, `capacity:` and `q` match the
5
+ // chart. Each working day is 8 hours of the Standard calendar `calendar.ts`
6
+ // emits.
21
7
 
22
- const MINUTES_PER_WORKING_DAY = 8 * 60; // Standard calendar
23
- const WORKING_DAYS_PER_WEEK = 5;
24
- const WORKING_DAYS_PER_MONTH = 22;
25
- const WORKING_DAYS_PER_YEAR = 252;
8
+ const MINUTES_PER_WORKING_DAY = 8 * 60;
26
9
 
27
- export function durationToMsProjMinutes(literal: string | undefined): number {
28
- if (!literal) return MINUTES_PER_WORKING_DAY; // 1d default
29
- const trimmed = literal.trim().toLowerCase();
30
- if (!trimmed) return MINUTES_PER_WORKING_DAY;
31
- if (trimmed in SIZE_BUCKET_DAYS) {
32
- return SIZE_BUCKET_DAYS[trimmed] * MINUTES_PER_WORKING_DAY;
33
- }
34
- const match = NUMERIC_RE.exec(trimmed);
35
- if (!match) return MINUTES_PER_WORKING_DAY;
36
- const value = Number(match[1]);
37
- const unit = (match[2] ?? 'd').toLowerCase();
38
- if (!Number.isFinite(value) || value <= 0) return MINUTES_PER_WORKING_DAY;
39
- let days = value;
40
- if (unit === 'w') days = value * WORKING_DAYS_PER_WEEK;
41
- else if (unit === 'm') days = value * WORKING_DAYS_PER_MONTH;
42
- else if (unit === 'y') days = value * WORKING_DAYS_PER_YEAR;
10
+ /**
11
+ * Working minutes for a task of `days` working days. An item with no
12
+ * duration gets one day: a zero-length task is a milestone in MS Project.
13
+ */
14
+ export function workingDaysToMinutes(days: number | undefined): number {
15
+ if (days === undefined || !(days > 0)) return MINUTES_PER_WORKING_DAY;
43
16
  return Math.round(days * MINUTES_PER_WORKING_DAY);
44
17
  }
45
18