Skip to content

feat: Add option to add recurring entries (expenses/incomes/payments) - #342

Open
DennisBauer wants to merge 7 commits into
mainfrom
feat/recurring-entries
Open

feat: Add option to add recurring entries (expenses/incomes/payments)#342
DennisBauer wants to merge 7 commits into
mainfrom
feat/recurring-entries

Conversation

@DennisBauer

@DennisBauer DennisBauer commented Aug 10, 2026

Copy link
Copy Markdown
Contributor

Summary by CodeRabbit

  • New Features
    • Added recurring entries and schedules with daily, weekly, monthly, and yearly repetition options.
    • Added schedule management, detail views, occurrence previews, skip/unskip actions, and options to end schedules.
    • Scheduled occurrences now appear as clearly marked placeholders in activity and balances.
    • Added settlement entries with recipient selection and validation.
    • Added offline support, synchronization, and real-time schedule updates.
  • Bug Fixes
    • Added localized messages for invalid recurring configurations and unavailable recurring entries.
    • Improved handling of deleted scheduled occurrences to prevent duplicate projections.

@DennisBauer DennisBauer added the feature New feature or request label Aug 10, 2026
@coderabbitai

coderabbitai Bot commented Aug 10, 2026

Copy link
Copy Markdown

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 89ae3039-d12e-41b5-a653-e792aed90ce0

📥 Commits

Reviewing files that changed from the base of the PR and between ca027e6 and a8eaafa.

📒 Files selected for processing (1)
  • features/tabgroup/presentation/src/commonTest/kotlin/de/tabmates/features/tabgroup/presentation/navigation/groupoverview/UpcomingSchedulesTest.kt
🚧 Files skipped from review as they are similar to previous changes (1)
  • features/tabgroup/presentation/src/commonTest/kotlin/de/tabmates/features/tabgroup/presentation/navigation/groupoverview/UpcomingSchedulesTest.kt

Walkthrough

This change adds recurring-series models, persistence, synchronization, scheduled-entry projection, REST operations, WebSocket handling, Compose editing and detail screens, settlement entries, localized errors, and related tests.

Changes

Recurring entries and schedules

Layer / File(s) Summary
Domain scheduling and projection
features/tabgroup/domain/...
Adds recurrence models, occurrence calculation, scheduled placeholder projection, and scheduling tests.
Data contracts and local persistence
features/tabgroup/data/..., features/tabgroup/database/...
Adds recurring DTOs, Room entities, DAOs, schema version 8, mappings, slot claims, and local writers.
Remote service and synchronization
core/data/..., features/tabgroup/data/...
Adds recurring-series REST operations, error mapping, offline-first repository behavior, sync persistence, and WebSocket updates.
Entry editing and schedule presentation
features/tabgroup/presentation/...
Adds settlement creation, recurring-entry editing, repeat previews, schedule detail navigation, scheduled-row rendering, and localized resources.

Estimated code review effort: 5 (Critical) | ~120 minutes

Possibly related PRs

  • TabMates/app#337: Both changes modify group settings and group detail navigation wiring.
  • TabMates/app#339: Both changes update shared error handling and tab-entry realtime synchronization paths.
🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly summarizes the main change: adding recurring entries for expenses, incomes, and payments.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 11

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
features/tabgroup/presentation/src/commonMain/kotlin/de/tabmates/features/tabgroup/presentation/navigation/addentry/AddEntryViewModel.kt (1)

240-245: 🎯 Functional Correctness | 🔴 Critical | 🏗️ Heavy lift

A series edit loses the template splits.

existingSplits is derived only from existing, which is null when the screen edits a series. splitsByParticipant is therefore empty, and splitInputs sets included = if (isEditing) split != null else true (Line 323), which evaluates to false for every participant because isEditing is true. The split editor opens with nobody selected, and saving an expense or income series fails with add_entry_error_no_splits. The loaded amounts and split type from series.rule.splits are also discarded.

Map series?.rule?.splits into the same shape as the entry splits, and select rows from the series when the screen edits a series.

Also applies to: 285-295

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@features/tabgroup/presentation/src/commonMain/kotlin/de/tabmates/features/tabgroup/presentation/navigation/addentry/AddEntryViewModel.kt`
around lines 240 - 245, Update the split-loading logic around existingSplits and
splitsByParticipant to use series?.rule?.splits when editing a series, mapping
those rule splits into the same participant-keyed shape as entry splits and
preserving their amounts and split type. Ensure splitInputs selects participants
based on the mapped series splits while retaining the existing entry behavior.
🧹 Nitpick comments (18)
features/tabgroup/domain/src/commonMain/kotlin/de/tabmates/features/tabgroup/domain/recurring/ScheduledLedger.kt (1)

21-25: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Correct the deleted-entry contract.

This documentation says that soft-deleted entries remain in the result. ScheduledEntryProjector.kt at Lines 28-31 and RecurringSeriesRepository.kt at Lines 24-28 state that persistence removes these entries and retains only their claimed slots.

Document the persistence behavior and the claimed-slot requirement. Do not require implementations to expose rows that persistence removes.

Proposed documentation update
-     * Every entry of [groupId], soft-deleted ones included, followed by a placeholder for each
-     * occurrence that is due and unwritten.
+     * Every persisted entry of [groupId], followed by a placeholder for each occurrence that is
+     * due and unwritten.
      *
-     * Deleted entries are kept in the result because callers filter them themselves, and because
-     * dropping them here would hide the slots they occupy from the projection.
+     * Soft-deleted recurring entries can be absent from persistence. Implementations must retain
+     * their claimed slots so projection does not recreate those occurrences.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@features/tabgroup/domain/src/commonMain/kotlin/de/tabmates/features/tabgroup/domain/recurring/ScheduledLedger.kt`
around lines 21 - 25, Update the documentation for ScheduledLedger to state that
persistence removes soft-deleted entries from the result while retaining their
claimed slots, and that implementations must preserve those claimed slots when
projecting due unwritten occurrences. Remove the requirement that deleted entry
rows remain exposed to callers.
features/tabgroup/domain/src/commonMain/kotlin/de/tabmates/features/tabgroup/domain/models/TabEntry.kt (1)

37-56: 🗄️ Data Integrity & Integration | 🔵 Trivial | 🏗️ Heavy lift

Make recurring provenance atomic.

The constructors accept recurringSeriesId, recurringOccurrenceDate, and isScheduledPlaceholder independently. They permit a recurring entry with an incomplete slot identity or a placeholder without a slot.

ScheduledEntryProjector.project at Lines 51-53 ignores an existing entry when either identity property is null. The projector can then add a duplicate placeholder and double-count the occurrence.

Represent these properties with one nullable provenance value, or enforce the invariant in every subtype.

Also applies to: 80-82, 104-106, 128-130

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@features/tabgroup/domain/src/commonMain/kotlin/de/tabmates/features/tabgroup/domain/models/TabEntry.kt`
around lines 37 - 56, The recurring provenance fields in TabEntry and every
affected subtype constructor are independently nullable, allowing incomplete
identities and invalid placeholders. Make recurringSeriesId and
recurringOccurrenceDate a single nullable provenance value, or enforce that they
are both set together and require isScheduledPlaceholder to have valid
provenance; update ScheduledEntryProjector.project to match entries using the
atomic provenance so it cannot create duplicate placeholders.
features/tabgroup/data/src/commonMain/kotlin/de/tabmates/features/tabgroup/data/mappers/RecurringSeriesMappers.kt (2)

153-174: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Return a typed failure instead of throwing for a settlement template without a receiver.

requireNotNull throws IllegalArgumentException from the mapping layer. The coding guidelines require expected operation errors to be represented with Result.Failure or EmptyResult. Validate the receiver where the template is built, or make toDto() return a Result so the caller can map the error to UiText.

As per coding guidelines: "Always represent expected operation errors with Result.Failure or EmptyResult".

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@features/tabgroup/data/src/commonMain/kotlin/de/tabmates/features/tabgroup/data/mappers/RecurringSeriesMappers.kt`
around lines 153 - 174, Replace the requireNotNull call in the settlement branch
of the recurring template mapper with typed failure handling using
Result.Failure or EmptyResult. Propagate the failure through the toDto() caller,
or validate receivedByUserId before building the DTO, so the missing receiver
can be mapped to UiText without throwing IllegalArgumentException.

Source: Coding guidelines


84-91: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Remove the unused unresolvedParticipantIds() helper.

The repository has no call sites. RecurringSeriesLocalWriter.ensureParticipantsExist accepts domain models and recomputes the participant IDs independently.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@features/tabgroup/data/src/commonMain/kotlin/de/tabmates/features/tabgroup/data/mappers/RecurringSeriesMappers.kt`
around lines 84 - 91, Remove the unused
RecurringSeriesDto.unresolvedParticipantIds() helper, including its associated
documentation, while leaving referencedParticipants() and
RecurringSeriesLocalWriter.ensureParticipantsExist unchanged.
features/tabgroup/data/src/desktopTest/kotlin/de/tabmates/features/tabgroup/data/sync/OfflineFirstSyncRepositoryTest.kt (1)

37-37: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Assert the recorded series refreshes.

The fake records refreshedGroupIds, but no test reads it. The new behavior in OfflineFirstSyncRepository.backfillNewAndPendingGroups refreshes recurring series for every newly known and pending group. Pass the fake explicitly in deltaSyncBackfillsEntriesForNewlyKnownGroup and assert the recorded ids, so a regression in that loop fails a test.

💚 Proposed assertion in `deltaSyncBackfillsEntriesForNewlyKnownGroup`
             val pendingStore = FakePendingTabEntryBackfillStore()
+            val recurringSeriesRepository = FakeRecurringSeriesRepository()
 
-            repository(service, cursorStore, tabEntryService, pendingStore).sync()
+            repository(
+                service,
+                cursorStore,
+                tabEntryService,
+                pendingStore,
+                recurringSeriesRepository = recurringSeriesRepository,
+            ).sync()
 
             assertEquals(listOf("g2"), tabEntryService.receivedGroupIds)
+            assertEquals(listOf("g2"), recurringSeriesRepository.refreshedGroupIds)
             assertEquals(setOf("e9"), localEntryIds())
             assertTrue(pendingStore.getAll().isEmpty())

Consider also covering full-sync series pruning and slot-claim recording once snapshot(...) accepts recurring fixtures.

Also applies to: 47-48

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@features/tabgroup/data/src/desktopTest/kotlin/de/tabmates/features/tabgroup/data/sync/OfflineFirstSyncRepositoryTest.kt`
at line 37, Update deltaSyncBackfillsEntriesForNewlyKnownGroup to create and
pass an explicit FakeRecurringSeriesRepository, then assert its
refreshedGroupIds contains the newly known group and any pending group expected
by the backfill. Use the fake’s recorded values to verify
backfillNewAndPendingGroups refreshes every relevant group.
features/tabgroup/data/src/desktopTest/kotlin/de/tabmates/features/tabgroup/data/tabentry/TabEntryRealtimeSyncTest.kt (1)

