// ─────────────────────────────────────────────────────────────
// First Episodes by Month — DVJS + SVG (namespace-safe)
// Filters: Agency, Year, Start Date, End Date
// Rule: first appointment must be ≥ MIN_FIRST_HOURS
// Chart: stacked bars by calendar year + legend (colour/hatch per year)
// ─────────────────────────────────────────────────────────────
const MIN_FIRST_HOURS = 0.5;
// Front-matter driven filters (use any of these keys; case-insensitive fallbacks)
const getFM = (k) => dv.current()?.[k];
const FILTER_AGENCY = (getFM("Agency Filter") ?? getFM("agency filter") ?? "").toString().trim();
const FILTER_YEAR = (getFM("Year Filter") ?? getFM("year filter") ?? "").toString().trim();
const rawStart = getFM("Start Date") ?? getFM("start date") ?? "";
const rawEnd = getFM("End Date") ?? getFM("end date") ?? "";
const monthOrder = ["Jan","Feb","Mar","Apr","May","Jun","Jul","Aug","Sep","Oct","Nov","Dec"];
const SVGNS = "http://www.w3.org/2000/svg";
// Helpers
const toDate = (v) => {
// Accepts scalar or array; returns { date, key } or null.
if (Array.isArray(v)) v = v[0];
if (!v) return null;
// Preserve date-only YAML/Dataview values as local calendar dates.
// Avoid Date -> toISOString() for display, because BST can shift midnight
// back to the previous UTC day.
if (typeof v?.toFormat === "function") {
const key = v.toFormat("yyyy-MM-dd");
return { date: new Date(`${key}T00:00:00`), key };
}
if (typeof v?.toISODate === "function") {
const key = v.toISODate();
return { date: new Date(`${key}T00:00:00`), key };
}
const match = String(v).match(/^(\d{4})-(\d{2})-(\d{2})/);
if (match) {
const key = `${match[1]}-${match[2]}-${match[3]}`;
return { date: new Date(`${key}T00:00:00`), key };
}
const d = new Date(v);
if (Number.isNaN(d.getTime())) return null;
const yyyy = d.getFullYear();
const mm = String(d.getMonth() + 1).padStart(2, "0");
const dd = String(d.getDate()).padStart(2, "0");
return { date: d, key: `${yyyy}-${mm}-${dd}` };
};
const toNumber = (v) => {
if (Array.isArray(v)) v = v[0];
if (v == null || v === "") return null;
const n = parseFloat(String(v).trim());
return Number.isNaN(n) ? null : n;
};
const svgEl = (name, attrs = {}) => {
const el = document.createElementNS(SVGNS, name);
for (const [k, v] of Object.entries(attrs)) el.setAttribute(k, String(v));
return el;
};
// Parse date-window (inclusive). If only one bound provided, use that side only.
let START_INFO = toDate(rawStart);
let END_INFO = toDate(rawEnd);
let START_DATE = START_INFO?.date ?? null;
let END_DATE = END_INFO?.date ?? null;
// If start > end, swap (helps avoid accidental inversions)
if (START_DATE && END_DATE && START_DATE > END_DATE) {
const tmpDate = START_DATE;
START_DATE = END_DATE;
END_DATE = tmpDate;
const tmpInfo = START_INFO;
START_INFO = END_INFO;
END_INFO = tmpInfo;
}
// 1) Load + normalise records from tagged pages
const src = dv.pages("#clienthours").where(p => p.clientref && p.date);
const records = Array.from(src).map(p => {
const dateInfo = toDate(p.date);
return {
clientref: p.clientref,
agency: (p.agency ?? "").toString(),
date: dateInfo?.date ?? null,
dateKey: dateInfo?.key ?? "",
hours: toNumber(p.hours) ?? 0
};
}).filter(p => p.date !== null);
// 2) Group by client and pick earliest record with hours ≥ MIN_FIRST_HOURS
records.sort((a,b) => a.date - b.date);
const byClient = new Map();
for (const r of records) {
if (!byClient.has(r.clientref)) byClient.set(r.clientref, []);
byClient.get(r.clientref).push(r);
}
const firstEpisodes = [];
for (const [clientref, rows] of byClient.entries()) {
rows.sort((a,b) => a.date - b.date);
const firstQual = rows.find(rr => (rr.hours ?? 0) >= MIN_FIRST_HOURS);
if (!firstQual) continue;
firstEpisodes.push({ clientref, agency: firstQual.agency, date: firstQual.date });
}
// 3) Apply filters (agency/year + date window inclusive)
const filtered = firstEpisodes.filter(ep => {
const agencyOk = !FILTER_AGENCY || ep.agency === FILTER_AGENCY;
const yearOk = !FILTER_YEAR || ep.date.getFullYear().toString() === FILTER_YEAR;
const startOk = !START_DATE || ep.date >= START_DATE; // inclusive
const endOk = !END_DATE || ep.date <= END_DATE; // inclusive
return agencyOk && yearOk && startOk && endOk;
});
// 4) Count by month AND year
const yearSet = new Set();
for (const ep of filtered) yearSet.add(ep.date.getFullYear());
const years = Array.from(yearSet).sort((a,b) => a - b); // ascending
// countsByMonthYear[month][year] = count
const countsByMonthYear = Object.fromEntries(monthOrder.map(m => [m, Object.create(null)]));
for (const ep of filtered) {
const m = ep.date.toLocaleString("en-GB", { month: "short" });
const y = ep.date.getFullYear();
if (!(m in countsByMonthYear)) continue;
countsByMonthYear[m][y] = (countsByMonthYear[m][y] ?? 0) + 1;
}
// chartData: one entry per month with a year->count map and total
const chartData = monthOrder.map(m => {
const byY = countsByMonthYear[m];
const total = years.reduce((acc, y) => acc + (byY[y] ?? 0), 0);
return { month: m, byYear: byY, total };
});
// 5) Summary + table (keep your original table: totals per month)
const bits = [];
if (FILTER_AGENCY) bits.push(`Agency: **${FILTER_AGENCY}**`);
if (FILTER_YEAR) bits.push(`Year: **${FILTER_YEAR}**`);
bits.push(`Min First Hours: **${MIN_FIRST_HOURS}**`);
if (START_DATE || END_DATE) {
const fmt = info => info?.key ?? "—";
bits.push(`Window: **${fmt(START_INFO)} → ${fmt(END_INFO)}**`);
}
dv.paragraph(`First episodes by month ${bits.length ? "(" + bits.join(" · ") + ")" : ""}:`);
dv.table(["Month", "First Episodes"], chartData.map(d => [d.month, d.total]));
// 6) SVG bar chart (stacked by year + legend BELOW chart)
// NOTE: this block derives its own year list from chartData to avoid "years is not defined".
const container = this.container.createEl("div", { cls: "first-episodes-chart" });
container.style.width = "100%";
container.style.maxWidth = "900px";
// Derive years locally from chartData (safe even if you didn't paste earlier code)
const yearsLocal = Array.from(
new Set(
chartData.flatMap(d => Object.keys(d.byYear ?? {}).map(k => Number(k))).filter(n => Number.isFinite(n))
)
).sort((a,b) => a - b);
const width = 900;
const legendRowH = 16;
const legendTitleH = 16;
const legendPadding = 10;
const legendH = yearsLocal.length ? (legendTitleH + yearsLocal.length * legendRowH + legendPadding) : 0;
// Give the chart more vertical room: base height + legend band
const baseHeight = 320;
const height = baseHeight + legendH;
// Margins for the plotting area (legend is outside this)
const margin = { top: 28, right: 20, bottom: 44, left: 44 };
const innerW = width - margin.left - margin.right;
const innerH = baseHeight - margin.top - margin.bottom;
const svg = svgEl("svg", { viewBox: `0 0 ${width} ${height}`, preserveAspectRatio: "xMidYMid meet" });
svg.style.width = "100%";
svg.style.height = "auto";
container.appendChild(svg);
// ----- Patterns / styles per year -----
const defs = svgEl("defs");
svg.appendChild(defs);
const palette = [
"#4C78A8", "#F58518", "#54A24B", "#E45756", "#72B7B2",
"#B279A2", "#FF9DA6", "#9D755D", "#BAB0AC"
];
const useColour = true; // set false for hatch-only
const hatchOnlyStroke = "currentColor";
function addHatchPattern(id, stroke, angleDeg, spacing, strokeW) {
const p = svgEl("pattern", {
id,
patternUnits: "userSpaceOnUse",
width: spacing,
height: spacing,
patternTransform: `rotate(${angleDeg})`
});
p.appendChild(svgEl("line", {
x1: 0, y1: 0,
x2: 0, y2: spacing,
stroke,
"stroke-width": strokeW,
"stroke-linecap": "square",
"stroke-opacity": "0.9"
}));
defs.appendChild(p);
}
const yearStyle = new Map();
yearsLocal.forEach((yr, idx) => {
const colour = palette[idx % palette.length];
const angle = (idx % 4) * 45;
const spacing = 6 + (idx % 3) * 2;
const strokeW = 1 + (idx % 2) * 0.5;
const patId = `fe-hatch-${yr}`;
addHatchPattern(patId, useColour ? colour : hatchOnlyStroke, angle, spacing, strokeW);
yearStyle.set(yr, {
fill: useColour ? colour : "none",
fillOpacity: useColour ? 0.35 : 0,
patternUrl: `url(#${patId})`
});
});
// Scales
const maxY = Math.max(1, ...chartData.map(d => d.total));
const xStep = innerW / chartData.length;
const barW = xStep * 0.7;
const x = i => margin.left + i * xStep + (xStep - barW) / 2;
const y = v => margin.top + innerH - (v / maxY) * innerH;
// Axes group
const axisG = svg.appendChild(svgEl("g"));
axisG.appendChild(svgEl("line", {
x1: margin.left, y1: margin.top, x2: margin.left, y2: margin.top + innerH, stroke: "currentColor"
}));
axisG.appendChild(svgEl("line", {
x1: margin.left, y1: margin.top + innerH, x2: margin.left + innerW, y2: margin.top + innerH, stroke: "currentColor"
}));
// Y ticks
const yTicks = Math.min(6, maxY);
for (let t = 0; t <= yTicks; t++) {
const val = Math.round((t / yTicks) * maxY);
const yy = y(val);
axisG.appendChild(svgEl("line", {
x1: margin.left, y1: yy, x2: margin.left + innerW, y2: yy,
stroke: "currentColor", "stroke-opacity": "0.15"
}));
const lbl = svgEl("text", { x: margin.left - 8, y: yy + 4, "text-anchor": "end", fill: "currentColor" });
lbl.style.fontSize = "12px";
lbl.textContent = String(val);
axisG.appendChild(lbl);
}
// Bars + X labels (STACKED by year)
const barsG = svg.appendChild(svgEl("g"));
chartData.forEach((d, i) => {
const baseX = x(i);
let cumulative = 0;
yearsLocal.forEach(yr => {
const c = (d.byYear?.[yr] ?? d.byYear?.[String(yr)] ?? 0);
if (!c) return;
const y0 = y(cumulative);
const y1 = y(cumulative + c);
const segH = Math.max(1, y0 - y1);
const st = yearStyle.get(yr);
// optional tinted base
if (st.fill !== "none" && st.fillOpacity > 0) {
barsG.appendChild(svgEl("rect", {
x: baseX, y: y1, width: barW, height: segH,
fill: st.fill, "fill-opacity": String(st.fillOpacity), stroke: "none"
}));
}
// hatch overlay (always)
barsG.appendChild(svgEl("rect", {
x: baseX, y: y1, width: barW, height: segH,
fill: st.patternUrl, "fill-opacity": "1",
stroke: "currentColor", "stroke-opacity": "0.15"
}));
cumulative += c;
});
if (d.total === 0) {
barsG.appendChild(svgEl("rect", {
x: baseX,
y: margin.top + innerH - 1,
width: barW,
height: 1,
fill: "currentColor",
"fill-opacity": "0.15"
}));
}
const xl = svgEl("text", {
x: margin.left + i * xStep + xStep / 2,
y: margin.top + innerH + 18,
"text-anchor": "middle",
fill: "currentColor"
});
xl.style.fontSize = "12px";
xl.textContent = d.month;
barsG.appendChild(xl);
});
// Title
const title = svgEl("text", { x: margin.left, y: 18, fill: "currentColor" });
title.style.fontSize = "14px";
title.style.fontWeight = "600";
title.textContent = "First Episodes by Month (stacked by year)";
svg.appendChild(title);
// Legend BELOW chart (in reserved band)
if (yearsLocal.length) {
const legendG = svg.appendChild(svgEl("g"));
const boxW = 14, boxH = 10;
const legendX = margin.left;
const legendTop = baseHeight + 14;
const lt = svgEl("text", { x: legendX, y: legendTop, fill: "currentColor" });
lt.style.fontSize = "12px";
lt.style.fontWeight = "600";
lt.textContent = "Year key";
legendG.appendChild(lt);
yearsLocal.forEach((yr, idx) => {
const yRow = legendTop + legendTitleH + idx * legendRowH;
const st = yearStyle.get(yr);
if (st.fill !== "none" && st.fillOpacity > 0) {
legendG.appendChild(svgEl("rect", {
x: legendX,
y: yRow - boxH + 2,
width: boxW,
height: boxH,
fill: st.fill,
"fill-opacity": String(st.fillOpacity),
stroke: "none"
}));
}
legendG.appendChild(svgEl("rect", {
x: legendX,
y: yRow - boxH + 2,
width: boxW,
height: boxH,
fill: st.patternUrl,
stroke: "currentColor",
"stroke-opacity": "0.35"
}));
const t = svgEl("text", { x: legendX + boxW + 8, y: yRow + 2, fill: "currentColor" });
t.style.fontSize = "12px";
t.textContent = String(yr);
legendG.appendChild(t);
});
}