feat(charts): show actual dates in trend chart tooltips
Deploy HiHala Dashboard / deploy (push) Successful in 11s
Deploy HiHala Dashboard / deploy (push) Successful in 11s
Replace opaque W1/D1/month abbreviation tooltip titles with human-readable period labels (e.g. "Week 1 · 1 Apr – 7 Apr", "1 April 2025", "April 2025") in both Dashboard and Comparison trend charts. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -128,7 +128,7 @@ export default function PeriodSelectorDemo({ data, seasons, includeVAT, allowedM
|
|||||||
return sy===ey ? sy : `${L.monthShort[parseInt(s.slice(5,7))-1]} '${sy.slice(-2)}–${L.monthShort[parseInt(e.slice(5,7))-1]} '${ey.slice(-2)}`;
|
return sy===ey ? sy : `${L.monthShort[parseInt(s.slice(5,7))-1]} '${sy.slice(-2)}–${L.monthShort[parseInt(e.slice(5,7))-1]} '${ey.slice(-2)}`;
|
||||||
};
|
};
|
||||||
|
|
||||||
const trendData = useMemo(() => {
|
const trendResult = useMemo(() => {
|
||||||
const group = (rows: MuseumRecord[], ps: string) => {
|
const group = (rows: MuseumRecord[], ps: string) => {
|
||||||
const s=new Date(ps); const acc: Record<number,MuseumRecord[]> = {};
|
const s=new Date(ps); const acc: Record<number,MuseumRecord[]> = {};
|
||||||
rows.forEach(r => {
|
rows.forEach(r => {
|
||||||
@@ -142,17 +142,36 @@ export default function PeriodSelectorDemo({ data, seasons, includeVAT, allowedM
|
|||||||
};
|
};
|
||||||
const pg = group(prevData, prevStart), cg = group(currData, currStart);
|
const pg = group(prevData, prevStart), cg = group(currData, currStart);
|
||||||
const maxK = Math.max(...Object.keys(pg).map(Number), ...Object.keys(cg).map(Number), 1);
|
const maxK = Math.max(...Object.keys(pg).map(Number), ...Object.keys(cg).map(Number), 1);
|
||||||
|
const cs0 = new Date(currStart);
|
||||||
|
const fmt = (d: Date) => d.toLocaleDateString('en-GB', { day: 'numeric', month: 'short' });
|
||||||
const labels = Array.from({length:maxK}, (_,i) =>
|
const labels = Array.from({length:maxK}, (_,i) =>
|
||||||
gran==='week' ? `W${i+1}` : gran==='month' ? L.monthShort[(new Date(currStart).getMonth()+i)%12] : `D${i+1}`
|
gran==='week' ? `W${i+1}` : gran==='month' ? L.monthShort[(cs0.getMonth()+i)%12] : `D${i+1}`
|
||||||
);
|
);
|
||||||
|
const tooltipLabels = Array.from({length:maxK}, (_,i) => {
|
||||||
|
if (gran==='week') {
|
||||||
|
const ws = new Date(cs0.getTime() + i * 7 * 86400000);
|
||||||
|
const we = new Date(cs0.getTime() + (i+1) * 7 * 86400000 - 86400000);
|
||||||
|
return `Week ${i+1} · ${fmt(ws)} – ${fmt(we)}`;
|
||||||
|
}
|
||||||
|
if (gran==='month') {
|
||||||
|
const ms = new Date(cs0.getFullYear(), cs0.getMonth() + i, 1);
|
||||||
|
return ms.toLocaleDateString('en-GB', { month: 'long', year: 'numeric' });
|
||||||
|
}
|
||||||
|
const ds = new Date(cs0.getTime() + i * 86400000);
|
||||||
|
return ds.toLocaleDateString('en-GB', { day: 'numeric', month: 'long', year: 'numeric' });
|
||||||
|
});
|
||||||
return {
|
return {
|
||||||
labels,
|
tooltipLabels,
|
||||||
datasets: [
|
data: {
|
||||||
{ label:periodLabel(prevStart,prevEnd), data:labels.map((_,i) => pg[i+1]||0), borderColor:chartColors.muted, backgroundColor:'transparent', borderWidth:2, tension:0.4, pointRadius:gran==='week'?3:1, pointBackgroundColor:chartColors.muted },
|
labels,
|
||||||
{ label:periodLabel(currStart,currEnd), data:labels.map((_,i) => cg[i+1]||0), borderColor:chartColors.primary, backgroundColor:chartColors.primary+'15', borderWidth:2, tension:0.4, fill:true, pointRadius:gran==='week'?4:2, pointBackgroundColor:chartColors.primary },
|
datasets: [
|
||||||
]
|
{ label:periodLabel(prevStart,prevEnd), data:labels.map((_,i) => pg[i+1]||0), borderColor:chartColors.muted, backgroundColor:'transparent', borderWidth:2, tension:0.4, pointRadius:gran==='week'?3:1, pointBackgroundColor:chartColors.muted },
|
||||||
|
{ label:periodLabel(currStart,currEnd), data:labels.map((_,i) => cg[i+1]||0), borderColor:chartColors.primary, backgroundColor:chartColors.primary+'15', borderWidth:2, tension:0.4, fill:true, pointRadius:gran==='week'?4:2, pointBackgroundColor:chartColors.primary },
|
||||||
|
]
|
||||||
|
}
|
||||||
};
|
};
|
||||||
}, [prevData, currData, prevStart, currStart, prevEnd, currEnd, metric, gran, getVal, L]);
|
}, [prevData, currData, prevStart, currStart, prevEnd, currEnd, metric, gran, getVal, L]);
|
||||||
|
const trendData = trendResult.data;
|
||||||
|
|
||||||
const museumData = useMemo(() => {
|
const museumData = useMemo(() => {
|
||||||
const all = [...new Set(data.map(r => r.museum_name))].filter(Boolean) as string[];
|
const all = [...new Set(data.map(r => r.museum_name))].filter(Boolean) as string[];
|
||||||
@@ -173,6 +192,18 @@ export default function PeriodSelectorDemo({ data, seasons, includeVAT, allowedM
|
|||||||
const chartOpts: any = { ...baseOpts, plugins:{ ...baseOpts.plugins, legend:{ position:'top', align:'end', labels:{ boxWidth:12, padding:12 } } } };
|
const chartOpts: any = { ...baseOpts, plugins:{ ...baseOpts.plugins, legend:{ position:'top', align:'end', labels:{ boxWidth:12, padding:12 } } } };
|
||||||
return { chartOpts };
|
return { chartOpts };
|
||||||
}, [baseOpts]);
|
}, [baseOpts]);
|
||||||
|
const trendOpts: any = useMemo(() => ({
|
||||||
|
...chartOpts,
|
||||||
|
plugins: {
|
||||||
|
...chartOpts.plugins,
|
||||||
|
tooltip: {
|
||||||
|
...chartOpts.plugins.tooltip,
|
||||||
|
callbacks: {
|
||||||
|
title: (items: any[]) => trendResult.tooltipLabels[items[0]?.dataIndex] ?? items[0]?.label,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}), [chartOpts, trendResult.tooltipLabels]);
|
||||||
|
|
||||||
const metricOpts = [
|
const metricOpts = [
|
||||||
{ value:'revenue', label:L.revenue }, { value:'visitors', label:L.visitors },
|
{ value:'revenue', label:L.revenue }, { value:'visitors', label:L.visitors },
|
||||||
@@ -274,7 +305,7 @@ export default function PeriodSelectorDemo({ data, seasons, includeVAT, allowedM
|
|||||||
{granOpts.map(o => <button key={o.value} type="button" aria-pressed={gran===o.value} className={`alt-ctrl${gran===o.value?' alt-ctrl-on':''}`} onClick={() => setGran(o.value)}>{o.label}</button>)}
|
{granOpts.map(o => <button key={o.value} type="button" aria-pressed={gran===o.value} className={`alt-ctrl${gran===o.value?' alt-ctrl-on':''}`} onClick={() => setGran(o.value)}>{o.label}</button>)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="alt-chart-wrap"><Line data={trendData} options={chartOpts} /></div>
|
<div className="alt-chart-wrap"><Line data={trendData} options={trendOpts} /></div>
|
||||||
</div>
|
</div>
|
||||||
<div className="alt-chart-card">
|
<div className="alt-chart-card">
|
||||||
<div className="alt-chart-header">
|
<div className="alt-chart-header">
|
||||||
|
|||||||
@@ -88,7 +88,7 @@ export default function DashboardDemo({ data, seasons: _seasons, includeVAT, set
|
|||||||
return rows.reduce((s,r) => s + parseFloat(String((r as any)[f[m]]||0)), 0);
|
return rows.reduce((s,r) => s + parseFloat(String((r as any)[f[m]]||0)), 0);
|
||||||
}, [revenueField]);
|
}, [revenueField]);
|
||||||
|
|
||||||
const trendData = useMemo(() => {
|
const trendResult = useMemo(() => {
|
||||||
const group = (rows: MuseumRecord[], ps: string) => {
|
const group = (rows: MuseumRecord[], ps: string) => {
|
||||||
const s = new Date(ps); const acc: Record<number, MuseumRecord[]> = {};
|
const s = new Date(ps); const acc: Record<number, MuseumRecord[]> = {};
|
||||||
rows.forEach(r => {
|
rows.forEach(r => {
|
||||||
@@ -102,18 +102,37 @@ export default function DashboardDemo({ data, seasons: _seasons, includeVAT, set
|
|||||||
};
|
};
|
||||||
const pg = group(prevData, prevStart), cg = group(filteredData, start);
|
const pg = group(prevData, prevStart), cg = group(filteredData, start);
|
||||||
const maxK = Math.max(...Object.keys(pg).map(Number), ...Object.keys(cg).map(Number), 1);
|
const maxK = Math.max(...Object.keys(pg).map(Number), ...Object.keys(cg).map(Number), 1);
|
||||||
|
const s0 = new Date(start);
|
||||||
|
const fmt = (d: Date) => d.toLocaleDateString('en-GB', { day: 'numeric', month: 'short' });
|
||||||
const labels = Array.from({length:maxK}, (_,i) =>
|
const labels = Array.from({length:maxK}, (_,i) =>
|
||||||
gran==='week' ? `W${i+1}` : gran==='month' ? L.monthShort[(new Date(start).getMonth()+i)%12] : `D${i+1}`
|
gran==='week' ? `W${i+1}` : gran==='month' ? L.monthShort[(s0.getMonth()+i)%12] : `D${i+1}`
|
||||||
);
|
);
|
||||||
|
const tooltipLabels = Array.from({length:maxK}, (_,i) => {
|
||||||
|
if (gran==='week') {
|
||||||
|
const ws = new Date(s0.getTime() + i * 7 * 86400000);
|
||||||
|
const we = new Date(s0.getTime() + (i+1) * 7 * 86400000 - 86400000);
|
||||||
|
return `Week ${i+1} · ${fmt(ws)} – ${fmt(we)}`;
|
||||||
|
}
|
||||||
|
if (gran==='month') {
|
||||||
|
const ms = new Date(s0.getFullYear(), s0.getMonth() + i, 1);
|
||||||
|
return ms.toLocaleDateString('en-GB', { month: 'long', year: 'numeric' });
|
||||||
|
}
|
||||||
|
const ds = new Date(s0.getTime() + i * 86400000);
|
||||||
|
return ds.toLocaleDateString('en-GB', { day: 'numeric', month: 'long', year: 'numeric' });
|
||||||
|
});
|
||||||
const prevYear = parseInt(start.slice(0,4))-1;
|
const prevYear = parseInt(start.slice(0,4))-1;
|
||||||
return {
|
return {
|
||||||
labels,
|
tooltipLabels,
|
||||||
datasets: [
|
data: {
|
||||||
{ label:`${prevYear}`, data:labels.map((_,i) => pg[i+1]||0), borderColor:chartColors.muted, backgroundColor:'transparent', borderWidth:1.5, tension:0.4, pointRadius:0, borderDash:[4,3] },
|
labels,
|
||||||
{ label:start.slice(0,4), data:labels.map((_,i) => cg[i+1]||0), borderColor:chartColors.primary, backgroundColor:chartColors.primary+'18', borderWidth:2.5, tension:0.4, fill:true, pointRadius:gran==='week'?3:1, pointBackgroundColor:chartColors.primary },
|
datasets: [
|
||||||
]
|
{ label:`${prevYear}`, data:labels.map((_,i) => pg[i+1]||0), borderColor:chartColors.muted, backgroundColor:'transparent', borderWidth:1.5, tension:0.4, pointRadius:0, borderDash:[4,3] },
|
||||||
|
{ label:start.slice(0,4), data:labels.map((_,i) => cg[i+1]||0), borderColor:chartColors.primary, backgroundColor:chartColors.primary+'18', borderWidth:2.5, tension:0.4, fill:true, pointRadius:gran==='week'?3:1, pointBackgroundColor:chartColors.primary },
|
||||||
|
]
|
||||||
|
}
|
||||||
};
|
};
|
||||||
}, [filteredData, prevData, prevStart, start, metric, gran, getVal, L]);
|
}, [filteredData, prevData, prevStart, start, metric, gran, getVal, L]);
|
||||||
|
const trendData = trendResult.data;
|
||||||
|
|
||||||
const museumData = useMemo(() => {
|
const museumData = useMemo(() => {
|
||||||
const g = groupByMuseum(filteredData, includeVAT);
|
const g = groupByMuseum(filteredData, includeVAT);
|
||||||
@@ -172,6 +191,19 @@ export default function DashboardDemo({ data, seasons: _seasons, includeVAT, set
|
|||||||
const barNoLegend: any = { ...chartOpts, plugins:{ ...chartOpts.plugins, legend:{ display:false } } };
|
const barNoLegend: any = { ...chartOpts, plugins:{ ...chartOpts.plugins, legend:{ display:false } } };
|
||||||
return { chartOpts, barHorizOpts, barNoLegend };
|
return { chartOpts, barHorizOpts, barNoLegend };
|
||||||
}, [baseOpts]);
|
}, [baseOpts]);
|
||||||
|
const trendOpts: any = useMemo(() => ({
|
||||||
|
...chartOpts,
|
||||||
|
plugins: {
|
||||||
|
...chartOpts.plugins,
|
||||||
|
tooltip: {
|
||||||
|
...chartOpts.plugins.tooltip,
|
||||||
|
callbacks: {
|
||||||
|
title: (items: any[]) => trendResult.tooltipLabels[items[0]?.dataIndex] ?? items[0]?.label,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}), [chartOpts, trendResult.tooltipLabels]);
|
||||||
|
|
||||||
const pieOptions: any = useMemo(() => ({
|
const pieOptions: any = useMemo(() => ({
|
||||||
responsive: true, maintainAspectRatio: false,
|
responsive: true, maintainAspectRatio: false,
|
||||||
plugins: {
|
plugins: {
|
||||||
@@ -250,7 +282,7 @@ export default function DashboardDemo({ data, seasons: _seasons, includeVAT, set
|
|||||||
{granOpts.map(o => <button key={o.value} type="button" aria-pressed={gran===o.value} className={`alt-ctrl${gran===o.value?' alt-ctrl-on':''}`} onClick={() => setGran(o.value)}>{o.label}</button>)}
|
{granOpts.map(o => <button key={o.value} type="button" aria-pressed={gran===o.value} className={`alt-ctrl${gran===o.value?' alt-ctrl-on':''}`} onClick={() => setGran(o.value)}>{o.label}</button>)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="alt-chart-wrap alt-chart-wrap--tall"><Line data={trendData} options={chartOpts} /></div>
|
<div className="alt-chart-wrap alt-chart-wrap--tall"><Line data={trendData} options={trendOpts} /></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="alt-chart-card">
|
<div className="alt-chart-card">
|
||||||
|
|||||||
Reference in New Issue
Block a user