13-13: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Add coverage for the new realtime branches.

The wiring is correct: the real RecurringSeriesLocalWriter runs against the in-memory database. Two new behaviors in TabEntryRealtimeSync stay untested here.

  • RECURRING_SERIES_CHANGED decodes a RecurringSeriesDto and persists one series. A field-name or shape mismatch in that payload would only surface at runtime.
  • handleUpsert records a recurring slot claim before the soft-delete branch. A test that emits an ack for a soft-deleted generated entry would lock in that the claim row survives.

Add one frame test per behavior with a RecurringSeriesDto fixture.

Also applies to: 180-180

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@features/tabgroup/data/src/desktopTest/kotlin/de/tabmates/features/tabgroup/data/tabentry/TabEntryRealtimeSyncTest.kt`
at line 13, Add two frame-based tests in TabEntryRealtimeSyncTest: one emitting
a RECURRING_SERIES_CHANGED payload using a RecurringSeriesDto fixture and
asserting the series is persisted, and another emitting an acknowledged upsert
for a soft-deleted generated entry and asserting its recurring slot claim
remains. Reuse the existing in-memory database, real RecurringSeriesLocalWriter
wiring, and established frame-test helpers.
features/tabgroup/presentation/src/commonMain/kotlin/de/tabmates/features/tabgroup/presentation/navigation/addentry/AddEntryScreen.kt (1)

807-823: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Let paidByDisplay delegate to participantDisplay.

participantDisplay and paidByDisplay (Lines 825-835) resolve the same three cases against the same map. Keep one implementation so the removed-member and current-user labels cannot diverge.

♻️ Proposed refactor
 `@Composable`
-private fun paidByDisplay(state: AddEntryState): String {
-    // Resolved through the wider map, not the member list: the payer of an edited entry may have
-    // been removed from the group since, and their real name is still known.
-    val paidBy = state.participantsById[state.paidByUserId]
-    return when {
-        paidBy == null -> stringResource(Res.string.expense_detail_removed_member)
-        paidBy.userId == state.currentUserId -> stringResource(Res.string.add_entry_paid_by_you)
-        else -> paidBy.username
-    }
-}
+private fun paidByDisplay(state: AddEntryState): String =
+    participantDisplay(state, state.paidByUserId)
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@features/tabgroup/presentation/src/commonMain/kotlin/de/tabmates/features/tabgroup/presentation/navigation/addentry/AddEntryScreen.kt`
around lines 807 - 823, Update paidByDisplay to delegate to participantDisplay
using the same state and user ID inputs, and remove its duplicate
participant-resolution logic. Preserve the existing removed-member,
current-user, and username results through the shared participantDisplay
implementation.
features/tabgroup/presentation/src/commonMain/kotlin/de/tabmates/features/tabgroup/presentation/navigation/MainGraph.kt (1)

292-301: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Guard the migration against a non-top EditSettlement entry.

The migration entry renders no content. entry<EditSettlement> only composes when the key is the top of the back stack, so a persisted stack that holds EditSettlement below another entry keeps the deprecated key until the user navigates back to it. At that point removeAll { it is EditSettlement } plus add also drops any entries that sat above it. Consider running the same conversion once at restore time on the whole back stack instead of inside the entry body.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@features/tabgroup/presentation/src/commonMain/kotlin/de/tabmates/features/tabgroup/presentation/navigation/MainGraph.kt`
around lines 292 - 301, The EditSettlement migration currently runs only when
that entry reaches the top and can remove entries above it. Move the conversion
out of the entry<EditSettlement> body and into the back-stack restoration flow,
converting every persisted EditSettlement entry in place to EditEntry while
preserving all other entries and their ordering; retain the deprecated route
only as needed for deserialization.
features/tabgroup/presentation/src/commonMain/kotlin/de/tabmates/features/tabgroup/presentation/navigation/addentry/RepeatEditorScreen.kt (2)

227-235: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Use selectable semantics for the radio rows.

The Row is clickable and the RadioButton also has its own onClick. Accessibility services then expose two separate targets for one option, and the row is not announced as a radio button. Use Modifier.selectable with role = Role.RadioButton and pass onClick = null to the RadioButton.

♻️ Proposed refactor
     Row(
         verticalAlignment = Alignment.CenterVertically,
         modifier =
             Modifier
                 .fillMaxWidth()
-                .clickable(onClick = onClick)
+                .selectable(
+                    selected = selected,
+                    role = Role.RadioButton,
+                    onClick = onClick,
+                )
                 .padding(horizontal = 8.dp),
     ) {
-        RadioButton(selected = selected, onClick = onClick)
+        RadioButton(selected = selected, onClick = null)

Add the imports:

import androidx.compose.foundation.selection.selectable
import androidx.compose.ui.semantics.Role
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@features/tabgroup/presentation/src/commonMain/kotlin/de/tabmates/features/tabgroup/presentation/navigation/addentry/RepeatEditorScreen.kt`
around lines 227 - 235, Update the radio-option Row modifier to use selectable
with Role.RadioButton instead of clickable, and set RadioButton’s onClick to
null so each option exposes a single accessible radio target. Add the required
selectable and Role imports.

192-200: 🚀 Performance & Scalability | 🔵 Trivial | 💤 Low value

Cache the occurrence calculation across recompositions.

RecurringOccurrenceCalculator.upcomingOccurrences runs on every recomposition of RepeatPreview. The inputs change only when the repeat config changes. Wrap the call in remember.

♻️ Proposed refactor
-    val dates =
-        RecurringOccurrenceCalculator.upcomingOccurrences(
-            rule = repeat.toPreviewRule(),
-            after = state.repeatStartDate.minus(1, DateTimeUnit.DAY),
-            limit = PREVIEW_COUNT,
-        )
+    val dates =
+        remember(repeat) {
+            RecurringOccurrenceCalculator.upcomingOccurrences(
+                rule = repeat.toPreviewRule(),
+                after = repeat.startDate.minus(1, DateTimeUnit.DAY),
+                limit = PREVIEW_COUNT,
+            )
+        }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@features/tabgroup/presentation/src/commonMain/kotlin/de/tabmates/features/tabgroup/presentation/navigation/addentry/RepeatEditorScreen.kt`
around lines 192 - 200, Cache the upcoming occurrence calculation in
RepeatPreview by wrapping the RecurringOccurrenceCalculator.upcomingOccurrences
call in remember, keyed by the repeat configuration and state.repeatStartDate so
it recomputes only when those inputs change while preserving the existing rule,
anchor date, and limit.
features/tabgroup/presentation/src/commonTest/kotlin/de/tabmates/features/tabgroup/presentation/testing/FakeRecurringSeriesRepository.kt (2)

39-39: 🎯 Functional Correctness | 🔵 Trivial | ⚡ Quick win

getClaimedSlotsForGroup ignores groupId.

The fake returns every claim regardless of group. FakeScheduledLedger feeds this set into ScheduledEntryProjector.project, so in a test with two groups the claims of one group suppress occurrences in the other. Filter the claims through the series that belong to the requested group.

♻️ Proposed refactor
-    override fun getClaimedSlotsForGroup(groupId: String): Flow<Set<RecurringSlot>> = claimedSlots
+    override fun getClaimedSlotsForGroup(groupId: String): Flow<Set<RecurringSlot>> =
+        combine(series, claimedSlots) { all, claims ->
+            val seriesIds = all.filter { it.groupId == groupId }.mapTo(mutableSetOf()) { it.seriesId }
+            claims.filterTo(mutableSetOf()) { it.seriesId in seriesIds }
+        }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@features/tabgroup/presentation/src/commonTest/kotlin/de/tabmates/features/tabgroup/presentation/testing/FakeRecurringSeriesRepository.kt`
at line 39, Update FakeRecurringSeriesRepository.getClaimedSlotsForGroup to
filter claimedSlots by the recurring series belonging to the requested groupId
before returning them, rather than returning all claims. Preserve the
Flow<Set<RecurringSlot>> contract and ensure claims from other groups cannot
reach FakeScheduledLedger or ScheduledEntryProjector.project.

22-23: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Rename the result and centralize the feature fake.

  • Rename createResult to writeResult; updateSeries also returns it.
  • Add features/tabgroup/testing for the shared fake. Preserve the data fake’s fail-fast write behavior and the presentation fake’s configurable state and writes when consolidating them.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@features/tabgroup/presentation/src/commonTest/kotlin/de/tabmates/features/tabgroup/presentation/testing/FakeRecurringSeriesRepository.kt`
around lines 22 - 23, Rename createResult to writeResult in
FakeRecurringSeriesRepository and update both createSeries and updateSeries to
use it. Consolidate the fake into the shared features/tabgroup/testing location,
preserving the data fake’s fail-fast write behavior while retaining the
presentation fake’s configurable state and write handling.

Source: Coding guidelines

features/tabgroup/presentation/src/commonTest/kotlin/de/tabmates/features/tabgroup/presentation/testing/FakeScheduledLedger.kt (1)

22-30: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Expose the backing fakes.

Both repositories are private val. A test that uses the default recurringSeriesRepository cannot add series or claims to it, so it must construct and hold its own instance. Change both to val so callers can drive them through the ledger.

♻️ Proposed refactor
 class FakeScheduledLedger(
-    private val tabEntryRepository: FakeTabEntryRepository = FakeTabEntryRepository(),
-    private val recurringSeriesRepository: FakeRecurringSeriesRepository = FakeRecurringSeriesRepository(),
+    val tabEntryRepository: FakeTabEntryRepository = FakeTabEntryRepository(),
+    val recurringSeriesRepository: FakeRecurringSeriesRepository = FakeRecurringSeriesRepository(),
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@features/tabgroup/presentation/src/commonTest/kotlin/de/tabmates/features/tabgroup/presentation/testing/FakeScheduledLedger.kt`
around lines 22 - 30, Expose the backing repositories in FakeScheduledLedger by
changing tabEntryRepository and recurringSeriesRepository from private val to
public val, while preserving their default instances and existing behavior.
features/tabgroup/presentation/src/commonMain/kotlin/de/tabmates/features/tabgroup/presentation/navigation/groupoverview/GroupDetailPane.kt (2)

