Plan: Scheduled (recurring) Study Sessions on the Calendar
Status: Approved for v1 execution. Open questions resolved 2026-06-21; drag/drop deferred to v2. Companion to
system-spec.md(implements the “Weekly Study Plans” / “Spaced Repetition scheduling” items listed there as future work,system-spec.md:715-718).
Goals
- Study sessions can be scheduled (future-planned) and appear on the calendar in the order they occur.
- Support recurrence (several days per week / weekly / bi-weekly / monthly / yearly / one-time).
- Calendar ordering:
plannedStartascending, tie-break bymode(INPUT → PROCESSING → REVIEW). - Create/edit via the existing
StudySessionFormModalextended with an explicit “Schedule for later” mode. Reschedule in v1 is form-modal only; drag/drop deferred to v2. - Scheduled-only sessions do NOT auto-create timers. The discriminator is
plannedStart != null AND actualStart IS NULL— which requires removing the unconditionalactualStart = nowdefault inStudyService.createSession(see Phase 2). - Starting a recurring scheduled session spawns a new live session (copy of template with
actualStart = now, recurrence stripped); the recurring template row is left intact.
Key findings driving the design
StudySessionalready hasplannedStart/plannedEnd(shared/.../models/StudySession.kt:6-19); we extend it rather than introduce a new entity.- Tasks do not implement recurrence — they are single-shot (
dueDateTime/scheduledDateTime). Habits are the in-house recurrence precedent: aFrequencyenum (shared/.../models/Frequency.kt:3-25) + anchor date, expanded client-side viahabitOccursOn(CalendarScreen.kt:96-105) andHabit.nextOccurrencewith bi-weekly parity (models/Habit.kt:52-128,isSameParityWeek:158-164). - There is no RRULE library in the repo (
gradle/libs.versions.toml+ allbuild.gradle.ktssearched). Recurrence is hand-rolledFrequency+ math. We follow that convention. - Calendar only has
TaskItem/HabitItem/TransactionItemtoday (CalendarModels.kt:53-59). Study never appears. - Two calendar implementations exist:
- Shared
CalendarScreen(shared/.../ui/screens/CalendarScreen.kt) — the active one rendered on desktop viaCalendarScreenDestination.kt:35. Recurrence-aware (Habits viahabitOccursOn). No drag/drop. - Desktop
composeApp/.../calendar/components (CalendarDay.kt,WeekView,DayView,CalendarTaskDragDrop.kt) — dormant, no screen wires them in, but task drag/drop + recurrence expansion scaffolding lives here.
- Shared
- Server
GET /study/sessions?startDate&endDatealready returns a date range (StudyRouting.kt:270-296), half-open[start, endDate+1day)query inStudyService.getAllSessions(:731-770), preferringactualStartand falling back toplannedStart. Default order isDESC; we need an ascending option for the calendar. - Auto-timer cascade today:
createSession(StudyService.kt:529-581) and stage-transition auto-sessions (:310-366) both launch temp timers. Stage-cascade sessions setactualStart, so they remain unaffected; only a scheduled-only session (actualStart == null && plannedStart != null) should skip the timer. - Critical caveat:
StudyService.createSessionat line 541 unconditionally defaultsactualStarttonowwhen null, and the desktop/shared viewmodels (StudyViewModel.createSession, lines 405-422 / 407-441) do the same on the client. The discriminator above does not work until both defaults are gated on schedule-mode — see Phase 2. - The active shared
habitOccursOn(CalendarScreen.kt:96-105) currently treatsBI_WEEKLYidentically toWEEKLY(no parity check) — inconsistent withHabit.nextOccurrenceparity (Habit.kt:52-128). We fix this alongside the study-session work so both renderers share parity math via a newshared/.../models/RecurrenceMath.kthelper. - Migrations are Flyway,
V<n>__snake_case.sql, idempotent (ALTER TABLE … ADD COLUMN IF NOT EXISTS,CREATE INDEX IF NOT EXISTS). V5 stored ISO weekdays as a comma-separatedVARCHAR(32)inusersettings.study_virtual_habit_iso_days— direct precedent for ouriso_weekdayscolumn (match the width). Next migration number: V30 (highest existing is V29). - The server path for
StudyService.ktisserver/src/main/kotlin/com/esteban/ruano/service/StudyService.kt(noservice/study/subfolder — earlier drafts of this plan had that wrong).
Phase 1 — Data model & migration
Migration: server/src/main/resources/db/migration/V30__study_session_recurrence.sql
-- Recurrence fields, applied only to "scheduled-only" sessions (actual_start IS NULL).
-- frequency: matches shared/models/Frequency.kt values (one_time, daily, weekly, bi_weekly, monthly, yearly).
-- iso_weekdays: comma-separated ISO day numbers 1=Mon .. 7=Sun (NULL or empty = every day, same convention as usersettings.study_virtual_habit_iso_days in V5).
-- recurrence_end_date: inclusive last day the recurrence expands; NULL = open-ended.
-- Widths match V5's `study_virtual_habit_iso_days` (VARCHAR(32)) for consistency.
ALTER TABLE study_sessions ADD COLUMN IF NOT EXISTS frequency VARCHAR(32) NULL DEFAULT 'one_time';
ALTER TABLE study_sessions ADD COLUMN IF NOT EXISTS iso_weekdays VARCHAR(32) NULL;
ALTER TABLE study_sessions ADD COLUMN IF NOT EXISTS recurrence_end_date timestamp NULL;
CREATE INDEX IF NOT EXISTS study_sessions_planned_start_idx ON study_sessions(planned_start);Server entity — server/.../database/entities/StudySession.kt:12
Add to StudySessions:
frequency = varchar("frequency", 32).default(Frequency.ONE_TIME.value)isoWeekdays = varchar("iso_weekdays", 32).nullable()recurrenceEndDate = datetime("recurrence_end_date").nullable()
DTOs — server/.../models/study/StudySessionDTO.kt:5-43
Add frequency: String? = null, isoWeekdays: String? = null, recurrenceEndDate: String? = null to StudySessionDTO, CreateStudySessionDTO, UpdateStudySessionDTO.
Converter — server/.../database/converters/StudyConverters.kt:136-153
Map the three new columns in ResultRow.toStudySessionDTO.
Shared domain model — shared/.../models/StudySession.kt:6-19
Add frequency: String? = "one_time", isoWeekdays: String? = null, recurrenceEndDate: String? = null.
Phase 2 — Server business logic
StudyService.createSession (server/.../service/StudyService.kt:529-581)
Fix the unconditional actualStart default at line 541 so scheduled-only sessions can persist with actualStart = null:
// Before:
val actualStart = dto.actualStart?.let { parseDateTime(it) } ?: now
// After:
// Only default actualStart to "now" for live sessions (no plannedStart).
// Scheduled-only sessions keep actualStart = null so they don't auto-fire a timer.
val actualStart = dto.actualStart?.let { parseDateTime(it) }
?: if (plannedStart == null) now else nullThen:
- Persist
frequency/isoWeekdays/recurrenceEndDatefrom the DTO. - Skip the auto-timer coroutine (
:560-578) when the session is scheduled-only (actualStart == null && plannedStart != null). Honors “No auto timer.” - Validate
frequencyagainstFrequencyvalues; validateisoWeekdaysis empty or a CSV of digits 1..7 (extendValidator, mirroring V5 conventions).
StudyService.kt:605-648 updateSession
Allow editing recurrence fields. Existing stage-cascade auto-sessions (:310-366) remain unaffected (they set actualStart).
StudyService.startScheduledSession(id) — new
Endpoint: POST /study/sessions/{id}/start (add to StudyRouting.kt).
Behavior:
- Load the template row (must have
plannedStart != null && actualStart == null; reject otherwise). - Insert a new
study_sessionsrow copyingtopicId,studyItemId,mode,plannedStart,plannedEndfrom the template, withactualStart = now,frequency = 'one_time',isoWeekdays = null,recurrenceEndDate = null. - The existing auto-timer cascade (
StudyService.kt:560-578) fires for the new row, exactly as today. - Return the new session id (and DTO for the client to navigate to).
- Do not modify the template row. The series keeps recurring on its remaining matching days.
StudyService.kt:731-770 getAllSessions
- Existing date-range query already works (
actualStart OR plannedStartfallback). - Add an ascending order option (new
order: SortOrderparameter or aforCalendar: Booleanflag) so the calendar getsplannedStart ASC NULLS_LAST, mode ASC, createdAt ASC. - Publish the new param over the existing
GET /study/sessionsroute (StudyRouting.kt:270-296) asorder=asc(optional, default unchanged).
completeSession (:651-703)
Confirm recurrence fields are ignored for logged sessions; cancel/skip the temp timer path normally. Completion always targets a live session row, never a recurring template (templates are started via startScheduledSession first).
Phase 3 — Shared client
shared/.../services/study/StudyService.kt:163
getSessions already passes start/end. Add optional order/ascending query param. DTO serialization picks up the new fields automatically (kotlinx @Serializable).
shared/.../viewmodel/StudyViewModel.kt and composeApp/.../viewmodels/StudyViewModel.kt
Fix the client-side actualStart default. Both viewmodels currently do session.actualStart ?: now.formatDefault() in createSession (shared :407-441, desktop :405-422). Gate that on schedule-mode:
val sessionWithStart = if (formState.isScheduledMode) {
session.copy(actualStart = null) // template — no auto-timer
} else {
session.copy(actualStart = session.actualStart ?: now.formatDefault())
}Then:
- Expose
scheduledSessions: StateFlow<List<StudySession>>(separate flow withorder=asc) for the calendar — clearer than letting the calendar re-filter. - Add a
StudySessionFormState(shared viewmodel currently lacks one — see Phase 6) with:frequency,isoWeekdays,recurrenceEndDate,isScheduledMode. Wire them into the DTO atcreateSessionandupdateSession. - Add a
startScheduledSession(id)action that calls the new server endpoint and emits a navigation/refresh event for the new live session.
Phase 4 — Calendar integration (shared CalendarScreen)
Extract shared recurrence math — shared/.../models/RecurrenceMath.kt (new)
Move isSameParityWeek out of Habit.kt:158-164 (and its startOfIsoWeek helper) into a new top-level file so both habitOccursOn and studySessionOccursOn import it. Keep public visibility; Habit.nextOccurrence and the new study predicate both call it.
Fix bi-weekly parity in habitOccursOn (CalendarScreen.kt:96-105)
Today habitOccursOn treats BI_WEEKLY identically to WEEKLY — inconsistent with Habit.nextOccurrence. Split the branches:
Frequency.WEEKLY -> date.dayOfWeek == anchor.dayOfWeek
Frequency.BI_WEEKLY -> date.dayOfWeek == anchor.dayOfWeek && isSameParityWeek(anchor, date)Add a regression test in the same location as VirtualListRowsTest.kt.
Shared CalEntry
Add a new variant in shared/.../ui/screens/CalendarScreen.kt:
data class S(val session: StudySession) : CalEntry(The dormant desktop CalendarModels.kt is not touched — see file map.)
Occurrence predicate — generalized studySessionOccursOn for isoWeekdays:
fun studySessionOccursOn(s: StudySession, date: LocalDate): Boolean {
val anchor = s.plannedStart?.toLocalDateTime()?.date ?: return false
if (s.recurrenceEndDate != null && date > parseDate(s.recurrenceEndDate)) return false
return when (Frequency.fromString(s.frequency ?: "one_time")) {
ONE_TIME -> date == anchor
DAILY -> date >= anchor
WEEKLY, BI_WEEKLY -> {
val allowedDays = s.isoWeekdays?.takeIf { it.isNotBlank() }?.split(",")?.map { it.toInt() }
?: listOf(anchor.dayOfWeek.isoDayNumber)
val weekdayOk = date.dayOfWeek.isoDayNumber in allowedDays
weekdayOk && (Frequency.fromString(s.frequency) != BI_WEEKLY || isSameParityWeek(anchor, date))
}
MONTHLY -> date.dayOfMonth == anchor.dayOfMonth && date >= anchor
YEARLY -> date.dayOfMonth == anchor.dayOfMonth && date.monthNumber == anchor.monthNumber && date >= anchor
}
}Uses the shared isSameParityWeek from RecurrenceMath.kt (not a copy).
Add sessionsOn(d) alongside tasksOn/habitsOn/txOn (CalendarScreen.kt:146-148), gated on a new showStudy toggle next to showTasks/showHabits/showTx (:137-139).
CalendarScreen public signature (CalendarScreen.kt:117-130)
Add for v1: studySessions: List<StudySession> = emptyList(), onStudySessionClick: (StudySession) -> Unit = {}, onStartScheduledSession: (StudySession) -> Unit = {} (calls the new endpoint via the viewmodel). Do not add onRescheduleStudySession — drag/drop is deferred to v2.
Rendering — CalCellItemRow (CalendarScreen.kt:404-448)
Add CalEntry.S branch: book/study icon (e.g. Icons.Default.MenuBook), topic color dot from session.topic?.color, time from plannedStart, label = topic name + mode. Order within a day cell: tasks → habits → study → transactions (v1: section grouping, not time-merged).
DayDetailRail (CalendarScreen.kt:452-561)
Add a “Study” section listing the day’s scheduled sessions with Start now (calls onStartScheduledSession) and Edit (opens modal preloaded) actions. No reschedule action in v1.
Phase 5 — Drag/drop reschedule (deferred to v2)
Deferred. Reschedule in v1 happens only via the form modal. Recorded constraints for v2:
- The active shared
CalendarScreenhas no drag/drop today. The dormant desktopCalendarTaskDragDrop.ktuses AWTStringSelection(JVM-only) and is not reusable incommonMain. The v2 implementation must use Compose Multiplatform’sModifier.dragAndDropSource/dragAndDropTarget(Compose Multiplatform 1.6+). - Recurring drag semantics still unresolved: shift the whole series anchor, or track per-occurrence exceptions. Decide before starting v2.
- When v2 lands, add the
onRescheduleStudySessioncallback toCalendarScreen’s signature.
Phase 6 — Desktop form modal & navigation
composeApp/.../components/StudySessionFormModal.kt
The modal today only edits actualStart (no UI for plannedStart/plannedEnd). Add an explicit mode toggle plus the planned-time and recurrence controls:
- “Schedule for later” toggle at the top of the modal.
- When ON (
isScheduledMode = true):- Show
plannedStartandplannedEnddate+time pickers; hide the actual-start picker. - Show recurrence controls:
Frequencydropdown (reuseFrequencyenum).- Weekday multi-select: 7 toggles Mon..Sun storing CSV (V5 convention).
- Optional
recurrenceEndDatedate picker.
- Show
- When OFF (
isScheduledMode = false): keep the existing actualStart picker, hide planned/recurrence controls — preserves today’s “log a session” flow. - Editing an existing session: infer toggle from row —
plannedStart != null && actualStart == null→ start in schedule mode. - Persist via the desktop
StudyViewModel(composeApp/.../viewmodels/StudyViewModel.kt:405-422); see Phase 3 for the gatedactualStartdefault.
StudyScreenDestination.kt + calendar destination wiring
- Clicking a calendar study chip opens
StudySessionFormModalpreloaded with the session (auto-detects schedule mode). - “Add for day” dropdown on a calendar day offers “Schedule study session” — opens the modal with
isScheduledMode = trueandplannedStartprefilled to the selected day. - Wire
onStartScheduledSessionto the desktop viewmodel’sstartScheduledSession(id), then navigate to the new live session (timer cascade fires automatically).
Phase 7 — Docs & tests
- Update
docs/features/study/system-spec.md(the “Weekly Study Plans” / “Spaced Repetition scheduling” note at:715-718becomes implemented) andfeature-guide.md. - Tests:
server/src/test/.../service/study/StudyServiceTest.kt: recurrence create/update;actualStartleft NULL whenplannedStartis provided and DTO has noactualStart;getAllSessionsascending order;startScheduledSessionspawns a new row with recurrence stripped and template untouched.shared/src/commonTest/...: newStudySessionOccursOnTest.kt(mirrorVirtualListRowsTest.ktlocation/style). Cases: ONE_TIME, DAILY, WEEKLY withisoWeekdayssubset, WEEKLY with nullisoWeekdays(anchor weekday), BI_WEEKLY parity, MONTHLY, YEARLY,recurrenceEndDatecutoff.- Regression test for
habitOccursOnBI_WEEKLY parity (now enforced where it wasn’t before).
- Typecheck/lint: confirm the exact Gradle task and add it to
AGENTS.mdfor future sessions (likely./gradlew :server:test :shared:jvmTestplus a lint pass; will ask the user for the canonical command).
Open questions
Drag/drop scope— Resolved: deferred to v2.Recurring drag/drop semantics— Moot until v2 (see Phase 5).BI_WEEKLY parity— Resolved: fixhabitOccursOnto enforce parity AND apply the same logic instudySessionOccursOn. Shared viaRecurrenceMath.kt.- Virtual rows — should scheduled sessions on today’s weekday also surface in the existing virtual rows (
VirtualListRows.kt), which today only show the next BACKLOG item? With recurrence, a “today is a scheduled study day” signal may be more useful than the generic backlog head. Status: punt to v1.1 follow-up; not load-bearing for the calendar feature.
File map (files to add or edit)
| File | Action |
|---|---|
server/src/main/resources/db/migration/V30__study_session_recurrence.sql | Add |
server/src/main/kotlin/com/esteban/ruano/database/entities/StudySession.kt | Edit (columns) |
server/src/main/kotlin/com/esteban/ruano/models/study/StudySessionDTO.kt | Edit (DTOs) |
server/src/main/kotlin/com/esteban/ruano/database/converters/StudyConverters.kt | Edit (mapping) |
server/src/main/kotlin/com/esteban/ruano/service/StudyService.kt | Edit (gate actualStart default; create/update; new startScheduledSession; getAllSessions order) |
server/src/main/kotlin/com/esteban/ruano/routing/StudyRouting.kt | Edit (optional order param on GET; new POST /sessions/{id}/start) |
shared/src/commonMain/kotlin/com/esteban/ruano/oter/models/StudySession.kt | Edit (fields) |
shared/src/commonMain/kotlin/com/esteban/ruano/oter/models/RecurrenceMath.kt | Add (move isSameParityWeek here from Habit.kt) |
shared/src/commonMain/kotlin/com/esteban/ruano/oter/models/Habit.kt | Edit (delegate parity to RecurrenceMath.kt) |
shared/src/commonMain/kotlin/com/esteban/ruano/oter/services/study/StudyService.kt | Edit (order param; new startScheduledSession) |
shared/src/commonMain/kotlin/com/esteban/ruano/oter/viewmodel/StudyViewModel.kt | Edit (gated actualStart; form state; scheduledSessions flow; startScheduledSession action) |
shared/src/commonMain/kotlin/com/esteban/ruano/oter/ui/screens/CalendarScreen.kt | Edit (CalEntry.S, studySessionOccursOn, sessionsOn, showStudy toggle, render, fix habitOccursOn BI_WEEKLY) |
shared/src/commonMain/kotlin/com/esteban/ruano/oter/ui/screens/StudyScreen.kt | Edit (form wiring if needed) |
composeApp/src/desktopMain/kotlin/com/esteban/ruano/oter/ui/components/StudySessionFormModal.kt | Edit (schedule-mode toggle, planned-time pickers, recurrence controls) |
composeApp/src/desktopMain/kotlin/com/esteban/ruano/oter/ui/viewmodels/StudyViewModel.kt | Edit (gated actualStart; startScheduledSession action) |
composeApp/src/desktopMain/kotlin/com/esteban/ruano/oter/ui/navigation/StudyScreenDestination.kt | Edit (modal + calendar wiring, onStartScheduledSession) |
composeApp/src/desktopMain/kotlin/com/esteban/ruano/oter/ui/navigation/CalendarScreenDestination.kt | Edit (pass studySessions, onStudySessionClick, onStartScheduledSession) |
docs/features/study/system-spec.md, feature-guide.md | Edit |
server/src/test/kotlin/com/esteban/ruano/service/study/StudyServiceTest.kt | Edit |
shared/src/commonTest/kotlin/com/esteban/ruano/oter/study/StudySessionOccursOnTest.kt | Add |
Removed (was in earlier drafts): composeApp/.../calendar/CalendarModels.kt — that directory holds dormant desktop calendar components (CalendarDay, WeekView, DayView, CalendarTaskDragDrop) that no active screen imports. The active calendar is the shared CalendarScreen only.