Skip to Content
FeaturesStudyPlan: Scheduled (recurring) Study Sessions on the Calendar

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: plannedStart ascending, tie-break by mode (INPUT → PROCESSING → REVIEW).
  • Create/edit via the existing StudySessionFormModal extended 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 unconditional actualStart = now default in StudyService.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

  • StudySession already has plannedStart / 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: a Frequency enum (shared/.../models/Frequency.kt:3-25) + anchor date, expanded client-side via habitOccursOn (CalendarScreen.kt:96-105) and Habit.nextOccurrence with bi-weekly parity (models/Habit.kt:52-128, isSameParityWeek :158-164).
  • There is no RRULE library in the repo (gradle/libs.versions.toml + all build.gradle.kts searched). Recurrence is hand-rolled Frequency + math. We follow that convention.
  • Calendar only has TaskItem / HabitItem / TransactionItem today (CalendarModels.kt:53-59). Study never appears.
  • Two calendar implementations exist:
    1. Shared CalendarScreen (shared/.../ui/screens/CalendarScreen.kt) — the active one rendered on desktop via CalendarScreenDestination.kt:35. Recurrence-aware (Habits via habitOccursOn). No drag/drop.
    2. 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.
  • Server GET /study/sessions?startDate&endDate already returns a date range (StudyRouting.kt:270-296), half-open [start, endDate+1day) query in StudyService.getAllSessions (:731-770), preferring actualStart and falling back to plannedStart. Default order is DESC; 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 set actualStart, so they remain unaffected; only a scheduled-only session (actualStart == null && plannedStart != null) should skip the timer.
  • Critical caveat: StudyService.createSession at line 541 unconditionally defaults actualStart to now when 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 treats BI_WEEKLY identically to WEEKLY (no parity check) — inconsistent with Habit.nextOccurrence parity (Habit.kt:52-128). We fix this alongside the study-session work so both renderers share parity math via a new shared/.../models/RecurrenceMath.kt helper.
  • 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-separated VARCHAR(32) in usersettings.study_virtual_habit_iso_days — direct precedent for our iso_weekdays column (match the width). Next migration number: V30 (highest existing is V29).
  • The server path for StudyService.kt is server/src/main/kotlin/com/esteban/ruano/service/StudyService.kt (no service/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 null

Then:

  • Persist frequency / isoWeekdays / recurrenceEndDate from the DTO.
  • Skip the auto-timer coroutine (:560-578) when the session is scheduled-only (actualStart == null && plannedStart != null). Honors “No auto timer.”
  • Validate frequency against Frequency values; validate isoWeekdays is empty or a CSV of digits 1..7 (extend Validator, 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:

  1. Load the template row (must have plannedStart != null && actualStart == null; reject otherwise).
  2. Insert a new study_sessions row copying topicId, studyItemId, mode, plannedStart, plannedEnd from the template, with actualStart = now, frequency = 'one_time', isoWeekdays = null, recurrenceEndDate = null.
  3. The existing auto-timer cascade (StudyService.kt:560-578) fires for the new row, exactly as today.
  4. Return the new session id (and DTO for the client to navigate to).
  5. 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 plannedStart fallback).
  • Add an ascending order option (new order: SortOrder parameter or a forCalendar: Boolean flag) so the calendar gets plannedStart ASC NULLS_LAST, mode ASC, createdAt ASC.
  • Publish the new param over the existing GET /study/sessions route (StudyRouting.kt:270-296) as order=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 with order=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 at createSession and updateSession.
  • 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 CalendarScreen has no drag/drop today. The dormant desktop CalendarTaskDragDrop.kt uses AWT StringSelection (JVM-only) and is not reusable in commonMain. The v2 implementation must use Compose Multiplatform’s Modifier.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 onRescheduleStudySession callback to CalendarScreen’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:

  1. “Schedule for later” toggle at the top of the modal.
  2. When ON (isScheduledMode = true):
    • Show plannedStart and plannedEnd date+time pickers; hide the actual-start picker.
    • Show recurrence controls:
      • Frequency dropdown (reuse Frequency enum).
      • Weekday multi-select: 7 toggles Mon..Sun storing CSV (V5 convention).
      • Optional recurrenceEndDate date picker.
  3. When OFF (isScheduledMode = false): keep the existing actualStart picker, hide planned/recurrence controls — preserves today’s “log a session” flow.
  4. Editing an existing session: infer toggle from row — plannedStart != null && actualStart == null → start in schedule mode.
  5. Persist via the desktop StudyViewModel (composeApp/.../viewmodels/StudyViewModel.kt:405-422); see Phase 3 for the gated actualStart default.

StudyScreenDestination.kt + calendar destination wiring

  • Clicking a calendar study chip opens StudySessionFormModal preloaded with the session (auto-detects schedule mode).
  • “Add for day” dropdown on a calendar day offers “Schedule study session” — opens the modal with isScheduledMode = true and plannedStart prefilled to the selected day.
  • Wire onStartScheduledSession to the desktop viewmodel’s startScheduledSession(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-718 becomes implemented) and feature-guide.md.
  • Tests:
    • server/src/test/.../service/study/StudyServiceTest.kt: recurrence create/update; actualStart left NULL when plannedStart is provided and DTO has no actualStart; getAllSessions ascending order; startScheduledSession spawns a new row with recurrence stripped and template untouched.
    • shared/src/commonTest/...: new StudySessionOccursOnTest.kt (mirror VirtualListRowsTest.kt location/style). Cases: ONE_TIME, DAILY, WEEKLY with isoWeekdays subset, WEEKLY with null isoWeekdays (anchor weekday), BI_WEEKLY parity, MONTHLY, YEARLY, recurrenceEndDate cutoff.
    • Regression test for habitOccursOn BI_WEEKLY parity (now enforced where it wasn’t before).
  • Typecheck/lint: confirm the exact Gradle task and add it to AGENTS.md for future sessions (likely ./gradlew :server:test :shared:jvmTest plus a lint pass; will ask the user for the canonical command).

Open questions

  1. Drag/drop scopeResolved: deferred to v2.
  2. Recurring drag/drop semanticsMoot until v2 (see Phase 5).
  3. BI_WEEKLY parityResolved: fix habitOccursOn to enforce parity AND apply the same logic in studySessionOccursOn. Shared via RecurrenceMath.kt.
  4. 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)