1329-1360: 🚀 Performance & Scalability | 🔵 Trivial | ⚖️ Poor tradeoff

Optional: render the recurring list lazily.

RecurringTab builds every row in a Column. A group with many schedules composes all rows at once. The file already imports LazyColumn and rememberLazyListState. Consider LazyColumn with stickyHeader or item section labels.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@features/tabgroup/presentation/src/commonMain/kotlin/de/tabmates/features/tabgroup/presentation/navigation/groupoverview/GroupDetailPane.kt`
around lines 1329 - 1360, Optionally replace the eager Column rendering in
RecurringTab with a LazyColumn using the existing LazyColumn and
rememberLazyListState imports. Render the active and ended section labels as
list items or sticky headers, and each RecurringRow as a lazy item while
preserving ordering, spacing, callbacks, and the existing bottom clearance.

1373-1384: 🚀 Performance & Scalability | 🔵 Trivial | ⚡ Quick win

RecurringOccurrenceCalculator.upcomingOccurrences is called during composition without remember. Both call sites re-run the date walk on every recomposition although the inputs change rarely.

  • features/tabgroup/presentation/src/commonMain/kotlin/de/tabmates/features/tabgroup/presentation/navigation/groupoverview/GroupDetailPane.kt#L1373-L1384: wrap the next-occurrence calculation in remember(series, today); this runs once per rendered schedule row.
  • features/tabgroup/presentation/src/commonMain/kotlin/de/tabmates/features/tabgroup/presentation/navigation/addentry/RepeatEditorScreen.kt#L192-L200: wrap the preview calculation in remember(repeat).
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@features/tabgroup/presentation/src/commonMain/kotlin/de/tabmates/features/tabgroup/presentation/navigation/groupoverview/GroupDetailPane.kt`
around lines 1373 - 1384, The next-occurrence calculation in GroupDetailPane.kt
lines 1373-1384 should be wrapped with remember(series, today), preserving the
existing active/attention checks and calculation. The preview calculation in
RepeatEditorScreen.kt lines 192-200 should likewise be wrapped with
remember(repeat); both sites must retain their current results while avoiding
repeated date walks during recomposition.
features/tabgroup/presentation/src/commonMain/kotlin/de/tabmates/features/tabgroup/presentation/navigation/groupoverview/GroupOverviewViewModel.kt (1)

60-77: 🚀 Performance & Scalability | 🔵 Trivial

Watch the observer count on the overview screen.

ScheduledLedger.observeEntriesForGroup combines three database flows per group (DefaultScheduledLedger.kt:22-24). This screen calls it once per group, so the number of active Room observers is now three times the group count, and each emission re-runs ScheduledEntryProjector.project. Confirm this stays acceptable for accounts with many groups. If not, add a ledger API that observes all groups at once.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@features/tabgroup/presentation/src/commonMain/kotlin/de/tabmates/features/tabgroup/presentation/navigation/groupoverview/GroupOverviewViewModel.kt`
around lines 60 - 77, Review enrichWithStats and
ScheduledLedger.observeEntriesForGroup for overview screens with many groups;
avoid creating three database observers and rerunning
ScheduledEntryProjector.project once per group. If the current observer count is
not acceptable, add and use a ledger API that observes entries for all requested
groups in one combined flow while preserving the existing per-item enrichment
behavior.
features/tabgroup/presentation/src/commonMain/kotlin/de/tabmates/features/tabgroup/presentation/navigation/groupdetail/GroupDetailViewModel.kt (1)

109-117: 🚀 Performance & Scalability | 🔵 Trivial | 💤 Low value

Optional: the group's series are observed twice.

recurringSeriesRepository.getSeriesForGroup(groupId) is collected here, and DefaultScheduledLedger collects the same flow for the projection (features/tabgroup/data/src/commonMain/kotlin/de/tabmates/features/tabgroup/data/recurring/DefaultScheduledLedger.kt:22-24). Two Room observers run for one screen. If the query becomes expensive, expose the used series from the ledger instead so one reader serves both.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@features/tabgroup/presentation/src/commonMain/kotlin/de/tabmates/features/tabgroup/presentation/navigation/groupdetail/GroupDetailViewModel.kt`
around lines 109 - 117, Optionally eliminate the duplicate series observation by
exposing the series collected by DefaultScheduledLedger and reusing that stream
in GroupDetailViewModel’s recurringSeries/sideInputs flow. Preserve the existing
empty-list startup behavior and ensure both scheduledLedger projections and
sideInputs consume the same series source.
features/tabgroup/presentation/src/commonTest/kotlin/de/tabmates/features/tabgroup/presentation/navigation/addentry/AddEntryRecurringTest.kt (1)

216-219: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Use Turbine to collect ViewModel state.

These tests manually manage StateFlow subscriptions. Use Turbine so each test owns collection lifetime and assertions.

  • features/tabgroup/presentation/src/commonTest/kotlin/de/tabmates/features/tabgroup/presentation/navigation/addentry/AddEntryRecurringTest.kt#L216-L219: Replace the background collector helper with Turbine collection in each state-dependent test.
  • features/tabgroup/presentation/src/commonTest/kotlin/de/tabmates/features/tabgroup/presentation/navigation/groupdetail/GroupDetailScheduledEntriesTest.kt#L65-L65: Replace activation through first() with a Turbine collection that awaits the loaded state.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@features/tabgroup/presentation/src/commonTest/kotlin/de/tabmates/features/tabgroup/presentation/navigation/addentry/AddEntryRecurringTest.kt`
around lines 216 - 219, Replace the manual background collector in
AddEntryRecurringTest.kt (lines 216-219) with Turbine collection scoped within
each state-dependent test, removing the activate helper while preserving
idle/initial-state handling and assertions. In
GroupDetailScheduledEntriesTest.kt (line 65), replace first()-based activation
with Turbine and await the loaded state before continuing; update both sites to
own collection lifetimes per test.

Source: Coding guidelines

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In
`@features/tabgroup/data/src/commonMain/kotlin/de/tabmates/features/tabgroup/data/mappers/RecurringSeriesMappers.kt`:
- Around line 311-328: Update the end mapping in the recurring-series mapper so
incomplete UNTIL or COUNT rows never become RecurringEnd.Never. When the
required endUntilDate or endCount is missing, use the existing stop-generation
fallback if available; otherwise mark the row unreadable and log it, while
preserving normal NEVER, UNTIL, and COUNT mappings.

In
`@features/tabgroup/data/src/commonMain/kotlin/de/tabmates/features/tabgroup/data/recurring/DefaultScheduledLedger.kt`:
- Around line 20-41: Update DefaultScheduledLedger.observeEntriesForGroup so
projection also refreshes at each UTC date boundary, not only when repository
flows emit. Add a date-boundary flow or ticker to the combined inputs, derive
today from an injected Clock rather than directly from Clock.System.now(), and
preserve the existing UTC-date behavior while enabling deterministic testing.

In
`@features/tabgroup/domain/src/commonMain/kotlin/de/tabmates/features/tabgroup/domain/recurring/RecurringOccurrenceCalculator.kt`:
- Line 24: The MAX_SLOT_SCAN limit in walkSlots causes valid recurring series to
be silently truncated, affecting dueOccurrences, upcomingOccurrences, and
isOccurrenceDate. Replace the truncation with an initial-slot or
resumable-position calculation, or enforce a domain limit that makes the limit
unreachable, while preserving complete results for valid series; add a
regression test covering a series beyond MAX_SLOT_SCAN.

In
`@features/tabgroup/presentation/src/commonMain/kotlin/de/tabmates/features/tabgroup/presentation/navigation/addentry/AddEntryViewModel.kt`:
- Around line 775-779: Update todayLocal() to convert the current instant using
TimeZone.UTC instead of TimeZone.currentSystemDefault(), keeping the returned
LocalDate behavior unchanged so form date calculations align with the
scheduler’s UTC day.
- Around line 89-96: The series-edit flow incorrectly reuses entry loading and
split selection. In AddEntryViewModel.kt lines 89-96, add an isEditingEntry flag
based only on entryId and gate tabEntryRepository.getTabEntryById with it; in
lines 240-295, derive existingSplits and splitsByParticipant from
series?.rule?.splits when editing a series, then use those values to select the
corresponding rows in splitInputs.
- Around line 431-433: Update onRepeatEndChange so a RecurringEnd.Until date
earlier than the current repeatStartDate is clamped to repeatStartDate before
updating state; preserve non-date end variants and continue hiding the picker.

In
`@features/tabgroup/presentation/src/commonMain/kotlin/de/tabmates/features/tabgroup/presentation/navigation/addentry/RepeatEditorScreen.kt`:
- Around line 299-313: Update StepperRow so both IconButton modifiers provide
localized accessibility content descriptions through semantics, using Res.string
resources that describe decrement and increment actions. Add a maxValue
parameter, disable the increment button when value reaches that limit, and
coerce incremented values to maxValue; update repeatInterval and
RecurringEnd.Count call sites with appropriate bounds.

In
`@features/tabgroup/presentation/src/commonMain/kotlin/de/tabmates/features/tabgroup/presentation/navigation/groupdetail/GroupDetailViewModel.kt`:
- Around line 84-86: Remove the unused tabEntryRepository property and import
from GroupDetailViewModel, then remove its constructor argument from
GroupDetailViewModelTest and GroupDetailScheduledEntriesTest while retaining the
repository argument passed to FakeScheduledLedger.

In
`@features/tabgroup/presentation/src/commonMain/kotlin/de/tabmates/features/tabgroup/presentation/navigation/groupoverview/GroupDetailPane.kt`:
- Around line 504-554: In the Box around the entry rows, move ScheduledRowChip
after the when(entry) block so it is drawn above ExpenseRow, SettlementRow, and
IncomeRow. Update the row click handling so scheduled entries receive no click
handler (or otherwise omit Modifier.clickable), rather than only guarding
callbacks; preserve normal click behavior for non-scheduled rows.
- Around line 267-287: In the GroupDetailPane tab-rendering block, compute
recurring-series attention once before DetailTab.entries.forEach and reuse that
value when determining needsAttention. Add localized accessibility semantics
with a contentDescription to the attention-dot Box, describing that a schedule
needs attention, while preserving the existing visual indicator.

