File size: 10,555 Bytes
5e2d1eb
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
// Schedule import for Progress mode: planned dates from the project schedule
// instead of typed in area by area β€” the link Buildots makes between what the
// camera saw and the programme (P6 / MS Project). Reads a CSV export (P6,
// MS Project, Smartsheet and Excel all export one) or an MS Project XML file,
// then matches each activity to the app's areas and trades by name:
//
//   "L3 Unit 3B – hang drywall"   β†’ area "L3 Unit 3B", trade Walls
//   "Level 4 ceiling grid"        β†’ every area on floor L4, trade Ceiling
//   "L3 Unit 3B fit-out"          β†’ area "L3 Unit 3B", the area's own window
//
// Nothing is applied until the user has seen what matched and what did not.
// Pure functions (tests/schedule-import.test.mjs).
import { floorOf } from './progress-project.js';

const MONTHS = { jan: 0, feb: 1, mar: 2, apr: 3, may: 4, jun: 5, jul: 6, aug: 7, sep: 8, oct: 9, nov: 10, dec: 11 };

/**
 * Schedule dates as local noon (the app stores capture dates the same way).
 * ISO 2026-09-01 (optionally with a time), US 9/1/2026 or 09/01/26, P6 01-Sep-26 / 01-Sep-2026, "Sep 1, 2026".
 * A trailing "A" (P6 actual) or "*" (constraint) is ignored.
 */
export function parseScheduleDate(raw) {
  const s = String(raw ?? '').trim().replace(/\s*[A*]$/, '');
  if (!s) return null;
  let m = s.match(/^(\d{4})-(\d{1,2})-(\d{1,2})/);
  if (m) return new Date(+m[1], +m[2] - 1, +m[3], 12).getTime();
  m = s.match(/^(\d{1,2})\/(\d{1,2})\/(\d{2,4})/);
  if (m) return new Date(+m[3] < 100 ? 2000 + +m[3] : +m[3], +m[1] - 1, +m[2], 12).getTime();
  m = s.match(/^(\d{1,2})[- ]([A-Za-z]{3})[a-z]*[- ,]+(\d{2,4})/);
  if (m && MONTHS[m[2].toLowerCase()] != null) return new Date(+m[3] < 100 ? 2000 + +m[3] : +m[3], MONTHS[m[2].toLowerCase()], +m[1], 12).getTime();
  m = s.match(/^([A-Za-z]{3})[a-z]*\.? (\d{1,2}),? (\d{4})/);
  if (m && MONTHS[m[1].toLowerCase()] != null) return new Date(+m[3], MONTHS[m[1].toLowerCase()], +m[2], 12).getTime();
  return null;
}

/** RFC-4180-ish CSV: quoted fields, doubled quotes, commas/newlines inside quotes; also tab-separated. */
export function parseCsv(text) {
  const delim = (text.split('\n')[0].match(/\t/g) || []).length > (text.split('\n')[0].match(/,/g) || []).length ? '\t' : ',';
  const rows = [];
  let row = [], field = '', q = false;
  for (let i = 0; i < text.length; i++) {
    const ch = text[i];
    if (q) {
      if (ch === '"' && text[i + 1] === '"') { field += '"'; i++; }
      else if (ch === '"') q = false;
      else field += ch;
    } else if (ch === '"') q = true;
    else if (ch === delim) { row.push(field); field = ''; }
    else if (ch === '\n' || ch === '\r') {
      if (ch === '\r' && text[i + 1] === '\n') i++;
      row.push(field); field = '';
      if (row.some(c => c.trim())) rows.push(row);
      row = [];
    } else field += ch;
  }
  row.push(field);
  if (row.some(c => c.trim())) rows.push(row);
  return rows;
}

const HEADERS = {
  name: /^(activity|task|activity name|task name|name|description|activity description)$/i,
  start: /^(start|start date|planned start|early start|baseline start|bl start)$/i,
  finish: /^(finish|finish date|end|end date|planned finish|early finish|baseline finish|bl finish)$/i,
  area: /^(area|location|zone|room|space|wbs name)$/i,
  trade: /^(trade|discipline|activity code|responsibility)$/i,
};