FileAction
server/src/main/resources/db/migration/V30__study_session_recurrence.sqlAdd
server/src/main/kotlin/com/esteban/ruano/database/entities/StudySession.ktEdit (columns)
server/src/main/kotlin/com/esteban/ruano/models/study/StudySessionDTO.ktEdit (DTOs)
server/src/main/kotlin/com/esteban/ruano/database/converters/StudyConverters.ktEdit (mapping)
server/src/main/kotlin/com/esteban/ruano/service/StudyService.ktEdit (gate actualStart default; create/update; new startScheduledSession; getAllSessions order)
server/src/main/kotlin/com/esteban/ruano/routing/StudyRouting.ktEdit (optional order param on GET; new POST /sessions/{id}/start)
shared/src/commonMain/kotlin/com/esteban/ruano/oter/models/StudySession.ktEdit (fields)
shared/src/commonMain/kotlin/com/esteban/ruano/oter/models/RecurrenceMath.ktAdd (move isSameParityWeek here from Habit.kt)
shared/src/commonMain/kotlin/com/esteban/ruano/oter/models/Habit.ktEdit (delegate parity to RecurrenceMath.kt)
shared/src/commonMain/kotlin/com/esteban/ruano/oter/services/study/StudyService.ktEdit (order param; new startScheduledSession)
shared/src/commonMain/kotlin/com/esteban/ruano/oter/viewmodel/StudyViewModel.ktEdit (gated actualStart; form state; scheduledSessions flow; startScheduledSession action)
shared/src/commonMain/kotlin/com/esteban/ruano/oter/ui/screens/CalendarScreen.ktEdit (CalEntry.S, studySessionOccursOn, sessionsOn, showStudy toggle, render, fix habitOccursOn BI_WEEKLY)
shared/src/commonMain/kotlin/com/esteban/ruano/oter/ui/screens/StudyScreen.ktEdit (form wiring if needed)
composeApp/src/desktopMain/kotlin/com/esteban/ruano/oter/ui/components/StudySessionFormModal.ktEdit (schedule-mode toggle, planned-time pickers, recurrence controls)
composeApp/src/desktopMain/kotlin/com/esteban/ruano/oter/ui/viewmodels/StudyViewModel.ktEdit (gated actualStart; startScheduledSession action)
composeApp/src/desktopMain/kotlin/com/esteban/ruano/oter/ui/navigation/StudyScreenDestination.ktEdit (modal + calendar wiring, onStartScheduledSession)
composeApp/src/desktopMain/kotlin/com/esteban/ruano/oter/ui/navigation/CalendarScreenDestination.ktEdit (pass studySessions, onStudySessionClick, onStartScheduledSession)
docs/features/study/system-spec.md, feature-guide.mdEdit
server/src/test/kotlin/com/esteban/ruano/service/study/StudyServiceTest.ktEdit
shared/src/commonTest/kotlin/com/esteban/ruano/oter/study/StudySessionOccursOnTest.ktAdd

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.