cospend: filter recent activity by chart category selection
All checks were successful
CI / update (push) Successful in 1m27s

Clicking a category on the bar chart now filters the recent activity
list to show only payments in that category. Includes a clear filter
button and empty state message. Also increases recent splits from 10
to 30 for better coverage when filtering.
This commit is contained in:
2026-02-04 16:57:44 +01:00
parent 8776ab894b
commit 83de5fed34
3 changed files with 92 additions and 6 deletions

View File

@@ -2,7 +2,7 @@
import { onMount } from 'svelte';
import { Chart, registerables } from 'chart.js';
let { data = { labels: [], datasets: [] }, title = '', height = '400px' } = $props<{ data?: any, title?: string, height?: string }>();
let { data = { labels: [], datasets: [] }, title = '', height = '400px', onFilterChange = null } = $props<{ data?: any, title?: string, height?: string, onFilterChange?: ((categories: string[] | null) => void) | null }>();
let canvas = $state();
let chart = $state();
@@ -42,6 +42,19 @@
return categoryColorMap[category] || nordColors[index % nordColors.length];
}
function emitFilter() {
if (!onFilterChange || !chart) return;
const allVisible = chart.data.datasets.every((_, idx) => !chart.getDatasetMeta(idx).hidden);
if (allVisible) {
onFilterChange(null);
} else {
const visible = chart.data.datasets
.filter((_, idx) => !chart.getDatasetMeta(idx).hidden)
.map(ds => ds.label.toLowerCase());
onFilterChange(visible);
}
}
function createChart() {
if (!canvas || !data.datasets) return;
@@ -135,7 +148,6 @@
},
onClick: (event, legendItem, legend) => {
const datasetIndex = legendItem.datasetIndex;
const clickedMeta = chart.getDatasetMeta(datasetIndex);
// Check if only this dataset is currently visible
const onlyThisVisible = chart.data.datasets.every((dataset, idx) => {
@@ -156,6 +168,7 @@
}
chart.update();
emitFilter();
}
},
title: {
@@ -229,6 +242,7 @@
}
chart.update();
emitFilter();
}
}
},

View File

@@ -89,7 +89,7 @@ export const GET: RequestHandler = async ({ locals, url }) => {
},
{ $unwind: '$paymentId' },
{ $sort: { 'paymentId.date': -1, 'paymentId.createdAt': -1 } },
{ $limit: 10 }
{ $limit: 30 }
]);
// For settlements, fetch the other user's split info

View File

@@ -24,6 +24,13 @@
let error = $state(null);
let monthlyExpensesData = $state(data.monthlyExpensesData || { labels: [], datasets: [] });
let expensesLoading = $state(false);
let categoryFilter = $state(null);
let filteredSplits = $derived(
categoryFilter
? (balance.recentSplits || []).filter(split => categoryFilter.includes(split.paymentId?.category))
: balance.recentSplits || []
);
// Component references for refreshing
let enhancedBalanceComponent;
@@ -177,6 +184,7 @@
data={monthlyExpensesData}
title="Monthly Expenses by Category"
height="400px"
onFilterChange={(categories) => categoryFilter = categories}
/>
{:else}
<div class="loading">
@@ -194,9 +202,17 @@
<div class="error">Error: {error}</div>
{:else if balance.recentSplits && balance.recentSplits.length > 0}
<div class="recent-activity">
<h2>Recent Activity</h2>
<div class="recent-activity-header">
<h2>Recent Activity{#if categoryFilter} <span class="filter-label">{categoryFilter.map(c => getCategoryName(c)).join(', ')}</span>{/if}</h2>
{#if categoryFilter}
<button class="clear-filter" onclick={() => categoryFilter = null}>Clear filter</button>
{/if}
</div>
{#if filteredSplits.length === 0}
<p class="no-results">No recent activity in {categoryFilter.map(c => getCategoryName(c)).join(', ')}.</p>
{/if}
<div class="activity-dialog">
{#each balance.recentSplits as split}
{#each filteredSplits as split}
{#if isSettlementPayment(split.paymentId)}
<!-- Settlement Payment Display - User -> User Flow -->
<a
@@ -363,11 +379,49 @@
margin-right: auto;
}
.recent-activity h2 {
.recent-activity-header {
display: flex;
justify-content: space-between;
align-items: baseline;
gap: 1rem;
margin-bottom: 1rem;
}
.recent-activity h2 {
margin: 0;
color: var(--nord0);
}
.filter-label {
font-weight: 400;
font-size: 1rem;
color: var(--nord3);
}
.clear-filter {
background: none;
border: 1px solid var(--nord4);
border-radius: 0.5rem;
padding: 0.25rem 0.75rem;
color: var(--nord3);
cursor: pointer;
font-size: 0.85rem;
white-space: nowrap;
transition: all 0.2s;
}
.clear-filter:hover {
border-color: var(--blue);
color: var(--blue);
}
.no-results {
text-align: center;
color: var(--nord3);
font-style: italic;
padding: 1rem 0;
}
@media (prefers-color-scheme: dark) {
.recent-activity {
background: var(--accent-dark);
@@ -377,6 +431,24 @@
.recent-activity h2 {
color: var(--font-default-dark);
}
.filter-label {
color: var(--nord4);
}
.clear-filter {
border-color: var(--nord3);
color: var(--nord4);
}
.clear-filter:hover {
border-color: var(--blue);
color: var(--blue);
}
.no-results {
color: var(--nord4);
}
}
.activity-dialog {