In
`@features/tabgroup/presentation/src/commonTest/kotlin/de/tabmates/features/tabgroup/presentation/navigation/groupdetail/GroupDetailScheduledEntriesTest.kt`:
- Line 146: Update the LocalDate.plusOneYear extension in
GroupDetailScheduledEntriesTest to use calendar-aware year addition that clamps
invalid dates to the target month’s last valid day, preserving February 29 to
February 28 when the following year is not a leap year.

---

Outside diff comments:
In
`@features/tabgroup/presentation/src/commonMain/kotlin/de/tabmates/features/tabgroup/presentation/navigation/addentry/AddEntryViewModel.kt`:
- Around line 240-245: Update the split-loading logic around existingSplits and
splitsByParticipant to use series?.rule?.splits when editing a series, mapping
those rule splits into the same participant-keyed shape as entry splits and
preserving their amounts and split type. Ensure splitInputs selects participants
based on the mapped series splits while retaining the existing entry behavior.

---

Nitpick comments:
In
`@features/tabgroup/data/src/commonMain/kotlin/de/tabmates/features/tabgroup/data/mappers/RecurringSeriesMappers.kt`:
- Around line 153-174: Replace the requireNotNull call in the settlement branch
of the recurring template mapper with typed failure handling using
Result.Failure or EmptyResult. Propagate the failure through the toDto() caller,
or validate receivedByUserId before building the DTO, so the missing receiver
can be mapped to UiText without throwing IllegalArgumentException.
- Around line 84-91: Remove the unused
RecurringSeriesDto.unresolvedParticipantIds() helper, including its associated
documentation, while leaving referencedParticipants() and
RecurringSeriesLocalWriter.ensureParticipantsExist unchanged.

In
`@features/tabgroup/data/src/desktopTest/kotlin/de/tabmates/features/tabgroup/data/sync/OfflineFirstSyncRepositoryTest.kt`:
- Line 37: Update deltaSyncBackfillsEntriesForNewlyKnownGroup to create and pass
an explicit FakeRecurringSeriesRepository, then assert its refreshedGroupIds
contains the newly known group and any pending group expected by the backfill.
Use the fake’s recorded values to verify backfillNewAndPendingGroups refreshes
every relevant group.

In
`@features/tabgroup/data/src/desktopTest/kotlin/de/tabmates/features/tabgroup/data/tabentry/TabEntryRealtimeSyncTest.kt`:
- Line 13: Add two frame-based tests in TabEntryRealtimeSyncTest: one emitting a
RECURRING_SERIES_CHANGED payload using a RecurringSeriesDto fixture and
asserting the series is persisted, and another emitting an acknowledged upsert
for a soft-deleted generated entry and asserting its recurring slot claim
remains. Reuse the existing in-memory database, real RecurringSeriesLocalWriter
wiring, and established frame-test helpers.

In
`@features/tabgroup/domain/src/commonMain/kotlin/de/tabmates/features/tabgroup/domain/models/TabEntry.kt`:
- Around line 37-56: The recurring provenance fields in TabEntry and every
affected subtype constructor are independently nullable, allowing incomplete
identities and invalid placeholders. Make recurringSeriesId and
recurringOccurrenceDate a single nullable provenance value, or enforce that they
are both set together and require isScheduledPlaceholder to have valid
provenance; update ScheduledEntryProjector.project to match entries using the
atomic provenance so it cannot create duplicate placeholders.

In
`@features/tabgroup/domain/src/commonMain/kotlin/de/tabmates/features/tabgroup/domain/recurring/ScheduledLedger.kt`:
- Around line 21-25: Update the documentation for ScheduledLedger to state that
persistence removes soft-deleted entries from the result while retaining their
claimed slots, and that implementations must preserve those claimed slots when
projecting due unwritten occurrences. Remove the requirement that deleted entry
rows remain exposed to callers.

In
`@features/tabgroup/presentation/src/commonMain/kotlin/de/tabmates/features/tabgroup/presentation/navigation/addentry/AddEntryScreen.kt`:
- Around line 807-823: Update paidByDisplay to delegate to participantDisplay
using the same state and user ID inputs, and remove its duplicate
participant-resolution logic. Preserve the existing removed-member,
current-user, and username results through the shared participantDisplay
implementation.

In
`@features/tabgroup/presentation/src/commonMain/kotlin/de/tabmates/features/tabgroup/presentation/navigation/addentry/RepeatEditorScreen.kt`:
- Around line 227-235: Update the radio-option Row modifier to use selectable
with Role.RadioButton instead of clickable, and set RadioButton’s onClick to
null so each option exposes a single accessible radio target. Add the required
selectable and Role imports.
- Around line 192-200: Cache the upcoming occurrence calculation in
RepeatPreview by wrapping the RecurringOccurrenceCalculator.upcomingOccurrences
call in remember, keyed by the repeat configuration and state.repeatStartDate so
it recomputes only when those inputs change while preserving the existing rule,
anchor date, and limit.

In
`@features/tabgroup/presentation/src/commonMain/kotlin/de/tabmates/features/tabgroup/presentation/navigation/groupdetail/GroupDetailViewModel.kt`:
- Around line 109-117: Optionally eliminate the duplicate series observation by
exposing the series collected by DefaultScheduledLedger and reusing that stream
in GroupDetailViewModel’s recurringSeries/sideInputs flow. Preserve the existing
empty-list startup behavior and ensure both scheduledLedger projections and
sideInputs consume the same series source.

In
`@features/tabgroup/presentation/src/commonMain/kotlin/de/tabmates/features/tabgroup/presentation/navigation/groupoverview/GroupDetailPane.kt`:
- Around line 1329-1360: Optionally replace the eager Column rendering in
RecurringTab with a LazyColumn using the existing LazyColumn and
rememberLazyListState imports. Render the active and ended section labels as
list items or sticky headers, and each RecurringRow as a lazy item while
preserving ordering, spacing, callbacks, and the existing bottom clearance.
- Around line 1373-1384: The next-occurrence calculation in GroupDetailPane.kt
lines 1373-1384 should be wrapped with remember(series, today), preserving the
existing active/attention checks and calculation. The preview calculation in
RepeatEditorScreen.kt lines 192-200 should likewise be wrapped with
remember(repeat); both sites must retain their current results while avoiding
repeated date walks during recomposition.

In
`@features/tabgroup/presentation/src/commonMain/kotlin/de/tabmates/features/tabgroup/presentation/navigation/groupoverview/GroupOverviewViewModel.kt`:
- Around line 60-77: Review enrichWithStats and
ScheduledLedger.observeEntriesForGroup for overview screens with many groups;
avoid creating three database observers and rerunning
ScheduledEntryProjector.project once per group. If the current observer count is
not acceptable, add and use a ledger API that observes entries for all requested
groups in one combined flow while preserving the existing per-item enrichment
behavior.

In
`@features/tabgroup/presentation/src/commonMain/kotlin/de/tabmates/features/tabgroup/presentation/navigation/MainGraph.kt`:
- Around line 292-301: The EditSettlement migration currently runs only when
that entry reaches the top and can remove entries above it. Move the conversion
out of the entry<EditSettlement> body and into the back-stack restoration flow,
converting every persisted EditSettlement entry in place to EditEntry while
preserving all other entries and their ordering; retain the deprecated route
only as needed for deserialization.

In
`@features/tabgroup/presentation/src/commonTest/kotlin/de/tabmates/features/tabgroup/presentation/navigation/addentry/AddEntryRecurringTest.kt`:
- Around line 216-219: Replace the manual background collector in
AddEntryRecurringTest.kt (lines 216-219) with Turbine collection scoped within
each state-dependent test, removing the activate helper while preserving
idle/initial-state handling and assertions. In
GroupDetailScheduledEntriesTest.kt (line 65), replace first()-based activation
with Turbine and await the loaded state before continuing; update both sites to
own collection lifetimes per test.

In
`@features/tabgroup/presentation/src/commonTest/kotlin/de/tabmates/features/tabgroup/presentation/testing/FakeRecurringSeriesRepository.kt`:
- Line 39: Update FakeRecurringSeriesRepository.getClaimedSlotsForGroup to
filter claimedSlots by the recurring series belonging to the requested groupId
before returning them, rather than returning all claims. Preserve the
Flow<Set<RecurringSlot>> contract and ensure claims from other groups cannot
reach FakeScheduledLedger or ScheduledEntryProjector.project.
- Around line 22-23: Rename createResult to writeResult in
FakeRecurringSeriesRepository and update both createSeries and updateSeries to
use it. Consolidate the fake into the shared features/tabgroup/testing location,
preserving the data fake’s fail-fast write behavior while retaining the
presentation fake’s configurable state and write handling.

In
`@features/tabgroup/presentation/src/commonTest/kotlin/de/tabmates/features/tabgroup/presentation/testing/FakeScheduledLedger.kt`:
- Around line 22-30: Expose the backing repositories in FakeScheduledLedger by
changing tabEntryRepository and recurringSeriesRepository from private val to
public val, while preserving their default instances and existing behavior.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 6e349e12-f58f-4a70-a871-aaddbf252d63

📥 Commits

Reviewing files that changed from the base of the PR and between 46c3cf2 and 09a3254.

