Skip to content

Commit 0ad329f

Browse files
authored
fix: recurring event next alert (#144)
* fix: recurring event next alert fixes #143 * fix: bug bot
1 parent 051e8e3 commit 0ad329f

3 files changed

Lines changed: 166 additions & 4 deletions

File tree

android/app/src/main/java/com/github/quarck/calnotify/calendar/EventRecord.kt

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -169,10 +169,18 @@ fun EventRecord.nextAlarmTime(currentTime: Long): Long {
169169
return ret
170170
}
171171

172-
fun EventRecord.getNextAlertTimeAfter(anchor: Long): Long? {
172+
/**
173+
* Gets the next alert time after the given anchor time.
174+
*
175+
* @param anchor The time to compare against (typically current time)
176+
* @param instanceStartTime For recurring events, pass the instance's start time.
177+
* If null, uses the master event's startTime (wrong for recurring!)
178+
*/
179+
fun EventRecord.getNextAlertTimeAfter(anchor: Long, instanceStartTime: Long? = null): Long? {
180+
val effectiveStartTime = instanceStartTime ?: this.startTime
173181
val futureReminders = this
174182
.reminders
175-
.map { this.startTime - it.millisecondsBefore }
183+
.map { effectiveStartTime - it.millisecondsBefore }
176184
.filter { it > anchor }
177185
return futureReminders.maxOrNull()
178186
}

android/app/src/main/java/com/github/quarck/calnotify/textutils/EventFormatter.kt

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -160,9 +160,11 @@ class EventFormatter(
160160
remindersEnabled: Boolean
161161
): NextNotificationInfo? {
162162
// Get next GCal reminder if enabled
163+
// Pass displayedStartTime for recurring events (master event's startTime would be wrong)
164+
// Note: displayedStartTime handles instanceStartTime=0L fallback to startTime
163165
val nextGCalTime: Long? = if (displayNextGCalReminder) {
164166
val eventRecord = calendarProvider.getEvent(ctx, event.eventId)
165-
eventRecord?.getNextAlertTimeAfter(currentTime)
167+
eventRecord?.getNextAlertTimeAfter(currentTime, event.displayedStartTime)
166168
} else null
167169

168170
// Get next app alert if enabled (show for muted events too - they still get alerts on silent channel)
@@ -193,9 +195,11 @@ class EventFormatter(
193195
val currentTime = clock.currentTimeMillis()
194196

195197
// Find soonest GCal reminder across all events
198+
// Pass displayedStartTime for recurring events (master event's startTime would be wrong)
199+
// Note: displayedStartTime handles instanceStartTime=0L fallback to startTime
196200
val soonestGCalTime: Long? = if (displayNextGCalReminder) {
197201
events.mapNotNull { event ->
198-
calendarProvider.getEvent(ctx, event.eventId)?.getNextAlertTimeAfter(currentTime)
202+
calendarProvider.getEvent(ctx, event.eventId)?.getNextAlertTimeAfter(currentTime, event.displayedStartTime)
199203
}.minOrNull()
200204
} else null
201205

android/app/src/test/java/com/github/quarck/calnotify/textutils/EventFormatterRobolectricTest.kt

Lines changed: 150 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,8 @@ import com.github.quarck.calnotify.calendar.EventOrigin
1414
import com.github.quarck.calnotify.calendar.EventRecord
1515
import com.github.quarck.calnotify.calendar.EventReminderRecord
1616
import com.github.quarck.calnotify.calendar.EventStatus
17+
import com.github.quarck.calnotify.calendar.displayedStartTime
18+
import com.github.quarck.calnotify.calendar.getNextAlertTimeAfter
1719
import com.github.quarck.calnotify.reminders.ReminderStateInterface
1820
import com.github.quarck.calnotify.utils.CNPlusUnitTestClock
1921
import io.mockk.every
@@ -802,5 +804,153 @@ class EventFormatterRobolectricTest {
802804
assertEquals("1m", EventFormatter.formatDurationCompact(0))
803805
assertEquals("1m", EventFormatter.formatDurationCompact(30 * 1000L)) // 30 seconds
804806
}
807+
808+
// === Recurring event tests ===
809+
810+
@Test
811+
fun `getNextAlertTimeAfter - recurring event uses instance start time not master start time`() {
812+
// Simulate a recurring weekly meeting that started 6 months ago
813+
val sixMonthsAgo = baseTime - 180 * Consts.DAY_IN_MILLISECONDS
814+
val thisWeeksInstance = baseTime + 2 * Consts.HOUR_IN_MILLISECONDS
815+
816+
// Master event (from CalendarProvider) has old start time
817+
val masterEventRecord = createMockEventRecord(
818+
eventId = 100L,
819+
startTime = sixMonthsAgo, // Master event started 6 months ago!
820+
reminders = listOf(EventReminderRecord.minutes(60)) // 1 hour before
821+
)
822+
823+
// Without instance time: reminder would be 6 months ago (wrong!)
824+
val wrongResult = masterEventRecord.getNextAlertTimeAfter(baseTime)
825+
assertNull("Without instanceStartTime, recurring event reminder would be in past", wrongResult)
826+
827+
// With instance time: reminder should be 1 hour before this week's instance
828+
val correctResult = masterEventRecord.getNextAlertTimeAfter(baseTime, thisWeeksInstance)
829+
assertNotNull("With instanceStartTime, should find future reminder", correctResult)
830+
831+
val expectedReminderTime = thisWeeksInstance - Consts.HOUR_IN_MILLISECONDS
832+
assertEquals("Reminder should be 1hr before instance start", expectedReminderTime, correctResult)
833+
}
834+
835+
@Test
836+
fun `formatNextNotificationIndicator - recurring event shows correct indicator`() {
837+
val eventId = 500L
838+
val sixMonthsAgo = baseTime - 180 * Consts.DAY_IN_MILLISECONDS
839+
val thisWeeksInstance = baseTime + 2 * Consts.HOUR_IN_MILLISECONDS
840+
841+
// Master event has old start time (simulating recurring event)
842+
val mockCalendarProvider = mockk<CalendarProviderInterface>()
843+
every { mockCalendarProvider.getEvent(any(), eq(eventId)) } returns createMockEventRecord(
844+
eventId = eventId,
845+
startTime = sixMonthsAgo, // Master started 6 months ago
846+
reminders = listOf(EventReminderRecord.minutes(60)) // 1 hour before
847+
)
848+
849+
val testFormatter = EventFormatter(
850+
ctx = context,
851+
clock = testClock,
852+
calendarProvider = mockCalendarProvider
853+
)
854+
855+
// Create EventAlertRecord with THIS WEEK's instance time
856+
val event = EventAlertRecord(
857+
calendarId = 1L,
858+
eventId = eventId,
859+
isAllDay = false,
860+
isRepeating = true, // This is a recurring event!
861+
alertTime = baseTime,
862+
notificationId = eventId.toInt(),
863+
title = "Weekly Team Meeting",
864+
desc = "",
865+
startTime = sixMonthsAgo, // Master event start (old)
866+
endTime = sixMonthsAgo + Consts.HOUR_IN_MILLISECONDS,
867+
instanceStartTime = thisWeeksInstance, // THIS WEEK's instance
868+
instanceEndTime = thisWeeksInstance + Consts.HOUR_IN_MILLISECONDS,
869+
location = "",
870+
lastStatusChangeTime = baseTime,
871+
snoozedUntil = 0L,
872+
displayStatus = EventDisplayStatus.Hidden,
873+
color = 0,
874+
origin = EventOrigin.ProviderBroadcast,
875+
timeFirstSeen = baseTime,
876+
eventStatus = EventStatus.Confirmed,
877+
attendanceStatus = AttendanceStatus.None,
878+
flags = 0L
879+
)
880+
881+
val result = testFormatter.formatNextNotificationIndicator(
882+
event = event,
883+
displayNextGCalReminder = true,
884+
displayNextAppAlert = false,
885+
remindersEnabled = false
886+
)
887+
888+
// Should show indicator because reminder is 1hr before THIS WEEK's instance
889+
assertNotNull("Recurring event should show GCal indicator using instance time", result)
890+
assertTrue("Should contain calendar emoji", result!!.contains("📅"))
891+
assertTrue("Should show 1h duration", result.contains("1h"))
892+
}
893+
894+
@Test
895+
fun `formatNextNotificationIndicator - zero instanceStartTime falls back via displayedStartTime`() {
896+
val eventId = 600L
897+
val futureStartTime = baseTime + 2 * Consts.HOUR_IN_MILLISECONDS
898+
899+
// Master event with future start time
900+
val mockCalendarProvider = mockk<CalendarProviderInterface>()
901+
every { mockCalendarProvider.getEvent(any(), eq(eventId)) } returns createMockEventRecord(
902+
eventId = eventId,
903+
startTime = futureStartTime,
904+
reminders = listOf(EventReminderRecord.minutes(60)) // 1 hour before
905+
)
906+
907+
val testFormatter = EventFormatter(
908+
ctx = context,
909+
clock = testClock,
910+
calendarProvider = mockCalendarProvider
911+
)
912+
913+
// EventAlertRecord with instanceStartTime = 0L (not set)
914+
// displayedStartTime property will fall back to startTime
915+
val event = EventAlertRecord(
916+
calendarId = 1L,
917+
eventId = eventId,
918+
isAllDay = false,
919+
isRepeating = false,
920+
alertTime = baseTime,
921+
notificationId = eventId.toInt(),
922+
title = "Test Event",
923+
desc = "",
924+
startTime = futureStartTime,
925+
endTime = futureStartTime + Consts.HOUR_IN_MILLISECONDS,
926+
instanceStartTime = 0L, // NOT SET - should fall back to startTime
927+
instanceEndTime = 0L,
928+
location = "",
929+
lastStatusChangeTime = baseTime,
930+
snoozedUntil = 0L,
931+
displayStatus = EventDisplayStatus.Hidden,
932+
color = 0,
933+
origin = EventOrigin.ProviderBroadcast,
934+
timeFirstSeen = baseTime,
935+
eventStatus = EventStatus.Confirmed,
936+
attendanceStatus = AttendanceStatus.None,
937+
flags = 0L
938+
)
939+
940+
// Verify displayedStartTime falls back correctly
941+
assertEquals("displayedStartTime should fall back to startTime when instanceStartTime is 0",
942+
futureStartTime, event.displayedStartTime)
943+
944+
val result = testFormatter.formatNextNotificationIndicator(
945+
event = event,
946+
displayNextGCalReminder = true,
947+
displayNextAppAlert = false,
948+
remindersEnabled = false
949+
)
950+
951+
// Should show indicator because displayedStartTime falls back to startTime
952+
assertNotNull("Zero instanceStartTime should fall back to startTime via displayedStartTime", result)
953+
assertTrue("Should contain calendar emoji", result!!.contains("📅"))
954+
}
805955
}
806956

0 commit comments

Comments
 (0)