diff --git a/lib/features/insights/data/insights_repository.dart b/lib/features/insights/data/insights_repository.dart index 944a4c0..2c06d98 100644 --- a/lib/features/insights/data/insights_repository.dart +++ b/lib/features/insights/data/insights_repository.dart @@ -14,21 +14,20 @@ class CategorySpend { } class InsightsRepository { - // Total spend per category for [month] in a group (deleted expenses excluded). - Future> monthlyByCategory({ + // Total spend per category between [from] (inclusive) and [toExclusive] + // (exclusive) in a group, deleted expenses excluded. + Future> byCategory({ required String groupId, - required DateTime month, + required DateTime from, + required DateTime toExclusive, }) async { - final first = DateTime(month.year, month.month, 1); - final next = DateTime(month.year, month.month + 1, 1); - final data = await supabase .from('expenses') .select('amount, categories(name, icon)') .eq('group_id', groupId) .isFilter('deleted_at', null) - .gte('occurred_on', isoDate(first)) - .lt('occurred_on', isoDate(next)); + .gte('occurred_on', isoDate(from)) + .lt('occurred_on', isoDate(toExclusive)); final totals = {}; for (final row in data as List) { diff --git a/lib/features/insights/logic/insights_range.dart b/lib/features/insights/logic/insights_range.dart new file mode 100644 index 0000000..ca3d248 --- /dev/null +++ b/lib/features/insights/logic/insights_range.dart @@ -0,0 +1,27 @@ +/// A selectable time window for the Insights breakdown. +/// +/// Boundaries are local-midnight dates computed with calendar arithmetic +/// (`DateTime(y, m, d ± n)`), never `Duration` — see the DST note in +/// `core/dates.dart`. `from` is inclusive, `toExclusive` is the start of the +/// day after "today", so a `>= from` / `< toExclusive` filter covers today. +typedef DateWindow = ({DateTime from, DateTime toExclusive}); + +enum InsightsRange { + lastWeek('Last week'), + monthToDate('Month to date'), + yearToDate('Year to date'); + + const InsightsRange(this.label); + + final String label; + + DateWindow window(DateTime now) { + final toExclusive = DateTime(now.year, now.month, now.day + 1); + final from = switch (this) { + InsightsRange.lastWeek => DateTime(now.year, now.month, now.day - 6), + InsightsRange.monthToDate => DateTime(now.year, now.month, 1), + InsightsRange.yearToDate => DateTime(now.year, 1, 1), + }; + return (from: from, toExclusive: toExclusive); + } +} diff --git a/lib/features/insights/providers/insights_provider.dart b/lib/features/insights/providers/insights_provider.dart index d9e2197..d707a78 100644 --- a/lib/features/insights/providers/insights_provider.dart +++ b/lib/features/insights/providers/insights_provider.dart @@ -1,9 +1,17 @@ import 'package:tally/features/insights/data/insights_repository.dart'; +import 'package:tally/features/insights/logic/insights_range.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; -// Current-month spend per category for a group. +typedef InsightsArgs = ({String groupId, InsightsRange range}); + +// Spend per category for a group over the selected [InsightsRange]. Keyed by +// (groupId, range) so each range stays cached independently. final insightsProvider = - FutureProvider.family, String>((ref, groupId) { - return InsightsRepository() - .monthlyByCategory(groupId: groupId, month: DateTime.now()); + FutureProvider.family, InsightsArgs>((ref, args) { + final w = args.range.window(DateTime.now()); + return InsightsRepository().byCategory( + groupId: args.groupId, + from: w.from, + toExclusive: w.toExclusive, + ); }); diff --git a/lib/features/insights/ui/insights_screen.dart b/lib/features/insights/ui/insights_screen.dart index b6640fe..ded842b 100644 --- a/lib/features/insights/ui/insights_screen.dart +++ b/lib/features/insights/ui/insights_screen.dart @@ -1,54 +1,105 @@ import 'package:decimal/decimal.dart'; +import 'package:tally/core/dates.dart'; import 'package:tally/core/icons.dart'; import 'package:tally/core/money.dart'; import 'package:tally/core/widgets/page_body.dart'; import 'package:tally/features/insights/data/insights_repository.dart'; +import 'package:tally/features/insights/logic/insights_range.dart'; import 'package:tally/features/insights/providers/insights_provider.dart'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:intl/intl.dart'; -class InsightsScreen extends ConsumerWidget { + +class InsightsScreen extends ConsumerStatefulWidget { const InsightsScreen({super.key, required this.groupId}); final String groupId; @override - Widget build(BuildContext context, WidgetRef ref) { + ConsumerState createState() => _InsightsScreenState(); +} + +class _InsightsScreenState extends ConsumerState { + InsightsRange _range = InsightsRange.monthToDate; + + @override + Widget build(BuildContext context) { final theme = Theme.of(context); - final spendAsync = ref.watch(insightsProvider(groupId)); - final monthLabel = DateFormat('MMMM yyyy').format(DateTime.now()); + final spendAsync = + ref.watch(insightsProvider((groupId: widget.groupId, range: _range))); + + final now = DateTime.now(); + final window = _range.window(now); + final lastDay = DateTime(now.year, now.month, now.day); + final spanLabel = '${formatDay(window.from)} – ${formatDayYear(lastDay)}'; return Scaffold( appBar: AppBar(title: const Text('Insights')), - body: PageBody( - child: spendAsync.when( - loading: () => const Center(child: CircularProgressIndicator()), - error: (e, _) => Center(child: Text('Error: $e')), - data: (spend) { - if (spend.isEmpty) { - return Center( - child: Text('No spending in $monthLabel yet', - style: theme.textTheme.titleMedium), - ); - } - final total = spend.fold(Decimal.zero, (s, c) => s + c.total); - return ListView( - padding: const EdgeInsets.all(24), + body: Column( + children: [ + Padding( + padding: const EdgeInsets.fromLTRB(24, 16, 24, 0), + child: Row( children: [ - Text(monthLabel, style: theme.textTheme.titleSmall), - const SizedBox(height: 4), - Text( - formatCurrency(total), - style: theme.textTheme.headlineMedium?.copyWith( - fontWeight: FontWeight.bold, - color: theme.colorScheme.primary, - ), + Expanded( + child: Text('Spending', style: theme.textTheme.titleMedium), + ), + DropdownButton( + value: _range, + onChanged: (r) { + if (r != null) setState(() => _range = r); + }, + items: [ + for (final r in InsightsRange.values) + DropdownMenuItem(value: r, child: Text(r.label)), + ], ), - const SizedBox(height: 24), - ...spend.map((c) => _CategoryBar(spend: c, total: total)), ], - ); - }, - ), + ), + ), + Expanded( + child: PageBody( + child: spendAsync.when( + loading: () => + const Center(child: CircularProgressIndicator()), + error: (e, _) => Center(child: Text('Error: $e')), + data: (spend) { + if (spend.isEmpty) { + return Center( + child: Text( + 'No spending for ${_range.label.toLowerCase()} yet', + style: theme.textTheme.titleMedium, + ), + ); + } + final total = + spend.fold(Decimal.zero, (s, c) => s + c.total); + return ListView( + padding: const EdgeInsets.all(24), + children: [ + Text(_range.label, style: theme.textTheme.titleSmall), + const SizedBox(height: 2), + Text( + spanLabel, + style: theme.textTheme.bodySmall?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + ), + ), + const SizedBox(height: 8), + Text( + formatCurrency(total), + style: theme.textTheme.headlineMedium?.copyWith( + fontWeight: FontWeight.bold, + color: theme.colorScheme.primary, + ), + ), + const SizedBox(height: 24), + ...spend.map((c) => _CategoryBar(spend: c, total: total)), + ], + ); + }, + ), + ), + ), + ], ), ); } diff --git a/test/features/insights/insights_range_test.dart b/test/features/insights/insights_range_test.dart new file mode 100644 index 0000000..041ef93 --- /dev/null +++ b/test/features/insights/insights_range_test.dart @@ -0,0 +1,29 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:tally/features/insights/logic/insights_range.dart'; + +void main() { + group('InsightsRange.window', () { + test('mid-month reference date', () { + final now = DateTime(2026, 9, 15, 13, 30); + + expect(InsightsRange.lastWeek.window(now), + (from: DateTime(2026, 9, 9), toExclusive: DateTime(2026, 9, 16))); + expect(InsightsRange.monthToDate.window(now), + (from: DateTime(2026, 9, 1), toExclusive: DateTime(2026, 9, 16))); + expect(InsightsRange.yearToDate.window(now), + (from: DateTime(2026, 1, 1), toExclusive: DateTime(2026, 9, 16))); + }); + + test('last week rolls back across a month boundary', () { + final now = DateTime(2026, 3, 3); + expect(InsightsRange.lastWeek.window(now).from, DateTime(2026, 2, 25)); + }); + + test('last week rolls back across a year boundary', () { + final now = DateTime(2026, 1, 2); + expect(InsightsRange.lastWeek.window(now).from, DateTime(2025, 12, 27)); + expect(InsightsRange.yearToDate.window(now).from, DateTime(2026, 1, 1)); + expect(InsightsRange.monthToDate.window(now).from, DateTime(2026, 1, 1)); + }); + }); +} diff --git a/test/features/screens_smoke_test.dart b/test/features/screens_smoke_test.dart index 5bf0400..a777b92 100644 --- a/test/features/screens_smoke_test.dart +++ b/test/features/screens_smoke_test.dart @@ -8,6 +8,7 @@ import 'package:tally/features/groups/providers/groups_provider.dart'; import 'package:tally/features/groups/ui/group_detail_screen.dart'; import 'package:tally/features/import/providers/import_providers.dart'; import 'package:tally/features/insights/data/insights_repository.dart'; +import 'package:tally/features/insights/logic/insights_range.dart'; import 'package:tally/features/insights/providers/insights_provider.dart'; import 'package:tally/features/insights/ui/insights_screen.dart'; import 'package:tally/features/realtime/group_realtime_provider.dart'; @@ -97,11 +98,14 @@ void main() { await tester.pumpWidget(_host( const InsightsScreen(groupId: _gid), [ - insightsProvider(_gid).overrideWith((ref) async => [ - CategorySpend(name: 'Rent', icon: 'home', total: dec('1000')), - CategorySpend( - name: 'Groceries', icon: 'shopping_cart', total: dec('250')), - ]), + insightsProvider((groupId: _gid, range: InsightsRange.monthToDate)) + .overrideWith((ref) async => [ + CategorySpend(name: 'Rent', icon: 'home', total: dec('1000')), + CategorySpend( + name: 'Groceries', + icon: 'shopping_cart', + total: dec('250')), + ]), ], )); await tester.pumpAndSettle(); @@ -110,13 +114,46 @@ void main() { expect(tester.takeException(), isNull); }); + testWidgets('InsightsScreen switches the range via the dropdown', + (tester) async { + _desktop(tester); + addTearDown(tester.view.reset); + await tester.pumpWidget(_host( + const InsightsScreen(groupId: _gid), + [ + insightsProvider((groupId: _gid, range: InsightsRange.monthToDate)) + .overrideWith((ref) async => + [CategorySpend(name: 'Rent', icon: 'home', total: dec('1000'))]), + insightsProvider((groupId: _gid, range: InsightsRange.yearToDate)) + .overrideWith((ref) async => [ + CategorySpend( + name: 'Flights', icon: 'flight', total: dec('4200')), + ]), + ], + )); + await tester.pumpAndSettle(); + expect(find.text('Rent'), findsOneWidget); + + await tester.tap(find.byType(DropdownButton)); + await tester.pumpAndSettle(); + await tester.tap(find.text('Year to date').last); + await tester.pumpAndSettle(); + + expect(find.text('Flights'), findsOneWidget); + expect(find.text('Rent'), findsNothing); + expect(tester.takeException(), isNull); + }); + testWidgets('InsightsScreen shows an empty message with no spend', (tester) async { _desktop(tester); addTearDown(tester.view.reset); await tester.pumpWidget(_host( const InsightsScreen(groupId: _gid), - [insightsProvider(_gid).overrideWith((ref) async => [])], + [ + insightsProvider((groupId: _gid, range: InsightsRange.monthToDate)) + .overrideWith((ref) async => []), + ], )); await tester.pumpAndSettle(); expect(find.textContaining('No spending'), findsOneWidget);