📒 Files selected for processing (84)
  • core/data/src/commonMain/kotlin/de/tabmates/core/data/networking/HttpClientExt.kt
  • core/domain/src/commonMain/kotlin/de/tabmates/core/domain/util/DataError.kt
  • core/presentation/src/commonMain/composeResources/values-de/string.xml
  • core/presentation/src/commonMain/composeResources/values/string.xml
  • core/presentation/src/commonMain/kotlin/de/tabmates/core/presentation/util/DataErrorToUiText.kt
  • features/tabgroup/data/src/commonMain/kotlin/de/tabmates/features/tabgroup/data/dto/RecurringSeriesDto.kt
  • features/tabgroup/data/src/commonMain/kotlin/de/tabmates/features/tabgroup/data/dto/RecurringSeriesRequestDtos.kt
  • features/tabgroup/data/src/commonMain/kotlin/de/tabmates/features/tabgroup/data/dto/SyncResponseDto.kt
  • features/tabgroup/data/src/commonMain/kotlin/de/tabmates/features/tabgroup/data/dto/TabEntryDto.kt
  • features/tabgroup/data/src/commonMain/kotlin/de/tabmates/features/tabgroup/data/mappers/RecurringSeriesMappers.kt
  • features/tabgroup/data/src/commonMain/kotlin/de/tabmates/features/tabgroup/data/mappers/SyncMappers.kt
  • features/tabgroup/data/src/commonMain/kotlin/de/tabmates/features/tabgroup/data/mappers/TabEntryMappers.kt
  • features/tabgroup/data/src/commonMain/kotlin/de/tabmates/features/tabgroup/data/network/dto/TabEntryWsMessages.kt
  • features/tabgroup/data/src/commonMain/kotlin/de/tabmates/features/tabgroup/data/recurring/DefaultScheduledLedger.kt
  • features/tabgroup/data/src/commonMain/kotlin/de/tabmates/features/tabgroup/data/recurring/KtorRecurringSeriesService.kt
  • features/tabgroup/data/src/commonMain/kotlin/de/tabmates/features/tabgroup/data/recurring/OfflineFirstRecurringSeriesRepository.kt
  • features/tabgroup/data/src/commonMain/kotlin/de/tabmates/features/tabgroup/data/recurring/RecurringSeriesService.kt
  • features/tabgroup/data/src/commonMain/kotlin/de/tabmates/features/tabgroup/data/sync/OfflineFirstSyncRepository.kt
  • features/tabgroup/data/src/commonMain/kotlin/de/tabmates/features/tabgroup/data/sync/RecurringSeriesLocalWriter.kt
  • features/tabgroup/data/src/commonMain/kotlin/de/tabmates/features/tabgroup/data/tabentry/TabEntryOutbox.kt
  • features/tabgroup/data/src/commonMain/kotlin/de/tabmates/features/tabgroup/data/tabentry/TabEntryRealtimeSync.kt
  • features/tabgroup/data/src/desktopTest/kotlin/de/tabmates/features/tabgroup/data/sync/FakeRecurringSeriesRepository.kt
  • features/tabgroup/data/src/desktopTest/kotlin/de/tabmates/features/tabgroup/data/sync/OfflineFirstSyncRepositoryTest.kt
  • features/tabgroup/data/src/desktopTest/kotlin/de/tabmates/features/tabgroup/data/tabentry/TabEntryRealtimeSyncTest.kt
  • features/tabgroup/database/schemas/de.tabmates.features.tabgroup.database.TabMatesDatabase/8.json
  • features/tabgroup/database/src/commonMain/kotlin/de/tabmates/features/tabgroup/database/TabMatesDatabase.kt
  • features/tabgroup/database/src/commonMain/kotlin/de/tabmates/features/tabgroup/database/dao/RecurringSeriesDao.kt
  • features/tabgroup/database/src/commonMain/kotlin/de/tabmates/features/tabgroup/database/dao/RecurringSlotClaimDao.kt
  • features/tabgroup/database/src/commonMain/kotlin/de/tabmates/features/tabgroup/database/entities/RecurringExceptionEntity.kt
  • features/tabgroup/database/src/commonMain/kotlin/de/tabmates/features/tabgroup/database/entities/RecurringSeriesEntity.kt
  • features/tabgroup/database/src/commonMain/kotlin/de/tabmates/features/tabgroup/database/entities/RecurringSeriesWithDetails.kt
  • features/tabgroup/database/src/commonMain/kotlin/de/tabmates/features/tabgroup/database/entities/RecurringSlotClaimEntity.kt
  • features/tabgroup/database/src/commonMain/kotlin/de/tabmates/features/tabgroup/database/entities/RecurringTemplateSplitEntity.kt
  • features/tabgroup/database/src/commonMain/kotlin/de/tabmates/features/tabgroup/database/entities/TabEntryEntity.kt
  • features/tabgroup/database/src/commonMain/kotlin/de/tabmates/features/tabgroup/database/entities/types/RecurrenceFrequencyDatabase.kt
  • features/tabgroup/database/src/commonMain/kotlin/de/tabmates/features/tabgroup/database/entities/types/RecurringEndTypeDatabase.kt
  • features/tabgroup/domain/src/commonMain/kotlin/de/tabmates/features/tabgroup/domain/models/SyncSnapshot.kt
  • features/tabgroup/domain/src/commonMain/kotlin/de/tabmates/features/tabgroup/domain/models/TabEntry.kt
  • features/tabgroup/domain/src/commonMain/kotlin/de/tabmates/features/tabgroup/domain/recurring/RecurrenceFrequency.kt
  • features/tabgroup/domain/src/commonMain/kotlin/de/tabmates/features/tabgroup/domain/recurring/RecurringEnd.kt
  • features/tabgroup/domain/src/commonMain/kotlin/de/tabmates/features/tabgroup/domain/recurring/RecurringOccurrenceCalculator.kt
  • features/tabgroup/domain/src/commonMain/kotlin/de/tabmates/features/tabgroup/domain/recurring/RecurringSeries.kt
  • features/tabgroup/domain/src/commonMain/kotlin/de/tabmates/features/tabgroup/domain/recurring/RecurringSeriesRepository.kt
  • features/tabgroup/domain/src/commonMain/kotlin/de/tabmates/features/tabgroup/domain/recurring/ScheduledEntryProjector.kt
  • features/tabgroup/domain/src/commonMain/kotlin/de/tabmates/features/tabgroup/domain/recurring/ScheduledLedger.kt
  • features/tabgroup/domain/src/commonTest/kotlin/de/tabmates/features/tabgroup/domain/recurring/RecurringOccurrenceCalculatorTest.kt
  • features/tabgroup/domain/src/commonTest/kotlin/de/tabmates/features/tabgroup/domain/recurring/ScheduledEntryProjectorTest.kt
  • features/tabgroup/presentation/src/commonMain/composeResources/values-de/string.xml
  • features/tabgroup/presentation/src/commonMain/composeResources/values/string.xml
  • features/tabgroup/presentation/src/commonMain/kotlin/de/tabmates/features/tabgroup/presentation/navigation/MainGraph.kt
  • features/tabgroup/presentation/src/commonMain/kotlin/de/tabmates/features/tabgroup/presentation/navigation/MainNavKeys.kt
  • features/tabgroup/presentation/src/commonMain/kotlin/de/tabmates/features/tabgroup/presentation/navigation/addentry/AddEntryScreen.kt
  • features/tabgroup/presentation/src/commonMain/kotlin/de/tabmates/features/tabgroup/presentation/navigation/addentry/AddEntryState.kt
  • features/tabgroup/presentation/src/commonMain/kotlin/de/tabmates/features/tabgroup/presentation/navigation/addentry/AddEntryViewModel.kt
  • features/tabgroup/presentation/src/commonMain/kotlin/de/tabmates/features/tabgroup/presentation/navigation/addentry/EntryKind.kt
  • features/tabgroup/presentation/src/commonMain/kotlin/de/tabmates/features/tabgroup/presentation/navigation/addentry/RepeatConfig.kt
  • features/tabgroup/presentation/src/commonMain/kotlin/de/tabmates/features/tabgroup/presentation/navigation/addentry/RepeatEditorScreen.kt
  • features/tabgroup/presentation/src/commonMain/kotlin/de/tabmates/features/tabgroup/presentation/navigation/editsettlement/EditSettlementEvent.kt
  • features/tabgroup/presentation/src/commonMain/kotlin/de/tabmates/features/tabgroup/presentation/navigation/editsettlement/EditSettlementRoot.kt
  • features/tabgroup/presentation/src/commonMain/kotlin/de/tabmates/features/tabgroup/presentation/navigation/editsettlement/EditSettlementState.kt
  • features/tabgroup/presentation/src/commonMain/kotlin/de/tabmates/features/tabgroup/presentation/navigation/editsettlement/EditSettlementViewModel.kt
  • features/tabgroup/presentation/src/commonMain/kotlin/de/tabmates/features/tabgroup/presentation/navigation/groupdetail/GroupDetailRoot.kt
  • features/tabgroup/presentation/src/commonMain/kotlin/de/tabmates/features/tabgroup/presentation/navigation/groupdetail/GroupDetailViewModel.kt
  • features/tabgroup/presentation/src/commonMain/kotlin/de/tabmates/features/tabgroup/presentation/navigation/groupoverview/GroupDetailPane.kt
  • features/tabgroup/presentation/src/commonMain/kotlin/de/tabmates/features/tabgroup/presentation/navigation/groupoverview/GroupOverviewViewModel.kt
  • features/tabgroup/presentation/src/commonMain/kotlin/de/tabmates/features/tabgroup/presentation/navigation/grouppeople/GroupPeopleViewModel.kt
  • features/tabgroup/presentation/src/commonMain/kotlin/de/tabmates/features/tabgroup/presentation/navigation/home/HomeViewModel.kt
  • features/tabgroup/presentation/src/commonMain/kotlin/de/tabmates/features/tabgroup/presentation/navigation/recurringdetail/RecurringSeriesDetailEvent.kt
  • features/tabgroup/presentation/src/commonMain/kotlin/de/tabmates/features/tabgroup/presentation/navigation/recurringdetail/RecurringSeriesDetailRoot.kt
  • features/tabgroup/presentation/src/commonMain/kotlin/de/tabmates/features/tabgroup/presentation/navigation/recurringdetail/RecurringSeriesDetailState.kt
  • features/tabgroup/presentation/src/commonMain/kotlin/de/tabmates/features/tabgroup/presentation/navigation/recurringdetail/RecurringSeriesDetailViewModel.kt
  • features/tabgroup/presentation/src/commonMain/kotlin/de/tabmates/features/tabgroup/presentation/navigation/recurringdetail/ScheduleSummary.kt
  • features/tabgroup/presentation/src/commonTest/kotlin/de/tabmates/features/tabgroup/presentation/navigation/addentry/AddEntryRecurringTest.kt
  • features/tabgroup/presentation/src/commonTest/kotlin/de/tabmates/features/tabgroup/presentation/navigation/addentry/AddEntryViewModelTest.kt
  • features/tabgroup/presentation/src/commonTest/kotlin/de/tabmates/features/tabgroup/presentation/navigation/editsettlement/EditSettlementViewModelTest.kt
  • features/tabgroup/presentation/src/commonTest/kotlin/de/tabmates/features/tabgroup/presentation/navigation/groupdetail/GroupDetailScheduledEntriesTest.kt
  • features/tabgroup/presentation/src/commonTest/kotlin/de/tabmates/features/tabgroup/presentation/navigation/groupdetail/GroupDetailViewModelTest.kt
  • features/tabgroup/presentation/src/commonTest/kotlin/de/tabmates/features/tabgroup/presentation/navigation/groupoverview/GroupOverviewViewModelTest.kt
  • features/tabgroup/presentation/src/commonTest/kotlin/de/tabmates/features/tabgroup/presentation/navigation/grouppeople/GroupPeopleViewModelTest.kt
  • features/tabgroup/presentation/src/commonTest/kotlin/de/tabmates/features/tabgroup/presentation/navigation/home/HomeViewModelTest.kt
  • features/tabgroup/presentation/src/commonTest/kotlin/de/tabmates/features/tabgroup/presentation/testing/FakeConnectionStatusRepository.kt
  • features/tabgroup/presentation/src/commonTest/kotlin/de/tabmates/features/tabgroup/presentation/testing/FakeRecurringSeriesRepository.kt
  • features/tabgroup/presentation/src/commonTest/kotlin/de/tabmates/features/tabgroup/presentation/testing/FakeScheduledLedger.kt
  • features/tabgroup/presentation/src/commonTest/kotlin/de/tabmates/features/tabgroup/presentation/testing/RecurringFixtures.kt
