Skip to content

Commit b2b0c17

Browse files
mobile: group recurring bills by due status
1 parent 1e93376 commit b2b0c17

1 file changed

Lines changed: 91 additions & 31 deletions

File tree

apps/mobile/app/screens/GroupDetailScreen.tsx

Lines changed: 91 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,8 @@ const recurringLabels: Record<RecurringExpenseDto['frequency'], string> = {
3434
monthly: 'Monthly',
3535
};
3636

37+
type RecurringSectionKey = 'overdue' | 'today' | 'upcoming';
38+
3739
const categoryLabels: Record<ExpenseCategory, string> = {
3840
FOOD: 'Food',
3941
TRAVEL: 'Travel',
@@ -43,14 +45,27 @@ const categoryLabels: Record<ExpenseCategory, string> = {
4345
OTHER: 'Other',
4446
};
4547

46-
const getRecurringScheduleLabel = (nextOccurrenceAt: string) => {
48+
const recurringSectionTitles: Record<RecurringSectionKey, string> = {
49+
overdue: 'Overdue',
50+
today: 'Due Today',
51+
upcoming: 'Upcoming',
52+
};
53+
54+
const getRecurringSection = (nextOccurrenceAt: string): RecurringSectionKey => {
4755
const now = new Date();
4856
const today = new Date(now.getFullYear(), now.getMonth(), now.getDate()).getTime();
4957
const next = new Date(nextOccurrenceAt);
5058
const nextDay = new Date(next.getFullYear(), next.getMonth(), next.getDate()).getTime();
5159

52-
if (nextDay < today) return 'Overdue';
53-
if (nextDay === today) return 'Due today';
60+
if (nextDay < today) return 'overdue';
61+
if (nextDay === today) return 'today';
62+
return 'upcoming';
63+
};
64+
65+
const getRecurringScheduleLabel = (nextOccurrenceAt: string) => {
66+
const section = getRecurringSection(nextOccurrenceAt);
67+
if (section === 'overdue') return 'Overdue';
68+
if (section === 'today') return 'Due today';
5469
return 'Upcoming';
5570
};
5671

@@ -190,6 +205,22 @@ export function GroupDetailScreen({
190205
});
191206
}, [categoryFilter, expenses, memberById, payerFilter, query]);
192207

208+
const recurringSections = React.useMemo(() => {
209+
const grouped: Record<RecurringSectionKey, RecurringExpenseDto[]> = {
210+
overdue: [],
211+
today: [],
212+
upcoming: [],
213+
};
214+
215+
[...recurringExpenses]
216+
.sort((a, b) => new Date(a.nextOccurrenceAt).getTime() - new Date(b.nextOccurrenceAt).getTime())
217+
.forEach((item) => {
218+
grouped[getRecurringSection(item.nextOccurrenceAt)].push(item);
219+
});
220+
221+
return grouped;
222+
}, [recurringExpenses]);
223+
193224
const groupedExpenses = React.useMemo(() => {
194225
const now = new Date();
195226
const startOfToday = new Date(now.getFullYear(), now.getMonth(), now.getDate()).getTime();
@@ -456,34 +487,43 @@ export function GroupDetailScreen({
456487
<SectionHeader title="Recurring Bills" />
457488
{recurringExpenses.length > 0 ? (
458489
<View style={styles.recurringList}>
459-
{recurringExpenses.map((item) => {
460-
const payer = memberById.get(item.payerId);
461-
const scheduleLabel = getRecurringScheduleLabel(item.nextOccurrenceAt);
462-
const lastGeneratedLabel = formatLastGeneratedLabel(item.lastGeneratedAt);
463-
return (
464-
<Card key={item.id} style={styles.recurringCard}>
465-
<View style={styles.recurringHeaderRow}>
466-
<View style={{ flex: 1 }}>
467-
<Text style={[styles.recurringTitle, { color: colors.text_primary }]}>{item.description}</Text>
468-
<Text style={[styles.recurringMeta, { color: colors.text_secondary }]}>{recurringLabels[item.frequency]} • {scheduleLabel}</Text>
469-
</View>
470-
<View style={styles.recurringActions}>
471-
<TouchableOpacity onPress={() => startRecurringEdit(item)}>
472-
<MaterialCommunityIcons name="pencil-outline" size={22} color={colors.text_primary} />
473-
</TouchableOpacity>
474-
<TouchableOpacity onPress={() => setDeleteRecurringTarget(item)}>
475-
<MaterialCommunityIcons name="trash-can-outline" size={22} color={colors.danger} />
476-
</TouchableOpacity>
477-
</View>
490+
{(Object.keys(recurringSections) as RecurringSectionKey[]).map((sectionKey) =>
491+
recurringSections[sectionKey].length > 0 ? (
492+
<View key={sectionKey} style={styles.recurringSectionGroup}>
493+
<View style={styles.recurringSectionHeader}>
494+
<Text style={[styles.recurringSectionTitle, { color: colors.text_secondary }]}>{recurringSectionTitles[sectionKey]}</Text>
495+
<Text style={[styles.recurringSectionCount, { color: colors.text_secondary }]}>{recurringSections[sectionKey].length}</Text>
478496
</View>
479-
<Text style={[styles.recurringAmount, { color: colors.primary }]}>{currencySymbol}{(Number(item.totalAmountCents) / 100).toFixed(2)}</Text>
480-
<Text style={[styles.recurringMeta, { color: colors.text_secondary }]}>Next {new Date(item.nextOccurrenceAt).toLocaleDateString()} • {lastGeneratedLabel}</Text>
481-
<Text style={[styles.recurringMeta, { color: colors.text_secondary }]}>Paid by {payer?.name ?? 'Unknown'} • {item.splits.length} participants</Text>
482-
</Card>
483-
);
484-
})}
485-
</View>
486-
) : (
497+
{recurringSections[sectionKey].map((item) => {
498+
const payer = memberById.get(item.payerId);
499+
const scheduleLabel = getRecurringScheduleLabel(item.nextOccurrenceAt);
500+
const lastGeneratedLabel = formatLastGeneratedLabel(item.lastGeneratedAt);
501+
return (
502+
<Card key={item.id} style={styles.recurringCard}>
503+
<View style={styles.recurringHeaderRow}>
504+
<View style={{ flex: 1 }}>
505+
<Text style={[styles.recurringTitle, { color: colors.text_primary }]}>{item.description}</Text>
506+
<Text style={[styles.recurringMeta, { color: colors.text_secondary }]}>{recurringLabels[item.frequency]} • {scheduleLabel}</Text>
507+
</View>
508+
<View style={styles.recurringActions}>
509+
<TouchableOpacity onPress={() => startRecurringEdit(item)}>
510+
<MaterialCommunityIcons name="pencil-outline" size={22} color={colors.text_primary} />
511+
</TouchableOpacity>
512+
<TouchableOpacity onPress={() => setDeleteRecurringTarget(item)}>
513+
<MaterialCommunityIcons name="trash-can-outline" size={22} color={colors.danger} />
514+
</TouchableOpacity>
515+
</View>
516+
</View>
517+
<Text style={[styles.recurringAmount, { color: colors.primary }]}>{currencySymbol}{(Number(item.totalAmountCents) / 100).toFixed(2)}</Text>
518+
<Text style={[styles.recurringMeta, { color: colors.text_secondary }]}>Next {new Date(item.nextOccurrenceAt).toLocaleDateString()} • {lastGeneratedLabel}</Text>
519+
<Text style={[styles.recurringMeta, { color: colors.text_secondary }]}>Paid by {payer?.name ?? 'Unknown'} • {item.splits.length} participants</Text>
520+
</Card>
521+
);
522+
})}
523+
</View>
524+
) : null,
525+
)}
526+
</View> ) : (
487527
<Card style={styles.recurringEmptyCard}>
488528
<Text style={[styles.recurringTitle, { color: colors.text_primary }]}>No recurring bills yet</Text>
489529
<Text style={[styles.recurringMeta, { color: colors.text_secondary }]}>Turn on recurring when you add rent, subscriptions, or utilities.</Text>
@@ -679,7 +719,25 @@ const styles = StyleSheet.create({
679719
marginBottom: spacing.xl,
680720
},
681721
recurringList: {
682-
gap: spacing.md,
722+
gap: spacing.lg,
723+
},
724+
recurringSectionGroup: {
725+
gap: spacing.sm,
726+
},
727+
recurringSectionHeader: {
728+
flexDirection: 'row',
729+
justifyContent: 'space-between',
730+
alignItems: 'center',
731+
},
732+
recurringSectionTitle: {
733+
fontSize: 12,
734+
fontWeight: '800',
735+
textTransform: 'uppercase',
736+
letterSpacing: 0.6,
737+
},
738+
recurringSectionCount: {
739+
fontSize: 12,
740+
fontWeight: '700',
683741
},
684742
recurringCard: {
685743
padding: spacing.lg,
@@ -779,3 +837,5 @@ const styles = StyleSheet.create({
779837

780838

781839

840+
841+

0 commit comments

Comments
 (0)