diff --git a/assets/css/base.css b/assets/css/base.css index 88f2025..948fda6 100644 --- a/assets/css/base.css +++ b/assets/css/base.css @@ -79,7 +79,6 @@ body { .utci-shell { position: relative; z-index: 1; - max-width: 1400px; margin: 0 auto; } diff --git a/assets/css/ui.css b/assets/css/ui.css index 2f0c828..0f5f80f 100644 --- a/assets/css/ui.css +++ b/assets/css/ui.css @@ -1258,6 +1258,65 @@ 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 { background: #fffcf2; border: 1px solid #c9b08a; diff --git a/assets/js/app.js b/assets/js/app.js index b4f6171..913427e 100644 --- a/assets/js/app.js +++ b/assets/js/app.js @@ -35,6 +35,7 @@ import { POLLEN_TYPES, COL_DESCRIPTIONS, UTCI_ENVIRONMENTS } from './config.js'; import { useAppState } from './hooks/useAppState.js'; import { DayTabs } from './components/DayTabs.js'; import { computeWhyFeelsLike, computeGlanceSummary } from './compute.js'; +import { exportDayXls } from './export.js'; const html = htm.bind(h); @@ -226,6 +227,15 @@ export function UTCIForecast() { 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 // row shape as glanceSummary items: { icon, label, value, alert }. const hhmm = (iso) => (iso ? iso.slice(11, 16) : ''); @@ -1008,8 +1018,9 @@ export function UTCIForecast() { - ${forecast && ((glanceSummary && glanceSummary.length > 0) || eventGlanceItems.length > 0) && html` + ${forecast && ((glanceSummary && glanceSummary.length > 0) || eventGlanceItems.length > 0 || visible.length > 0) && html` `} diff --git a/assets/js/components/DayTabs.js b/assets/js/components/DayTabs.js index bfdc103..292d7be 100644 --- a/assets/js/components/DayTabs.js +++ b/assets/js/components/DayTabs.js @@ -447,6 +447,10 @@ export function DayTabs({ 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.', }, + '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] || { title: `${dayName}'s forecast is part of SunScope Extra`, diff --git a/assets/js/export.js b/assets/js/export.js new file mode 100644 index 0000000..7ab3ae4 --- /dev/null +++ b/assets/js/export.js @@ -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'], + ['Δ UTCI−Air', '°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 0–1: 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); +} diff --git a/build.js b/build.js index c480c47..441bd5b 100644 --- a/build.js +++ b/build.js @@ -44,6 +44,13 @@ function cleanDist() { for (const f of targets) { 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 @@ -80,7 +87,8 @@ function rewriteHTML(html, version) { '$1about.min.css?v=' + version + '$2' ); const css = ' '; - const js = ' '; + // ESM module — browser loads chunks automatically on demand (e.g. xlsx-js-style) + const js = ' '; html = html.replace('', css + '\n' + js + '\n'); return html; } @@ -110,15 +118,17 @@ async function build() { fs.mkdirSync(ASSETS, { recursive: true }); 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...'); await esbuild.build({ entryPoints: [path.join(ROOT, 'assets/js/main.js')], bundle: true, + splitting: true, minify: true, - format: 'iife', - globalName: 'SunScope', - outfile: path.join(ASSETS, 'bundle.min.js'), + format: 'esm', + entryNames: 'bundle.min', + chunkNames: 'chunks/[name]-[hash]', + outdir: ASSETS, logLevel: 'warning', plugins: [stripJSXCommentsPlugin], });