💤 Files with no reviewable changes (5)
  • features/tabgroup/presentation/src/commonMain/kotlin/de/tabmates/features/tabgroup/presentation/navigation/editsettlement/EditSettlementEvent.kt
  • features/tabgroup/presentation/src/commonMain/kotlin/de/tabmates/features/tabgroup/presentation/navigation/editsettlement/EditSettlementRoot.kt
  • features/tabgroup/presentation/src/commonMain/kotlin/de/tabmates/features/tabgroup/presentation/navigation/editsettlement/EditSettlementViewModel.kt
  • features/tabgroup/presentation/src/commonMain/kotlin/de/tabmates/features/tabgroup/presentation/navigation/editsettlement/EditSettlementState.kt
  • features/tabgroup/presentation/src/commonTest/kotlin/de/tabmates/features/tabgroup/presentation/navigation/editsettlement/EditSettlementViewModelTest.kt

@DennisBauer
DennisBauer force-pushed the feat/recurring-entries branch from 09a3254 to f5fef85 Compare August 11, 2026 14:21
RecurringOccurrenceCalculator is a port of the server's generator and has
to stay one: the server decides which occurrences get written, this
decides which ones the client previews. Its test suite is ported
case-for-case, including the Jan-31 clamp-and-recover regression, because
a divergence books money on the wrong day with no error anywhere.

ScheduledEntryProjector builds placeholders as ordinary TabEntry values
carrying isScheduledPlaceholder, so every existing balance calculator
consumes them unchanged.
Four new tables plus two nullable columns on tab entries, all additive,
so Room derives the 7->8 migration.

The rule is flattened into the series row: the server only ever ships the
newest revision, so a separate table buys nothing.

recurring_slot_claim carries no foreign key on purpose. A generated entry
can reach this device before the schedule that produced it does, and an
FK would reject exactly that claim. It is also never deleted with its
entry: the server keeps a slot claimed whatever happens to the row in it,
so without the record a deliberately deleted occurrence reappears as a
placeholder on every projection.
Schedules are managed over REST with no outbox behind them. A schedule is
a standing instruction to write into other people's ledgers; one queued
offline would fire days later against a group that has moved on. Every
write needs a connection and reports its own failure.

The delta sync filters series by the cursor, so a just-joined group
arrives without the schedules it already had -- same gap the tab-entry
backfiller already covers, extended to series.

INVALID_RECURRING_RULE and RECURRING_ENTRIES_DISABLED are mapped by code:
the 503 means the feature is off, not that the server is struggling, and
the same request can succeed later untouched.
An occurrence that is due but unwritten still moves the ledger, so it has
to move the numbers too -- otherwise every balance jumps when the
server's sweep lands, and offline the group looks settled when it is not.

ScheduledLedger is the one place that answers "what is this group's
balance". Projecting in some screens and not others is how the same group
ends up owing two different amounts on home and on its own screen.

Settle-up deliberately keeps reading TabEntryRepository: it turns
balances into real settlement writes and must only act on entries that
exist.
One form for all three entry types and for schedules. Settlements gain a
receiver picker and drop the split editor; the kind toggle locks once
bound to something that exists, since entry type is fixed server-side and
a series' type is fixed for its whole life.

Setting a repeat saves a schedule *instead of* an entry. The server
writes the first occurrence itself, so saving both books the same thing
twice in everybody's ledger with no error raised.

The repeat editor is a full screen, not a sheet: a frequency list, an
interval stepper, a start date and three end options do not fit one
without nesting scrolls, and the date preview is what makes a monthly
schedule anchored on the 31st legible before it is saved. Its fields are
held separately from the assembled config so flipping through "Never"
does not reset a schedule the user already tuned.
A read-only tab beside the others, and a detail screen carrying the three
things a member can do: skip a future occurrence, edit from a future
occurrence onwards, end it.

needsAttention means one thing -- a member the template names has left,
so nothing is being created -- and it is surfaced loudly, naming them,
because nothing else on the screen would say so.

Placeholder rows are faded, chipped and not clickable. They count in the
balances above but have no server id to open, and every action lives on
the schedule instead.

EditSettlement is retired now that the add form handles settlements. Its
NavKey stays registered and redirects: dropping a polymorphic subclass
outright fails to deserialize a back stack persisted by an older build
and takes the whole restored stack down with it. Safe to delete a release
from now.
@DennisBauer
DennisBauer force-pushed the feat/recurring-entries branch from f5fef85 to 02356c7 Compare August 11, 2026 15:05

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In
`@features/tabgroup/presentation/src/commonMain/kotlin/de/tabmates/features/tabgroup/presentation/navigation/groupoverview/GroupDetailPane.kt`:
- Around line 1336-1343: Update the today state in the RecurringTab composition
so it refreshes when the UTC calendar date changes, rather than remaining fixed
by the no-key remember block. Use boundary-aware state or the existing ViewModel
date source, while preserving the current UTC date calculation and
next-occurrence behavior.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 59ce1619-f53d-4bd7-bb9e-e2f8d54191da

📥 Commits

Reviewing files that changed from the base of the PR and between 09a3254 and 02356c7.

📒 Files selected for processing (18)
  • features/tabgroup/data/build.gradle.kts
  • features/tabgroup/data/src/commonMain/kotlin/de/tabmates/features/tabgroup/data/di/TabgroupDataModule.kt
  • features/tabgroup/data/src/commonMain/kotlin/de/tabmates/features/tabgroup/data/mappers/RecurringSeriesMappers.kt
  • features/tabgroup/data/src/commonMain/kotlin/de/tabmates/features/tabgroup/data/recurring/DefaultScheduledLedger.kt
  • features/tabgroup/data/src/desktopTest/kotlin/de/tabmates/features/tabgroup/data/recurring/DefaultScheduledLedgerTest.kt
  • features/tabgroup/data/src/desktopTest/kotlin/de/tabmates/features/tabgroup/data/sync/OfflineFirstSyncRepositoryTest.kt
  • features/tabgroup/data/src/desktopTest/kotlin/de/tabmates/features/tabgroup/data/tabentry/TabEntryRealtimeSyncTest.kt
  • features/tabgroup/domain/src/commonMain/kotlin/de/tabmates/features/tabgroup/domain/recurring/ScheduledLedger.kt
  • features/tabgroup/presentation/src/commonMain/composeResources/values-de/string.xml
  • features/tabgroup/presentation/src/commonMain/composeResources/values/string.xml
  • features/tabgroup/presentation/src/commonMain/kotlin/de/tabmates/features/tabgroup/presentation/navigation/addentry/AddEntryViewModel.kt
  • features/tabgroup/presentation/src/commonMain/kotlin/de/tabmates/features/tabgroup/presentation/navigation/addentry/RepeatEditorScreen.kt
  • features/tabgroup/presentation/src/commonMain/kotlin/de/tabmates/features/tabgroup/presentation/navigation/groupdetail/GroupDetailViewModel.kt
  • features/tabgroup/presentation/src/commonMain/kotlin/de/tabmates/features/tabgroup/presentation/navigation/groupoverview/GroupDetailPane.kt
  • features/tabgroup/presentation/src/commonTest/kotlin/de/tabmates/features/tabgroup/presentation/navigation/addentry/AddEntryRecurringTest.kt
  • features/tabgroup/presentation/src/commonTest/kotlin/de/tabmates/features/tabgroup/presentation/navigation/groupdetail/GroupDetailScheduledEntriesTest.kt
  • features/tabgroup/presentation/src/commonTest/kotlin/de/tabmates/features/tabgroup/presentation/navigation/groupdetail/GroupDetailViewModelTest.kt
  • features/tabgroup/presentation/src/commonTest/kotlin/de/tabmates/features/tabgroup/presentation/testing/FakeRecurringSeriesRepository.kt
🚧 Files skipped from review as they are similar to previous changes (10)
  • features/tabgroup/domain/src/commonMain/kotlin/de/tabmates/features/tabgroup/domain/recurring/ScheduledLedger.kt
  • features/tabgroup/data/src/desktopTest/kotlin/de/tabmates/features/tabgroup/data/sync/OfflineFirstSyncRepositoryTest.kt
  • features/tabgroup/presentation/src/commonTest/kotlin/de/tabmates/features/tabgroup/presentation/navigation/groupdetail/GroupDetailViewModelTest.kt
  • features/tabgroup/presentation/src/commonMain/composeResources/values-de/string.xml
  • features/tabgroup/presentation/src/commonMain/composeResources/values/string.xml
  • features/tabgroup/data/src/commonMain/kotlin/de/tabmates/features/tabgroup/data/recurring/DefaultScheduledLedger.kt
  • features/tabgroup/presentation/src/commonMain/kotlin/de/tabmates/features/tabgroup/presentation/navigation/addentry/RepeatEditorScreen.kt
  • features/tabgroup/presentation/src/commonMain/kotlin/de/tabmates/features/tabgroup/presentation/navigation/groupdetail/GroupDetailViewModel.kt
  • features/tabgroup/data/src/commonMain/kotlin/de/tabmates/features/tabgroup/data/mappers/RecurringSeriesMappers.kt
  • features/tabgroup/presentation/src/commonMain/kotlin/de/tabmates/features/tabgroup/presentation/navigation/addentry/AddEntryViewModel.kt

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🧹 Nitpick comments (2)
features/tabgroup/presentation/src/commonMain/kotlin/de/tabmates/features/tabgroup/presentation/navigation/groupschedules/GroupSchedulesScreen.kt (1)