/** Activities from a CSV export: finds the header row (first row naming a start and a finish column). */
export function parseScheduleCsv(text) {
  const rows = parseCsv(text);
  const headerIdx = rows.findIndex(r => r.some(c => HEADERS.start.test(c.trim())) && r.some(c => HEADERS.finish.test(c.trim())));
  if (headerIdx < 0) return { activities: [], problems: ['No header row with a start and a finish column was found.'] };
  const header = rows[headerIdx].map(c => c.trim());
  const col = key => header.findIndex(h => HEADERS[key].test(h));
  const cols = { name: col('name'), start: col('start'), finish: col('finish'), area: col('area'), trade: col('trade') };
  if (cols.name < 0) cols.name = header.findIndex((h, i) => i !== cols.start && i !== cols.finish && h);
  const activities = [], problems = [];
  rows.slice(headerIdx + 1).forEach((r, i) => {
    const name = (r[cols.name] || '').trim();
    const start = parseScheduleDate(r[cols.start]), finish = parseScheduleDate(r[cols.finish]);
    if (!name) return;
    if (start == null || finish == null) { problems.push(`Row ${headerIdx + i + 2} (${name}): unreadable dates`); return; }
    activities.push({ name, start: Math.min(start, finish), finish: Math.max(start, finish),
      area: cols.area >= 0 ? (r[cols.area] || '').trim() || null : null, trade: cols.trade >= 0 ? (r[cols.trade] || '').trim() || null : null });
  });
  return { activities, problems };
}

const xmlText = s => s.replace(/<!\[CDATA\[([\s\S]*?)\]\]>/g, '$1').replace(/&lt;/g, '<').replace(/&gt;/g, '>').replace(/&quot;/g, '"').replace(/&apos;/g, "'").replace(/&amp;/g, '&');

/** Activities from an MS Project XML export (File β†’ Save As β†’ XML). Summary tasks are skipped. */
export function parseMsProjectXml(text) {
  const activities = [], problems = [];
  for (const block of text.match(/<Task>[\s\S]*?<\/Task>/g) || []) {
    const get = tag => { const m = block.match(new RegExp(`<${tag}>([\\s\\S]*?)</${tag}>`)); return m ? xmlText(m[1]).trim() : null; };
    if (get('Summary') === '1' || get('IsNull') === '1') continue;
    const name = get('Name');
    if (!name) continue;
    const start = parseScheduleDate(get('Start')), finish = parseScheduleDate(get('Finish'));
    if (start == null || finish == null) { problems.push(`${name}: unreadable dates`); continue; }
    activities.push({ name, start, finish, area: null, trade: null });
  }
  return { activities, problems };
}

export function parseSchedule(text, fileName = '') {
  return /\.xml$/i.test(fileName) || /^\s*<\?xml|<Project[\s>]/.test(text) ? parseMsProjectXml(text) : parseScheduleCsv(text);
}

// Trade keywords β†’ Progress mode's item keys. Order matters: "ceiling grid" is Ceiling, not Walls.
const TRADES = [
  ['ceiling', /\b(ceiling|ceilings|act|acoustic|grid|soffit|bulkhead)\b/i],
  ['exits', /\bexit\b/i],
  ['supply', /\b(diffuser|diffusers|supply air)\b/i],
  ['returns', /\b(return|returns|grille|grilles)\b/i],
  ['fixtures', /\b(light|lights|lighting|fixture|fixtures|luminaire|luminaires)\b/i],
  ['wall', /\b(drywall|gypsum|gyp|plasterboard|sheetrock|board|partition|partitions|stud|studs|framing|frame|tape|taping|mud|finish walls|wall|walls|paint|painting)\b/i],
];
export function tradeOf(text) {
  for (const [key, re] of TRADES) if (re.test(text || '')) return key;
  return null;
}

const norm = s => String(s || '').toLowerCase().replace(/[Β·β€’|_]+/g, ' ').replace(/[-–—]+/g, ' ').replace(/\s+/g, ' ').trim();

