data-centre-nameplates / schedule-import.js
constructelligence's picture
Upload folder using huggingface_hub
5e2d1eb verified
Raw History Blame Contribute Delete
10.6 kB
// 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;
}