Skip to content

Commit 4a7ecb2

Browse files
committed
Add stacked bar chart to Totals By Day page
1 parent 9f98201 commit 4a7ecb2

File tree

5 files changed

+267
-3
lines changed

5 files changed

+267
-3
lines changed
Lines changed: 122 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,122 @@
1+
<script lang="ts">
2+
import { BarChart } from 'layerchart';
3+
import '../styles/metrics-stacked-bar-chart.css';
4+
5+
type StackedDatum = Record<string, string | number>;
6+
type StackedSeries = { key: string; label: string; color?: string };
7+
8+
let {
9+
data,
10+
series,
11+
x = 'key',
12+
bandPadding = 0.2,
13+
labelType = 'string',
14+
minLabelWidth = 90,
15+
barWidth = 6,
16+
showLegend = true
17+
}: {
18+
data: StackedDatum[];
19+
series: StackedSeries[];
20+
x?: string;
21+
bandPadding?: number;
22+
labelType?: 'string' | 'emoji';
23+
minLabelWidth?: number;
24+
barWidth?: number;
25+
showLegend?: boolean;
26+
} = $props();
27+
28+
let containerWidth = $state(0);
29+
30+
const labelClass = $derived(labelType === 'emoji' ? 'emoji-tick-label' : 'text-tick-label');
31+
32+
const tickCount = $derived.by(() => {
33+
if (containerWidth <= 0) {
34+
return 4;
35+
}
36+
return Math.max(2, Math.floor(containerWidth / minLabelWidth));
37+
});
38+
39+
const chartWidth = $derived.by(() => {
40+
if (data.length === 0) {
41+
return containerWidth;
42+
}
43+
return Math.max(containerWidth, data.length * barWidth);
44+
});
45+
46+
const axisProps = $derived.by(() => ({
47+
xAxis: {
48+
ticks: tickCount,
49+
tickLabelProps: {
50+
class: labelClass
51+
}
52+
},
53+
yAxis: {
54+
ticks: 5
55+
}
56+
}));
57+
58+
const maxStackValue = $derived.by(() => {
59+
if (data.length === 0 || series.length === 0) {
60+
return 0;
61+
}
62+
return data.reduce((maxValue, datum) => {
63+
const total = series.reduce((sum, item) => {
64+
const value = Number(datum[item.key] ?? 0);
65+
return sum + (Number.isFinite(value) ? value : 0);
66+
}, 0);
67+
return Math.max(maxValue, total);
68+
}, 0);
69+
});
70+
71+
const chartPadding = $derived.by(() => {
72+
const label = Math.round(maxStackValue).toLocaleString();
73+
const estimatedLabelWidth = Math.max(48, Math.min(140, 12 + label.length * 8));
74+
return {
75+
top: 20,
76+
right: 12,
77+
bottom: 24,
78+
left: estimatedLabelWidth
79+
};
80+
});
81+
</script>
82+
83+
{#if data.length > 0 && series.length > 0}
84+
<div class="metrics-stacked-bar-chart__container">
85+
<div class="metrics-stacked-bar-chart__scroll" bind:clientWidth={containerWidth}>
86+
<article class="metrics-stacked-bar-chart" style={`width: ${chartWidth}px;`}>
87+
<BarChart
88+
{data}
89+
{series}
90+
{bandPadding}
91+
{x}
92+
seriesLayout="stack"
93+
padding={chartPadding}
94+
props={{
95+
bars: {
96+
radius: 0
97+
},
98+
...axisProps,
99+
tooltip: {
100+
root: {
101+
classes: { root: 'layerchart-tooltip' }
102+
}
103+
}
104+
}}
105+
/>
106+
</article>
107+
</div>
108+
{#if showLegend}
109+
<ul class="metrics-stacked-bar-chart__legend" aria-label="Chart legend">
110+
{#each series as item}
111+
<li class="metrics-stacked-bar-chart__legend-item">
112+
<span
113+
class="metrics-stacked-bar-chart__legend-swatch"
114+
style={`--legend-color: ${item.color ?? '#9aa0a6'}`}
115+
></span>
116+
<span class="metrics-stacked-bar-chart__legend-label">{item.label}</span>
117+
</li>
118+
{/each}
119+
</ul>
120+
{/if}
121+
</div>
122+
{/if}

desktop-app/src/components/ResultGrid.svelte

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -22,9 +22,10 @@
2222
command: string[];
2323
charts?: Snippet<[Array<Record<string, unknown>>, GridColumn[]]>;
2424
children?: Snippet;
25+
chartsClass?: string;
2526
}
2627
27-
let { title, description, command, charts, children }: Props = $props();
28+
let { title, description, command, charts, children, chartsClass }: Props = $props();
2829
2930
let isReloadingData = $state(true);
3031
let hasInitiallyLoaded = $state(false);
@@ -263,7 +264,11 @@
263264
</form>
264265

265266
{#if charts && rows.length && !errorMessage}
266-
<div class="result-grid-charts" aria-label="Chart area" role="img">
267+
<div
268+
class={`result-grid-charts${chartsClass ? ` ${chartsClass}` : ''}`}
269+
aria-label="Chart area"
270+
role="img"
271+
>
267272
{@render charts(rows, columns)}
268273
</div>
269274
{/if}
Lines changed: 73 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,81 @@
11
<script lang="ts">
2+
import MetricStackedBarChart from '../../components/MetricStackedBarChart.svelte';
23
import ResultGrid from '../../components/ResultGrid.svelte';
4+
import type { GridColumn } from '../../types';
5+
6+
const seriesColors = [
7+
'#0f62fe',
8+
'#24a148',
9+
'#8a3ffc',
10+
'#ff832b',
11+
'#007d79',
12+
'#fa4d56',
13+
'#a56eff'
14+
];
15+
16+
function getStackedChartProps({
17+
rows,
18+
columns
19+
}: {
20+
rows: Array<Record<string, unknown>>;
21+
columns: GridColumn[];
22+
}): {
23+
data: Array<Record<string, string | number>>;
24+
series: Array<{ key: string; label: string; color: string }>;
25+
} {
26+
if (!rows.length || !columns.length) {
27+
return { data: [], series: [] };
28+
}
29+
30+
const dateColumn = columns.find((column) => /date/i.test(column.header)) ?? columns[0];
31+
if (!dateColumn) {
32+
return { data: [], series: [] };
33+
}
34+
35+
const matchedSeries = columns.filter((column) => /sent\s*by/i.test(column.header));
36+
const seriesColumns = matchedSeries.length
37+
? matchedSeries
38+
: columns.filter((column) => {
39+
if (column.id === dateColumn.id) {
40+
return false;
41+
}
42+
return !/total|#\s*sent$/i.test(column.header);
43+
});
44+
45+
if (!seriesColumns.length) {
46+
return { data: [], series: [] };
47+
}
48+
49+
const data = rows
50+
.map((row) => {
51+
const entry: Record<string, string | number> = {
52+
date: String(row[dateColumn.id] ?? '')
53+
};
54+
for (const column of seriesColumns) {
55+
entry[column.id] = Number(row[column.id] ?? 0);
56+
}
57+
return entry;
58+
})
59+
.filter((entry) => entry.date);
60+
61+
const series = seriesColumns.map((column, index) => ({
62+
key: column.id,
63+
label: column.header,
64+
color: seriesColors[index % seriesColors.length]
65+
}));
66+
67+
return { data, series };
68+
}
369
</script>
470

571
<ResultGrid
672
title="Totals by Day"
773
description="See your message totals, per person or overall, for each day you've messaged"
874
command={['totals_by_day']}
9-
/>
75+
chartsClass="result-grid-charts--wide"
76+
>
77+
{#snippet charts(rows, columns)}
78+
{@const chartProps = getStackedChartProps({ rows, columns })}
79+
<MetricStackedBarChart data={chartProps.data} series={chartProps.series} x="date" />
80+
{/snippet}
81+
</ResultGrid>
Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
.metrics-stacked-bar-chart__container {
2+
display: flex;
3+
flex-direction: column;
4+
align-items: center;
5+
width: 100%;
6+
}
7+
8+
.metrics-stacked-bar-chart__scroll {
9+
width: 100%;
10+
overflow-x: auto;
11+
overflow-y: hidden;
12+
padding-bottom: 0.5rem;
13+
align-self: stretch;
14+
}
15+
16+
.metrics-stacked-bar-chart {
17+
height: 360px;
18+
min-width: 100%;
19+
}
20+
21+
.metrics-stacked-bar-chart__legend {
22+
display: flex;
23+
flex-wrap: wrap;
24+
justify-content: center;
25+
gap: 0.5rem 1.25rem;
26+
margin: 0.25rem 0 0;
27+
padding: 0;
28+
list-style: none;
29+
}
30+
31+
.metrics-stacked-bar-chart__legend-item {
32+
display: inline-flex;
33+
align-items: center;
34+
gap: 0.45rem;
35+
font-size: 0.85rem;
36+
color: #e0e0e0;
37+
}
38+
39+
.metrics-stacked-bar-chart__legend-swatch {
40+
width: 0.75rem;
41+
height: 0.75rem;
42+
border-radius: 0.2rem;
43+
background: var(--legend-color, #9aa0a6);
44+
box-shadow: 0 0 0 1px rgba(255, 255, 255, 0.18);
45+
}
46+
47+
/* Force Apple Color Emoji to ensure emojis are rendered as graphics, not text */
48+
.emoji-tick-label {
49+
font-family:
50+
'Apple Color Emoji', 'Segoe UI Emoji', 'Noto Color Emoji', 'Segoe UI Symbol', sans-serif;
51+
letter-spacing: normal;
52+
word-spacing: normal;
53+
}
54+
55+
.text-tick-label {
56+
font-family:
57+
-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif,
58+
'Segoe UI Symbol';
59+
letter-spacing: normal;
60+
word-spacing: normal;
61+
}

desktop-app/src/styles/result-grid.css

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -95,3 +95,7 @@
9595
.result-grid-charts text {
9696
fill: #fff;
9797
}
98+
99+
.result-grid-charts--wide {
100+
width: min(100%, 90vw);
101+
}

0 commit comments

Comments
 (0)