236-236: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Reuse DEFAULT_CURRENCY_DECIMALS instead of a local constant.

GroupDetailPane.kt imports de.tabmates.core.presentation.format.DEFAULT_CURRENCY_DECIMALS for the same fallback. A second private constant with the same meaning can drift.

♻️ Proposed refactor
+import de.tabmates.core.presentation.format.DEFAULT_CURRENCY_DECIMALS
-                        currency?.decimalDigits ?: DEFAULT_DECIMALS,
+                        currency?.decimalDigits ?: DEFAULT_CURRENCY_DECIMALS,
-
-private const val DEFAULT_DECIMALS = 2

Also applies to: 285-285

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@features/tabgroup/presentation/src/commonMain/kotlin/de/tabmates/features/tabgroup/presentation/navigation/groupschedules/GroupSchedulesScreen.kt`
at line 236, Replace the local decimal fallback used by GroupSchedulesScreen
with the shared DEFAULT_CURRENCY_DECIMALS symbol, including both occurrences
referenced in the diff. Import and reuse the existing format constant
consistently, removing the duplicate local constant if present.
features/tabgroup/presentation/src/commonMain/kotlin/de/tabmates/features/tabgroup/presentation/navigation/groupoverview/EntryIcon.kt (1)

23-43: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Add a modifier parameter, and consider moving the file to the shared components package.

Two points on this shared composable:

  • EntryIcon emits UI but accepts no modifier. Callers cannot add padding, size, or semantics. Add modifier: Modifier = Modifier as the first optional parameter and apply it to the root Box.
  • The doc comment states the badge is shared. GroupSchedulesScreen.kt imports it from the groupoverview package. DetailHero already lives in presentation.components. Moving EntryIcon there would keep shared components in one place.
♻️ Proposed refactor for the modifier parameter
 `@Composable`
 internal fun EntryIcon(
     icon: DrawableResource,
+    modifier: Modifier = Modifier,
     containerColor: Color = MaterialTheme.colorScheme.surfaceVariant,
     contentColor: Color = MaterialTheme.colorScheme.onSurfaceVariant,
 ) {
     Box(
         modifier =
-            Modifier
+            modifier
                 .size(40.dp)
                 .background(containerColor, RoundedCornerShape(10.dp)),
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@features/tabgroup/presentation/src/commonMain/kotlin/de/tabmates/features/tabgroup/presentation/navigation/groupoverview/EntryIcon.kt`
around lines 23 - 43, Update EntryIcon to accept modifier: Modifier = Modifier
as its first optional parameter and apply it to the root Box so callers can
customize the emitted UI. Move EntryIcon from the groupoverview package to
presentation.components, then update GroupSchedulesScreen and any other
references/imports to use the new shared-components location.

Source: Coding guidelines

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In
`@features/tabgroup/presentation/src/commonMain/kotlin/de/tabmates/features/tabgroup/presentation/navigation/groupoverview/UpcomingSchedule.kt`:
- Around line 22-55: Update RecurringOccurrenceCalculator to jump directly to
the first slot after the cutoff instead of scanning up to MAX_SLOT_SCAN, then
compute occurrence projections in GroupDetailViewModel and
GroupSchedulesViewModel before composition. Pass those precomputed results into
UpcomingSchedule/upcomingSchedules and the GroupSchedulesScreen composable,
removing the occurrence scans from both remember blocks; the overview projection
belongs in GroupDetailViewModel, not GroupOverviewViewModel. Affected sites:
features/tabgroup/presentation/src/commonMain/kotlin/de/tabmates/features/tabgroup/presentation/navigation/groupoverview/UpcomingSchedule.kt:22-55
must consume the projected results, and
features/tabgroup/presentation/src/commonMain/kotlin/de/tabmates/features/tabgroup/presentation/navigation/groupschedules/GroupSchedulesScreen.kt:176-189
must likewise consume ViewModel-provided projections.

In
`@features/tabgroup/presentation/src/commonMain/kotlin/de/tabmates/features/tabgroup/presentation/navigation/groupschedules/GroupSchedulesScreen.kt`:
- Around line 276-283: Update rememberTodayUtc so its date refreshes across UTC
midnight instead of being permanently cached by an unkeyed remember. Prefer
exposing and collecting the current date from GroupSchedulesViewModel, reusing
DefaultScheduledLedger’s boundary-driven emissions; otherwise use state
scheduled to update at the next UTC midnight, and ensure ScheduleRow receives
the refreshed cutoff.

---

Nitpick comments:
In
`@features/tabgroup/presentation/src/commonMain/kotlin/de/tabmates/features/tabgroup/presentation/navigation/groupoverview/EntryIcon.kt`:
- Around line 23-43: Update EntryIcon to accept modifier: Modifier = Modifier as
its first optional parameter and apply it to the root Box so callers can
customize the emitted UI. Move EntryIcon from the groupoverview package to
presentation.components, then update GroupSchedulesScreen and any other
references/imports to use the new shared-components location.

In
`@features/tabgroup/presentation/src/commonMain/kotlin/de/tabmates/features/tabgroup/presentation/navigation/groupschedules/GroupSchedulesScreen.kt`:
- Line 236: Replace the local decimal fallback used by GroupSchedulesScreen with
the shared DEFAULT_CURRENCY_DECIMALS symbol, including both occurrences
referenced in the diff. Import and reuse the existing format constant
consistently, removing the duplicate local constant if present.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: d01e4257-6322-48f6-b316-48eca7484026

📥 Commits

Reviewing files that changed from the base of the PR and between 02356c7 and ca027e6.

📒 Files selected for processing (19)
  • features/tabgroup/presentation/src/commonMain/composeResources/values-de/string.xml
  • features/tabgroup/presentation/src/commonMain/composeResources/values/string.xml
  • features/tabgroup/presentation/src/commonMain/kotlin/de/tabmates/features/tabgroup/presentation/components/DetailHero.kt
  • features/tabgroup/presentation/src/commonMain/kotlin/de/tabmates/features/tabgroup/presentation/navigation/MainGraph.kt
  • features/tabgroup/presentation/src/commonMain/kotlin/de/tabmates/features/tabgroup/presentation/navigation/MainNavKeys.kt
  • features/tabgroup/presentation/src/commonMain/kotlin/de/tabmates/features/tabgroup/presentation/navigation/addentry/RepeatEditorScreen.kt
  • features/tabgroup/presentation/src/commonMain/kotlin/de/tabmates/features/tabgroup/presentation/navigation/entrydetail/EntryDetailRoot.kt
  • features/tabgroup/presentation/src/commonMain/kotlin/de/tabmates/features/tabgroup/presentation/navigation/groupdetail/GroupDetailRoot.kt
  • features/tabgroup/presentation/src/commonMain/kotlin/de/tabmates/features/tabgroup/presentation/navigation/groupoverview/EntryIcon.kt
  • features/tabgroup/presentation/src/commonMain/kotlin/de/tabmates/features/tabgroup/presentation/navigation/groupoverview/GroupDetailPane.kt
  • features/tabgroup/presentation/src/commonMain/kotlin/de/tabmates/features/tabgroup/presentation/navigation/groupoverview/UpcomingSchedule.kt
  • features/tabgroup/presentation/src/commonMain/kotlin/de/tabmates/features/tabgroup/presentation/navigation/groupschedules/GroupSchedulesScreen.kt
  • features/tabgroup/presentation/src/commonMain/kotlin/de/tabmates/features/tabgroup/presentation/navigation/groupschedules/GroupSchedulesState.kt
  • features/tabgroup/presentation/src/commonMain/kotlin/de/tabmates/features/tabgroup/presentation/navigation/groupschedules/GroupSchedulesViewModel.kt
  • features/tabgroup/presentation/src/commonMain/kotlin/de/tabmates/features/tabgroup/presentation/navigation/groupsettings/GroupSettingsScreen.kt
  • features/tabgroup/presentation/src/commonMain/kotlin/de/tabmates/features/tabgroup/presentation/navigation/recurringdetail/RecurringSeriesDetailRoot.kt
  • features/tabgroup/presentation/src/commonMain/kotlin/de/tabmates/features/tabgroup/presentation/navigation/settlementdetail/SettlementDetailRoot.kt
  • features/tabgroup/presentation/src/commonTest/kotlin/de/tabmates/features/tabgroup/presentation/navigation/groupoverview/UpcomingSchedulesTest.kt
  • features/tabgroup/presentation/src/commonTest/kotlin/de/tabmates/features/tabgroup/presentation/navigation/groupschedules/GroupSchedulesViewModelTest.kt
🚧 Files skipped from review as they are similar to previous changes (5)
  • features/tabgroup/presentation/src/commonMain/kotlin/de/tabmates/features/tabgroup/presentation/navigation/addentry/RepeatEditorScreen.kt
  • features/tabgroup/presentation/src/commonMain/composeResources/values/string.xml
  • features/tabgroup/presentation/src/commonMain/kotlin/de/tabmates/features/tabgroup/presentation/navigation/recurringdetail/RecurringSeriesDetailRoot.kt
  • features/tabgroup/presentation/src/commonMain/kotlin/de/tabmates/features/tabgroup/presentation/navigation/MainGraph.kt
  • features/tabgroup/presentation/src/commonMain/kotlin/de/tabmates/features/tabgroup/presentation/navigation/groupdetail/GroupDetailRoot.kt

Comment on lines +22 to +55
internal fun upcomingSchedules(
series: List<RecurringSeries>,
today: LocalDate,
): List<UpcomingSchedule> =
series
.filter { it.isActive }
.map { candidate ->
UpcomingSchedule(
series = candidate,
// A parked schedule promises no date: the server writes nothing for it until a
// member repairs the template.
nextDate =
if (candidate.needsAttention) {
null
} else {
RecurringOccurrenceCalculator
.upcomingOccurrences(
rule = candidate.rule,
after = today,
limit = 1,
skippedDates = candidate.skippedOccurrenceDates,
).firstOrNull()
},
)
}
// An active schedule whose dates have run out has nothing upcoming to promise, so it belongs
// on the schedules screen and not in a section named for what is coming.
.filter { it.series.needsAttention || it.nextDate != null }
// Parked first: they are the only rows asking for something, and the peek limit could
// otherwise bury the one schedule that is silently producing nothing.
.sortedWith(
compareByDescending<UpcomingSchedule> { it.series.needsAttention }
.thenBy(nullsLast()) { it.nextDate },
)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🚀 Performance & Scalability | 🟠 Major | 🏗️ Heavy lift

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

