Export data spreadsheet
This commit is contained in:
fraxle
2026-06-05 13:37:49 +01:00
parent 8540ae6782
commit 6a64e687b3
6 changed files with 446 additions and 8 deletions
-1
View File
@@ -79,7 +79,6 @@ body {
.utci-shell { .utci-shell {
position: relative; position: relative;
z-index: 1; z-index: 1;
max-width: 1400px;
margin: 0 auto; margin: 0 auto;
} }
+59
View File
@@ -1258,6 +1258,65 @@
width: 100%; width: 100%;
} }
/* ── Pro: Export day data ──────────────────────────────────────────────── */
.export-panel {
margin-top: 10px;
background: #fffcf2;
border: 1px solid #c9b08a;
border-radius: 4px;
padding: 12px 14px;
}
.export-day-btn {
display: flex;
align-items: center;
gap: 7px;
width: 100%;
padding: 8px 12px;
background: linear-gradient(135deg, #c8922a 0%, #a06e18 100%);
color: #fffcf2;
border: none;
border-radius: 4px;
font-family: Manrope, sans-serif;
font-size: 13px;
font-weight: 600;
cursor: pointer;
letter-spacing: 0.02em;
transition: opacity 0.15s;
}
.export-day-btn:hover {
opacity: 0.88;
}
.export-day-btn:active {
opacity: 0.75;
}
.export-day-btn--locked {
background: linear-gradient(135deg, #c0b090 0%, #9a8060 100%);
cursor: pointer;
opacity: 0.75;
}
.export-day-btn--locked:hover {
opacity: 0.90;
}
.export-day-btn__icon {
font-size: 15px;
line-height: 1;
}
.export-day-hint {
margin-top: 7px;
font-family: Manrope, sans-serif;
font-size: 10px;
color: #9a7d5a;
text-align: center;
letter-spacing: 0.03em;
}
.insight-panel { .insight-panel {
background: #fffcf2; background: #fffcf2;
border: 1px solid #c9b08a; border: 1px solid #c9b08a;
+31 -2
View File
@@ -35,6 +35,7 @@ import { POLLEN_TYPES, COL_DESCRIPTIONS, UTCI_ENVIRONMENTS } from './config.js';
import { useAppState } from './hooks/useAppState.js'; import { useAppState } from './hooks/useAppState.js';
import { DayTabs } from './components/DayTabs.js'; import { DayTabs } from './components/DayTabs.js';
import { computeWhyFeelsLike, computeGlanceSummary } from './compute.js'; import { computeWhyFeelsLike, computeGlanceSummary } from './compute.js';
import { exportDayXls } from './export.js';
const html = htm.bind(h); const html = htm.bind(h);
@@ -226,6 +227,15 @@ export function UTCIForecast() {
return `${wd} ${day}${ord} ${mon} ${yr}`; return `${wd} ${day}${ord} ${mon} ${yr}`;
})(); })();
// Pro: export the selected day's full hourly data as a styled spreadsheet.
const handleExportDay = () => {
exportDayXls(visible, {
locationName: location?.name ?? 'Unknown',
dateLabel: glanceDate,
skinType,
});
};
// Day's events surfaced in the "Day at a glance" box. Reuses the same // Day's events surfaced in the "Day at a glance" box. Reuses the same
// row shape as glanceSummary items: { icon, label, value, alert }. // row shape as glanceSummary items: { icon, label, value, alert }.
const hhmm = (iso) => (iso ? iso.slice(11, 16) : ''); const hhmm = (iso) => (iso ? iso.slice(11, 16) : '');
@@ -1008,8 +1018,9 @@ export function UTCIForecast() {
</div> </div>
</div> </div>
</div> </div>
${forecast && ((glanceSummary && glanceSummary.length > 0) || eventGlanceItems.length > 0) && html` ${forecast && ((glanceSummary && glanceSummary.length > 0) || eventGlanceItems.length > 0 || visible.length > 0) && html`
<aside class="glance-rail"> <aside class="glance-rail">
${((glanceSummary && glanceSummary.length > 0) || eventGlanceItems.length > 0) && html`
<div class="insight-panel insight-panel--glance"> <div class="insight-panel insight-panel--glance">
<div class="insight-panel-title">At a glance${glanceDate ? ` · ${glanceDate}` : ''}</div> <div class="insight-panel-title">At a glance${glanceDate ? ` · ${glanceDate}` : ''}</div>
${glanceSummary.map(({ icon, label, value, alert }) => html` ${glanceSummary.map(({ icon, label, value, alert }) => html`
@@ -1026,7 +1037,25 @@ export function UTCIForecast() {
<span class="insight-value">${value}</span> <span class="insight-value">${value}</span>
</div> </div>
`)} `)}
</div> </div>`}
${visible.length > 0 && html`
<div class="export-panel">
${isPro
? html`
<button class="export-day-btn" onClick=${handleExportDay} title="Download full hourly data for this day as a spreadsheet">
<span class="export-day-btn__icon">⬇</span>
Export day data
</button>
<div class="export-day-hint">Excel / LibreOffice · 41 columns · hourly</div>`
: html`
<button class="export-day-btn export-day-btn--locked"
onClick=${() => { setProPromptSource('export'); setProPromptDay(0); }}
title="Export day data is part of SunScope Extra">
<span class="export-day-btn__icon">🔒</span>
Export day data
</button>
<div class="export-day-hint">SunScope Extra feature</div>`}
</div>`}
</aside>`} </aside>`}
</div> </div>
+4
View File
@@ -447,6 +447,10 @@ export function DayTabs({
title: 'Office is part of SunScope Extra', title: 'Office is part of SunScope Extra',
detail: 'Extra adds the Office profile with solar gain, managed indoor temperature, humidity, air quality, and rain probability — useful for commute planning and building comfort.', detail: 'Extra adds the Office profile with solar gain, managed indoor temperature, humidity, air quality, and rain probability — useful for commute planning and building comfort.',
}, },
'export': {
title: 'Export day data is part of SunScope Extra',
detail: 'Extra lets you download a full hourly spreadsheet for any day — all 41 columns including felt temperature, UV, soil, wind, and air quality, styled and ready to use in Excel or LibreOffice.',
},
}; };
const promptCopy = extraPromptCopy[proPromptSource] || { const promptCopy = extraPromptCopy[proPromptSource] || {
title: `${dayName}'s forecast is part of SunScope Extra`, title: `${dayName}'s forecast is part of SunScope Extra`,
+337
View File
@@ -0,0 +1,337 @@
// ────────────────────────────────────────────────────────────────────────────
// export.js — Pro: Export selected day data as a genuine styled .xlsx file.
//
// Uses xlsx-js-style (SheetJS fork) for real XLSX output with cell colours.
// ────────────────────────────────────────────────────────────────────────────
import { utciCategory, UTCI_BANDS, sunburnMinutes, burnLabel } from './utils.js';
// ── Palette (6-char hex, no #) ────────────────────────────────────────────
const P = {
brass: 'C8922A', brassDeep: 'A06E18',
honey: 'F0E4C4', cream: 'FFFCF2', warmCream: 'FDF8EE',
espresso: '1E1208', warmDark: '4A3420', muted: 'B09870',
border: 'C9B08A', line: 'EDE4CC', white: 'FFFFFF',
alert: 'C44A3A', warn: 'C8601A', blue: '3A5A9B',
};
// Group header row background colours
const GROUP_BG = {
timeSun: '7A5C2A', felt: '9B3A3A', air: '3A6B9B',
uvSolar: '9A6B1A', surf: '4A7A4A', wind: '3A5A7A',
sky: '3A6A6A', aq: '6A4A7A',
};
// ── Style builder ─────────────────────────────────────────────────────────
const thinBorder = (hex = P.line) => ({ style: 'thin', color: { rgb: hex } });
const allBorders = (hex = P.line) => ({
top: thinBorder(hex), bottom: thinBorder(hex),
left: thinBorder(hex), right: thinBorder(hex),
});
function S({ bg = P.cream, fg = P.warmDark, bold = false, align = 'right', sz = 10, numFmt, wrap = false, borderHex = P.line } = {}) {
return {
fill: { patternType: 'solid', fgColor: { rgb: bg } },
font: { sz, bold, color: { rgb: fg }, name: 'Calibri' },
alignment: { horizontal: align, vertical: 'center', wrapText: !!wrap },
border: allBorders(borderHex),
...(numFmt ? { numFmt } : {}),
};
}
// ── Column definitions ────────────────────────────────────────────────────
// [label, unit, align, wch (char width), numFmt]
const COLS = [
// Time & Sun (2)
['Time', '', 'center', 7, null ],
['Solar Elev', '°', 'right', 9, '0.0'],
// Felt Temperature (5)
['SunSoak', '°C', 'right', 9, '0.0'],
['Condition', '', 'left', 14, null ],
['UTCI', '°C', 'right', 7, '0.0'],
['Δ UTCIAir', '°C', 'right', 9, '0.0'],
['Tmrt', '°C', 'right', 7, '0.0'],
// Air (3)
['Air Temp', '°C', 'right', 8, '0.0'],
['Dew Point', '°C', 'right', 9, '0.0'],
['Humidity', '%', 'right', 8, '0' ],
// UV & Solar (7)
['UV Index', '', 'right', 8, '0.0'],
['UV-A', '', 'right', 7, '0.0'],
['UV-B', '', 'right', 7, '0.00'],
['Burn Time', '', 'right', 9, null ],
['Direct Rad', 'W/m²', 'right', 10, '0' ],
['Diffuse', 'W/m²', 'right', 9, '0' ],
['Global Rad', 'W/m²', 'right', 10, '0' ],
// Surfaces (6)
['Vehicle', '°C', 'right', 8, '0.0'],
['Indoor', '°C', 'right', 8, '0.0'],
['Concrete', '°C', 'right', 9, '0.0'],
['Soil Surf', '°C', 'right', 9, '0.0'],
['Soil 6cm', '°C', 'right', 8, '0.0'],
['Soil Moist', '%', 'right', 9, '0' ],
// Wind (3)
['Wind', 'm/s', 'right', 7, '0.0'],
['Gusts', 'm/s', 'right', 7, '0.0'],
['Direction', '', 'center', 9, null ],
// Sky & Precip (8)
['Cloud', '%', 'right', 7, '0' ],
['Cloud Low', '%', 'right', 9, '0' ],
['Cloud Mid', '%', 'right', 9, '0' ],
['Cloud High', '%', 'right', 10, '0' ],
['Precip', 'mm/h', 'right', 8, '0.00'],
['Rain Prob', '%', 'right', 9, '0' ],
['Snowfall', 'cm/h', 'right', 9, '0.00'],
['Visibility', 'km', 'right', 10, '0.0'],
// Air Quality (7)
['AQI', '', 'right', 7, '0' ],
['Grass Pollen','g/m³', 'right', 12, '0' ],
['Birch Pollen','g/m³', 'right', 12, '0' ],
['Alder Pollen','g/m³', 'right', 12, '0' ],
['Mugwort', 'g/m³', 'right', 10, '0' ],
['Olive Pollen','g/m³', 'right', 12, '0' ],
['Ragweed', 'g/m³', 'right', 10, '0' ],
];
const TOTAL_COLS = COLS.length; // 41
// Groups: [label, span, bgKey]
const GROUPS = [
['Time & Sun', 2, 'timeSun'],
['Felt Temperature', 5, 'felt' ],
['Air', 3, 'air' ],
['UV & Solar', 7, 'uvSolar'],
['Surfaces', 6, 'surf' ],
['Wind', 3, 'wind' ],
['Sky & Precipitation', 8, 'sky' ],
['Air Quality', 7, 'aq' ],
];
// ── Worksheet cell helpers (X = the XLSXStyle instance) ──────────────────
function addr(X, r, c) {
return X.utils.encode_cell({ r, c });
}
function setStr(X, ws, r, c, value, styleOpts) {
ws[addr(X, r, c)] = { v: value ?? '', t: 's', s: S(styleOpts) };
}
function setNum(X, ws, r, c, value, styleOpts) {
if (value == null) {
ws[addr(X, r, c)] = { v: '', t: 's', s: S(styleOpts) };
} else {
ws[addr(X, r, c)] = { v: Number(value), t: 'n', s: S(styleOpts) };
}
}
// ── UTCI colour helpers ───────────────────────────────────────────────────
function utciBgHex(val) {
if (val == null) return P.cream;
return (utciCategory(val)?.bg ?? '#' + P.cream).replace('#', '');
}
function utciFgHex(val) {
if (val == null) return P.warmDark;
return (utciCategory(val)?.fg ?? '#' + P.warmDark).replace('#', '');
}
// Blend a hex colour with white (ratio 01: 0 = full white, 1 = full colour)
function blendWhite(hex, ratio = 0.4) {
const h = hex.replace('#', '');
const n = parseInt(h, 16);
const blend = (c) => Math.round(255 + (c - 255) * ratio);
const r = blend((n >> 16) & 255);
const g = blend((n >> 8) & 255);
const b = blend( n & 255);
return [r, g, b].map(x => x.toString(16).padStart(2, '0')).join('');
}
function tempBg(val) {
return blendWhite(utciBgHex(val), 0.42);
}
// ── Build Forecast worksheet ──────────────────────────────────────────────
function buildForecastSheet(X, rows, locationName, dateLabel, skinType) {
const ws = {};
const merges = [];
// ── Row 0: Title ─────────────────────────────────────────────────────
const titleStyle = S({ bg: P.brass, fg: P.white, bold: true, align: 'center', sz: 12, borderHex: P.brassDeep });
setStr(X, ws, 0, 0, `SunScope · ${locationName} · ${dateLabel}`, { bg: P.brass, fg: P.white, bold: true, align: 'center', sz: 12, borderHex: P.brassDeep });
for (let c = 1; c < TOTAL_COLS; c++) {
ws[addr(X, 0, c)] = { v: '', t: 's', s: titleStyle };
}
merges.push({ s: { r: 0, c: 0 }, e: { r: 0, c: TOTAL_COLS - 1 } });
// ── Row 1: Group headers ──────────────────────────────────────────────
let colCursor = 0;
for (const [label, span, bgKey] of GROUPS) {
const bg = GROUP_BG[bgKey];
const groupStyle = S({ bg, fg: P.white, bold: true, align: 'center', sz: 10, borderHex: bg });
setStr(X, ws, 1, colCursor, label, { bg, fg: P.white, bold: true, align: 'center', sz: 10, borderHex: bg });
for (let i = 1; i < span; i++) {
ws[addr(X, 1, colCursor + i)] = { v: '', t: 's', s: groupStyle };
}
if (span > 1) merges.push({ s: { r: 1, c: colCursor }, e: { r: 1, c: colCursor + span - 1 } });
colCursor += span;
}
// ── Row 2: Column headers ─────────────────────────────────────────────
for (let c = 0; c < TOTAL_COLS; c++) {
const [label, unit] = COLS[c];
const text = unit ? `${label} (${unit})` : label;
setStr(X, ws, 2, c, text, { bg: P.honey, fg: P.espresso, bold: true, align: 'center', sz: 10, borderHex: P.border });
}
// ── Rows 3+: Data ─────────────────────────────────────────────────────
rows.forEach((r, i) => {
const rowIdx = 3 + i;
const isNight = r.elev < -0.833;
const rowBg = isNight ? 'EAE3D5' : (i % 2 === 1 ? 'FDF8EE' : P.cream);
const timeFg = isNight ? P.muted : P.warmDark;
const def = (bg = rowBg) => ({ bg, fg: P.warmDark, sz: 10, borderHex: P.line });
// Time & Sun
setStr(X, ws, rowIdx, 0, r.iso.slice(11, 16), { ...def(), fg: timeFg, align: 'center', bold: true });
setNum(X, ws, rowIdx, 1, r.elev, { ...def(), align: 'right', numFmt: '0.0' });
// Felt Temperature — SunSoak with UTCI band colour
const sBg = utciBgHex(r.utciAdj);
const sFg = utciFgHex(r.utciAdj);
const cond = r.utciAdj != null ? (utciCategory(r.utciAdj)?.label ?? '') : '';
setNum(X, ws, rowIdx, 2, r.utciAdj, { bg: sBg, fg: sFg, bold: true, align: 'right', sz: 10, numFmt: '0.0', borderHex: P.line });
setStr(X, ws, rowIdx, 3, cond, { bg: blendWhite(sBg, 0.55), fg: P.warmDark, align: 'left', sz: 10, borderHex: P.line });
setNum(X, ws, rowIdx, 4, r.utci, { bg: tempBg(r.utci), fg: P.espresso, sz: 10, numFmt: '0.0', borderHex: P.line });
const delta = (r.utci != null && r.Ta != null) ? r.utci - r.Ta : null;
setNum(X, ws, rowIdx, 5, delta, { bg: rowBg, fg: delta != null && delta > 3 ? P.warn : delta != null && delta < -3 ? '3F73C4' : P.warmDark, sz: 10, numFmt: '0.0', borderHex: P.line });
setNum(X, ws, rowIdx, 6, r.Tmrt, { bg: tempBg(r.Tmrt), fg: P.espresso, sz: 10, numFmt: '0.0', borderHex: P.line });
// Air
setNum(X, ws, rowIdx, 7, r.Ta, { bg: tempBg(r.Ta), fg: P.espresso, sz: 10, numFmt: '0.0', borderHex: P.line });
setNum(X, ws, rowIdx, 8, r.dew, { ...def(), numFmt: '0.0' });
setNum(X, ws, rowIdx, 9, r.RH, { ...def(), numFmt: '0' });
// UV & Solar
const uvFg = r.uv >= 8 ? P.alert : r.uv >= 3 ? P.warn : P.warmDark;
setNum(X, ws, rowIdx, 10, r.uv, { ...def(), fg: uvFg, numFmt: '0.0' });
setNum(X, ws, rowIdx, 11, r.uvA, { ...def(), numFmt: '0.0' });
setNum(X, ws, rowIdx, 12, r.uvB, { ...def(), numFmt: '0.00' });
const burnMins = sunburnMinutes(r.uv, skinType);
const burnStr = r.uv > 0 ? burnLabel(burnMins) : '—';
const burnFg = r.uv > 0 ? (burnMins < 30 ? P.alert : P.warn) : P.muted;
setStr(X, ws, rowIdx, 13, burnStr, { ...def(), fg: burnFg, align: 'right' });
setNum(X, ws, rowIdx, 14, r.dir, { ...def(), numFmt: '0' });
setNum(X, ws, rowIdx, 15, r.dif, { ...def(), numFmt: '0' });
setNum(X, ws, rowIdx, 16, r.glob, { ...def(), numFmt: '0' });
// Surfaces
setNum(X, ws, rowIdx, 17, r.vehicleT, { bg: tempBg(r.vehicleT), fg: P.espresso, sz: 10, numFmt: '0.0', borderHex: P.line });
setNum(X, ws, rowIdx, 18, r.indoorT, { bg: tempBg(r.indoorT), fg: P.espresso, sz: 10, numFmt: '0.0', borderHex: P.line });
setNum(X, ws, rowIdx, 19, r.concreteT, { bg: tempBg(r.concreteT), fg: P.espresso, sz: 10, numFmt: '0.0', borderHex: P.line });
setNum(X, ws, rowIdx, 20, r.soilT0, { ...def(), numFmt: '0.0' });
setNum(X, ws, rowIdx, 21, r.soilT6, { ...def(), numFmt: '0.0' });
setNum(X, ws, rowIdx, 22, r.soilM != null ? Math.round(r.soilM * 100) : null, { ...def(), numFmt: '0' });
// Wind
setNum(X, ws, rowIdx, 23, r.va, { ...def(), numFmt: '0.0' });
setNum(X, ws, rowIdx, 24, r.gust, { ...def(), numFmt: '0.0' });
setStr(X, ws, rowIdx, 25, r.compass?.label ?? (r.wd != null ? `${Math.round(r.wd)}°` : '—'), { ...def(), align: 'center' });
// Sky & Precip
setNum(X, ws, rowIdx, 26, r.cc, { ...def(), numFmt: '0' });
setNum(X, ws, rowIdx, 27, r.ccLow, { ...def(), numFmt: '0' });
setNum(X, ws, rowIdx, 28, r.ccMid, { ...def(), numFmt: '0' });
setNum(X, ws, rowIdx, 29, r.ccHigh, { ...def(), numFmt: '0' });
setNum(X, ws, rowIdx, 30, r.precip > 0 ? r.precip : null, { ...def(), fg: r.precip > 2 ? P.blue : P.warmDark, numFmt: '0.00' });
setNum(X, ws, rowIdx, 31, r.precipProb, { ...def(), fg: r.precipProb >= 70 ? P.blue : P.warmDark, numFmt: '0' });
setNum(X, ws, rowIdx, 32, r.snow > 0 ? r.snow : null, { ...def(), numFmt: '0.00' });
setNum(X, ws, rowIdx, 33, r.visKm, { ...def(), numFmt: '0.0' });
// Air Quality
setNum(X, ws, rowIdx, 34, r.aqi, { ...def(), fg: r.aqi >= 50 ? P.alert : P.warmDark, numFmt: '0' });
setNum(X, ws, rowIdx, 35, r.grassPollen, { ...def(), numFmt: '0' });
setNum(X, ws, rowIdx, 36, r.birchPollen, { ...def(), numFmt: '0' });
setNum(X, ws, rowIdx, 37, r.alderPollen, { ...def(), numFmt: '0' });
setNum(X, ws, rowIdx, 38, r.mugwortPollen, { ...def(), numFmt: '0' });
setNum(X, ws, rowIdx, 39, r.olivePollen, { ...def(), numFmt: '0' });
setNum(X, ws, rowIdx, 40, r.ragweedPollen, { ...def(), numFmt: '0' });
});
// ── Sheet metadata ────────────────────────────────────────────────────
ws['!ref'] = X.utils.encode_range({ s: { r: 0, c: 0 }, e: { r: 3 + rows.length - 1, c: TOTAL_COLS - 1 } });
ws['!cols'] = COLS.map(([,,,wch]) => ({ wch }));
ws['!merges'] = merges;
ws['!freeze'] = { xSplit: 1, ySplit: 3 };
return ws;
}
// ── Build Legend worksheet ────────────────────────────────────────────────
function buildLegendSheet(X) {
const ws = {};
const merges = [];
setStr(X, ws, 0, 0, 'SunSoak / UTCI Condition Scale', { bg: P.brass, fg: P.white, bold: true, align: 'center', sz: 11, borderHex: P.brassDeep });
ws[addr(X, 0, 1)] = { v: '', t: 's', s: S({ bg: P.brass, fg: P.white, borderHex: P.brassDeep }) };
merges.push({ s: { r: 0, c: 0 }, e: { r: 0, c: 1 } });
setStr(X, ws, 1, 0, 'Condition', { bg: P.honey, fg: P.espresso, bold: true, align: 'center', sz: 10, borderHex: P.border });
setStr(X, ws, 1, 1, 'SunSoak / UTCI range', { bg: P.honey, fg: P.espresso, bold: true, align: 'center', sz: 10, borderHex: P.border });
let prev = -Infinity;
UTCI_BANDS.forEach((band, i) => {
const bg = band.bg.replace('#', '');
const fg = band.fg.replace('#', '');
const rangeStr = band.max != null
? `${prev === -Infinity ? 'below' : `${prev}°C to`} ${band.max}°C`
: `${prev}°C and above`;
setStr(X, ws, 2 + i, 0, band.label, { bg, fg, bold: true, align: 'center', sz: 10, borderHex: P.border });
setStr(X, ws, 2 + i, 1, rangeStr, { bg: P.cream, fg: P.warmDark, align: 'left', sz: 10, borderHex: P.line });
if (band.max != null) prev = band.max;
});
ws['!ref'] = X.utils.encode_range({ s: { r: 0, c: 0 }, e: { r: 2 + UTCI_BANDS.length - 1, c: 1 } });
ws['!cols'] = [{ wch: 18 }, { wch: 22 }];
ws['!merges'] = merges;
return ws;
}
// ── Main export function ──────────────────────────────────────────────────
export async function exportDayXls(rows, { locationName = '', dateLabel = '', skinType = 'type2' } = {}) {
if (!rows || rows.length === 0) return;
const xlsxMod = await import('xlsx-js-style').catch(() => null);
if (!xlsxMod) {
alert('Export requires the built version of SunScope. Run npm run build and serve from dist/.');
return;
}
const X = xlsxMod.default ?? xlsxMod;
const wb = X.utils.book_new();
X.utils.book_append_sheet(wb, buildForecastSheet(X, rows, locationName, dateLabel, skinType), 'Forecast');
X.utils.book_append_sheet(wb, buildLegendSheet(X), 'Legend');
const buf = X.write(wb, { bookType: 'xlsx', type: 'array' });
const blob = new Blob([buf], { type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' });
const url = URL.createObjectURL(blob);
const slug = (locationName || 'location').toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/(^-|-$)/g, '');
const dateSlug = (dateLabel || '').replace(/\s+/g, '-').replace(/[^a-z0-9-]/gi, '').toLowerCase();
const a = document.createElement('a');
a.href = url;
a.download = `sunscope-${slug}-${dateSlug}.xlsx`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
}
+15 -5
View File
@@ -44,6 +44,13 @@ function cleanDist() {
for (const f of targets) { for (const f of targets) {
try { fs.unlinkSync(f); } catch (_) { /* ok if missing */ } try { fs.unlinkSync(f); } catch (_) { /* ok if missing */ }
} }
// Clean up any previously-generated ESM chunks
const chunksDir = path.join(ASSETS, 'chunks');
if (fs.existsSync(chunksDir)) {
for (const f of fs.readdirSync(chunksDir)) {
try { fs.unlinkSync(path.join(chunksDir, f)); } catch (_) {}
}
}
} }
// Recursively resolve CSS @import statements into one flat string // Recursively resolve CSS @import statements into one flat string
@@ -80,7 +87,8 @@ function rewriteHTML(html, version) {
'$1about.min.css?v=' + version + '$2' '$1about.min.css?v=' + version + '$2'
); );
const css = ' <link rel="stylesheet" href="./assets/bundle.min.css?v=' + version + '">'; const css = ' <link rel="stylesheet" href="./assets/bundle.min.css?v=' + version + '">';
const js = ' <script defer src="./assets/bundle.min.js?v=' + version + '"></script>'; // ESM module — browser loads chunks automatically on demand (e.g. xlsx-js-style)
const js = ' <script type="module" src="./assets/bundle.min.js?v=' + version + '"></script>';
html = html.replace('</head>', css + '\n' + js + '\n</head>'); html = html.replace('</head>', css + '\n' + js + '\n</head>');
return html; return html;
} }
@@ -110,15 +118,17 @@ async function build() {
fs.mkdirSync(ASSETS, { recursive: true }); fs.mkdirSync(ASSETS, { recursive: true });
console.log(' Output dir ready: dist/'); console.log(' Output dir ready: dist/');
// 2. Bundle + minify JavaScript // 2. Bundle + minify JavaScript (ESM with code splitting for lazy chunks)
console.log(' Bundling JS...'); console.log(' Bundling JS...');
await esbuild.build({ await esbuild.build({
entryPoints: [path.join(ROOT, 'assets/js/main.js')], entryPoints: [path.join(ROOT, 'assets/js/main.js')],
bundle: true, bundle: true,
splitting: true,
minify: true, minify: true,
format: 'iife', format: 'esm',
globalName: 'SunScope', entryNames: 'bundle.min',
outfile: path.join(ASSETS, 'bundle.min.js'), chunkNames: 'chunks/[name]-[hash]',
outdir: ASSETS,
logLevel: 'warning', logLevel: 'warning',
plugins: [stripJSXCommentsPlugin], plugins: [stripJSXCommentsPlugin],
}); });