Skip to content

Add user personal learn time feature - #89

Merged
Gamius00 merged 11 commits into
mainfrom
edit-learn-time
Jun 1, 2026
Merged

Add user personal learn time feature#89
Gamius00 merged 11 commits into
mainfrom
edit-learn-time

Conversation

@Gamius00

@Gamius00 Gamius00 commented May 31, 2026

Copy link
Copy Markdown
Member

Adds personal learning times and uses them when generating AI learning plans.

This branch introduces a new userLearningTimes Convex table with authenticated queries/mutations for listing, saving, and removing a user’s weekly learning availability. The app now exposes a Lernzeiten settings flow where users can add or edit available learning windows per weekday.

Learning plan generation now includes those personal learning times in the AI context and normalizes generated sessions into the user’s available time slots. If no learning times exist, or if the saved availability cannot fit the full recommended plan, the generated plan stores and displays a planning hint on the review and sessions screens.

Also includes a staged UI adjustment on the review screen that moves the “Lernplan erstellen” and add-session controls into a fixed bottom action bar with safe-area spacing.

Summary by CodeRabbit

  • New Features

    • Add "Lernzeiten" screens to view and edit your weekly learning-time windows, accessible from settings.
    • AI-generated learning plans now schedule sessions exclusively within your personal learning times and provide planning hints when availability is limited.
    • Planning hints appear as warning banners on plan and review screens to inform about scheduling constraints.
  • Bug Fixes / Behavior

    • Generated sessions are synced to your calendar only after you accept the plan.
    • Calendar syncing is disabled until a plan is accepted, preventing premature synchronization.

@coderabbitai

coderabbitai Bot commented May 31, 2026

Copy link
Copy Markdown

Review Change Stack

Caution

Review failed

The pull request is closed.

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 1199c1ce-6179-4ed8-9b3d-22b9f71a9e44

📥 Commits

Reviewing files that changed from the base of the PR and between 744d65d and d33957d.