fd -e kt 'RecurringOccurrenceCalculator' --exec cat -n {}

Repository: TabMates/app

Length of output: 24606


🏁 Script executed:

#!/bin/bash
set -euo pipefail

files=(
  "features/tabgroup/presentation/src/commonMain/kotlin/de/tabmates/features/tabgroup/presentation/navigation/groupoverview/UpcomingSchedule.kt"
  "features/tabgroup/presentation/src/commonMain/kotlin/de/tabmates/features/tabgroup/presentation/navigation/groupschedules/GroupSchedulesScreen.kt"
)
for file in "${files[@]}"; do
  echo "===== $file ====="
  wc -l "$file"
  sed -n '1,90p' "$file"
  sed -n '140,215p' "$file"
done

echo "===== related symbols and ViewModels ====="
rg -n -t kt \
  "upcomingSchedules|UpcomingSchedule|RecurringOccurrenceCalculator|class .*ViewModel|fun .*ViewModel|remember\\(" \
  features/tabgroup/presentation/src/commonMain/kotlin

Repository: TabMates/app

Length of output: 10240


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "===== Kotlin files ====="
fd -e kt . features/tabgroup/presentation/src/commonMain/kotlin | sort

echo "===== calculator and projection call sites ====="
rg -n --glob '*.kt' \
  "upcomingSchedules|UpcomingSchedule|RecurringOccurrenceCalculator|GroupSchedulesViewModel|GroupOverviewViewModel|ScheduleRow\\(" \
  features/tabgroup/presentation/src/commonMain/kotlin

echo "===== GroupSchedulesViewModel candidates ====="
fd -e kt . | while read -r file; do
  if rg -q "class GroupSchedulesViewModel|GroupSchedulesViewModel" "$file"; then
    echo "--- $file ---"
    cat -n "$file"
  fi
done

echo "===== GroupOverviewViewModel candidates ====="
fd -e kt . | while read -r file; do
  if rg -q "class GroupOverviewViewModel|GroupOverviewViewModel" "$file"; then
    echo "--- $file ---"
    cat -n "$file"
  fi
done

Repository: TabMates/app

Length of output: 50368


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "===== GroupDetailPane occurrence projection ====="
sed -n '450,525p' \
  features/tabgroup/presentation/src/commonMain/kotlin/de/tabmates/features/tabgroup/presentation/navigation/groupoverview/GroupDetailPane.kt
sed -n '1335,1370p' \
  features/tabgroup/presentation/src/commonMain/kotlin/de/tabmates/features/tabgroup/presentation/navigation/groupoverview/GroupDetailPane.kt

echo "===== GroupDetailViewModel and state ====="
sed -n '1,220p' \
  features/tabgroup/presentation/src/commonMain/kotlin/de/tabmates/features/tabgroup/presentation/navigation/groupdetail/GroupDetailViewModel.kt
rg -n --glob '*.kt' \
  "recurringSeries|GroupDetailState|GroupDetailPane\\(" \
  features/tabgroup/presentation/src/commonMain/kotlin/de/tabmates/features/tabgroup/presentation/navigation/groupdetail \
  features/tabgroup/presentation/src/commonMain/kotlin/de/tabmates/features/tabgroup/presentation/navigation/groupoverview

echo "===== GroupSchedulesState ====="
cat -n \
  features/tabgroup/presentation/src/commonMain/kotlin/de/tabmates/features/tabgroup/presentation/navigation/groupschedules/GroupSchedulesState.kt

echo "===== Calculator call-site summary ====="
rg -n --glob '*.kt' \
  "RecurringOccurrenceCalculator\\.upcomingOccurrences|upcomingSchedules\\(" \
  features/tabgroup/presentation/src/commonMain/kotlin

Repository: TabMates/app

Length of output: 22043


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "===== RecurringRule validation and construction ====="
rg -n --glob '*.kt' \
  "data class RecurringRule|class RecurringRule|RecurringRule\\(|startDate|MAX_SLOT_SCAN|interval must be" \
  features/tabgroup core

echo "===== Relevant ViewModel state construction ====="
sed -n '100,205p' \
  features/tabgroup/presentation/src/commonMain/kotlin/de/tabmates/features/tabgroup/presentation/navigation/groupdetail/GroupDetailViewModel.kt
sed -n '20,55p' \
  features/tabgroup/presentation/src/commonMain/kotlin/de/tabmates/features/tabgroup/presentation/navigation/groupschedules/GroupSchedulesViewModel.kt

echo "===== Standalone iteration and cutoff probe ====="
python3 - <<'PY'
from datetime import date, timedelta

MAX_SLOT_SCAN = 10_000

def scan_daily(start, after, interval=1, limit=1):
    taken = 0
    visited = 0
    result = []
    while visited < MAX_SLOT_SCAN:
        current = start + timedelta(days=interval * visited)
        visited += 1
        if taken >= limit:
            break
        if current <= after:
            continue
        taken += 1
        result.append(current)
    return visited, result

for start, after in [
    (date(2020, 1, 1), date(2026, 8, 1)),
    (date(1970, 1, 1), date(2026, 8, 1)),
]:
    visited, result = scan_daily(start, after)
    print(f"start={start} after={after} visited={visited} result={result}")
PY

Repository: TabMates/app

Length of output: 23380


Fix occurrence projection before merge. upcomingOccurrences scans one slot at a time and stops after MAX_SLOT_SCAN = 10_000. A daily rule that started more than 10,000 days before today returns no next date, even when a valid occurrence exists. Both remember blocks also perform this scan during composition. Make the calculator jump to the first slot after the cutoff, then compute the projections in GroupDetailViewModel and GroupSchedulesViewModel and pass the results to the composables. The overview projection is currently owned by GroupDetailViewModel, not GroupOverviewViewModel.

  • features/tabgroup/presentation/src/commonMain/kotlin/de/tabmates/features/tabgroup/presentation/navigation/groupoverview/UpcomingSchedule.kt#L22-L55
  • features/tabgroup/presentation/src/commonMain/kotlin/de/tabmates/features/tabgroup/presentation/navigation/groupschedules/GroupSchedulesScreen.kt#L176-L189
📍 Affects 2 files
  • features/tabgroup/presentation/src/commonMain/kotlin/de/tabmates/features/tabgroup/presentation/navigation/groupoverview/UpcomingSchedule.kt#L22-L55 (this comment)
  • features/tabgroup/presentation/src/commonMain/kotlin/de/tabmates/features/tabgroup/presentation/navigation/groupschedules/GroupSchedulesScreen.kt#L176-L189
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@features/tabgroup/presentation/src/commonMain/kotlin/de/tabmates/features/tabgroup/presentation/navigation/groupoverview/UpcomingSchedule.kt`
around lines 22 - 55, Update RecurringOccurrenceCalculator to jump directly to
the first slot after the cutoff instead of scanning up to MAX_SLOT_SCAN, then
compute occurrence projections in GroupDetailViewModel and
GroupSchedulesViewModel before composition. Pass those precomputed results into
UpcomingSchedule/upcomingSchedules and the GroupSchedulesScreen composable,
removing the occurrence scans from both remember blocks; the overview projection
belongs in GroupDetailViewModel, not GroupOverviewViewModel. Affected sites:
features/tabgroup/presentation/src/commonMain/kotlin/de/tabmates/features/tabgroup/presentation/navigation/groupoverview/UpcomingSchedule.kt:22-55
must consume the projected results, and
features/tabgroup/presentation/src/commonMain/kotlin/de/tabmates/features/tabgroup/presentation/navigation/groupschedules/GroupSchedulesScreen.kt:176-189
must likewise consume ViewModel-provided projections.

Comment on lines +276 to +283
@Composable
private fun rememberTodayUtc(): LocalDate =
remember {
Clock.System
.now()
.toLocalDateTime(TimeZone.UTC)
.date
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

rememberTodayUtc never refreshes.

The remember call has no key, so the value stays fixed for the lifetime of the composition. If the screen stays open across a UTC day boundary, ScheduleRow keeps the old cutoff and can show an occurrence that already passed. Expose the current date from GroupSchedulesViewModel, or use state that updates at the next UTC midnight. DefaultScheduledLedger already re-emits at that boundary and can serve as the source.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@features/tabgroup/presentation/src/commonMain/kotlin/de/tabmates/features/tabgroup/presentation/navigation/groupschedules/GroupSchedulesScreen.kt`
around lines 276 - 283, Update rememberTodayUtc so its date refreshes across UTC
midnight instead of being permanently cached by an unkeyed remember. Prefer
exposing and collecting the current date from GroupSchedulesViewModel, reusing
DefaultScheduledLedger’s boundary-driven emissions; otherwise use state
scheduled to update at the next UTC midnight, and ensure ScheduleRow receives
the refreshed cutoff.

Four tabs was one too many, and two of them overlapped: due occurrences
were already faded rows in Transactions while the rules behind them sat
in Repeating, so the same rent read twice. Upcoming is now a section
above the ledger carrying the schedules about to produce something, and
the full list -- ended ones included -- moves to its own screen behind
Manage and a group settings row.

The section is a boundary, not a merge. A due occurrence has moved the
balance already and belongs in the ledger at its own date; an upcoming
one has not, so its amount is muted and never reaches the stat cards.

Editing a schedule was unreachable. onEdit had a single call site -- the
banner only a parked schedule shows -- so a healthy one could not be
edited at all. The pencil and the end action now sit in the top bar the
way an entry's do.

HeroSection existed twice, byte for byte, in entry and settlement detail;
it is one DetailHero serving all three now, and EntryIcon moves out of
GroupDetailPane for the same reason. A schedule that changes shape on the
way to the screen listing them reads as a different kind of thing.

Rails were the rest of it. The transactions tab hangs off 24dp and the
new rows sat on 16; the repeat editor's root column had no padding at
all, so FieldRow drew its outline against both screen edges. Its radio
rows were also under Material's 48dp minimum touch target -- the row is
the selectable, and RadioButton only applies that floor when it is itself
clickable, so onClick = null dropped it and nothing put it back.
@DennisBauer
DennisBauer force-pushed the feat/recurring-entries branch from ca027e6 to a8eaafa Compare August 11, 2026 18:22
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

feature New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant