/* global React */ /* ============================================================ Helpers — formatters, hand-rolled SVG charts, primitives Exposes globally; loaded before pages. ============================================================ */ const { useState, useEffect, useRef, useMemo, useLayoutEffect } = React; // ------- Formatters ------- const fmtEurM = v => { if (v == null || isNaN(v)) return '—'; const a = Math.abs(v); if (a >= 1e9) return '€' + (v/1e9).toFixed(2) + 'B'; if (a >= 1e6) return '€' + (v/1e6).toFixed(1) + 'M'; if (a >= 1e3) return '€' + (v/1e3).toFixed(0) + 'K'; return '€' + Math.round(v); }; const fmtN = v => v == null ? '—' : Number(v).toLocaleString('en-US'); const fmtK = v => { if (v == null) return '—'; const a = Math.abs(v); if (a >= 1e6) return (v/1e6).toFixed(2) + 'M'; if (a >= 1e3) return (v/1e3).toFixed(1) + 'K'; return Number(v).toFixed(0); }; const fmtPct = (v, d=1) => v == null ? '—' : v.toFixed(d) + '%'; const fmtSignPct = (v, d=1) => v == null ? '—' : (v > 0 ? '+' : '') + v.toFixed(d) + '%'; const fmtEur = v => v == null ? '—' : '€' + Number(v).toLocaleString('en-US', {maximumFractionDigits: 0}); const MONTHS = ['', 'Oca','Şub','Mar','Nis','May','Haz','Tem','Ağu','Eyl','Eki','Kas','Ara']; const MONTHS_FULL = ['', 'Ocak','Şubat','Mart','Nisan','Mayıs','Haziran','Temmuz','Ağustos','Eylül','Ekim','Kasım','Aralık']; // ------- Delta / change indicator ------- const Delta = ({ value, suffix = '%', digits = 1, inverse = false }) => { if (value == null || isNaN(value)) return null; const pos = inverse ? value < 0 : value > 0; const neg = inverse ? value > 0 : value < 0; const cls = pos ? 'text-pos' : neg ? 'text-neg' : 'text-muted'; const arrow = value > 0 ? '▲' : value < 0 ? '▼' : '·'; return React.createElement('span', { className: `mono ${cls}`, style: { fontSize: 10.5, fontWeight: 500, letterSpacing: '0.02em' } }, `${arrow} ${value > 0 ? '+' : ''}${value.toFixed(digits)}${suffix}` ); }; // ------- Hand-rolled chart utilities ------- function useSize(ref) { const [size, setSize] = useState({ w: 0, h: 0 }); useLayoutEffect(() => { if (!ref.current) return; const ro = new ResizeObserver(entries => { for (const e of entries) setSize({ w: e.contentRect.width, h: e.contentRect.height }); }); ro.observe(ref.current); setSize({ w: ref.current.clientWidth, h: ref.current.clientHeight }); return () => ro.disconnect(); }, [ref]); return size; } // Sparkline — minimal, in-text size const Sparkline = ({ data, width = 80, height = 22, color, fill = true }) => { if (!data || data.length < 2) return null; const min = Math.min(...data), max = Math.max(...data); const range = max - min || 1; const dx = width / (data.length - 1); const pts = data.map((v, i) => `${i*dx},${height - ((v - min)/range) * height}`).join(' '); const c = color || 'var(--ink)'; return React.createElement('svg', { width, height, className: 'spark' }, fill && React.createElement('polygon', { points: `0,${height} ${pts} ${width},${height}`, fill: c, fillOpacity: 0.12 }), React.createElement('polyline', { points: pts, fill: 'none', stroke: c, strokeWidth: 1.4 }) ); }; // Bar chart — hand-built // - `valueLabel`: render value above each bar (vertical mode) // - `tooltipFormat(d, key)`: returns string or ReactNode for tooltip body const BarChart = ({ data, height = 240, valueKey = 'value', labelKey = 'label', format = fmtN, color, secondaryKey, secondaryColor, showLabels = true, horizontal = false, valueLabel = false, tooltipFormat, barLabel, secondaryLabel, onBarClick, minGroupWidth = 0 }) => { const ref = useRef(); const { w } = useSize(ref); const padL = horizontal ? 110 : 36, padR = 12, padT = 14, padB = horizontal ? 22 : 50; const innerW = Math.max(0, w - padL - padR); const innerH = Math.max(0, height - padT - padB); const c1 = color || 'var(--ink)'; const c2 = secondaryColor || 'var(--red)'; const grouped = !!secondaryKey; const allVals = data.flatMap(d => grouped ? [d[valueKey] || 0, d[secondaryKey] || 0] : [d[valueKey] || 0]); const max = Math.max(...allVals, 0) * 1.1 || 1; const ticks = 4; const [hover, setHover] = useState(null); if (horizontal) { const rowH = innerH / data.length; const barH = Math.min(18, rowH * 0.7); return React.createElement('div', { ref, style: { width: '100%', height, position: 'relative' } }, React.createElement('svg', { width: w, height, style: { display: 'block' } }, // gridlines Array.from({ length: ticks + 1 }, (_, i) => { const x = padL + (innerW * i / ticks); return React.createElement('g', { key: i }, React.createElement('line', { x1: x, x2: x, y1: padT, y2: padT + innerH, className: 'grid-line' }), React.createElement('text', { x, y: height - 6, textAnchor: 'middle', className: 'axis-text' }, format(max * i / ticks)) ); }), data.map((d, i) => { const y = padT + i * rowH + (rowH - barH) / 2; const v = d[valueKey] || 0; const bw = (v / max) * innerW; return React.createElement('g', { key: i }, React.createElement('text', { x: padL - 8, y: y + barH/2 + 3, textAnchor: 'end', className: 'axis-text' }, (d[labelKey] || '').slice(0, 18)), React.createElement('rect', { x: padL, y, width: bw, height: barH, fill: c1, rx: 2, className: 'bar', onMouseEnter: e => setHover({ x: e.clientX, y: e.clientY, d, v }), onMouseLeave: () => setHover(null) }), showLabels && bw > 30 && React.createElement('text', { x: padL + bw - 4, y: y + barH/2 + 3, textAnchor: 'end', style: { fill: 'var(--paper)', fontWeight: 600, fontFamily: 'var(--mono)', fontSize: 9.5 } }, format(v)) ); }) ), hover && React.createElement('div', { className: 'tip', style: { left: hover.x - ref.current.getBoundingClientRect().left + 10, top: hover.y - ref.current.getBoundingClientRect().top - 30 } }, `${hover.d[labelKey]}: ${format(hover.v)}`) ); } // When minGroupWidth is set (mobile), render an SVG wider than the container // and let it scroll horizontally, so each group has room and value labels stop // overlapping (#8). Desktop passes 0 → vw === w, behaviour unchanged. const vw = minGroupWidth ? Math.max(w, data.length * minGroupWidth + padL + padR) : w; const vInnerW = Math.max(0, vw - padL - padR); const scrollX = minGroupWidth && vw > w; const groupW = vInnerW / data.length; const barW = grouped ? Math.min(16, groupW * 0.35) : Math.min(28, groupW * 0.65); const gap = grouped ? 2 : 0; return React.createElement('div', { ref, className: scrollX ? 'chart-scroll-x' : undefined, style: { width: '100%', height, position: 'relative', overflowX: scrollX ? 'auto' : 'visible', WebkitOverflowScrolling: 'touch' } }, React.createElement('svg', { width: vw, height, style: { display: 'block' } }, // gridlines Array.from({ length: ticks + 1 }, (_, i) => { const y = padT + innerH - (innerH * i / ticks); return React.createElement('g', { key: i }, React.createElement('line', { x1: padL, x2: padL + vInnerW, y1: y, y2: y, className: 'grid-line' }), React.createElement('text', { x: padL - 6, y: y + 3, textAnchor: 'end', className: 'axis-text' }, format(max * i / ticks)) ); }), // bars data.map((d, i) => { const cx = padL + i * groupW + groupW / 2; const v1 = d[valueKey] || 0; const v2 = grouped ? (d[secondaryKey] || 0) : 0; const h1 = (v1 / max) * innerH; const h2 = (v2 / max) * innerH; const x1 = grouped ? cx - barW - gap/2 : cx - barW/2; const x2 = grouped ? cx + gap/2 : cx; const label = (d[labelKey] || '').toString(); return React.createElement('g', { key: i }, React.createElement('rect', { x: x1, y: padT + innerH - h1, width: barW, height: h1, fill: c1, rx: 2, className: 'bar', style: onBarClick ? { cursor: 'pointer' } : undefined, onMouseEnter: e => setHover({ x: e.clientX, y: e.clientY, d, label, v: v1, k: valueKey }), onMouseLeave: () => setHover(null), onClick: onBarClick ? () => onBarClick(d) : undefined }), valueLabel && h1 > 0 && React.createElement('text', { x: x1 + barW/2, y: padT + innerH - h1 - 4, textAnchor: 'middle', style: { fontFamily: 'var(--font-mono, var(--mono))', fontSize: 10.5, fontWeight: 600, fill: 'var(--text-dim)' } }, format(v1)), grouped && React.createElement('rect', { x: x2, y: padT + innerH - h2, width: barW, height: h2, fill: c2, rx: 2, className: 'bar', onMouseEnter: e => setHover({ x: e.clientX, y: e.clientY, d, label, v: v2, k: secondaryKey }), onMouseLeave: () => setHover(null) }), grouped && valueLabel && h2 > 0 && React.createElement('text', { x: x2 + barW/2, y: padT + innerH - h2 - 4, textAnchor: 'middle', style: { fontFamily: 'var(--font-mono, var(--mono))', fontSize: 10.5, fontWeight: 600, fill: c2 } }, format(v2)), // x label — rotated if long React.createElement('text', { x: cx, y: padT + innerH + (label.length > 4 ? 14 : 14), textAnchor: label.length > 4 ? 'end' : 'middle', className: 'axis-text', transform: label.length > 4 ? `rotate(-32 ${cx} ${padT + innerH + 14})` : '' }, label.length > 14 ? label.slice(0, 13) + '…' : label) ); }) ), hover && (() => { const tipBody = tooltipFormat ? tooltipFormat(hover.d, hover.k) : `${hover.label} · ${hover.k || ''}: ${format(hover.v)}`; return React.createElement('div', { className: 'tip', style: { left: hover.x - ref.current.getBoundingClientRect().left + 10, top: hover.y - ref.current.getBoundingClientRect().top - 30 } }, tipBody); })() ); }; // Composed: bars + line (e.g. revenue + load factor) const ComposedChart = ({ data, height = 280, barKey, lineKey, labelKey = 'label', barFormat = fmtEurM, lineFormat = v => v.toFixed(0) + '%', barColor = 'var(--ink)', lineColor = 'var(--red)', barLabel = 'Bar', lineLabel = 'Line' }) => { const ref = useRef(); const { w } = useSize(ref); const padL = 48, padR = 48, padT = 18, padB = 28; const innerW = Math.max(0, w - padL - padR); const innerH = Math.max(0, height - padT - padB); const barMax = Math.max(...data.map(d => d[barKey] || 0)) * 1.15 || 1; const lineVals = data.map(d => d[lineKey] || 0); const lineMin = Math.min(...lineVals) * 0.9; const lineMax = Math.max(...lineVals) * 1.05 || 1; const lineRange = lineMax - lineMin || 1; const groupW = innerW / data.length; const barW = Math.min(24, groupW * 0.55); const ticks = 4; const [hover, setHover] = useState(null); const linePts = data.map((d, i) => { const x = padL + i * groupW + groupW / 2; const v = d[lineKey] || 0; const y = padT + innerH - ((v - lineMin)/lineRange) * innerH; return { x, y, v, d }; }); return React.createElement('div', { ref, style: { width: '100%', height, position: 'relative' } }, React.createElement('svg', { width: w, height, style: { display: 'block' } }, // grid + left axis Array.from({ length: ticks + 1 }, (_, i) => { const y = padT + innerH - (innerH * i / ticks); return React.createElement('g', { key: i }, React.createElement('line', { x1: padL, x2: padL + innerW, y1: y, y2: y, className: 'grid-line' }), React.createElement('text', { x: padL - 6, y: y + 3, textAnchor: 'end', className: 'axis-text' }, barFormat(barMax * i / ticks)), React.createElement('text', { x: padL + innerW + 6, y: y + 3, textAnchor: 'start', className: 'axis-text' }, lineFormat(lineMin + lineRange * i / ticks)) ); }), // bars data.map((d, i) => { const cx = padL + i * groupW + groupW / 2; const v = d[barKey] || 0; // Clamp: a near-zero/negative value would yield a negative height, // which SVG rejects (console error). Revenue bars are never truly < 0. const h = Math.max(0, (v / barMax) * innerH); return React.createElement('g', { key: 'b' + i }, React.createElement('rect', { x: cx - barW/2, y: padT + innerH - h, width: barW, height: h, fill: barColor, rx: 2, className: 'bar', opacity: 0.9, onMouseEnter: e => setHover({ x: e.clientX, y: e.clientY, d, i }), onMouseLeave: () => setHover(null) }), React.createElement('text', { x: cx, y: padT + innerH + 14, textAnchor: 'middle', className: 'axis-text' }, d[labelKey]) ); }), // line React.createElement('polyline', { points: linePts.map(p => `${p.x},${p.y}`).join(' '), fill: 'none', stroke: lineColor, strokeWidth: 1.8 }), linePts.map((p, i) => React.createElement('circle', { key: 'p' + i, cx: p.x, cy: p.y, r: 3, fill: lineColor })) ), hover && React.createElement('div', { className: 'tip', style: { left: Math.max(0, hover.x - ref.current.getBoundingClientRect().left - 60), top: hover.y - ref.current.getBoundingClientRect().top - 50 } }, React.createElement('div', { style: { fontWeight: 600, marginBottom: 4, color: 'var(--paper)' } }, hover.d[labelKey]), React.createElement('div', { style: { display: 'flex', alignItems: 'center', gap: 6, color: 'var(--paper)' } }, React.createElement('span', { style: { width: 10, height: 3, background: barColor, flexShrink: 0 } }), `${barLabel}: ${barFormat(hover.d[barKey])}`), React.createElement('div', { style: { display: 'flex', alignItems: 'center', gap: 6, color: 'var(--paper)', marginTop: 2 } }, React.createElement('span', { style: { width: 10, height: 3, background: lineColor, flexShrink: 0 } }), `${lineLabel}: ${lineFormat(hover.d[lineKey])}`) ) ); }; // Catmull-Rom-ish smooth path through a list of [x,y] points function _smoothPath(points, tension = 0.2) { if (!points || points.length === 0) return ''; if (points.length === 1) return `M${points[0][0]},${points[0][1]}`; let d = `M${points[0][0]},${points[0][1]}`; for (let i = 0; i < points.length - 1; i++) { const p0 = points[i - 1] || points[i]; const p1 = points[i]; const p2 = points[i + 1]; const p3 = points[i + 2] || p2; const cp1x = p1[0] + (p2[0] - p0[0]) * tension; const cp1y = p1[1] + (p2[1] - p0[1]) * tension; const cp2x = p2[0] - (p3[0] - p1[0]) * tension; const cp2y = p2[1] - (p3[1] - p1[1]) * tension; d += ` C${cp1x},${cp1y} ${cp2x},${cp2y} ${p2[0]},${p2[1]}`; } return d; } // Line chart — single or multi-series. Skips null/undefined values per series // (used by booking curves to truncate "actual" line where data ends). // // Optional per-series `tooltipFormat(d)` overrides the default tooltip text. // `smooth: true` renders curved (cardinal spline) lines instead of straight segments. const LineChart = ({ data, height = 240, series = [], xKey = 'label', format = fmtN, xFormat = v => v, smooth = false, xTickIndices }) => { const ref = useRef(); const { w } = useSize(ref); const padL = 50, padR = 16, padT = 14, padB = 26; const innerW = Math.max(0, w - padL - padR); const innerH = Math.max(0, height - padT - padB); const valid = v => v != null && !isNaN(v); const allVals = data.flatMap(d => series.map(s => d[s.key])).filter(valid); const min = Math.min(...allVals, 0); const max = (allVals.length ? Math.max(...allVals) : 1) * 1.05 || 1; const range = max - min || 1; const dx = data.length > 1 ? innerW / (data.length - 1) : innerW; const ticks = 4; const [hover, setHover] = useState(null); const yFor = v => padT + innerH - ((v - min)/range) * innerH; const xFor = i => padL + i * dx; return React.createElement('div', { ref, style: { width: '100%', height, position: 'relative' } }, React.createElement('svg', { width: w, height, style: { display: 'block' } }, Array.from({ length: ticks + 1 }, (_, i) => { const y = padT + innerH - (innerH * i / ticks); return React.createElement('g', { key: i }, React.createElement('line', { x1: padL, x2: padL + innerW, y1: y, y2: y, className: 'grid-line' }), React.createElement('text', { x: padL - 6, y: y + 3, textAnchor: 'end', className: 'axis-text' }, format(min + range * i / ticks)) ); }), (() => { const tickSet = xTickIndices && xTickIndices.length ? new Set(xTickIndices) : null; return data.map((d, i) => { const show = tickSet ? tickSet.has(i) : i % Math.ceil(data.length / 8) === 0; if (!show) return null; return React.createElement('text', { key: 'x' + i, x: xFor(i), y: padT + innerH + 14, textAnchor: 'middle', className: 'axis-text' }, xFormat(d[xKey])); }); })(), series.map((s, si) => { // collect only points with valid values, preserving x position by index const validPairs = data .map((d, i) => valid(d[s.key]) ? [xFor(i), yFor(d[s.key])] : null) .filter(Boolean); const ptsStr = validPairs.map(p => p.join(',')).join(' '); const lastValidIdx = data.reduce((acc, d, i) => valid(d[s.key]) ? i : acc, -1); const firstValidIdx = data.findIndex(d => valid(d[s.key])); const useSmooth = smooth && validPairs.length > 1; const linePath = useSmooth ? _smoothPath(validPairs) : null; const fillD = useSmooth ? `${linePath} L${xFor(lastValidIdx)},${padT + innerH} L${xFor(firstValidIdx)},${padT + innerH} Z` : null; return React.createElement('g', { key: si }, s.fill && validPairs.length > 0 && (useSmooth ? React.createElement('path', { d: fillD, fill: s.color, fillOpacity: 0.12, stroke: 'none' }) : React.createElement('polygon', { points: `${xFor(firstValidIdx)},${padT + innerH} ${ptsStr} ${xFor(lastValidIdx)},${padT + innerH}`, fill: s.color, fillOpacity: 0.12 })), validPairs.length > 1 && (useSmooth ? React.createElement('path', { d: linePath, fill: 'none', stroke: s.color, strokeWidth: s.width || 1.6, strokeDasharray: s.dashed ? '7 4' : '', strokeLinecap: 'round', strokeLinejoin: 'round' }) : React.createElement('polyline', { points: ptsStr, fill: 'none', stroke: s.color, strokeWidth: s.width || 1.6, strokeDasharray: s.dashed ? '7 4' : '' })), data.map((d, i) => valid(d[s.key]) && React.createElement('circle', { key: i, cx: xFor(i), cy: yFor(d[s.key]), r: 2.5, fill: s.color, opacity: 0 })) ); }), // hover overlay data.map((d, i) => React.createElement('rect', { key: 'h' + i, x: xFor(i) - dx/2, y: padT, width: dx, height: innerH, fill: 'transparent', onMouseEnter: e => setHover({ x: e.clientX, y: e.clientY, d, i }), onMouseLeave: () => setHover(null) })) ), hover && React.createElement('div', { className: 'tip', style: { left: xFor(hover.i) - 50, top: padT - 10 } }, React.createElement('div', { style: { fontWeight: 600, marginBottom: 4, color: 'var(--paper)' } }, xFormat(hover.d[xKey])), series.map((s, si) => { const v = hover.d[s.key]; const text = valid(v) ? (s.tooltipFormat ? s.tooltipFormat(hover.d) : `${s.label}: ${format(v)}`) : `${s.label}: —`; return React.createElement('div', { key: si, style: { display: 'flex', alignItems: 'center', gap: 6, color: 'var(--paper)', marginTop: si === 0 ? 0 : 2 } }, React.createElement('span', { style: { width: 10, height: 3, background: s.color, flexShrink: 0 } }), text ); }) ), // Legend — uses inline SVG so dashed series show as dashed in the legend swatch React.createElement('div', { style: { position: 'absolute', top: 4, right: 12, display: 'flex', gap: 12, fontSize: 10 } }, series.map((s, si) => React.createElement('div', { key: si, style: { display: 'flex', alignItems: 'center', gap: 5, color: 'var(--text-dim)' } }, React.createElement('svg', { width: 18, height: 4, style: { flexShrink: 0 } }, React.createElement('line', { x1: 0, y1: 2, x2: 18, y2: 2, stroke: s.color, strokeWidth: s.width || 1.6, strokeDasharray: s.dashed ? '4 2' : 'none' }) ), s.label )) ) ); }; // Heatmap cell color helpers const heatColor = (v, opts = {}) => { // Diverging — for price changes, etc. v in roughly -10..+10 const { mid = 0, max = 10 } = opts; const t = Math.max(-1, Math.min(1, (v - mid) / max)); if (Math.abs(t) < 0.08) return { bg: 'var(--paper-edge)', fg: 'var(--text-dim)' }; if (t > 0) { // Raise — green/positive const a = 0.15 + Math.abs(t) * 0.55; return { bg: `color-mix(in srgb, var(--green) ${(a*100).toFixed(0)}%, var(--paper-soft))`, fg: a > 0.45 ? 'white' : 'var(--green)' }; } else { // Drop — red/negative const a = 0.15 + Math.abs(t) * 0.55; return { bg: `color-mix(in srgb, var(--red) ${(a*100).toFixed(0)}%, var(--paper-soft))`, fg: a > 0.45 ? 'white' : 'var(--red)' }; } }; const occColor = v => { // occupancy 0–100 — sequential if (v == null) return { bg: 'var(--paper-edge)', fg: 'var(--text-dim)' }; if (v >= 90) return { bg: 'var(--ink)', fg: 'var(--paper)' }; if (v >= 75) return { bg: 'color-mix(in srgb, var(--ink) 70%, var(--paper))', fg: 'white' }; if (v >= 55) return { bg: 'color-mix(in srgb, var(--ink) 40%, var(--paper))', fg: 'white' }; if (v >= 35) return { bg: 'color-mix(in srgb, var(--ink) 18%, var(--paper))', fg: 'var(--ink)' }; return { bg: 'var(--paper-edge)', fg: 'var(--text-dim)' }; }; // Keyboard shortcut display const Kbd = ({ children }) => React.createElement('span', { style: { fontFamily: 'var(--font-mono)', fontSize: 9.5, padding: '1px 5px', border: '1px solid var(--line-strong)', borderRadius: 3, color: 'var(--text-muted)', background: 'var(--bg-1)' } }, children); // Stars badge — clean numeric, no glyphs const Stars = ({ n }) => React.createElement('span', { className: 'stars' }, `${n}★`); // Panel wrapper const Panel = ({ title, sub, actions, children, flush = false, className = '' }) => React.createElement('div', { className: `panel ${className}` }, (title || actions) && React.createElement('div', { className: 'panel-head' }, React.createElement('div', null, title && React.createElement('div', { className: 'panel-head-title' }, title), sub && React.createElement('div', { className: 'panel-head-sub' }, sub) ), actions && React.createElement('div', { className: 'row gap-2' }, actions) ), React.createElement('div', { className: flush ? '' : 'panel-body' }, children) ); // KPI tile const KPI = ({ label, value, sub, delta, deltaInverse, sparkline, sparklineColor }) => React.createElement('div', { className: 'kpi' }, React.createElement('div', { className: 'kpi-label' }, label), React.createElement('div', { className: 'kpi-value' }, value), React.createElement('div', { className: 'kpi-sub' }, sub && React.createElement('span', null, sub), delta != null && React.createElement(Delta, { value: delta, inverse: deltaInverse }) ), sparkline && React.createElement('div', { className: 'kpi-spark' }, React.createElement(Sparkline, { data: sparkline, width: 80, height: 26, color: sparklineColor || 'var(--red)' }) ) ); // ---- Period / date helpers ------------------------------------------------ const _isoDate = (d) => { const y = d.getFullYear(), m = String(d.getMonth() + 1).padStart(2, '0'), dd = String(d.getDate()).padStart(2, '0'); return `${y}-${m}-${dd}`; }; const _monthStartISO = (year, m) => `${year}-${String(m).padStart(2, '0')}-01`; const _monthEndISO = (year, m) => _isoDate(new Date(year, m, 0)); // Month coverage weights for a date range — each overlapping month tagged with // the fraction of its days inside [from,to]. Lets monthly-grain segment tables // respond to a *specific date range* via pro-rata day weighting. const monthWeights = (from, to) => { const f = new Date(from + 'T00:00:00'), t = new Date(to + 'T00:00:00'); if (isNaN(f) || isNaN(t) || f > t) return []; const out = []; const cur = new Date(f.getFullYear(), f.getMonth(), 1); while (cur <= t) { const mStart = new Date(cur.getFullYear(), cur.getMonth(), 1); const mEnd = new Date(cur.getFullYear(), cur.getMonth() + 1, 0); const lo = f > mStart ? f : mStart; const hi = t < mEnd ? t : mEnd; const daysCovered = Math.round((hi - lo) / 86400000) + 1; out.push({ month: cur.getMonth() + 1, weight: Math.max(0, Math.min(1, daysCovered / mEnd.getDate())) }); cur.setMonth(cur.getMonth() + 1); } return out; }; // Aggregate monthly cross-tab rows for one dimension into YTD-array-shaped // records (revenue/pax/adr/roomnights/bookings/margin_pct), pro-rated by month // weight. Output matches the by__*_ytd shape SegmentBlock/HotelView expect. const aggMonthly = (rows, keyName, weights) => { const wmap = {}; weights.forEach(w => { if (w.weight > 0) wmap[w.month] = w.weight; }); const acc = {}; (rows || []).forEach(r => { const w = wmap[r.CheckInMonth]; if (!w) return; const k = r[keyName]; let a = acc[k]; if (!a) a = acc[k] = { [keyName]: k, revenue: 0, pax: 0, roomnights: 0, bookings: 0, margin: 0, _adrNum: 0, _adrDen: 0, Region: r.Region, SubRegion: r.SubRegion, Stars: r.Stars, Concept: r.Concept }; a.revenue += (r.revenue || 0) * w; a.pax += (r.pax || 0) * w; a.roomnights += (r.roomnights || 0) * w; a.bookings += (r.bookings || 0) * w; a.margin += (r.margin || 0) * w; a._adrNum += (r.adr || 0) * (r.revenue || 0) * w; a._adrDen += (r.revenue || 0) * w; }); return Object.values(acc).map(a => ({ [keyName]: a[keyName], Region: a.Region, SubRegion: a.SubRegion, Stars: a.Stars, Concept: a.Concept, revenue: a.revenue, pax: Math.round(a.pax), roomnights: Math.round(a.roomnights), bookings: Math.round(a.bookings), margin: a.margin, adr: a._adrDen ? a._adrNum / a._adrDen : 0, margin_pct: a.revenue ? a.margin / a.revenue * 100 : 0, })).sort((x, y) => (y.revenue || 0) - (x.revenue || 0)); }; // Period filter — month-range dropdowns + a precise date-range picker + presets. // `value` is { from, to } as 'YYYY-MM-DD'; `onChange` receives the same shape. // Used by Raporlama (Aylık Görünüm + Segment Detay) and Rakip Seti. const PERIOD_PRESETS = [ { id: 'season', label: 'Tüm Sezon', from: '2026-04-01', to: '2026-10-31' }, { id: 'ytd', label: 'YTD', from: '2026-01-01', to: '2026-05-05' }, { id: 'last3', label: 'Son 3 Ay', from: '2026-03-01', to: '2026-05-05' }, { id: 'year', label: 'Tüm Yıl', from: '2026-01-01', to: '2026-12-31' }, ]; const PeriodFilter = ({ value, onChange, year = 2026, presets = true }) => { const { from, to } = value; const fm = new Date(from + 'T00:00:00').getMonth() + 1; const tm = new Date(to + 'T00:00:00').getMonth() + 1; const monthOpts = Array.from({ length: 12 }, (_, i) => i + 1); const sameRange = (p) => p.from === from && p.to === to; const setStartMonth = (m) => { const nf = _monthStartISO(year, m); onChange({ from: nf, to: to < nf ? _monthEndISO(year, m) : to }); }; const setEndMonth = (m) => { const nt = _monthEndISO(year, m); onChange({ from: from > nt ? _monthStartISO(year, m) : from, to: nt }); }; const setFrom = (v) => { if (v) onChange({ from: v, to: to < v ? v : to }); }; const setTo = (v) => { if (v) onChange({ from: from > v ? v : from, to: v }); }; const monthSelect = (val, onSel) => React.createElement('select', { className: 'input', value: val, style: { height: 28, padding: '0 8px', minWidth: 92 }, onChange: e => onSel(Number(e.target.value)), }, monthOpts.map(m => React.createElement('option', { key: m, value: m }, MONTHS_FULL[m]))); const dateInput = (val, onSel) => React.createElement('input', { type: 'date', className: 'input', value: val, min: `${year}-01-01`, max: `${year}-12-31`, style: { height: 28, padding: '0 8px' }, onChange: e => onSel(e.target.value), }); const divider = () => React.createElement('span', { style: { width: 1, height: 20, background: 'var(--rule-soft)', margin: '0 2px' } }); return React.createElement('div', { className: 'panel period-filter', style: { padding: '10px 16px', display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap' } }, React.createElement('span', { style: { fontSize: 10, letterSpacing: '0.22em', textTransform: 'uppercase', fontWeight: 700, color: 'var(--text-muted)' } }, 'DÖNEM'), monthSelect(fm, setStartMonth), React.createElement('span', { style: { color: 'var(--text-faint)' } }, '–'), monthSelect(tm, setEndMonth), divider(), React.createElement('span', { style: { fontSize: 9.5, letterSpacing: '0.16em', textTransform: 'uppercase', fontWeight: 700, color: 'var(--text-faint)' } }, 'TARİH'), dateInput(from, setFrom), React.createElement('span', { style: { color: 'var(--text-faint)' } }, '–'), dateInput(to, setTo), presets && React.createElement('div', { style: { display: 'flex', gap: 4, marginLeft: 'auto', flexWrap: 'wrap' } }, PERIOD_PRESETS.map(p => React.createElement('div', { key: p.id, className: 'filter-chip' + (sameRange(p) ? ' active' : ''), onClick: () => onChange({ from: p.from, to: p.to }) }, p.label)) ) ); }; // Expose Object.assign(window, { fmtEurM, fmtN, fmtK, fmtPct, fmtSignPct, fmtEur, MONTHS, MONTHS_FULL, Delta, Sparkline, BarChart, ComposedChart, LineChart, heatColor, occColor, Kbd, Stars, Panel, KPI, useSize, PeriodFilter, PERIOD_PRESETS, monthWeights, aggMonthly, useState, useEffect, useRef, useMemo, useLayoutEffect });