⛔ Files ignored due to path filters (1)
  • convex/_generated/api.d.ts is excluded by !**/_generated/**
📒 Files selected for processing (10)
  • convex/dayEntries.test.ts
  • convex/dayEntries.ts
  • convex/learningPlanAi.ts
  • convex/learningPlanAiScheduling.test.ts
  • convex/learningPlans.test.ts
  • convex/learningPlans.ts
  • src/app/entry/new.tsx
  • src/app/learning-plans/[planId]/review.tsx
  • src/lib/entry-time.test.ts
  • src/lib/entry-time.ts

📝 Walkthrough

Walkthrough

Adds per-user weekly learning-time windows, CRUD APIs and UI to manage them, integrates those windows into AI study-plan generation and normalization (producing a planningHint when constrained), and updates calendar-sync behavior so sessions sync only after plan acceptance.

Changes

Learning Times Configuration and Plan Generation

Layer / File(s) Summary
Data schema and type definitions
convex/schema.ts, src/features/learning-plans/types.ts
New userLearningTimes table; added optional planningHint to learningPlans; snapshot type updated to include planningHint.
Convex query and mutation updates
convex/learningPlans.ts
getSnapshot exposes planningHint; getAiContext fetches userLearningTimes and computes occupiedEntries; replaceGeneratedSessions accepts and persists optional planningHint; day-entry sync deferred until plan accepted.
Learning times CRUD API
convex/learningTimes.ts
New module with auth helpers, listMine (sorted), upsertMine (validate + patch/insert), and removeMine.
AI plan generation with learning time constraints
convex/learningPlanAi.ts
Adds scheduling constants, extends LearningPlanAiContext, implements date/time utilities and slot construction, refactors normalizeSessions to place sessions within concrete slots and return planningHint, updates prompt and fallback handling in generatePlan.
Settings navigation to learning times
src/app/(app)/settings.tsx
Adds Timer icon and "Lernzeiten" settings row navigating to /learning-times.
Learning times overview and edit screens
src/app/learning-times/index.tsx, src/app/learning-times/edit.tsx
New overview listing user entries with add action, and an edit/create screen with day selector, time controls, validation, upsert/remove flows, and time picker sheet.
Plan display and review UI
src/app/learning-plans/[planId]/index.tsx, src/app/learning-plans/[planId]/review.tsx
Conditional planning-hint banners (CircleAlert) in plan screens; review screen removes automatic calendar-sync side effects and restructures bottom action layout.
Day entries dedupe & filtering
convex/dayEntries.ts
create deduplicates identical entries and returns existing _id; listByDayKeys excludes learning-plan sessions for non-accepted plans.
Server tests
convex/*.test.ts
New tests covering normalizeSessions scenarios, day-entry idempotency, occupied-entry inclusion in AI context, sync guard enforcement, and generated-plan-to-review transitions with planning hints.

🎯 4 (Complex) | ⏱️ ~45 minutes

  • Dayova/dayova-mvp#88: touches convex/dayEntries.ts and relates to schedule-entry write/conflict logic; likely relevant to dedupe and conflict handling.

"I hop from sunbeam to sunbeam, pencil and carrot in paw,
I map each weekday nibble-slot with careful, happy law.
The AI learns when I can study, when I must politely rest —
A rabbit's tidy learning plan, in time-bound, cozy nest." 🥕

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ 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 accurately summarizes the main feature introduced in the PR: a new user personal learning times system with settings, storage, and integration into AI-generated plans.
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.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch edit-learn-time

Warning

There were issues while running some tools. Please review the errors and either fix the tool's configuration or disable the tool if it's a critical failure.

🔧 ESLint

If the error stems from missing dependencies, add them to the package.json file. For unrecoverable errors (e.g., due to private dependencies), disable the tool in the CodeRabbit configuration.

ESLint install failed. For unrecoverable errors, disable the tool in CodeRabbit configuration.


Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

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

@Gamius00 Gamius00 added the ready-for-agent Fully specified, ready for an AFK agent label May 31, 2026

@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: 4

🧹 Nitpick comments (2)
convex/learningPlanAi.ts (2)

589-594: 💤 Low value

Minor: Duplicate validation of learning time window bounds.

Lines 592–594 validate that startMinutes and endMinutes are non-null and that endMinutes > startMinutes, then skip invalid windows. Lines 609–611 repeat the identical check inside the loop that processes those same windows. The second check is redundant because invalid windows were already filtered out by the first check.

♻️ Proposed cleanup

Store only validated windows in the map:

 for (const learningTime of learningTimes) {
   const startMinutes = parseTimeToMinutes(learningTime.startTime);
   const endMinutes = parseTimeToMinutes(learningTime.endTime);
   if (startMinutes === null || endMinutes === null || endMinutes <= startMinutes) {
     continue;
   }

   const windows = windowsByDay.get(learningTime.dayOfWeek) ?? [];
   windows.push(learningTime);
   windowsByDay.set(learningTime.dayOfWeek, windows);
 }

 const slots: LearningSlot[] = [];
 for (let offset = availableDays; offset >= 1; offset -= 1) {
   const date = buildDateFromOffset(examDateKey, offset);
   const dayOfWeek = getBerlinDayOfWeek(date);
   const windows = windowsByDay.get(dayOfWeek) ?? [];
   for (const window of windows) {
     const startMinutes = parseTimeToMinutes(window.startTime);
     const endMinutes = parseTimeToMinutes(window.endTime);
-    if (startMinutes === null || endMinutes === null || endMinutes <= startMinutes) {
-      continue;
-    }

     slots.push({

Also applies to: 606-611

🤖 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 `@convex/learningPlanAi.ts` around lines 589 - 594, The loop currently
validates learning window bounds twice; change it to first filter and store only
validated windows (using parseTimeToMinutes and ensuring startMinutes !== null,
endMinutes !== null and endMinutes > startMinutes) before the second processing
loop so you can remove the duplicate check present later (the second endMinutes
<= startMinutes/null check). Locate the for (const learningTime of
learningTimes) block and its subsequent processing loop and move/keep only the
validation in the initial collection step (e.g., building the map of windows) so
the later loop can assume valid startMinutes and endMinutes and drop the
redundant guard.

580-580: 💤 Low value

Optional: nextStartMinutes field is initialized but never mutated or read.

The LearningSlot type includes a nextStartMinutes field, initialized to startMinutes at line 619, but the code never updates or consumes it. This suggests incomplete slot-consumption tracking logic.

If the field was intended for future use (e.g., packing multiple sessions into a single window), consider removing it for now to avoid confusion, or implement the intended behavior.

Also applies to: 619-619

🤖 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 `@convex/learningPlanAi.ts` at line 580, The LearningSlot type defines
nextStartMinutes and initializes it to startMinutes (symbol: LearningSlot,
field: nextStartMinutes, initializer: startMinutes) but the code never reads or
updates it; either remove nextStartMinutes from the LearningSlot definition and
all initializations/usages to avoid dead state, or implement the intended
consumption logic: update nextStartMinutes whenever a slot is partially consumed
(where slots are allocated/adjusted in functions handling slot assignment) and
ensure all consumers read nextStartMinutes instead of startMinutes when
determining the next available time. Make the change consistently across the
LearningSlot construction and any slot-assignment code paths.
🤖 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 `@convex/learningPlanAi.ts`:
- Line 744: The dateKey is being set using slot.date.toISOString(), producing a
full timestamp; replace that with the same date-only format used elsewhere by
calling formatDateKey(slot.date) (or the module's formatDateKey function) so
dateKey matches the date-only strings produced by formatDateKey(date) and aligns
with session comparisons and calendar logic; update any surrounding comments or
variable names if needed to reflect it's a date-only key.
- Line 515: The formatDateKey function currently uses
date.toISOString().slice(0,10) which returns a UTC date and causes off-by-one
day errors versus the Berlin-based logic (e.g., getBerlinDayOfWeek); change
formatDateKey to format the Date in the "Europe/Berlin" timezone (e.g., via
Intl.DateTimeFormat or toLocaleDateString with timeZone: 'Europe/Berlin') and
produce a YYYY-MM-DD string (ISO date) from that Berlin-localized result instead
of slicing toISOString().

In `@src/app/learning-times/edit.tsx`:
- Around line 97-100: The save flow enables when learningTimes is still
undefined causing hasChanges to be true and upsertMine to overwrite existing
slots; fix by treating the query-loading state as not-ready: use the result
metadata from useQuery(api.learningTimes.listMine, ...)—or check learningTimes
!== undefined / query.isLoading—before computing selectedEntry and hasChanges
and before enabling the Save button or calling upsertMine; update the logic in
the selectedEntry/hasChanges computation and the Save button handler
(references: learningTimes, selectedEntry, hasChanges, useQuery,
api.learningTimes.listMine, upsertMine) so save is disabled while the selected
day is still loading (apply same guard to the other similar block at lines
~136-146).

In `@src/app/learning-times/index.tsx`:
- Around line 198-204: The FAB currently always renders and calls
openEditor(firstMissingDay) where firstMissingDay falls back to 1, causing
accidental overwrites; update the rendering logic around the Button in the
LearningTimes component so it is hidden or disabled when all seven days are
already configured: detect "allConfigured" by checking the weekday configuration
array (the same logic that computes firstMissingDay), and if true either don't
render the Button or render it with disabled={true} and an updated
accessibilityLabel; ensure openEditor is only invoked when a valid
firstMissingDay exists (e.g., guard on firstMissingDay !== null/undefined before
calling openEditor).

---

Nitpick comments:
In `@convex/learningPlanAi.ts`:
- Around line 589-594: The loop currently validates learning window bounds
twice; change it to first filter and store only validated windows (using
parseTimeToMinutes and ensuring startMinutes !== null, endMinutes !== null and
endMinutes > startMinutes) before the second processing loop so you can remove
the duplicate check present later (the second endMinutes <= startMinutes/null
check). Locate the for (const learningTime of learningTimes) block and its
subsequent processing loop and move/keep only the validation in the initial
collection step (e.g., building the map of windows) so the later loop can assume
valid startMinutes and endMinutes and drop the redundant guard.
- Line 580: The LearningSlot type defines nextStartMinutes and initializes it to
startMinutes (symbol: LearningSlot, field: nextStartMinutes, initializer:
startMinutes) but the code never reads or updates it; either remove
nextStartMinutes from the LearningSlot definition and all initializations/usages
to avoid dead state, or implement the intended consumption logic: update
nextStartMinutes whenever a slot is partially consumed (where slots are
allocated/adjusted in functions handling slot assignment) and ensure all
consumers read nextStartMinutes instead of startMinutes when determining the
next available time. Make the change consistently across the LearningSlot
construction and any slot-assignment code paths.
🪄 Autofix (Beta)

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: defaults

Review profile: CHILL

Plan: Pro

Run ID: 96a6b3d2-f869-4756-ae98-3a191aabc92e

📥 Commits

Reviewing files that changed from the base of the PR and between 27e7b94 and a192b70.

⛔ Files ignored due to path filters (1)
  • convex/_generated/api.d.ts is excluded by !**/_generated/**
📒 Files selected for processing (10)
  • convex/learningPlanAi.ts
  • convex/learningPlans.ts
  • convex/learningTimes.ts
  • convex/schema.ts
  • src/app/(app)/settings.tsx
  • src/app/learning-plans/[planId]/index.tsx
  • src/app/learning-plans/[planId]/review.tsx
  • src/app/learning-times/edit.tsx
  • src/app/learning-times/index.tsx
  • src/features/learning-plans/types.ts

Comment thread convex/learningPlanAi.ts
year: "numeric",
}).format(date);

const formatDateKey = (date: Date) => date.toISOString().slice(0, 10);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major | 🏗️ Heavy lift

Major: formatDateKey uses UTC, creating timezone mismatch with Berlin-based slot logic.

formatDateKey extracts the date portion from toISOString(), which always returns UTC. The rest of the code uses getBerlinDayOfWeek and expects Berlin timezone semantics. A Date object at 23:30 Berlin time on June 14 will be 21:30 UTC on June 14, so toISOString() still gives "2026-06-14", but at 01:30 Berlin time on June 15 it becomes 23:30 UTC on June 14, yielding "2026-06-14" instead of the correct Berlin date "2026-06-15".

This causes slot date keys to be off by one day near midnight in Berlin.

🔧 Recommended fix: format date in Berlin timezone
 const formatDateKey = (date: Date) => {
-  return date.toISOString().slice(0, 10);
+  const formatter = new Intl.DateTimeFormat("en-CA", {
+    timeZone: "Europe/Berlin",
+    year: "numeric",
+    month: "2-digit",
+    day: "2-digit",
+  });
+  return formatter.format(date); // returns YYYY-MM-DD in Berlin TZ
 };
🤖 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 `@convex/learningPlanAi.ts` at line 515, The formatDateKey function currently
uses date.toISOString().slice(0,10) which returns a UTC date and causes
off-by-one day errors versus the Berlin-based logic (e.g., getBerlinDayOfWeek);
change formatDateKey to format the Date in the "Europe/Berlin" timezone (e.g.,
via Intl.DateTimeFormat or toLocaleDateString with timeZone: 'Europe/Berlin')
and produce a YYYY-MM-DD string (ISO date) from that Berlin-localized result
instead of slicing toISOString().

Comment thread convex/learningPlanAi.ts
Comment on lines +97 to +100
const learningTimes = useQuery(
api.learningTimes.listMine,
user && isConvexAuthenticated ? {} : "skip",
);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Block saving until the selected day has finished loading.

While learningTimes is still undefined, selectedEntry is treated as missing, so hasChanges becomes true and the save button enables with the default 17:00-17:30 values. On an existing day, a quick tap here will patch that saved slot back to the defaults once upsertMine runs.

Suggested fix
 	const learningTimes = useQuery(
 		api.learningTimes.listMine,
 		user && isConvexAuthenticated ? {} : "skip",
 	);
+	const isLearningTimesLoading = learningTimes === undefined;
...
 	const hasChanges =
 		!selectedEntry ||
 		startTime !== (selectedEntry?.startTime ?? DEFAULT_START_TIME) ||
 		endTime !== (selectedEntry?.endTime ?? DEFAULT_END_TIME);
 	const canRemove = Boolean(selectedEntry) && !isSaving;
 	const canSave =
+		!isLearningTimesLoading &&
 		hasChanges &&
 		parseTimeToMinutes(endTime) > parseTimeToMinutes(startTime) &&
 		!isSaving &&
 		Boolean(user) &&
 		isConvexAuthenticated;

Also applies to: 136-146

🤖 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 `@src/app/learning-times/edit.tsx` around lines 97 - 100, The save flow enables
when learningTimes is still undefined causing hasChanges to be true and
upsertMine to overwrite existing slots; fix by treating the query-loading state
as not-ready: use the result metadata from useQuery(api.learningTimes.listMine,
...)—or check learningTimes !== undefined / query.isLoading—before computing
selectedEntry and hasChanges and before enabling the Save button or calling
upsertMine; update the logic in the selectedEntry/hasChanges computation and the
Save button handler (references: learningTimes, selectedEntry, hasChanges,
useQuery, api.learningTimes.listMine, upsertMine) so save is disabled while the
selected day is still loading (apply same guard to the other similar block at
lines ~136-146).

Comment on lines +198 to +204
<Button
accessibilityLabel="Lernzeit hinzufügen"
className="h-[74px] w-[74px] rounded-full px-0"
onPress={() => openEditor(firstMissingDay)}
>
<Plus size={32} color="#FFFFFF" strokeWidth={1.8} />
</Button>

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Hide or disable the add FAB once all 7 days are configured.

firstMissingDay falls back to 1, so after the user has filled every weekday this “Lernzeit hinzufügen” button opens Monday for editing instead of adding anything. That’s misleading and makes accidental overwrites easy.

Suggested fix
+	const hasMissingDay = LEARNING_DAYS.some(
+		(day) => !learningTimes?.some((entry) => entry.dayOfWeek === day.value),
+	);
 	const firstMissingDay =
 		LEARNING_DAYS.find(
 			(day) =>
 				!learningTimes?.some((entry) => entry.dayOfWeek === day.value),
 		)?.value ?? 1;
...
-				<Button
-					accessibilityLabel="Lernzeit hinzufügen"
-					className="h-[74px] w-[74px] rounded-full px-0"
-					onPress={() => openEditor(firstMissingDay)}
-				>
-					<Plus size={32} color="`#FFFFFF`" strokeWidth={1.8} />
-				</Button>
+				{hasMissingDay ? (
+					<Button
+						accessibilityLabel="Lernzeit hinzufügen"
+						className="h-[74px] w-[74px] rounded-full px-0"
+						onPress={() => openEditor(firstMissingDay)}
+					>
+						<Plus size={32} color="`#FFFFFF`" strokeWidth={1.8} />
+					</Button>
+				) : null}
🤖 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 `@src/app/learning-times/index.tsx` around lines 198 - 204, The FAB currently
always renders and calls openEditor(firstMissingDay) where firstMissingDay falls
back to 1, causing accidental overwrites; update the rendering logic around the
Button in the LearningTimes component so it is hidden or disabled when all seven
days are already configured: detect "allConfigured" by checking the weekday
configuration array (the same logic that computes firstMissingDay), and if true
either don't render the Button or render it with disabled={true} and an updated
accessibilityLabel; ensure openEditor is only invoked when a valid
firstMissingDay exists (e.g., guard on firstMissingDay !== null/undefined before
calling openEditor).

@Gamius00 Gamius00 modified the milestone: Beta Release V 1.1 May 31, 2026
@Gamius00 Gamius00 linked an issue May 31, 2026 that may be closed by this pull request
8 tasks

@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: 3

Caution

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

⚠️ Outside diff range comments (3)
src/app/learning-times/index.tsx (1)

102-109: ⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

goBack grows the navigation stack instead of popping.

When router.canGoBack() is true, pushing /settings appends a new entry rather than returning to the existing one. The user can end up with stacked screens, and a subsequent hardware back press lands on this screen again instead of exiting. Use router.back() for the pop case.

Suggested fix
 	const goBack = () => {
 		if (router.canGoBack()) {
-			router.push("/settings");
+			router.back();
 			return;
 		}
 
 		router.replace("/settings");
 	};
🤖 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 `@src/app/learning-times/index.tsx` around lines 102 - 109, The goBack function
currently pushes "/settings" when router.canGoBack() is true, which grows the
navigation stack; change the pop case to call router.back() instead of
router.push("/settings") so it returns to the previous entry, and keep
router.replace("/settings") as the fallback when cannot go back; update the
goBack function (referenced by the symbol goBack and usages of
router.canGoBack(), router.push(), router.replace()) accordingly.
convex/learningPlanAi.ts (2)

830-845: ⚠️ Potential issue | 🟠 Major | 🏗️ Heavy lift

Stop scheduling after the recommended plan minutes are exhausted.

This normalization is driven by slots.slice(0, MAX_GENERATED_SESSIONS), not by the requested plan length. If the model asks for two 30-minute blocks and the user has ten 60-minute windows, this code emits ten sessions totaling 600 minutes. That also hides the insufficiency case, because plannedMinutes can overshoot requestedMinutes instead of reflecting the recommended plan.

Also applies to: 875-885

🤖 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 `@convex/learningPlanAi.ts` around lines 830 - 845, The normalization currently
takes up to MAX_GENERATED_SESSIONS from slots and doesn't stop when the user's
requestedMinutes are exhausted, causing plannedMinutes to overshoot; update
normalizedSessions creation (and the similar logic around the block handling at
875-885) to consume slots sequentially while tracking remainingMinutes
initialized to requestedMinutes and only emit a session when its duration
(slot.endMinutes - slot.startMinutes) fits into remainingMinutes (or trim the
final session to the remainingMinutes), decrement remainingMinutes accordingly,
and stop producing more sessions once remainingMinutes <= 0; keep using
prioritizedSessions/sessions to pick the source and preserve
MIN_LEARNING_SLOT_MINUTES filtering but ensure the overall produced
plannedMinutes cannot exceed requestedMinutes.

827-843: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Pick the latest eligible template session, not the first one.

Line 839 uses find(...) on prioritizedSessions after sorting ascending by preferredDateKey. Once the earliest session becomes eligible, it wins every later slot as well, so later theory/practice/rehearsal templates never get applied and the normalized plan collapses into repeated copies of the first session.

🤖 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 `@convex/learningPlanAi.ts` around lines 827 - 843, The normalizedSessions
mapping picks the first prioritizedSessions item whose preferredDateKey <=
slot.dateKey, causing earlier templates to always win; change the selection to
pick the latest eligible template instead (i.e., the prioritizedSessions entry
with the greatest preferredDateKey that is <= slot.dateKey). In the
normalizedSessions logic (around the variables slots, prioritizedSessions,
preferredDateKey, session) replace the current find(...) with a “find-last”
approach (e.g., iterate reversed or use a findLast utility/Array.prototype.at
combined with reverse) so you choose the most recent eligible
prioritizedSessions entry, keeping the same fallback behavior to
prioritizedSessions[index % ...]?.session and sessions[index % ...] if none
match.
🧹 Nitpick comments (1)
convex/learningPlans.test.ts (1)

122-159: ⚡ Quick win

Assertion doesn't actually verify the draft session was left unsynced.

The session is at 2026-06-05 09:00 (30 min) and the final entry is created at 11:00 — these never overlap, so the creation would succeed whether or not the draft session produced a day entry. The test therefore passes even if replaceGeneratedSessions started syncing again. Assert directly that no "Lernen" day entry exists for the session's day:

💚 Strengthen the assertion
-	const createdId = await t.mutation(api.dayEntries.create, {
-		dayKey: "2026-06-05",
-		title: "Physik Test",
-		time: "11:00",
-		kind: "Leistungskontrolle",
-		plannedDateLabel: "5. Juni 2026",
-		durationMinutes: 45,
-		examTypeLabel: "Test",
-	});
-
-	expect(createdId).toBeTruthy();
+	const entries = await t.query(api.dayEntries.listByDayKeys, {
+		dayKeys: ["2026-06-05"],
+	});
+	expect(
+		entries["2026-06-05"]?.some((entry) => entry.kind === "Lernen"),
+	).toBe(false);
🤖 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 `@convex/learningPlans.test.ts` around lines 122 - 159, The test currently only
creates an unrelated day entry at 11:00 so it never fails if
replaceGeneratedSessions synced the generated "Lernen" session; after calling
internal.learningPlans.replaceGeneratedSessions you should query the day entries
for dayKey "2026-06-05" and assert that no entry exists with the generated
session title ("Lernen") (i.e. call the appropriate query on day entries for
that date and expect the result array to not contain an entry with title
"Lernen") before proceeding to create the unrelated 11:00 entry using
api.dayEntries.create; keep references to
internal.learningPlans.replaceGeneratedSessions and api.dayEntries.create so the
check is placed immediately after the replaceGeneratedSessions mutation.
🤖 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 `@convex/learningPlanAi.ts`:
- Around line 597-614: getOccupiedIntervalsByDay currently skips entries missing
time or durationMinutes, causing untimed/undurationed dayEntries to be ignored;
change it so any entry that lacks a valid time or lacks a positive
durationMinutes is treated as an all-day occupied interval (0 to 24*60) for that
entry.dayKey. Concretely, in getOccupiedIntervalsByDay (and where
parseTimeToMinutes is used) keep the existing behavior for entries with both a
valid time and positive durationMinutes (push {start, end: start +
durationMinutes}), but if entry.time is missing/parseTimeToMinutes returns null
or entry.durationMinutes is missing/<=0, push {start: 0, end: 24*60} for
entry.dayKey so untimed/undurationed OccupiedEntry records are subtracted from
learning windows. Ensure you still use entry.dayKey to group intervals.

In `@convex/learningPlans.ts`:
- Around line 671-698: The calendar scan in getAiContext iterates unbounded over
getLearningPlanCalendarDayKeys (derived from getAvailableDays), causing
potentially hundreds of sequential dayEntries queries; fix by capping the scan
window or replacing the loop with a ranged query: either introduce a
MAX_CALENDAR_SCAN_DAYS constant and apply it to the array returned by
getLearningPlanCalendarDayKeys before iterating, or compute the first and last
dayKey and perform a single ranged query against the dayEntries index
(withIndex("by_ownerTokenIdentifier_and_dayKey")) filtering ownerTokenIdentifier
and dayKey between those bounds (instead of per-day queries using
getDayKeyQueryVariants), and preserve deduplication behavior currently
implemented with seenEntryIds.

In `@src/app/learning-plans/`[planId]/review.tsx:
- Around line 155-169: The empty-state text currently always shows the "fully
occupied" message when snapshot exists but snapshot.sessions.length === 0;
update the rendering logic in review.tsx to derive the copy from
snapshot.plan.planningHint (or branch on its value) instead of hardcoding the
second variant: inspect snapshot.plan.planningHint (e.g., values like
"NO_LEARNING_TIMES" vs "ALL_TIMES_OCCUPIED" or equivalent) and render the
corresponding title and body for each case, or only render the existing "fully
occupied" card when planningHint indicates fully-occupied; keep the same styling
and components (View, Text) and use snapshot.plan.planningHint and
snapshot.sessions to decide which message to show.

---

Outside diff comments:
In `@convex/learningPlanAi.ts`:
- Around line 830-845: The normalization currently takes up to
MAX_GENERATED_SESSIONS from slots and doesn't stop when the user's
requestedMinutes are exhausted, causing plannedMinutes to overshoot; update
normalizedSessions creation (and the similar logic around the block handling at
875-885) to consume slots sequentially while tracking remainingMinutes
initialized to requestedMinutes and only emit a session when its duration
(slot.endMinutes - slot.startMinutes) fits into remainingMinutes (or trim the
final session to the remainingMinutes), decrement remainingMinutes accordingly,
and stop producing more sessions once remainingMinutes <= 0; keep using
prioritizedSessions/sessions to pick the source and preserve
MIN_LEARNING_SLOT_MINUTES filtering but ensure the overall produced
plannedMinutes cannot exceed requestedMinutes.
- Around line 827-843: The normalizedSessions mapping picks the first
prioritizedSessions item whose preferredDateKey <= slot.dateKey, causing earlier
templates to always win; change the selection to pick the latest eligible
template instead (i.e., the prioritizedSessions entry with the greatest
preferredDateKey that is <= slot.dateKey). In the normalizedSessions logic
(around the variables slots, prioritizedSessions, preferredDateKey, session)
replace the current find(...) with a “find-last” approach (e.g., iterate
reversed or use a findLast utility/Array.prototype.at combined with reverse) so
you choose the most recent eligible prioritizedSessions entry, keeping the same
fallback behavior to prioritizedSessions[index % ...]?.session and
sessions[index % ...] if none match.

In `@src/app/learning-times/index.tsx`:
- Around line 102-109: The goBack function currently pushes "/settings" when
router.canGoBack() is true, which grows the navigation stack; change the pop
case to call router.back() instead of router.push("/settings") so it returns to
the previous entry, and keep router.replace("/settings") as the fallback when
cannot go back; update the goBack function (referenced by the symbol goBack and
usages of router.canGoBack(), router.push(), router.replace()) accordingly.

---

Nitpick comments:
In `@convex/learningPlans.test.ts`:
- Around line 122-159: The test currently only creates an unrelated day entry at
11:00 so it never fails if replaceGeneratedSessions synced the generated
"Lernen" session; after calling internal.learningPlans.replaceGeneratedSessions
you should query the day entries for dayKey "2026-06-05" and assert that no
entry exists with the generated session title ("Lernen") (i.e. call the
appropriate query on day entries for that date and expect the result array to
not contain an entry with title "Lernen") before proceeding to create the
unrelated 11:00 entry using api.dayEntries.create; keep references to
internal.learningPlans.replaceGeneratedSessions and api.dayEntries.create so the
check is placed immediately after the replaceGeneratedSessions mutation.
🪄 Autofix (Beta)

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: defaults

Review profile: CHILL

Plan: Pro

Run ID: 08bd73af-1275-4c45-84c1-4fe7f8c39ca0

📥 Commits

Reviewing files that changed from the base of the PR and between a192b70 and d135f52.

📒 Files selected for processing (7)
  • convex/dayEntries.ts
  • convex/learningPlanAi.ts
  • convex/learningPlans.test.ts
  • convex/learningPlans.ts
  • src/app/(app)/settings.tsx
  • src/app/learning-plans/[planId]/review.tsx
  • src/app/learning-times/index.tsx

Comment thread convex/learningPlanAi.ts
Comment on lines +597 to +614
const getOccupiedIntervalsByDay = (occupiedEntries: OccupiedEntry[]) => {
const intervalsByDay = new Map<string, Array<{ start: number; end: number }>>();

for (const entry of occupiedEntries) {
if (!entry.time || !entry.durationMinutes || entry.durationMinutes <= 0) {
continue;
}

const start = parseTimeToMinutes(entry.time);
if (start === null) continue;

const intervals = intervalsByDay.get(entry.dayKey) ?? [];
intervals.push({ start, end: start + entry.durationMinutes });
intervalsByDay.set(entry.dayKey, intervals);
}

return intervalsByDay;
};

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

rg -n -C4 '\boccupiedEntries\b|\bdayKey\b|\btime\b|\bdurationMinutes\b' \
  convex/learningPlans.ts \
  convex/dayEntries.ts \
  convex/schema.ts

Repository: Dayova/dayova-mvp

Length of output: 21935


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# 1) Required guidelines read
if [ -f convex/_generated/ai/guidelines.md ]; then
  echo "=== convex/_generated/ai/guidelines.md (head) ==="
  head -n 120 convex/_generated/ai/guidelines.md
else
  echo "Missing: convex/_generated/ai/guidelines.md"
fi

# 2) Find all places that insert/patch dayEntries (where time/durationMinutes might be omitted)
echo "=== insert dayEntries locations ==="
rg -n --hidden --no-ignore -S 'ctx\.db\.(insert|patch)\("dayEntries"' convex -g'*.ts' -g'*.js' -C2

echo "=== insert dayEntries: quick scan for time/durationMinutes fields ==="
rg -n --hidden --no-ignore -S 'insert\("dayEntries".{0,200}(time|durationMinutes)' convex -g'*.ts' -g'*.js' -C2 || true

echo "=== patch dayEntries: quick scan for time/durationMinutes fields ==="
rg -n --hidden --no-ignore -S 'patch\("dayEntries".{0,200}(time|durationMinutes)' convex -g'*.ts' -g'*.js' -C2 || true

Repository: Dayova/dayova-mvp

Length of output: 8603


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Show dayEntries schema/validators and create() handler validation
echo "=== convex/dayEntries.ts: entryFields + create mutation (relevant lines) ==="
rg -n "const entryFields|export const create = mutation" convex/dayEntries.ts
python3 - <<'PY'
import itertools,sys,os,subprocess,re,math,statistics
path="convex/dayEntries.ts"
with open(path,'r',encoding='utf-8') as f:
    lines=f.readlines()
# print around create handler roughly based on earlier rg output; scan for "export const create = mutation"
for i,l in enumerate(lines):
    if "export const create = mutation" in l:
        start=max(0,i-40); end=min(len(lines), i+120)
        print("".join(f"{j+1:6d}:{lines[j]}" for j in range(start,end)))
        break
PY

# Inspect assertNoScheduleConflict
echo "=== convex/scheduleConflicts.ts: assertNoScheduleConflict ==="
rg -n "assertNoScheduleConflict" convex/scheduleConflicts.ts
python3 - <<'PY'
path="convex/scheduleConflicts.ts"
with open(path,'r',encoding='utf-8') as f:
    lines=f.readlines()
for i,l in enumerate(lines):
    if "export const assertNoScheduleConflict" in l or "function assertNoScheduleConflict" in l or "assertNoScheduleConflict" in l and "export" in l:
        start=max(0,i-40); end=min(len(lines), i+200)
        print("".join(f"{j+1:6d}:{lines[j]}" for j in range(start,end)))
        break
PY

# Inspect the occupiedEntries builder in convex/learningPlans.ts
echo "=== convex/learningPlans.ts: occupiedEntries builder (around lines ~660-710) ==="
python3 - <<'PY'
path="convex/learningPlans.ts"
with open(path,'r',encoding='utf-8') as f:
    lines=f.readlines()
# locate "occupiedEntries"
for i,l in enumerate(lines):
    if "const occupiedEntries" in l:
        start=max(0,i-30); end=min(len(lines), i+90)
        print("".join(f"{j+1:6d}:{lines[j]}" for j in range(start,end)))
        break
PY

# Inspect learningPlanAi.ts where occupiedEntries passed into getOccupiedIntervalsByDay
echo "=== convex/learningPlanAi.ts: getOccupiedIntervalsByDay usage context (around function) ==="
python3 - <<'PY'
path="convex/learningPlanAi.ts"
with open(path,'r',encoding='utf-8') as f:
    lines=f.readlines()
# find getOccupiedIntervalsByDay definition
for i,l in enumerate(lines):
    if "const getOccupiedIntervalsByDay" in l:
        start=max(0,i-60); end=min(len(lines), i+80)
        print("".join(f"{j+1:6d}:{lines[j]}" for j in range(start,end)))
        break
PY

Repository: Dayova/dayova-mvp

Length of output: 14997


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "=== convex/dayEntries.ts: entryFields + publicEntry (around entryFields) ==="
python3 - <<'PY'
import re
path="convex/dayEntries.ts"
with open(path,'r',encoding='utf-8') as f: lines=f.readlines()
# find "const entryFields"
for i,l in enumerate(lines):
    if "const entryFields" in l:
        start=max(0,i-20); end=min(len(lines), i+80)
        for j in range(start,end):
            print(f"{j+1:6d}:{lines[j].rstrip()}")
        break
PY

echo "=== convex/dayEntries.ts: publicEntry definition (if any) ==="
python3 - <<'PY'
path="convex/dayEntries.ts"
with open(path,'r',encoding='utf-8') as f: lines=f.readlines()
for i,l in enumerate(lines):
    if "const publicEntry" in l or "function publicEntry" in l or "export const publicEntry" in l:
        start=max(0,i-20); end=min(len(lines), i+120)
        for j in range(start,end):
            print(f"{j+1:6d}:{lines[j].rstrip()}")
        break
PY

echo "=== search for any 'kind' values / all-day semantics ==="
rg -n "kind.*\"|\".*kind\"|all[- ]day|day[- ]wide|Ganzt" convex/dayEntries.ts convex/learningPlanAi.ts convex/learningPlans.ts convex/scheduleConflicts.ts

Repository: Dayova/dayova-mvp

Length of output: 9538


🏁 Script executed:

#!/bin/bash
set -euo pipefail

rg -n "dayEntries\.create|api\.dayEntries\.create" -S . --glob '!**/node_modules/**'

Repository: Dayova/dayova-mvp

Length of output: 1029


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "=== convex/dayEntries.test.ts ==="
wc -l convex/dayEntries.test.ts
sed -n '1,140p' convex/dayEntries.test.ts

echo "=== src/app/entry/new.tsx (create day entry UI) ==="
wc -l src/app/entry/new.tsx
# show around where it constructs payload for api.dayEntries.create
rg -n "dayEntries\.create|createDayEntry|durationMinutes|time" src/app/entry/new.tsx
# print around those matches
python3 - <<'PY'
import re
path="src/app/entry/new.tsx"
with open(path,'r',encoding='utf-8') as f: lines=f.readlines()
hits=[]
for i,l in enumerate(lines):
    if any(k in l for k in ["api.dayEntries.create","createDayEntry","durationMinutes","time:","time?"]):
        hits.append(i)
for i in hits[:20]:
    start=max(0,i-20); end=min(len(lines), i+40)
    print("\n--- context lines",start+1,"-",end,"(match at",i+1,") ---\n")
    for j in range(start,end):
        print(f"{j+1:5d}:{lines[j].rstrip()}")
PY

Repository: Dayova/dayova-mvp

Length of output: 13521


Fix: untimed/undurationed dayEntries are silently ignored when generating occupied intervals

  • convex/dayEntries.ts defines both time and durationMinutes as v.optional(...), and dayEntries.create does not validate that they are present together—so dayEntries docs can exist with either field missing.
  • convex/learningPlanAi.ts#getOccupiedIntervalsByDay skips those entries (if (!entry.time || !entry.durationMinutes || entry.durationMinutes <= 0) continue;), meaning they won’t subtract from learning windows and the generated plan can overlap 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 `@convex/learningPlanAi.ts` around lines 597 - 614, getOccupiedIntervalsByDay
currently skips entries missing time or durationMinutes, causing
untimed/undurationed dayEntries to be ignored; change it so any entry that lacks
a valid time or lacks a positive durationMinutes is treated as an all-day
occupied interval (0 to 24*60) for that entry.dayKey. Concretely, in
getOccupiedIntervalsByDay (and where parseTimeToMinutes is used) keep the
existing behavior for entries with both a valid time and positive
durationMinutes (push {start, end: start + durationMinutes}), but if entry.time
is missing/parseTimeToMinutes returns null or entry.durationMinutes is
missing/<=0, push {start: 0, end: 24*60} for entry.dayKey so
untimed/undurationed OccupiedEntry records are subtracted from learning windows.
Ensure you still use entry.dayKey to group intervals.

Comment thread convex/learningPlans.ts
Comment on lines +671 to +698
const occupiedEntries: Array<{
dayKey: string;
time?: string;
durationMinutes?: number;
}> = [];
const seenEntryIds = new Set<string>();
for (const dayKey of getLearningPlanCalendarDayKeys(plan.examDateKey)) {
for (const queryDayKey of getDayKeyQueryVariants(dayKey)) {
const entries = await ctx.db
.query("dayEntries")
.withIndex("by_ownerTokenIdentifier_and_dayKey", (q) =>
q
.eq("ownerTokenIdentifier", identity.tokenIdentifier)
.eq("dayKey", queryDayKey),
)
.take(50);

for (const entry of entries) {
if (seenEntryIds.has(entry._id)) continue;
seenEntryIds.add(entry._id);
occupiedEntries.push({
dayKey,
time: entry.time,
durationMinutes: entry.durationMinutes,
});
}
}
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Unbounded calendar scan in getAiContext can issue hundreds of sequential queries.

The occupancy scan iterates over every calendar day returned by getLearningPlanCalendarDayKeys, whose length equals getAvailableDays(plan.examDateKey) — and that value has no upper bound. For an exam several weeks or months out, this becomes days × dayKeyVariants sequential dayEntries lookups (each take(50)), which slows AI-context retrieval and risks hitting Convex read limits during plan generation.

Since getAvailableDays is only consumed by this helper, capping it is a safe, localized fix:

⚡ Proposed cap on the scan window
+const MAX_PLAN_DAYS = 120;
+
 const getAvailableDays = (examDateKey: string) => {
 	const examTime = new Date(examDateKey).getTime();
 	if (!Number.isFinite(examTime)) return 7;

-	return Math.max(0, Math.ceil((examTime - Date.now()) / 86_400_000));
+	return Math.min(
+		MAX_PLAN_DAYS,
+		Math.max(0, Math.ceil((examTime - Date.now()) / 86_400_000)),
+	);
 };

Alternatively, replace the per-day loop with a single ranged query over by_ownerTokenIdentifier_and_dayKey between the first and last day key.

🤖 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 `@convex/learningPlans.ts` around lines 671 - 698, The calendar scan in
getAiContext iterates unbounded over getLearningPlanCalendarDayKeys (derived
from getAvailableDays), causing potentially hundreds of sequential dayEntries
queries; fix by capping the scan window or replacing the loop with a ranged
query: either introduce a MAX_CALENDAR_SCAN_DAYS constant and apply it to the
array returned by getLearningPlanCalendarDayKeys before iterating, or compute
the first and last dayKey and perform a single ranged query against the
dayEntries index (withIndex("by_ownerTokenIdentifier_and_dayKey")) filtering
ownerTokenIdentifier and dayKey between those bounds (instead of per-day queries
using getDayKeyQueryVariants), and preserve deduplication behavior currently
implemented with seenEntryIds.

Comment thread src/app/learning-plans/[planId]/review.tsx Outdated
Gamius00 added 5 commits May 31, 2026 22:01
# Conflicts:
#	convex/generatedGermanTextRepair.test.ts
#	convex/learningPlanAi.ts
#	convex/learningPlans.test.ts
#	convex/learningPlans.ts
@Gamius00
Gamius00 merged commit 1f45b05 into main Jun 1, 2026
1 check was pending
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

ready-for-agent Fully specified, ready for an AFK agent

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Add personal learn time

1 participant