// ─────────────────────────────────────────────────────────────
// Client Hours by Month and Year — current year, last year, year before
// Filters: Agency, Start Date, End Date
// Rules:
// - include only sessions with hours > 0.5
// Graph:
// - for each month: three bars, one per year
// - bars are stacked by agency, matching the existing client-activity chart style
// Tables:
// - monthly totals by year
// - yearly totals by agency
// ─────────────────────────────────────────────────────────────
const MIN_HOURS = 0.5;
const getFM = (k) => dv.current()?.[k];
const FILTER_AGENCY = (getFM("Agency Filter") ?? getFM("agency filter") ?? "").toString().trim();
const rawStart = getFM("Start Date") ?? getFM("start date") ?? "";
const rawEnd = getFM("End Date") ?? getFM("end date") ?? "";
const SVGNS = "http://www.w3.org/2000/svg";
const monthOrder = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"];
const firstOf = (v) => Array.isArray(v) ? v[0] : v;
const toDate = (v) => {
v = firstOf(v);
if (!v) return null;
if (typeof v?.toFormat === "function") return new Date(`${v.toFormat("yyyy-MM-dd")}T00:00:00`);
if (typeof v?.toISODate === "function") return new Date(`${v.toISODate()}T00:00:00`);
const match = String(v).match(/^(\d{4})-(\d{2})-(\d{2})/);
if (match) return new Date(`${match[1]}-${match[2]}-${match[3]}T00:00:00`);
const d = new Date(v);
return Number.isNaN(d.getTime()) ? null : d;
};
const toNumber = (v) => {
v = firstOf(v);
if (v == null || v === "") return 0;
const n = parseFloat(String(v).trim());
return Number.isNaN(n) ? 0 : n;
};
const fmtHours = (n) => Number(n || 0).toFixed(2);
const fmtISODate = (d) => {
if (!d) return "—";
const y = d.getFullYear();
const m = String(d.getMonth() + 1).padStart(2, "0");
const day = String(d.getDate()).padStart(2, "0");
return `${y}-${m}-${day}`;
};
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;
};
let START_DATE = toDate(rawStart);
let END_DATE = toDate(rawEnd);
if (START_DATE && END_DATE && START_DATE > END_DATE) {
const tmp = START_DATE;
START_DATE = END_DATE;
END_DATE = tmp;
}
const startOfMonth = (d) => new Date(d.getFullYear(), d.getMonth(), 1);
const endOfMonth = (d) => new Date(d.getFullYear(), d.getMonth() + 1, 0, 23, 59, 59, 999);
const addMonths = (d, n) => new Date(d.getFullYear(), d.getMonth() + n, 1);
const monthKey = (d) => `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}`;
const fmtMonthYear = (d) => d.toLocaleString("en-GB", { month: "short", year: "numeric" });
const currentPeriodEndMonth = startOfMonth(new Date());
const currentMonths = Array.from({ length: 12 }, (_, i) => addMonths(currentPeriodEndMonth, i - 11));
const periods = [
{ key: "yearBefore", label: "Year before", offsetMonths: -24 },
{ key: "lastYear", label: "Last year", offsetMonths: -12 },
{ key: "thisYear", label: "This year", offsetMonths: 0 }
].map(period => ({
...period,
months: currentMonths.map(d => addMonths(d, period.offsetMonths))
}));
const periodRangeLabel = (period) => `${fmtMonthYear(period.months[0])}–${fmtMonthYear(period.months[period.months.length - 1])}`;
// 1) Load + normalise records
const src = dv.pages("#clienthours").where(p => p.clientref && p.date);
const records = Array.from(src).map(p => ({
agency: (firstOf(p.agency) ?? "Unknown").toString().trim() || "Unknown",
date: toDate(p.date),
hours: toNumber(p.hours)
})).filter(r => r.date !== null);
// 2) Apply filters
const filtered = records.filter(r => {
const agencyOk = !FILTER_AGENCY || r.agency === FILTER_AGENCY;
const startOk = !START_DATE || r.date >= START_DATE;
const endOk = !END_DATE || r.date <= END_DATE;
const hoursOk = r.hours > MIN_HOURS;
return agencyOk && startOk && endOk && hoursOk;
});
// 3) Gather agencies
const agencies = Array.from(new Set(filtered.map(r => r.agency))).sort((a, b) => a.localeCompare(b));
if (agencies.length === 0) agencies.push("Unknown");
// 4) Aggregate by rolling comparison period, month, agency
const makeAgencyTotals = () => Object.fromEntries(agencies.map(a => [a, 0]));
const periodMonthAgencyTotals = new Map();
for (const period of periods) {
periodMonthAgencyTotals.set(period.key, new Map(period.months.map(d => [monthKey(d), makeAgencyTotals()])));
}
for (const r of filtered) {
const mk = monthKey(r.date);
for (const period of periods) {
const totals = periodMonthAgencyTotals.get(period.key).get(mk);
if (!totals) continue;
totals[r.agency] = (totals[r.agency] ?? 0) + r.hours;
}
}
const monthRows = currentMonths.map((monthStart, monthIndex) => {
const byPeriod = Object.fromEntries(periods.map(period => {
const comparisonMonth = period.months[monthIndex];
const byAgency = periodMonthAgencyTotals.get(period.key).get(monthKey(comparisonMonth));
const total = agencies.reduce((acc, agency) => acc + (byAgency[agency] ?? 0), 0);
return [period.key, { byAgency, total, month: comparisonMonth }];
}));
return { month: monthStart.toLocaleString("en-GB", { month: "short" }), monthIndex, currentMonth: monthStart, byPeriod };
});
const periodAgencyTotals = Object.fromEntries(periods.map(period => [period.key, makeAgencyTotals()]));
for (const row of monthRows) {
for (const period of periods) {
for (const agency of agencies) {
periodAgencyTotals[period.key][agency] += row.byPeriod[period.key].byAgency[agency] ?? 0;
}
}
}
const periodTotals = Object.fromEntries(periods.map(period => [
period.key,
agencies.reduce((acc, agency) => acc + (periodAgencyTotals[period.key][agency] ?? 0), 0)
]));
// 5) Summary and tables
const bits = [];
if (FILTER_AGENCY) bits.push(`Agency: **${FILTER_AGENCY}**`);
if (START_DATE || END_DATE) bits.push(`Outer Window: **${fmtISODate(START_DATE)} → ${fmtISODate(END_DATE)}**`);
bits.push(`Min Session Hours: **>${MIN_HOURS}**`);
bits.push(`Current rolling window: **${fmtMonthYear(currentMonths[0])} → ${fmtMonthYear(currentMonths[currentMonths.length - 1])}**`);
dv.paragraph(`Client hours by month, comparing the current rolling 12 months with the same months in the previous two years (${bits.join(" · ")}):`);
dv.header(3, "Monthly client hours comparison");
dv.table(
["Month", ...periods.map(period => `${period.label} (${periodRangeLabel(period)})`)],
monthRows.map(row => [row.month, ...periods.map(period => fmtHours(row.byPeriod[period.key].total))])
);
dv.header(3, "Rolling-period totals by agency");
dv.table(
["Agency", ...periods.map(period => period.label), "Total"],
[
...agencies.map(agency => [
agency,
...periods.map(period => fmtHours(periodAgencyTotals[period.key][agency])),
fmtHours(periods.reduce((acc, period) => acc + periodAgencyTotals[period.key][agency], 0))
]),
["Total", ...periods.map(period => fmtHours(periodTotals[period.key])), fmtHours(periods.reduce((acc, period) => acc + periodTotals[period.key], 0))]
]
);
// 6) SVG chart (render only in live Obsidian preview context)
if (typeof document !== "undefined" && this?.container?.createEl) {
const container = this.container.createEl("div", { cls: "client-hours-chart" });
container.style.width = "100%";
container.style.maxWidth = "1200px";
const width = 1200;
const baseHeight = 390;
const legendRowH = 18;
const legendTitleH = 16;
const legendPadding = 10;
const legendH = (3 + agencies.length) * legendRowH + legendTitleH + legendPadding;
const height = baseHeight + legendH;
const margin = { top: 30, right: 24, bottom: 58, left: 58 };
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);
const defs = svgEl("defs");
svg.appendChild(defs);
const palette = ["#4C78A8", "#F58518", "#54A24B", "#E45756", "#72B7B2", "#B279A2", "#FF9DA6", "#9D755D", "#BAB0AC"];
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 agencyStyle = new Map();
agencies.forEach((agency, idx) => {
const colour = palette[idx % palette.length];
const patId = `agency-hatch-${idx}`;
addHatchPattern(patId, colour, (idx % 4) * 45, 6 + (idx % 3) * 2, 1 + ((idx % 2) * 0.4));
agencyStyle.set(agency, { fill: colour, fillOpacity: 0.35, patternUrl: `url(#${patId})` });
});
const periodOutline = new Map([
[periods[0].key, { strokeDasharray: "2 2", strokeOpacity: 0.35 }],
[periods[1].key, { strokeDasharray: "5 3", strokeOpacity: 0.50 }],
[periods[2].key, { strokeDasharray: "", strokeOpacity: 0.28 }]
]);
const maxY = Math.max(1, ...monthRows.flatMap(row => periods.map(period => row.byPeriod[period.key].total)));
const xStep = innerW / monthRows.length;
const groupW = xStep * 0.82;
const barGap = 5;
const barW = Math.max(6, (groupW - (barGap * 2)) / 3);
const groupX = i => margin.left + i * xStep + (xStep - groupW) / 2;
const yScale = v => margin.top + innerH - (v / maxY) * innerH;
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" }));
const yTicks = Math.min(6, Math.max(1, Math.ceil(maxY)));
for (let t = 0; t <= yTicks; t++) {
const val = (t / yTicks) * maxY;
const yy = yScale(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 = fmtHours(val);
axisG.appendChild(lbl);
}
const barsG = svg.appendChild(svgEl("g"));
monthRows.forEach((row, i) => {
const gx = groupX(i);
periods.forEach((period, periodIdx) => {
const x = gx + periodIdx * (barW + barGap);
const byAgency = row.byPeriod[period.key].byAgency;
const total = row.byPeriod[period.key].total;
let cumulative = 0;
agencies.forEach(agency => {
const value = byAgency[agency] ?? 0;
if (!value) return;
const y0 = yScale(cumulative);
const y1 = yScale(cumulative + value);
const h = Math.max(1, y0 - y1);
const st = agencyStyle.get(agency);
barsG.appendChild(svgEl("rect", { x, y: y1, width: barW, height: h, fill: st.fill, "fill-opacity": String(st.fillOpacity), stroke: "none" }));
barsG.appendChild(svgEl("rect", { x, y: y1, width: barW, height: h, fill: st.patternUrl, "fill-opacity": "1", stroke: "currentColor", "stroke-opacity": "0.10" }));
cumulative += value;
});
if (total > 0) {
const yy = yScale(total);
const h = Math.max(1, margin.top + innerH - yy);
const yo = periodOutline.get(period.key);
barsG.appendChild(svgEl("rect", { x, y: yy, width: barW, height: h, fill: "none", stroke: "currentColor", "stroke-opacity": String(yo.strokeOpacity), "stroke-dasharray": yo.strokeDasharray }));
} else {
barsG.appendChild(svgEl("rect", { x, 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 = row.month;
barsG.appendChild(xl);
});
const title = svgEl("text", { x: margin.left, y: 18, fill: "currentColor" });
title.style.fontSize = "14px";
title.style.fontWeight = "600";
title.textContent = `Client Hours by Month (${periods.map(period => periodRangeLabel(period)).join(" / ")}; stacked by agency)`;
svg.appendChild(title);
const legendG = svg.appendChild(svgEl("g"));
const legendX = margin.left;
const legendTop = baseHeight + 14;
const boxW = 14;
const boxH = 10;
const legendTitle = svgEl("text", { x: legendX, y: legendTop, fill: "currentColor" });
legendTitle.style.fontSize = "12px";
legendTitle.style.fontWeight = "600";
legendTitle.textContent = "Key";
legendG.appendChild(legendTitle);
periods.forEach((period, idx) => {
const yRow = legendTop + legendTitleH + idx * legendRowH;
const yo = periodOutline.get(period.key);
legendG.appendChild(svgEl("rect", { x: legendX, y: yRow - boxH + 2, width: boxW, height: boxH, fill: "none", stroke: "currentColor", "stroke-opacity": String(yo.strokeOpacity), "stroke-dasharray": yo.strokeDasharray }));
const t = svgEl("text", { x: legendX + boxW + 8, y: yRow + 2, fill: "currentColor" });
t.style.fontSize = "12px";
t.textContent = `${period.label}: ${periodRangeLabel(period)}`;
legendG.appendChild(t);
});
agencies.forEach((agency, idx) => {
const st = agencyStyle.get(agency);
const yRow = legendTop + legendTitleH + (3 + idx) * legendRowH;
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 = agency;
legendG.appendChild(t);
});
} else {
dv.paragraph("_Chart omitted in markdown export context._");
}
// Exportable Markdown/SVG version for Quartz portfolio snapshots.
// This duplicates the live report calculations, but returns a Markdown string.
const MIN_HOURS = 0.5;
// Export blocks run through the Templater exporter, where dv.current() may not
// exist. Keep export settings explicit rather than depending on dv.current().
const FILTER_AGENCY = "";
const rawStart = "2022-01-01";
const rawEnd = "";
const firstOf = (v) => Array.isArray(v) ? v[0] : v;
const toDate = (v) => {
v = firstOf(v);
if (!v) return null;
if (typeof v?.toFormat === "function") return new Date(`${v.toFormat("yyyy-MM-dd")}T00:00:00`);
if (typeof v?.toISODate === "function") return new Date(`${v.toISODate()}T00:00:00`);
const match = String(v).match(/^(\d{4})-(\d{2})-(\d{2})/);
if (match) return new Date(`${match[1]}-${match[2]}-${match[3]}T00:00:00`);
const d = new Date(v);
return Number.isNaN(d.getTime()) ? null : d;
};
const toNumber = (v) => {
v = firstOf(v);
if (v == null || v === "") return 0;
const n = parseFloat(String(v).trim());
return Number.isNaN(n) ? 0 : n;
};
const fmtHours = (n) => Number(n || 0).toFixed(2);
const fmtISODate = (d) => {
if (!d) return "—";
const y = d.getFullYear();
const m = String(d.getMonth() + 1).padStart(2, "0");
const day = String(d.getDate()).padStart(2, "0");
return `${y}-${m}-${day}`;
};
const esc = (s) => String(s ?? "")
.replace(/&/g, "&")
.replace(/</g, "<")
.replace(/>/g, ">")
.replace(/"/g, """);
const cell = (s) => String(s ?? "").replace(/\|/g, "\\|").replace(/\n/g, " ").trim();
let START_DATE = toDate(rawStart);
let END_DATE = toDate(rawEnd);
if (START_DATE && END_DATE && START_DATE > END_DATE) {
const tmp = START_DATE;
START_DATE = END_DATE;
END_DATE = tmp;
}
const startOfMonth = (d) => new Date(d.getFullYear(), d.getMonth(), 1);
const endOfMonth = (d) => new Date(d.getFullYear(), d.getMonth() + 1, 0, 23, 59, 59, 999);
const addMonths = (d, n) => new Date(d.getFullYear(), d.getMonth() + n, 1);
const monthKey = (d) => `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}`;
const fmtMonthYear = (d) => d.toLocaleString("en-GB", { month: "short", year: "numeric" });
const currentPeriodEndMonth = startOfMonth(new Date());
const currentMonths = Array.from({ length: 12 }, (_, i) => addMonths(currentPeriodEndMonth, i - 11));
const periods = [
{ key: "yearBefore", label: "Year before", offsetMonths: -24 },
{ key: "lastYear", label: "Last year", offsetMonths: -12 },
{ key: "thisYear", label: "This year", offsetMonths: 0 }
].map(period => ({
...period,
months: currentMonths.map(d => addMonths(d, period.offsetMonths))
}));
const periodRangeLabel = (period) => `${fmtMonthYear(period.months[0])}–${fmtMonthYear(period.months[period.months.length - 1])}`;
const records = Array.from(dv.pages("#clienthours").where(p => p.clientref && p.date)).map(p => ({
agency: (firstOf(p.agency) ?? "Unknown").toString().trim() || "Unknown",
date: toDate(p.date),
hours: toNumber(p.hours)
})).filter(r => r.date !== null);
const filtered = records.filter(r => {
const agencyOk = !FILTER_AGENCY || r.agency === FILTER_AGENCY;
const startOk = !START_DATE || r.date >= START_DATE;
const endOk = !END_DATE || r.date <= END_DATE;
const hoursOk = r.hours > MIN_HOURS;
return agencyOk && startOk && endOk && hoursOk;
});
const agencies = Array.from(new Set(filtered.map(r => r.agency))).sort((a, b) => a.localeCompare(b));
if (agencies.length === 0) agencies.push("Unknown");
const makeAgencyTotals = () => Object.fromEntries(agencies.map(a => [a, 0]));
const periodMonthAgencyTotals = new Map();
for (const period of periods) {
periodMonthAgencyTotals.set(period.key, new Map(period.months.map(d => [monthKey(d), makeAgencyTotals()])));
}
for (const r of filtered) {
const mk = monthKey(r.date);
for (const period of periods) {
const totals = periodMonthAgencyTotals.get(period.key).get(mk);
if (!totals) continue;
totals[r.agency] = (totals[r.agency] ?? 0) + r.hours;
}
}
const monthRows = currentMonths.map((monthStart, monthIndex) => {
const byPeriod = Object.fromEntries(periods.map(period => {
const comparisonMonth = period.months[monthIndex];
const byAgency = periodMonthAgencyTotals.get(period.key).get(monthKey(comparisonMonth));
const total = agencies.reduce((acc, agency) => acc + (byAgency[agency] ?? 0), 0);
return [period.key, { byAgency, total, month: comparisonMonth }];
}));
return { month: monthStart.toLocaleString("en-GB", { month: "short" }), monthIndex, currentMonth: monthStart, byPeriod };
});
const periodAgencyTotals = Object.fromEntries(periods.map(period => [period.key, makeAgencyTotals()]));
for (const row of monthRows) {
for (const period of periods) {
for (const agency of agencies) periodAgencyTotals[period.key][agency] += row.byPeriod[period.key].byAgency[agency] ?? 0;
}
}
const periodTotals = Object.fromEntries(periods.map(period => [period.key, agencies.reduce((acc, agency) => acc + (periodAgencyTotals[period.key][agency] ?? 0), 0)]));
const lines = [];
const bits = [];
if (FILTER_AGENCY) bits.push(`Agency: **${FILTER_AGENCY}**`);
if (START_DATE || END_DATE) bits.push(`Outer Window: **${fmtISODate(START_DATE)} → ${fmtISODate(END_DATE)}**`);
bits.push(`Min Session Hours: **>${MIN_HOURS}**`);
bits.push(`Current rolling window: **${fmtMonthYear(currentMonths[0])} → ${fmtMonthYear(currentMonths[currentMonths.length - 1])}**`);
lines.push(`Client hours by month, comparing the current rolling 12 months with the same months in the previous two years (${bits.join(" · ")}):`, "");
lines.push("### Monthly client hours comparison", "");
lines.push(`| Month | ${periods.map(period => `${period.label} (${periodRangeLabel(period)})`).join(" | ")} |`);
lines.push(`| --- | ${periods.map(() => "---:").join(" | ")} |`);
for (const row of monthRows) lines.push(`| ${row.month} | ${periods.map(period => fmtHours(row.byPeriod[period.key].total)).join(" | ")} |`);
lines.push("");
lines.push("### Rolling-period totals by agency", "");
lines.push(`| Agency | ${periods.map(period => period.label).join(" | ")} | Total |`);
lines.push(`| --- | ${periods.map(() => "---:").join(" | ")} | ---: |`);
for (const agency of agencies) {
const total = periods.reduce((acc, period) => acc + periodAgencyTotals[period.key][agency], 0);
lines.push(`| ${cell(agency)} | ${periods.map(period => fmtHours(periodAgencyTotals[period.key][agency])).join(" | ")} | ${fmtHours(total)} |`);
}
lines.push(`| **Total** | ${periods.map(period => `**${fmtHours(periodTotals[period.key])}**`).join(" | ")} | **${fmtHours(periods.reduce((acc, period) => acc + periodTotals[period.key], 0))}** |`, "");
// SVG chart as text
const width = 1200;
const baseHeight = 390;
const legendRowH = 18;
const legendTitleH = 16;
const legendPadding = 10;
const legendH = (3 + agencies.length) * legendRowH + legendTitleH + legendPadding;
const height = baseHeight + legendH;
const margin = { top: 30, right: 24, bottom: 58, left: 58 };
const innerW = width - margin.left - margin.right;
const innerH = baseHeight - margin.top - margin.bottom;
const palette = ["#4C78A8", "#F58518", "#54A24B", "#E45756", "#72B7B2", "#B279A2", "#FF9DA6", "#9D755D", "#BAB0AC"];
const agencyStyle = new Map(agencies.map((agency, idx) => {
const colour = palette[idx % palette.length];
return [agency, { fill: colour, fillOpacity: 0.35, patId: `export-agency-hatch-${idx}`, angle: (idx % 4) * 45, spacing: 6 + (idx % 3) * 2, strokeW: 1 + ((idx % 2) * 0.4) }];
}));
const periodOutline = new Map([
[periods[0].key, { strokeDasharray: "2 2", strokeOpacity: 0.35 }],
[periods[1].key, { strokeDasharray: "5 3", strokeOpacity: 0.50 }],
[periods[2].key, { strokeDasharray: "", strokeOpacity: 0.28 }]
]);
const maxY = Math.max(1, ...monthRows.flatMap(row => periods.map(period => row.byPeriod[period.key].total)));
const xStep = innerW / monthRows.length;
const groupW = xStep * 0.82;
const barGap = 5;
const barW = Math.max(6, (groupW - (barGap * 2)) / 3);
const groupX = i => margin.left + i * xStep + (xStep - groupW) / 2;
const yScale = v => margin.top + innerH - (v / maxY) * innerH;
const svg = [];
svg.push(`<div class="portfolio-chart client-hours-chart">`);
svg.push(`<svg viewBox="0 0 ${width} ${height}" role="img" aria-label="Client hours by rolling comparison period" xmlns="http://www.w3.org/2000/svg">`);
svg.push(`<defs>`);
for (const [agency, st] of agencyStyle.entries()) {
svg.push(`<pattern id="${st.patId}" patternUnits="userSpaceOnUse" width="${st.spacing}" height="${st.spacing}" patternTransform="rotate(${st.angle})"><line x1="0" y1="0" x2="0" y2="${st.spacing}" stroke="${st.fill}" stroke-width="${st.strokeW}" stroke-linecap="square" stroke-opacity="0.9" /></pattern>`);
}
svg.push(`</defs>`);
svg.push(`<text x="${margin.left}" y="18" fill="currentColor" font-size="14" font-weight="600">Client Hours by Month (${periods.map(period => periodRangeLabel(period)).join(" / ")}; stacked by agency)</text>`);
svg.push(`<line x1="${margin.left}" y1="${margin.top}" x2="${margin.left}" y2="${margin.top + innerH}" stroke="currentColor" />`);
svg.push(`<line x1="${margin.left}" y1="${margin.top + innerH}" x2="${margin.left + innerW}" y2="${margin.top + innerH}" stroke="currentColor" />`);
const yTicks = Math.min(6, Math.max(1, Math.ceil(maxY)));
for (let t = 0; t <= yTicks; t++) {
const val = (t / yTicks) * maxY;
const yy = yScale(val);
svg.push(`<line x1="${margin.left}" y1="${yy.toFixed(1)}" x2="${margin.left + innerW}" y2="${yy.toFixed(1)}" stroke="currentColor" stroke-opacity="0.15" />`);
svg.push(`<text x="${margin.left - 8}" y="${(yy + 4).toFixed(1)}" text-anchor="end" fill="currentColor" font-size="12">${fmtHours(val)}</text>`);
}
for (const [i, row] of monthRows.entries()) {
const gx = groupX(i);
for (const [periodIdx, period] of periods.entries()) {
const x = gx + periodIdx * (barW + barGap);
const byAgency = row.byPeriod[period.key].byAgency;
const total = row.byPeriod[period.key].total;
let cumulative = 0;
for (const agency of agencies) {
const value = byAgency[agency] ?? 0;
if (!value) continue;
const y0 = yScale(cumulative);
const y1 = yScale(cumulative + value);
const h = Math.max(1, y0 - y1);
const st = agencyStyle.get(agency);
svg.push(`<rect x="${x.toFixed(1)}" y="${y1.toFixed(1)}" width="${barW.toFixed(1)}" height="${h.toFixed(1)}" fill="${st.fill}" fill-opacity="${st.fillOpacity}" />`);
svg.push(`<rect x="${x.toFixed(1)}" y="${y1.toFixed(1)}" width="${barW.toFixed(1)}" height="${h.toFixed(1)}" fill="url(#${st.patId})" stroke="currentColor" stroke-opacity="0.10" />`);
cumulative += value;
}
if (total > 0) {
const yy = yScale(total);
const h = Math.max(1, margin.top + innerH - yy);
const yo = periodOutline.get(period.key);
svg.push(`<rect x="${x.toFixed(1)}" y="${yy.toFixed(1)}" width="${barW.toFixed(1)}" height="${h.toFixed(1)}" fill="none" stroke="currentColor" stroke-opacity="${yo.strokeOpacity}" stroke-dasharray="${yo.strokeDasharray}" />`);
} else {
svg.push(`<rect x="${x.toFixed(1)}" y="${margin.top + innerH - 1}" width="${barW.toFixed(1)}" height="1" fill="currentColor" fill-opacity="0.15" />`);
}
}
svg.push(`<text x="${(margin.left + i * xStep + xStep / 2).toFixed(1)}" y="${margin.top + innerH + 18}" text-anchor="middle" fill="currentColor" font-size="12">${row.month}</text>`);
}
const legendX = margin.left;
const legendTop = baseHeight + 14;
const boxW = 14;
const boxH = 10;
svg.push(`<text x="${legendX}" y="${legendTop}" fill="currentColor" font-size="12" font-weight="600">Key</text>`);
periods.forEach((period, idx) => {
const yRow = legendTop + legendTitleH + idx * legendRowH;
const yo = periodOutline.get(period.key);
svg.push(`<rect x="${legendX}" y="${yRow - boxH + 2}" width="${boxW}" height="${boxH}" fill="none" stroke="currentColor" stroke-opacity="${yo.strokeOpacity}" stroke-dasharray="${yo.strokeDasharray}" />`);
svg.push(`<text x="${legendX + boxW + 8}" y="${yRow + 2}" fill="currentColor" font-size="12">${period.label}: ${periodRangeLabel(period)}</text>`);
});
agencies.forEach((agency, idx) => {
const st = agencyStyle.get(agency);
const yRow = legendTop + legendTitleH + (3 + idx) * legendRowH;
svg.push(`<rect x="${legendX}" y="${yRow - boxH + 2}" width="${boxW}" height="${boxH}" fill="${st.fill}" fill-opacity="${st.fillOpacity}" />`);
svg.push(`<rect x="${legendX}" y="${yRow - boxH + 2}" width="${boxW}" height="${boxH}" fill="url(#${st.patId})" stroke="currentColor" stroke-opacity="0.35" />`);
svg.push(`<text x="${legendX + boxW + 8}" y="${yRow + 2}" fill="currentColor" font-size="12">${esc(agency)}</text>`);
});
svg.push(`</svg>`);
svg.push(`</div>`);
lines.push("### Client hours graph", "", svg.join("\n"), "");
return lines.join("\n");