/** A floor named in an activity ("Level 4 …", "L4 …", "4th floor …") in the same keys floorOf gives areas. */
function floorInText(text) {
  const s = String(text || '');
  let m = s.match(/\b(?:l|lvl|level|fl|floor)\s*-?\s*0*(\d+)\b/i) || s.match(/\b0*(\d+)(?:st|nd|rd|th)?\s*(?:f|fl|floor)\b/i);
  if (m) return `L${Number(m[1])}`;
  m = s.match(/\b(?:b|basement)\s*-?\s*0*(\d+)\b/i);
  return m ? `B${Number(m[1])}` : null;
}

/**
 * Match activities to areas and trades. An activity names an area when the
 * area's name appears in it (longest name wins, so "L3 Unit 3B" beats "L3");
 * otherwise, if it names a floor, it applies to every area on that floor that
 * has no activity of its own for that trade. Returns per-area windows and
 * per-trade windows (earliest start β†’ latest finish across matching activities)
 * plus what could not be placed.
 */
export function matchSchedule(activities, areaNames) {
  const areas = [...areaNames].sort((a, b) => norm(b).length - norm(a).length);
  const plan = {}, unmatched = [], used = [];
  const widen = (w, a) => ({ start: Math.min(w?.start ?? Infinity, a.start), finish: Math.max(w?.finish ?? -Infinity, a.finish) });
  const direct = new Set();   // "area|trade" set by an activity naming the area itself
  const floorActs = [];
  for (const act of activities) {
    const hay = norm(`${act.area || ''} ${act.name}`);
    const area = areas.find(n => norm(n) && (norm(act.area) === norm(n) || hay.includes(norm(n))));
    const trade = tradeOf(act.trade) || tradeOf(act.name);
    if (area) {
      const p = (plan[area] ||= { window: null, trades: {}, activities: [] });
      if (trade) { p.trades[trade] = widen(p.trades[trade], act); direct.add(`${area}|${trade}`); }
      else p.window = widen(p.window, act);
      p.activities.push(act.name);
      used.push(act);
    } else if (floorInText(act.area || act.name)) floorActs.push({ act, floor: floorInText(act.area || act.name), trade });
    else unmatched.push(act.name);
  }
  for (const { act, floor, trade } of floorActs) {
    const onFloor = areaNames.filter(n => floorOf(n) === floor);
    if (!onFloor.length) { unmatched.push(act.name); continue; }
    for (const area of onFloor) {
      const p = (plan[area] ||= { window: null, trades: {}, activities: [] });
      if (trade) { if (direct.has(`${area}|${trade}`)) continue; p.trades[trade] = widen(p.trades[trade], act); }
      else if (!p.window) p.window = { start: act.start, finish: act.finish };
      p.activities.push(`${act.name} (floor ${floor})`);
    }
    used.push(act);
  }
  // An area whose schedule only names trades gets a window spanning them β€”
  // marked as derived, so applying it never replaces dates the area already has.
  for (const p of Object.values(plan)) {
    if (!p.window && Object.keys(p.trades).length) {
      p.window = Object.values(p.trades).reduce((w, t) => ({ start: Math.min(w.start, t.start), finish: Math.max(w.finish, t.finish) }), { start: Infinity, finish: -Infinity });
      p.derivedWindow = true;
    }
  }
  return { plan, unmatched, matched: used.length };
}

/** Write a match into a Progress store (mutates and returns it). Imported dates replace typed ones for matched areas only. */
export function applySchedule(store, plan) {
  for (const [area, p] of Object.entries(plan)) {
    const rec = (store.areas[area] ||= { captures: [] });
    const hasOwn = Number.isFinite(rec.schedule?.start) && Number.isFinite(rec.schedule?.finish);
    const setWindow = p.window && !(p.derivedWindow && hasOwn);
    rec.schedule = { ...(rec.schedule || {}), ...(setWindow ? { start: p.window.start, finish: p.window.finish } : {}),
      trades: { ...(rec.schedule?.trades || {}), ...p.trades } };
  }
  return store;
}