Skip to content

Commit 370c3f2

Browse files
authored
feat: group level of participation (#930)
* feat: group level of participation * feat: add charts
1 parent 718f013 commit 370c3f2

File tree

20 files changed

+2445
-207
lines changed

20 files changed

+2445
-207
lines changed

infrastructure/control-panel/config/admin-enames.json

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@
44
"@82f7a77a-f03a-52aa-88fc-1b1e488ad498",
55
"@35a31f0d-dd76-5780-b383-29f219fcae99",
66
"@82f7a77a-f03a-52aa-88fc-1b1e488ad498",
7-
"@af7e4f55-ad9d-537c-81ef-4f3a234bdd2c"
7+
"@af7e4f55-ad9d-537c-81ef-4f3a234bdd2c",
8+
"@6e1bbcd4-1f59-5bd8-aa3c-6f5301c356d7",
9+
"@b995a88a-90d1-56fc-ba42-1e1eb664861c"
810
]
911
}

infrastructure/control-panel/package.json

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@
2424
"@sveltejs/kit": "^2.22.0",
2525
"@sveltejs/vite-plugin-svelte": "^6.0.0",
2626
"@tailwindcss/vite": "^4.0.0",
27+
"@types/d3-shape": "^3.1.8",
2728
"@types/node": "^22",
2829
"@types/qrcode": "^1.5.6",
2930
"eslint": "^9.18.0",
@@ -48,10 +49,12 @@
4849
"@inlang/paraglide-js": "^2.0.0",
4950
"@xyflow/svelte": "^1.2.2",
5051
"clsx": "^2.1.1",
52+
"d3-shape": "^3.2.0",
5153
"flowbite": "^3.1.2",
5254
"flowbite-svelte": "^1.10.7",
5355
"flowbite-svelte-icons": "^2.2.1",
5456
"jose": "^6.2.0",
57+
"layercake": "^10.0.2",
5558
"lowdb": "^7.0.1",
5659
"lucide-svelte": "^0.561.0",
5760
"qrcode": "^1.5.4",
Lines changed: 154 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,154 @@
1+
<script lang="ts">
2+
import { LayerCake, Svg } from 'layercake';
3+
import {
4+
GROUP_NO_SENDER_BUCKET_KEY,
5+
type GroupSenderRow
6+
} from '$lib/services/evaultService';
7+
import GroupDonutArcs from './GroupDonutArcs.svelte';
8+
9+
interface Props {
10+
senderRows: GroupSenderRow[];
11+
/** Group route param; used for per-sender message list links. */
12+
groupEvaultId: string;
13+
}
14+
let { senderRows, groupEvaultId }: Props = $props();
15+
16+
/** When false (default), system / no-sender bucket is omitted from the donut and legend. */
17+
let showSystemNoSender = $state(false);
18+
19+
function isSystemNoSenderRow(r: GroupSenderRow): boolean {
20+
return (
21+
r.bucketKey === GROUP_NO_SENDER_BUCKET_KEY ||
22+
r.ename === '' ||
23+
r.displayName.trim().toLowerCase() === 'system / no sender'
24+
);
25+
}
26+
27+
type DonutSlice = {
28+
id: string;
29+
label: string;
30+
sub: string;
31+
value: number;
32+
color: string;
33+
bucketKey: string;
34+
evaultPageId: string | null;
35+
};
36+
37+
const PALETTE = [
38+
'#2563eb',
39+
'#7c3aed',
40+
'#0d9488',
41+
'#059669',
42+
'#d97706',
43+
'#dc2626',
44+
'#db2777',
45+
'#4f46e5',
46+
'#ea580c',
47+
'#0891b2'
48+
];
49+
50+
const slices = $derived.by((): DonutSlice[] => {
51+
const rows = senderRows.filter((r) => {
52+
if (!showSystemNoSender && isSystemNoSenderRow(r)) {
53+
return false;
54+
}
55+
return r.messageCount > 0;
56+
});
57+
return rows.map((r, i) => ({
58+
id: `${i}-${r.bucketKey}`,
59+
label: r.displayName,
60+
sub: r.ename === '' ? 'No sender' : r.ename,
61+
value: r.messageCount,
62+
color: PALETTE[i % PALETTE.length],
63+
bucketKey: r.bucketKey,
64+
evaultPageId: r.evaultPageId
65+
}));
66+
});
67+
68+
const totalMessages = $derived(slices.reduce((s, x) => s + x.value, 0));
69+
70+
const messagesListHref = (bucketKey: string) =>
71+
`/groups/${encodeURIComponent(groupEvaultId)}/messages?bucket=${encodeURIComponent(bucketKey)}`;
72+
</script>
73+
74+
<div class="rounded-lg border border-gray-200 bg-white p-5 shadow-sm">
75+
<h2 class="text-lg font-semibold text-gray-900">Contribution by sender</h2>
76+
<p class="mt-1 text-sm text-gray-500">
77+
Share of scanned messages per sender (same counts as the table below)
78+
</p>
79+
80+
{#if senderRows.some((r) => isSystemNoSenderRow(r) && r.messageCount > 0)}
81+
<label
82+
class="mt-4 flex cursor-pointer items-center gap-2 text-sm text-gray-700 select-none"
83+
>
84+
<input
85+
type="checkbox"
86+
bind:checked={showSystemNoSender}
87+
class="h-4 w-4 rounded border-gray-300 text-blue-600 focus:ring-blue-500"
88+
/>
89+
<span>Show system / no sender messages in chart</span>
90+
</label>
91+
{/if}
92+
93+
{#if slices.length === 0}
94+
<p class="mt-6 text-center text-sm text-gray-500">No sender data to chart.</p>
95+
{:else}
96+
<div class="mt-6 flex flex-col gap-6 lg:flex-row lg:items-center lg:gap-10">
97+
<div
98+
class="layercake-donut h-[min(22rem,55vw)] min-h-[220px] w-full max-w-[22rem] shrink-0"
99+
>
100+
<LayerCake
101+
ssr={true}
102+
data={slices}
103+
x="id"
104+
y="value"
105+
xDomain={slices.map((s) => s.id)}
106+
yDomain={[0, Math.max(...slices.map((s) => s.value), 1)]}
107+
padding={{ top: 8, right: 8, bottom: 8, left: 8 }}
108+
>
109+
<Svg label="Donut chart of messages per sender">
110+
<GroupDonutArcs {groupEvaultId} />
111+
</Svg>
112+
</LayerCake>
113+
</div>
114+
115+
<ul class="min-w-0 flex-1 space-y-2 text-sm">
116+
{#each slices as s (s.id)}
117+
{@const pct =
118+
totalMessages > 0 ? Math.round((s.value / totalMessages) * 1000) / 10 : 0}
119+
<li class="flex items-start gap-3">
120+
<span
121+
class="mt-1.5 h-3 w-3 shrink-0 rounded-sm ring-1 ring-gray-200"
122+
style:background-color={s.color}
123+
aria-hidden="true"
124+
></span>
125+
<div class="min-w-0 flex-1">
126+
<div class="font-medium text-gray-900">
127+
{#if s.evaultPageId}
128+
<a
129+
href="/evaults/{encodeURIComponent(s.evaultPageId)}"
130+
class="text-blue-600 hover:underline">{s.label}</a>
131+
{:else}
132+
{s.label}
133+
{/if}
134+
</div>
135+
<div class="truncate font-mono text-xs text-gray-500">{s.sub}</div>
136+
</div>
137+
<div class="shrink-0 text-right text-gray-700 tabular-nums">
138+
<a href={messagesListHref(s.bucketKey)} class="text-blue-600 hover:underline"
139+
>{s.value}</a>
140+
<span class="text-gray-400">({pct}%)</span>
141+
</div>
142+
</li>
143+
{/each}
144+
</ul>
145+
</div>
146+
{/if}
147+
</div>
148+
149+
<style>
150+
.layercake-donut :global(.layercake-container) {
151+
width: 100%;
152+
height: 100%;
153+
}
154+
</style>
Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
<script lang="ts">
2+
import { getContext } from 'svelte';
3+
import { arc, pie, type PieArcDatum } from 'd3-shape';
4+
5+
type DonutSlice = {
6+
id: string;
7+
label: string;
8+
sub: string;
9+
value: number;
10+
color: string;
11+
bucketKey: string;
12+
evaultPageId: string | null;
13+
};
14+
15+
type Ctx = {
16+
width: import('svelte/store').Readable<number>;
17+
height: import('svelte/store').Readable<number>;
18+
data: import('svelte/store').Readable<DonutSlice[]>;
19+
};
20+
21+
const { width, height, data } = getContext('LayerCake') as Ctx;
22+
23+
interface Props {
24+
groupEvaultId: string;
25+
}
26+
let { groupEvaultId }: Props = $props();
27+
28+
const sliceHref = (bucketKey: string) =>
29+
`/groups/${encodeURIComponent(groupEvaultId)}/messages?bucket=${encodeURIComponent(bucketKey)}`;
30+
31+
const pieLayout = pie<DonutSlice>().value((d) => d.value).sort(null);
32+
</script>
33+
34+
<g transform="translate({$width / 2},{$height / 2})">
35+
{#each pieLayout($data ?? []) as a (a.data.id)}
36+
{@const outerR = Math.min($width, $height) / 2 - 6}
37+
{@const innerR = outerR * 0.56}
38+
{@const d = arc<PieArcDatum<DonutSlice>>()
39+
.innerRadius(innerR)
40+
.outerRadius(outerR)
41+
.cornerRadius(1.5)(a)}
42+
{#if d}
43+
<a
44+
href={sliceHref(a.data.bucketKey)}
45+
class="cursor-pointer outline-none transition-opacity hover:opacity-90 focus-visible:opacity-90"
46+
>
47+
<path {d} fill={a.data.color} stroke="#fff" stroke-width="1.5" class="outline-none">
48+
<title>{a.data.label} — {a.data.sub}: {a.data.value} messages (open list)</title>
49+
</path>
50+
</a>
51+
{/if}
52+
{/each}
53+
</g>

0 commit comments

Comments
 (0)