Files
forge/scripts/dump-to-archives.mjs
T
Dmytro Tkachenko 28d817ebe9 Init
2026-08-29 11:59:28 +03:00

159 lines
8.0 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Convert a Let it Snow chrome.storage.local dump into FORGE's seed archives +
// a config bundle. Run: node scripts/dump-to-archives.mjs [path/to/storage-dump.json]
// then reload the DB: npm run reseed
//
// Outputs (server/data/):
// active_archive.json — { tickets:[sn_tickets], jira:{num:info} } (operational, live board)
// closed_archive.json — { tickets:{num:merged} } (analytics master)
// config.json — thresholds, FX, size thresholds, SLA norms, palettes
//
// The closed archive MERGES two dump sources by RITM number:
// - analytics_data.ticketsMeta (954) → analytics fields: openedBy, openedDate,
// closedDate, toDoAt, inUatAt, jiraKey, year, businessUnit, SLA minutes, cost
// - analytics_meta_cache (965) → operational fields: lastActivityAt/By,
// stateChangedAt/By, requestedFor, openedAt (datetime), _updated
// ticketsMeta wins for analytics; meta_cache fills operational gaps; union of both.
import { readFileSync, writeFileSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import path from 'node:path';
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
const dumpPath = process.argv[2] || path.join(root, 'storage-dump.json');
const dataDir = path.join(root, 'server', 'data');
const dump = JSON.parse(readFileSync(dumpPath, 'utf-8'));
const cs = dump.chromeStorageLocal ?? {};
const wl = dump.windowLocalStorage ?? {};
const sn = cs.sn_tickets ?? [];
const metaCache = cs.analytics_meta_cache ?? {};
const ticketsMeta = cs.analytics_data?.ticketsMeta ?? [];
const jiraRaw = cs.jira_status_map ?? {};
// --- active (unchanged) ----------------------------------------------------
const jira = {};
for (const [num, j] of Object.entries(jiraRaw)) {
if (j && typeof j === 'object') {
jira[num] = { status: j.status ?? null, statusChangedAt: j.statusChangedAt ?? null,
key: j.key ?? null, url: j.url ?? null, assignee: j.assignee ?? null };
}
}
// Enrich jira with per-status durations (ms) from the board-state snapshot, keyed
// to RITM via customfield_26001. Powers chart #17 (Jira status durations). The
// board's column order is the workflow order used to render the chart.
const boardState = cs.jira_board_state?.boardState ?? {};
const jiraColumns = (boardState.columns ?? []).map(c => c?.name).filter(Boolean);
for (const issues of Object.values(boardState.colIssues ?? {})) {
for (const iss of (issues ?? [])) {
const ritm = iss?.fields?.customfield_26001;
if (!ritm || typeof ritm !== 'string') continue;
const sd = iss._statusDurations;
if (!sd || typeof sd !== 'object') continue;
const e = jira[ritm] ?? (jira[ritm] = { status: null, statusChangedAt: null, key: null, url: null, assignee: null });
e.statusDurations = sd; // { statusName: milliseconds }
if (!e.key) e.key = iss.key ?? null;
if (!e.status && iss.fields?.status?.name) e.status = iss.fields.status.name;
}
}
// --- merged closed ---------------------------------------------------------
const byNumTM = {};
for (const t of ticketsMeta) if (t?.number) byNumTM[t.number] = t;
const closed = {};
const nums = new Set([...Object.keys(metaCache), ...Object.keys(byNumTM)]);
for (const num of nums) {
const mc = metaCache[num] ?? {};
const tm = byNumTM[num] ?? {};
closed[num] = {
// analytics (ticketsMeta wins, fall back to meta_cache)
state: tm.state ?? mc.state ?? '',
shortDesc: tm.shortDesc ?? mc.shortDesc ?? '',
assignedTo: tm.assignedTo ?? mc.assignedTo ?? null,
brand: tm.brand ?? mc.brand ?? null,
market: tm.market ?? mc.market ?? null,
businessUnit: tm.businessUnit ?? mc.businessUnit ?? null,
finalCost: tm.finalCost ?? mc.finalCost ?? null,
currencyCode: tm.currencyCode ?? mc.currencyCode ?? null,
ttfrMinutes: tm.ttfrMinutes ?? mc.ttfrMinutes ?? null,
clientRespMinutes: tm.clientRespMinutes ?? mc.clientRespMinutes ?? null,
firstReplyAt: tm.firstReplyAt ?? mc.firstReplyAt ?? null,
firstAssignedDate: tm.firstAssignedDate ?? mc.firstAssignedDate ?? null,
fulfillmentDate: tm.fulfillmentDate ?? mc.fulfillmentDate ?? null,
openedBy: tm.openedBy ?? null,
openedDate: tm.openedDate ?? null,
closedDate: tm.closedDate ?? null,
toDoAt: tm.toDoAt ?? null,
inUatAt: tm.inUatAt ?? null,
jiraKey: tm.jiraKey ?? null,
year: tm.year ?? null,
// operational (meta_cache)
requestedFor: mc.requestedFor ?? null,
openedAt: mc.openedAt ?? tm.openedDate ?? null,
stateChangedAt: mc.stateChangedAt ?? null,
stateChangedBy: mc.stateChangedBy ?? null,
lastActivityAt: mc.lastActivityAt ?? null,
lastActivityBy: mc.lastActivityBy ?? null,
_updated: mc._updated ?? null,
};
}
// --- config bundle ---------------------------------------------------------
const parseWl = (k, fallback) => { try { return JSON.parse(wl[k]); } catch { return fallback; } };
const config = {
insightsThresholds: cs.insights_thresholds ?? {},
fxRates: cs.fx_rates_cache?.rates ?? { GBP: 1, EUR: 1.2, MXN: 20 },
sizeThresholds: parseWl('otd_cost_thresh', { XS: 120, S: 300, M: 600, L: 1200, XL: 3000, XXL: 9000 }),
norms: {
otd: parseWl('otd_day_norms', { XS: 7, S: 14, M: 30, L: 90, XL: 90, XXL: 90 }),
avgdays: parseWl('avgdays_day_norms', { XS: 7, S: 14, M: 30, L: 90, XL: 90, XXL: 90 }),
asla: parseWl('asla_day_norms', { XS: 1, S: 1, M: 1, L: 1, XL: 1, XXL: 1 }),
psla: parseWl('psla_day_norms', { XS: 3, S: 6, M: 9, L: 15, XL: 30, XXL: 30 }),
ttfr: parseWl('lisr_ttfr_norms', { XS: 24, S: 24, M: 24, L: 24, XL: 24, XXL: 24 }),
cresp: parseWl('lisr_cresp_norms', { XS: 24, S: 24, M: 24, L: 24, XL: 24, XXL: 24 }),
},
brandColors: cs.brand_colors ?? {},
snowColors: cs.snow_colors ?? {},
statesOrder: cs.sn_states_order ?? [],
displayCurrency: cs.display_currency ?? 'GBP',
colleagues: String(cs.colleague_names ?? '').split(/[\n,]/).map(s => s.trim()).filter(Boolean),
latamAssignees: String(cs.latam_assignees ?? '').split(/[\n,]/).map(s => s.trim()).filter(Boolean),
jiraColumns,
};
// --- finance (per-ticket cost / PO / invoiced, from the SharePoint xlsx) ------
// Column names are data-driven; map the known ones case-insensitively.
const financeRaw = cs.finance_xlsx_data?.rows ?? {};
const cell = (row, ...names) => {
for (const k of Object.keys(row)) if (names.some(n => k.toLowerCase() === n.toLowerCase())) return row[k];
return null;
};
const finance = { rows: {} };
for (const [num, row] of Object.entries(financeRaw)) {
if (!row || typeof row !== 'object') continue;
const po = cell(row, 'PO');
finance.rows[num] = {
cost: cell(row, 'Cost'),
currency: cell(row, 'Currency'),
po: po == null ? '' : String(po).trim(), // '' = in finance but no PO; key for waiting-PO
invoiced: cell(row, 'Invoiced'),
milestone: cell(row, 'Milestone'),
state: cell(row, 'State'),
pm: cell(row, 'PM'),
};
}
const generatedAt = new Date().toISOString();
writeFileSync(path.join(dataDir, 'finance.json'), JSON.stringify({ schema: 1, generatedAt, count: Object.keys(finance.rows).length, rows: finance.rows }));
writeFileSync(path.join(dataDir, 'active_archive.json'),
JSON.stringify({ schema: 2, generatedAt, count: sn.length, tickets: sn, jira }));
writeFileSync(path.join(dataDir, 'closed_archive.json'),
JSON.stringify({ schema: 2, generatedAt, count: Object.keys(closed).length, tickets: closed }));
writeFileSync(path.join(dataDir, 'config.json'), JSON.stringify(config, null, 2));
const overlap = Object.keys(closed).filter(n => sn.some(t => t.number === n)).length;
console.log(`active=${sn.length} closed(merged meta_cacheticketsMeta)=${Object.keys(closed).length} jira=${Object.keys(jira).length} overlap=${overlap} (active wins)`);
console.log(`ticketsMeta analytics rows folded in: ${Object.keys(byNumTM).length}`);
console.log('Wrote server/data/{active,closed}_archive.json + config.json — now run: npm run reseed');