From e8f71975b777b28e8e7b5bcab5d5c3177fdf836d Mon Sep 17 00:00:00 2001 From: Gamius00 Date: Sat, 30 May 2026 23:51:39 +0200 Subject: [PATCH 1/8] Added user personal learn time --- convex/_generated/api.d.ts | 2 + convex/learningPlanAi.ts | 231 +++++++++++++++++++-- convex/learningPlans.ts | 10 + convex/schema.ts | 14 ++ src/app/(app)/settings.tsx | 7 +- src/app/learning-plans/[planId]/index.tsx | 14 +- src/app/learning-plans/[planId]/review.tsx | 17 +- src/features/learning-plans/types.ts | 1 + 8 files changed, 270 insertions(+), 26 deletions(-) diff --git a/convex/_generated/api.d.ts b/convex/_generated/api.d.ts index 9664735..34c7ce5 100644 --- a/convex/_generated/api.d.ts +++ b/convex/_generated/api.d.ts @@ -13,6 +13,7 @@ import type * as env from "../env.js"; import type * as fileStorage from "../fileStorage.js"; import type * as learningPlanAi from "../learningPlanAi.js"; import type * as learningPlans from "../learningPlans.js"; +import type * as learningTimes from "../learningTimes.js"; import type * as users from "../users.js"; import type { @@ -27,6 +28,7 @@ declare const fullApi: ApiFromModules<{ fileStorage: typeof fileStorage; learningPlanAi: typeof learningPlanAi; learningPlans: typeof learningPlans; + learningTimes: typeof learningTimes; users: typeof users; }>; diff --git a/convex/learningPlanAi.ts b/convex/learningPlanAi.ts index d9e00f7..cb5b271 100644 --- a/convex/learningPlanAi.ts +++ b/convex/learningPlanAi.ts @@ -17,6 +17,8 @@ const MAX_PROMPT_CONTEXT_CHARS = 70_000; const MAX_SESSION_TITLE_CHARS = 28; const LLM_GENERATION_TIMEOUT_MS = 60_000; const MODEL_ID = "gemini-3-flash-preview"; +const MIN_LEARNING_SLOT_MINUTES = 10; +const MAX_GENERATED_SESSIONS = 20; const vertexProviderOptions = { google: { @@ -155,6 +157,11 @@ type LearningPlanAiContext = { fileType: string; fileSizeBytes: number; }>; + learningTimes: Array<{ + dayOfWeek: number; + startTime: string; + endTime: string; + }>; accessKey: string; }; @@ -369,6 +376,51 @@ const formatDateLabel = (date: Date) => year: "numeric", }).format(date); +const formatDateKey = (date: Date) => date.toISOString().slice(0, 10); + +const formatTimeFromMinutes = (minutes: number) => { + const hours = Math.floor(minutes / 60); + const rest = minutes % 60; + return `${hours.toString().padStart(2, "0")}:${rest + .toString() + .padStart(2, "0")}`; +}; + +const parseTimeToMinutes = (time: string) => { + const [hours, minutes] = time.split(":").map(Number); + if ( + !Number.isInteger(hours) || + !Number.isInteger(minutes) || + hours < 0 || + hours > 23 || + minutes < 0 || + minutes > 59 + ) { + return null; + } + + return hours * 60 + minutes; +}; + +const getBerlinDayOfWeek = (date: Date) => { + const value = new Intl.DateTimeFormat("en-US", { + timeZone: "Europe/Berlin", + weekday: "short", + }).format(date); + + return ( + { + Mon: 1, + Tue: 2, + Wed: 3, + Thu: 4, + Fri: 5, + Sat: 6, + Sun: 7, + } as Record + )[value] ?? 1; +}; + const buildDateFromOffset = ( examDateKey: string, dayOffsetBeforeExam: number, @@ -381,6 +433,88 @@ const buildDateFromOffset = ( return date; }; +type LearningTimeWindow = LearningPlanAiContext["learningTimes"][number]; + +type LearningSlot = { + date: Date; + dateKey: string; + dayOfWeek: number; + startMinutes: number; + endMinutes: number; + nextStartMinutes: number; +}; + +const buildLearningSlots = ( + examDateKey: string, + availableDays: number, + learningTimes: LearningTimeWindow[], +) => { + const windowsByDay = new 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({ + date, + dateKey: formatDateKey(date), + dayOfWeek, + startMinutes, + endMinutes, + nextStartMinutes: startMinutes, + }); + } + } + + return slots.sort( + (left, right) => + left.dateKey.localeCompare(right.dateKey) || + left.startMinutes - right.startMinutes, + ); +}; + +const describeLearningTimes = (learningTimes: LearningTimeWindow[]) => { + if (learningTimes.length === 0) return "Keine persönlichen Lernzeiten hinterlegt."; + + const dayLabels: Record = { + 1: "Montag", + 2: "Dienstag", + 3: "Mittwoch", + 4: "Donnerstag", + 5: "Freitag", + 6: "Samstag", + 7: "Sonntag", + }; + + return learningTimes + .slice() + .sort((left, right) => left.dayOfWeek - right.dayOfWeek) + .map( + (time) => + `${dayLabels[time.dayOfWeek] ?? "Lerntag"} ${time.startTime}-${time.endTime}`, + ) + .join("\n"); +}; + const distributeSessionOffsets = ( availableDays: number, sessions: z.infer["sessions"], @@ -425,29 +559,82 @@ const normalizeSessions = ( examDateKey: string, availableDays: number, sessions: z.infer["sessions"], + learningTimes: LearningTimeWindow[], ) => { + const slots = buildLearningSlots(examDateKey, availableDays, learningTimes); + const requestedMinutes = sessions.reduce( + (total, session) => total + session.durationMinutes, + 0, + ); + const availableMinutes = slots.reduce( + (total, slot) => total + (slot.endMinutes - slot.startMinutes), + 0, + ); const distributedOffsets = distributeSessionOffsets(availableDays, sessions); - return sessions - .map((session, index) => { - const date = buildDateFromOffset( - examDateKey, - distributedOffsets[index] ?? 0, - ); + const prioritizedSessions = sessions + .map((session, index) => ({ + session, + preferredDateKey: formatDateKey( + buildDateFromOffset(examDateKey, distributedOffsets[index] ?? 0), + ), + })) + .sort((left, right) => + left.preferredDateKey.localeCompare(right.preferredDateKey), + ); + const normalizedSessions = slots + .filter( + (slot) => + slot.endMinutes - slot.startMinutes >= MIN_LEARNING_SLOT_MINUTES, + ) + .slice(0, MAX_GENERATED_SESSIONS) + .map((slot, index) => { + const source = + prioritizedSessions.find( + (item) => item.preferredDateKey <= slot.dateKey, + )?.session ?? + prioritizedSessions[index % Math.max(prioritizedSessions.length, 1)] + ?.session ?? + sessions[index % Math.max(sessions.length, 1)]; + const phase = source?.phase ?? "practice"; + const durationMinutes = slot.endMinutes - slot.startMinutes; + return { - phase: session.phase, + phase, title: - compactSingleLine(session.title, MAX_SESSION_TITLE_CHARS) || - fallbackTitleByPhase[session.phase], - dateKey: date.toISOString(), - dateLabel: formatDateLabel(date), - startTime: session.startTime, - durationMinutes: session.durationMinutes, - goal: session.goal.trim(), - tasks: session.tasks.map((task) => task.trim()).filter(Boolean), - expectedOutcome: session.expectedOutcome.trim(), + compactSingleLine( + source?.title ?? fallbackTitleByPhase[phase], + MAX_SESSION_TITLE_CHARS, + ) || fallbackTitleByPhase[phase], + dateKey: slot.date.toISOString(), + dateLabel: formatDateLabel(slot.date), + startTime: formatTimeFromMinutes(slot.startMinutes), + durationMinutes, + goal: + source?.goal.trim() ?? + "Nutze diese kurze Lernzeit, um dich gezielt auf die Prüfung vorzubereiten.", + tasks: source?.tasks.map((task) => task.trim()).filter(Boolean) ?? [ + "Wiederhole die wichtigsten Begriffe.", + "Löse eine kurze passende Übungsaufgabe.", + ], + expectedOutcome: + source?.expectedOutcome.trim() ?? + "Du hast einen kleinen, konkreten Fortschritt für die Prüfung gemacht.", }; }) .sort((left, right) => left.dateKey.localeCompare(right.dateKey)); + + const plannedMinutes = normalizedSessions.reduce( + (total, session) => total + session.durationMinutes, + 0, + ); + const planningHint = + learningTimes.length === 0 + ? "Du hast noch keine persönlichen Lernzeiten hinterlegt. Der Lernplan kann erst konkrete Lerneinheiten erstellen, wenn du in den Einstellungen Lernzeiten anlegst." + : plannedMinutes < requestedMinutes || availableMinutes < requestedMinutes + ? `Deine hinterlegten Lernzeiten reichen nicht für den vollständigen empfohlenen Plan. Geplant wurden ${plannedMinutes} von ${requestedMinutes} Minuten innerhalb deiner verfügbaren Lernzeiten.` + : undefined; + + return { sessions: normalizedSessions, planningHint }; }; const buildBaseContext = (context: LearningPlanAiContext) => { @@ -575,6 +762,7 @@ export const generatePlan = action({ context.accessKey, ); const availableDays = getAvailableDays(context.plan.examDateKey); + const personalLearningTimes = describeLearningTimes(context.learningTimes); const model = createVertexModel(); const userContent: Array< | { type: "text"; text: string } @@ -584,6 +772,8 @@ export const generatePlan = action({ type: "text", text: `${buildBaseContext(context)} Verfügbare Tage bis zur Prüfung: ${availableDays} +Persönliche Lernzeiten aus den Einstellungen: +${personalLearningTimes} Wissensanalyse: ${qaText} @@ -599,6 +789,8 @@ MVP-Vorgabe: - Session-Titel müssen kurze UI-Labels mit maximal ${MAX_SESSION_TITLE_CHARS} Zeichen sein. - insight.strengths darf maximal 4 Punkte enthalten, insight.gaps maximal 5 Punkte. - Jeder Lernblock darf maximal 5 Aufgaben enthalten, der gesamte Plan maximal 5 Lernblöcke. +- Plane ausschließlich innerhalb der persönlichen Lernzeiten aus den Einstellungen. Verwende keine anderen Tage oder Uhrzeiten. +- Wenn die persönlichen Lernzeiten nicht für den gesamten empfohlenen Plan reichen, plane nur so viel wie in diese Lernzeiten passt. - Nutze dayOffsetBeforeExam relativ zum Prüfungstag: 1 = einen Tag vor der Prüfung. - Verteile mehrere Sessions auf unterschiedliche Kalendertage, solange genug Tage verfügbar sind. Wiederhole dayOffsetBeforeExam nicht, wenn eine Alternative möglich ist. - Wenn Antworten nur Platzhalter oder Unsinn enthalten, erstelle trotzdem einen remedialen Grundlagenplan und setze strengths auf []. @@ -630,20 +822,19 @@ MVP-Vorgabe: "Aus diesen Antworten konnte kein stabiler Lernplan erstellt werden. Ergänze mindestens ein paar konkrete Stichworte zu deinem Wissenstand und versuche es erneut.", ); - const sessions = normalizeSessions( + const { sessions, planningHint } = normalizeSessions( context.plan.examDateKey, availableDays, result.output.sessions, + context.learningTimes, ); - if (sessions.length === 0) { - throw new Error("Die KI hat keine nutzbaren Lerntage erzeugt."); - } await ctx.runMutation(internal.learningPlans.replaceGeneratedSessions, { learningPlanId: args.learningPlanId, knowledgeAnswersJson: JSON.stringify(args.answers), sourceSummary: result.output.sourceSummary, insight: result.output.insight, + planningHint, sessions, }); diff --git a/convex/learningPlans.ts b/convex/learningPlans.ts index 9b64ec3..bfa7f1a 100644 --- a/convex/learningPlans.ts +++ b/convex/learningPlans.ts @@ -351,6 +351,7 @@ export const getSnapshot = query({ knowledgeQuestions: plan.knowledgeQuestions ?? [], sourceSummary: plan.sourceSummary, insight: plan.insight, + planningHint: plan.planningHint, }, documents: documents.map(publicDocument), answers: answers.map(publicAnswer), @@ -613,10 +614,17 @@ export const getAiContext = internalQuery({ q.eq("learningPlanId", args.learningPlanId), ) .take(20); + const learningTimes = await ctx.db + .query("userLearningTimes") + .withIndex("by_ownerTokenIdentifier", (q) => + q.eq("ownerTokenIdentifier", identity.tokenIdentifier), + ) + .take(7); return { plan, documents, + learningTimes, accessKey: buildPlanAccessKey(args.learningPlanId), }; }, @@ -676,6 +684,7 @@ export const replaceGeneratedSessions = internalMutation({ knowledgeAnswersJson: v.string(), sourceSummary: v.string(), insight: planInsightValidator, + planningHint: v.optional(v.string()), sessions: v.array(generatedSessionValidator), }, handler: async (ctx, args) => { @@ -721,6 +730,7 @@ export const replaceGeneratedSessions = internalMutation({ knowledgeAnswersJson: args.knowledgeAnswersJson, sourceSummary: args.sourceSummary, insight: args.insight, + planningHint: args.planningHint, status: "generated", updatedAt: now, }); diff --git a/convex/schema.ts b/convex/schema.ts index ab06b48..760bafe 100644 --- a/convex/schema.ts +++ b/convex/schema.ts @@ -45,6 +45,19 @@ export default defineSchema({ }) .index("by_userId", ["userId"]) .index("by_userId_and_questionId", ["userId", "questionId"]), + userLearningTimes: defineTable({ + ownerTokenIdentifier: v.string(), + dayOfWeek: v.number(), + startTime: v.string(), + endTime: v.string(), + createdAt: v.number(), + updatedAt: v.number(), + }) + .index("by_ownerTokenIdentifier", ["ownerTokenIdentifier"]) + .index("by_ownerTokenIdentifier_and_dayOfWeek", [ + "ownerTokenIdentifier", + "dayOfWeek", + ]), dayEntries: defineTable({ ownerTokenIdentifier: v.string(), dayKey: v.string(), @@ -84,6 +97,7 @@ export default defineSchema({ knowledgeAnswersJson: v.optional(v.string()), sourceSummary: v.optional(v.string()), insight: v.optional(planInsightValidator), + planningHint: v.optional(v.string()), examDayEntryId: v.optional(v.id("dayEntries")), acceptedAt: v.optional(v.number()), createdAt: v.number(), diff --git a/src/app/(app)/settings.tsx b/src/app/(app)/settings.tsx index db27e3b..64597cb 100644 --- a/src/app/(app)/settings.tsx +++ b/src/app/(app)/settings.tsx @@ -10,7 +10,7 @@ import { Switch, View, } from "react-native"; -import { Bell, Logout, Settings } from "~/components/ui/icon"; +import { Bell, Logout, Settings, Timer } from "~/components/ui/icon"; import { ListRow } from "~/components/ui/list-row"; import { Screen, ScreenScroll } from "~/components/ui/screen"; import { useAuth } from "~/context/AuthContext"; @@ -169,6 +169,11 @@ export default function SettingsScreen() { /> } /> + router.push("/learning-times")} + /> diff --git a/src/app/learning-plans/[planId]/index.tsx b/src/app/learning-plans/[planId]/index.tsx index d197c88..ac9056a 100644 --- a/src/app/learning-plans/[planId]/index.tsx +++ b/src/app/learning-plans/[planId]/index.tsx @@ -5,7 +5,7 @@ import { View } from "react-native"; import { api } from "#convex/_generated/api"; import type { Id } from "#convex/_generated/dataModel"; import { ScreenHeader } from "~/components/screen-header"; -import { Clock3, Route2 } from "~/components/ui/icon"; +import { CircleAlert, Clock3, Route2 } from "~/components/ui/icon"; import { Screen, ScreenScroll } from "~/components/ui/screen"; import { Surface } from "~/components/ui/surface"; import { Text } from "~/components/ui/text"; @@ -135,6 +135,18 @@ export default function LearningPlanSessionsScreen() { + {snapshot?.plan.planningHint ? ( + + + + {snapshot.plan.planningHint} + + + ) : null} + {snapshot?.sessions.map((session) => ( diff --git a/src/app/learning-plans/[planId]/review.tsx b/src/app/learning-plans/[planId]/review.tsx index c15f603..3c22052 100644 --- a/src/app/learning-plans/[planId]/review.tsx +++ b/src/app/learning-plans/[planId]/review.tsx @@ -13,7 +13,7 @@ import { api } from "#convex/_generated/api"; import type { Id } from "#convex/_generated/dataModel"; import { ScreenHeader as Header } from "~/components/screen-header"; import { Button } from "~/components/ui/button"; -import { Check, Plus } from "~/components/ui/icon"; +import { Check, CircleAlert, Plus } from "~/components/ui/icon"; import { Text } from "~/components/ui/text"; import { useAuth } from "~/context/AuthContext"; import { @@ -25,7 +25,7 @@ import type { PlanSession, } from "~/features/learning-plans/types"; import { getErrorMessage } from "~/features/learning-plans/utils"; -import { goBackOrReplace, useBackIntent } from "~/lib/navigation"; +import { goBackOrReplace } from "~/lib/navigation"; const planPath = (id: Id<"learningPlans">, step: string) => `/learning-plans/${id}/${step}` as const; @@ -131,8 +131,6 @@ export default function LearningPlanReviewScreen() { return true; }, [planId, router]); - useBackIntent(Boolean(planId), goBack); - return ( @@ -153,6 +151,17 @@ export default function LearningPlanReviewScreen() { title="Lernplan erstellen" description="Passe deine Lerntage an und trage den Plan danach in den Kalender ein." /> + {snapshot?.plan.planningHint ? ( + + + + {snapshot.plan.planningHint} + + + ) : null} {snapshot?.sessions.map((session) => ( Date: Sat, 30 May 2026 23:52:10 +0200 Subject: [PATCH 2/8] Added user personal lertime --- convex/learningTimes.ts | 136 +++++++++++ src/app/learning-times/edit.tsx | 374 +++++++++++++++++++++++++++++++ src/app/learning-times/index.tsx | 208 +++++++++++++++++ 3 files changed, 718 insertions(+) create mode 100644 convex/learningTimes.ts create mode 100644 src/app/learning-times/edit.tsx create mode 100644 src/app/learning-times/index.tsx diff --git a/convex/learningTimes.ts b/convex/learningTimes.ts new file mode 100644 index 0000000..6799b48 --- /dev/null +++ b/convex/learningTimes.ts @@ -0,0 +1,136 @@ +import { v } from "convex/values"; +import type { MutationCtx, QueryCtx } from "./_generated/server"; +import { mutation, query } from "./_generated/server"; + +const timePattern = /^([01]\d|2[0-3]):([0-5]\d)$/; + +const requireIdentity = async (ctx: QueryCtx | MutationCtx) => { + const identity = await ctx.auth.getUserIdentity(); + if (!identity) { + throw new Error("Nicht authentifiziert."); + } + return identity; +}; + +const parseTimeToMinutes = (time: string) => { + const match = timePattern.exec(time); + if (!match) return null; + + return Number(match[1]) * 60 + Number(match[2]); +}; + +const validateLearningTime = (args: { + dayOfWeek: number; + startTime: string; + endTime: string; +}) => { + if ( + !Number.isInteger(args.dayOfWeek) || + args.dayOfWeek < 1 || + args.dayOfWeek > 7 + ) { + throw new Error("Bitte wähle einen gültigen Lerntag aus."); + } + + const startMinutes = parseTimeToMinutes(args.startTime); + const endMinutes = parseTimeToMinutes(args.endTime); + if (startMinutes === null || endMinutes === null) { + throw new Error("Bitte gib gültige Uhrzeiten ein."); + } + + if (endMinutes <= startMinutes) { + throw new Error("Die Endzeit muss nach der Startzeit liegen."); + } +}; + +export const listMine = query({ + args: {}, + handler: async (ctx) => { + const identity = await requireIdentity(ctx); + const rows = await ctx.db + .query("userLearningTimes") + .withIndex("by_ownerTokenIdentifier", (q) => + q.eq("ownerTokenIdentifier", identity.tokenIdentifier), + ) + .take(7); + + return rows + .map((row) => ({ + id: row._id, + dayOfWeek: row.dayOfWeek, + startTime: row.startTime, + endTime: row.endTime, + })) + .sort((a, b) => a.dayOfWeek - b.dayOfWeek); + }, +}); + +export const upsertMine = mutation({ + args: { + dayOfWeek: v.number(), + startTime: v.string(), + endTime: v.string(), + }, + handler: async (ctx, args) => { + validateLearningTime(args); + + const identity = await requireIdentity(ctx); + const now = Date.now(); + const existing = await ctx.db + .query("userLearningTimes") + .withIndex("by_ownerTokenIdentifier_and_dayOfWeek", (q) => + q + .eq("ownerTokenIdentifier", identity.tokenIdentifier) + .eq("dayOfWeek", args.dayOfWeek), + ) + .unique(); + + if (existing) { + await ctx.db.patch("userLearningTimes", existing._id, { + startTime: args.startTime, + endTime: args.endTime, + updatedAt: now, + }); + return existing._id; + } + + return await ctx.db.insert("userLearningTimes", { + ownerTokenIdentifier: identity.tokenIdentifier, + dayOfWeek: args.dayOfWeek, + startTime: args.startTime, + endTime: args.endTime, + createdAt: now, + updatedAt: now, + }); + }, +}); + +export const removeMine = mutation({ + args: { + dayOfWeek: v.number(), + }, + handler: async (ctx, args) => { + if ( + !Number.isInteger(args.dayOfWeek) || + args.dayOfWeek < 1 || + args.dayOfWeek > 7 + ) { + throw new Error("Bitte wähle einen gültigen Lerntag aus."); + } + + const identity = await requireIdentity(ctx); + const existing = await ctx.db + .query("userLearningTimes") + .withIndex("by_ownerTokenIdentifier_and_dayOfWeek", (q) => + q + .eq("ownerTokenIdentifier", identity.tokenIdentifier) + .eq("dayOfWeek", args.dayOfWeek), + ) + .unique(); + + if (!existing) return { success: true }; + + await ctx.db.delete("userLearningTimes", existing._id); + return { success: true }; + }, +}); diff --git a/src/app/learning-times/edit.tsx b/src/app/learning-times/edit.tsx new file mode 100644 index 0000000..37aef49 --- /dev/null +++ b/src/app/learning-times/edit.tsx @@ -0,0 +1,374 @@ +import { api } from "#convex/_generated/api"; +import { useConvexAuth, useMutation, useQuery } from "convex/react"; +import { useLocalSearchParams, useRouter } from "expo-router"; +import { StatusBar } from "expo-status-bar"; +import { useMemo, useState } from "react"; +import { + ActivityIndicator, + Alert, + Platform, + Pressable, + View, +} from "react-native"; +import { ScreenHeader as Header } from "~/components/screen-header"; +import { Button } from "~/components/ui/button"; +import { + DateTimePickerSheet, + type DateTimePickerEvent, +} from "~/components/ui/date-time-picker-sheet"; +import { + Field, + FieldAccessory, + FieldLabel, + FieldTrigger, +} from "~/components/ui/field"; +import { CalendarDays, ChevronDown, Timer, Trash2 } from "~/components/ui/icon"; +import { Screen, ScreenScroll } from "~/components/ui/screen"; +import { SelectSheet } from "~/components/ui/select-sheet"; +import { Text } from "~/components/ui/text"; +import { useAuth } from "~/context/AuthContext"; + +const LEARNING_DAYS = [ + { label: "Montag", value: 1 }, + { label: "Dienstag", value: 2 }, + { label: "Mittwoch", value: 3 }, + { label: "Donnerstag", value: 4 }, + { label: "Freitag", value: 5 }, + { label: "Samstag", value: 6 }, + { label: "Sonntag", value: 7 }, +] as const; + +type LearningDayLabel = (typeof LEARNING_DAYS)[number]["label"]; +type TimeField = "start" | "end"; + +const DEFAULT_START_TIME = "17:00"; +const DEFAULT_END_TIME = "17:30"; + +const formatTime = (date: Date) => + `${date.getHours().toString().padStart(2, "0")}:${date + .getMinutes() + .toString() + .padStart(2, "0")}`; + +const dateForTime = (time: string) => { + const [hours, minutes] = time.split(":").map(Number); + const date = new Date(); + date.setHours(hours || 0, minutes || 0, 0, 0); + return date; +}; + +const parseTimeToMinutes = (time: string) => { + const [hours, minutes] = time.split(":").map(Number); + return (hours || 0) * 60 + (minutes || 0); +}; + +function TimeControl({ + label, + value, + onPress, +}: { + label: string; + value: string; + onPress: () => void; +}) { + return ( + + + {value} + + + + ); +} + +export default function LearningTimesScreen() { + const router = useRouter(); + const params = useLocalSearchParams<{ day?: string }>(); + const { user } = useAuth(); + const { isAuthenticated: isConvexAuthenticated } = useConvexAuth(); + const learningTimes = useQuery( + api.learningTimes.listMine, + user && isConvexAuthenticated ? {} : "skip", + ); + const saveLearningTime = useMutation(api.learningTimes.upsertMine); + const removeLearningTime = useMutation(api.learningTimes.removeMine); + + const initialDay = + LEARNING_DAYS.find((day) => String(day.value) === params.day)?.label ?? + "Montag"; + const [selectedDay, setSelectedDay] = useState(initialDay); + const [startTime, setStartTime] = useState(DEFAULT_START_TIME); + const [endTime, setEndTime] = useState(DEFAULT_END_TIME); + const [daySheetVisible, setDaySheetVisible] = useState(false); + const [activeTimeField, setActiveTimeField] = useState( + null, + ); + const [isSaving, setIsSaving] = useState(false); + const [feedback, setFeedback] = useState(null); + + const selectedDayValue = + LEARNING_DAYS.find((day) => day.label === selectedDay)?.value ?? 1; + const selectedEntry = useMemo( + () => learningTimes?.find((entry) => entry.dayOfWeek === selectedDayValue), + [learningTimes, selectedDayValue], + ); + + const currentEntryKey = selectedEntry + ? `${selectedEntry.dayOfWeek}:${selectedEntry.startTime}:${selectedEntry.endTime}` + : `${selectedDayValue}:empty`; + const [appliedEntryKey, setAppliedEntryKey] = useState(currentEntryKey); + + if (appliedEntryKey !== currentEntryKey) { + setAppliedEntryKey(currentEntryKey); + setStartTime(selectedEntry?.startTime ?? DEFAULT_START_TIME); + setEndTime(selectedEntry?.endTime ?? DEFAULT_END_TIME); + setFeedback(null); + } + + const hasChanges = + !selectedEntry || + startTime !== (selectedEntry?.startTime ?? DEFAULT_START_TIME) || + endTime !== (selectedEntry?.endTime ?? DEFAULT_END_TIME); + const canRemove = Boolean(selectedEntry) && !isSaving; + const canSave = + hasChanges && + parseTimeToMinutes(endTime) > parseTimeToMinutes(startTime) && + !isSaving && + Boolean(user) && + isConvexAuthenticated; + + const goBack = () => { + if (router.canGoBack()) { + router.back(); + return; + } + + router.replace("/learning-times"); + }; + + const updateTime = (event: DateTimePickerEvent, selectedDate?: Date) => { + if (event.type !== "set" || !selectedDate || !activeTimeField) return; + + const nextTime = formatTime(selectedDate); + if (activeTimeField === "start") { + setStartTime(nextTime); + } else { + setEndTime(nextTime); + } + setFeedback(null); + if (Platform.OS === "android") setActiveTimeField(null); + }; + + const save = async () => { + if (parseTimeToMinutes(endTime) <= parseTimeToMinutes(startTime)) { + Alert.alert( + "Uhrzeit prüfen", + "Die Endzeit muss nach der Startzeit liegen.", + ); + return; + } + + setIsSaving(true); + setFeedback(null); + try { + await saveLearningTime({ + dayOfWeek: selectedDayValue, + startTime, + endTime, + }); + router.replace("/learning-times"); + } catch (error) { + Alert.alert( + "Lernzeit konnte nicht gespeichert werden", + error instanceof Error ? error.message : "Bitte versuche es erneut.", + ); + } finally { + setIsSaving(false); + } + }; + + const remove = async () => { + setIsSaving(true); + setFeedback(null); + try { + await removeLearningTime({ dayOfWeek: selectedDayValue }); + setStartTime(DEFAULT_START_TIME); + setEndTime(DEFAULT_END_TIME); + setFeedback("Lernzeit entfernt."); + router.replace("/learning-times"); + } catch (error) { + Alert.alert( + "Lernzeit konnte nicht entfernt werden", + error instanceof Error ? error.message : "Bitte versuche es erneut.", + ); + } finally { + setIsSaving(false); + } + }; + + const renderDaySelectSheet = () => { + if (!daySheetVisible) return null; + + return ( + day.label)} + selectedValue={selectedDay} + onSelect={setSelectedDay} + onClose={() => setDaySheetVisible(false)} + renderOptionIcon={(_option, isSelected) => ( + + )} + /> + ); + }; + + return ( + + + +
+ + + + + Lernzeit bearbeiten + + + Hier kannst du individuell deine Lernzeiten anpassen, so wie es + passt. + + + + + + Lernzeit + setDaySheetVisible(true)} + style={{ + boxShadow: "0 1px 4px rgba(0, 0, 0, 0.08)", + }} + > + + Lerntag + + + {selectedDay} + + + + + + + + + setActiveTimeField("start")} + /> + setActiveTimeField("end")} + /> + + + + {learningTimes === undefined ? ( + + + + ) : null} + + {feedback ? ( + + {feedback} + + ) : null} + + + + + + + + + {renderDaySelectSheet()} + + setActiveTimeField(null)} + /> + + ); +} diff --git a/src/app/learning-times/index.tsx b/src/app/learning-times/index.tsx new file mode 100644 index 0000000..9d49c99 --- /dev/null +++ b/src/app/learning-times/index.tsx @@ -0,0 +1,208 @@ +import { api } from "#convex/_generated/api"; +import { useConvexAuth, useQuery } from "convex/react"; +import { useRouter } from "expo-router"; +import { StatusBar } from "expo-status-bar"; +import { ActivityIndicator, Pressable, View } from "react-native"; +import { ScreenHeader as Header } from "~/components/screen-header"; +import { Button } from "~/components/ui/button"; +import { ClipboardEdit, Plus } from "~/components/ui/icon"; +import { Screen, ScreenScroll } from "~/components/ui/screen"; +import { Text } from "~/components/ui/text"; +import { useAuth } from "~/context/AuthContext"; + +const LEARNING_DAYS = [ + { abbreviation: "Mo", label: "Montag", value: 1 }, + { abbreviation: "Di", label: "Dienstag", value: 2 }, + { abbreviation: "Mi", label: "Mittwoch", value: 3 }, + { abbreviation: "Do", label: "Donnerstag", value: 4 }, + { abbreviation: "Fr", label: "Freitag", value: 5 }, + { abbreviation: "Sa", label: "Samstag", value: 6 }, + { abbreviation: "So", label: "Sonntag", value: 7 }, +] as const; + +function LearningTimeRow({ + abbreviation, + accessibilityLabel, + onPress, + timeRange, +}: { + abbreviation: string; + accessibilityLabel: string; + onPress: () => void; + timeRange: string; +}) { + return ( + + + + {abbreviation} + + + + + + Lernzeit + + + {timeRange} + + + + + + + + ); +} + +export default function LearningTimesOverviewScreen() { + const router = useRouter(); + const { user } = useAuth(); + const { isAuthenticated: isConvexAuthenticated } = useConvexAuth(); + const learningTimes = useQuery( + api.learningTimes.listMine, + user && isConvexAuthenticated ? {} : "skip", + ); + + const rows = + learningTimes?.map((entry) => { + const day = LEARNING_DAYS.find( + (learningDay) => learningDay.value === entry.dayOfWeek, + ); + + return { + abbreviation: day?.abbreviation ?? "?", + dayOfWeek: entry.dayOfWeek, + label: day?.label ?? "Lerntag", + timeRange: `${entry.startTime} - ${entry.endTime}`, + }; + }) ?? []; + const firstMissingDay = + LEARNING_DAYS.find( + (day) => + !learningTimes?.some((entry) => entry.dayOfWeek === day.value), + )?.value ?? 1; + + const goBack = () => { + if (router.canGoBack()) { + router.back(); + return; + } + + router.replace("/settings"); + }; + + const openEditor = (dayOfWeek: number) => { + router.push(`/learning-times/edit?day=${dayOfWeek}`); + }; + + return ( + + + +
+ + + + Lernzeiten anpassen + + + Trage hier deine wiederkehrend verfügbaren Zeiten ein, an denen du + lernen kannst. + + + + + {learningTimes === undefined ? ( + + + + ) : null} + + {learningTimes?.length === 0 ? ( + + + Noch keine Lernzeiten eingetragen + + + Füge mit dem Plus deine erste wiederkehrende Lernzeit hinzu. + + + ) : null} + + {rows.map((row) => ( + openEditor(row.dayOfWeek)} + /> + ))} + + + + + + + + + ); +} From f1a85893e04feb7c7e7242b19d8905d0b64e8b26 Mon Sep 17 00:00:00 2001 From: Gamius00 Date: Sun, 31 May 2026 10:29:30 +0200 Subject: [PATCH 3/8] Added optional Lernsessions --- src/app/learning-plans/[planId]/review.tsx | 101 +++++++++++---------- 1 file changed, 54 insertions(+), 47 deletions(-) diff --git a/src/app/learning-plans/[planId]/review.tsx b/src/app/learning-plans/[planId]/review.tsx index 343b381..886f55b 100644 --- a/src/app/learning-plans/[planId]/review.tsx +++ b/src/app/learning-plans/[planId]/review.tsx @@ -26,12 +26,14 @@ import type { } from "~/features/learning-plans/types"; import { getErrorMessage } from "~/features/learning-plans/utils"; import { goBackOrReplace } from "~/lib/navigation"; +import { useSafeAreaInsets } from "react-native-safe-area-context"; const planPath = (id: Id<"learningPlans">, step: string) => `/learning-plans/${id}/${step}` as const; export default function LearningPlanReviewScreen() { const router = useRouter(); + const insets = useSafeAreaInsets(); const params = useLocalSearchParams<{ planId?: string }>(); const planId = params.planId as Id<"learningPlans"> | undefined; const { user } = useAuth(); @@ -141,7 +143,7 @@ export default function LearningPlanReviewScreen() { flexGrow: 1, paddingHorizontal: 32, paddingTop: 80, - paddingBottom: 60, + paddingBottom: 150, }} keyboardShouldPersistTaps="handled" showsVerticalScrollIndicator={false} @@ -176,54 +178,59 @@ export default function LearningPlanReviewScreen() { {errorMessage} ) : null} - - - - - - + + + + + + + Date: Sun, 31 May 2026 14:31:15 +0200 Subject: [PATCH 4/8] Sync Learn Sessions with Exam creation --- convex/dayEntries.ts | 8 +- convex/learningPlanAi.ts | 171 ++++++++++++++++++--- convex/learningPlans.test.ts | 130 +++++++++++++++- convex/learningPlans.ts | 86 +++++++++-- src/app/(app)/settings.tsx | 2 +- src/app/learning-plans/[planId]/review.tsx | 39 +++-- src/app/learning-times/index.tsx | 17 +- 7 files changed, 384 insertions(+), 69 deletions(-) diff --git a/convex/dayEntries.ts b/convex/dayEntries.ts index cc62989..9220e3d 100644 --- a/convex/dayEntries.ts +++ b/convex/dayEntries.ts @@ -179,7 +179,13 @@ export const listByDayKeys = query({ plan = await ctx.db.get("learningPlans", session.learningPlanId); planCache.set(session.learningPlanId, plan); } - if (!plan || plan.ownerTokenIdentifier !== ownerTokenIdentifier) continue; + if ( + !plan || + plan.ownerTokenIdentifier !== ownerTokenIdentifier || + plan.status !== "accepted" + ) { + continue; + } grouped[requestedDayKey] = [ ...(grouped[requestedDayKey] ?? []), diff --git a/convex/learningPlanAi.ts b/convex/learningPlanAi.ts index b48f577..1eb82d2 100644 --- a/convex/learningPlanAi.ts +++ b/convex/learningPlanAi.ts @@ -19,6 +19,7 @@ const LLM_GENERATION_TIMEOUT_MS = 60_000; const MODEL_ID = "gemini-3-flash-preview"; const MIN_LEARNING_SLOT_MINUTES = 10; const MAX_GENERATED_SESSIONS = 20; +const MAX_RECOMMENDED_SESSION_MINUTES = 720; const GERMAN_UI_TEXT_RULE = "All visible German UI text must use correct umlauts and ß, not ae/oe/ue/ss substitutions."; const KNOWLEDGE_QUESTIONS_OUTPUT_DESCRIPTION = `${GERMAN_UI_TEXT_RULE} Return exactly five short diagnostic questions that reveal what the learning plan needs to cover.`; @@ -239,7 +240,11 @@ const generatedPlanSchema = z ), dayOffsetBeforeExam: z.number().int().min(0).max(120), startTime: z.string().regex(/^\d{2}:\d{2}$/), - durationMinutes: z.number().int().min(15).max(180), + durationMinutes: z + .number() + .int() + .min(MIN_LEARNING_SLOT_MINUTES) + .max(MAX_RECOMMENDED_SESSION_MINUTES), goal: z .string() .min(20) @@ -298,6 +303,11 @@ type LearningPlanAiContext = { startTime: string; endTime: string; }>; + occupiedEntries: Array<{ + dayKey: string; + time?: string; + durationMinutes?: number; + }>; accessKey: string; }; @@ -309,6 +319,8 @@ type ModelDocumentInput = { fileSizeBytes: number; }; +type GeneratedPlan = z.infer; + const createVertexModel = () => { const apiKey = readOptionalEnv("GOOGLE_VERTEX_API_KEY"); if (apiKey) { @@ -580,10 +592,57 @@ type LearningSlot = { nextStartMinutes: number; }; +type OccupiedEntry = LearningPlanAiContext["occupiedEntries"][number]; + +const getOccupiedIntervalsByDay = (occupiedEntries: OccupiedEntry[]) => { + const intervalsByDay = new Map>(); + + 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; +}; + +const subtractOccupiedIntervals = ( + startMinutes: number, + endMinutes: number, + occupiedIntervals: Array<{ start: number; end: number }>, +) => { + let freeIntervals = [{ start: startMinutes, end: endMinutes }]; + + for (const occupied of occupiedIntervals) { + freeIntervals = freeIntervals.flatMap((free) => { + if (occupied.start >= free.end || occupied.end <= free.start) { + return [free]; + } + + return [ + { start: free.start, end: Math.max(free.start, occupied.start) }, + { start: Math.min(free.end, occupied.end), end: free.end }, + ].filter( + (interval) => interval.end - interval.start >= MIN_LEARNING_SLOT_MINUTES, + ); + }); + } + + return freeIntervals; +}; + const buildLearningSlots = ( examDateKey: string, availableDays: number, learningTimes: LearningTimeWindow[], + occupiedEntries: OccupiedEntry[], ) => { const windowsByDay = new Map(); for (const learningTime of learningTimes) { @@ -598,9 +657,11 @@ const buildLearningSlots = ( windowsByDay.set(learningTime.dayOfWeek, windows); } + const occupiedIntervalsByDay = getOccupiedIntervalsByDay(occupiedEntries); const slots: LearningSlot[] = []; for (let offset = availableDays; offset >= 1; offset -= 1) { const date = buildDateFromOffset(examDateKey, offset); + const dateKey = formatDateKey(date); const dayOfWeek = getBerlinDayOfWeek(date); const windows = windowsByDay.get(dayOfWeek) ?? []; for (const window of windows) { @@ -610,14 +671,21 @@ const buildLearningSlots = ( continue; } - slots.push({ - date, - dateKey: formatDateKey(date), - dayOfWeek, + const freeIntervals = subtractOccupiedIntervals( startMinutes, endMinutes, - nextStartMinutes: startMinutes, - }); + occupiedIntervalsByDay.get(dateKey) ?? [], + ); + for (const interval of freeIntervals) { + slots.push({ + date, + dateKey, + dayOfWeek, + startMinutes: interval.start, + endMinutes: interval.end, + nextStartMinutes: interval.start, + }); + } } } @@ -651,6 +719,42 @@ const describeLearningTimes = (learningTimes: LearningTimeWindow[]) => { .join("\n"); }; +const buildFallbackGeneratedPlan = ( + context: LearningPlanAiContext, + answers: Array<{ questionId: string; answer: string }>, +): GeneratedPlan => { + const answeredCount = answers.filter((answer) => answer.answer.trim()).length; + return { + sourceSummary: + "Der Lernplan basiert auf den beantworteten Wissensfragen und den verfügbaren Lernzeiten.", + insight: { + summary: `Du hast ${answeredCount} Antworten gegeben. Daraus wird ein vorsichtiger Alternativplan erstellt, der sich an deinen freien Lernzeiten orientiert.`, + strengths: [], + gaps: [ + "Die verfügbaren Lernzeiten sind knapp oder bereits belegt. Nutze die vorgeschlagenen kurzen Alternativen gezielt zur Wiederholung.", + ], + }, + sessions: [ + { + phase: "practice", + title: "Kurz üben", + dayOffsetBeforeExam: Math.min( + Math.max(getAvailableDays(context.plan.examDateKey), 1), + 120, + ), + startTime: "17:00", + durationMinutes: 30, + goal: "Wiederhole die wichtigsten Punkte aus deinen Antworten in einem kurzen Lernblock.", + tasks: [ + "Markiere die wichtigsten Begriffe.", + "Löse eine kurze passende Übungsaufgabe.", + ], + expectedOutcome: "Du hast eine konkrete Wiederholung abgeschlossen.", + }, + ], + }; +}; + const distributeSessionOffsets = ( availableDays: number, sessions: z.infer["sessions"], @@ -696,8 +800,14 @@ const normalizeSessions = ( availableDays: number, sessions: z.infer["sessions"], learningTimes: LearningTimeWindow[], + occupiedEntries: OccupiedEntry[], ) => { - const slots = buildLearningSlots(examDateKey, availableDays, learningTimes); + const slots = buildLearningSlots( + examDateKey, + availableDays, + learningTimes, + occupiedEntries, + ); const requestedMinutes = sessions.reduce( (total, session) => total + session.durationMinutes, 0, @@ -769,6 +879,8 @@ const normalizeSessions = ( const planningHint = learningTimes.length === 0 ? "Du hast noch keine persönlichen Lernzeiten hinterlegt. Der Lernplan kann erst konkrete Lerneinheiten erstellen, wenn du in den Einstellungen Lernzeiten anlegst." + : availableMinutes === 0 + ? "Deine hinterlegte Lernzeit ist aktuell vollständig durch andere Termine belegt. Passe deine Lernzeiten an oder verschiebe bestehende Termine, damit wir passende Lernblöcke vorschlagen können." : plannedMinutes < requestedMinutes || availableMinutes < requestedMinutes ? `Deine hinterlegten Lernzeiten reichen nicht für den vollständigen empfohlenen Plan. Geplant wurden ${plannedMinutes} von ${requestedMinutes} Minuten innerhalb deiner verfügbaren Lernzeiten.` : undefined; @@ -931,6 +1043,7 @@ MVP-Vorgabe: - Jeder Lernblock darf maximal 5 Aufgaben enthalten, der gesamte Plan maximal 5 Lernblöcke. - Plane ausschließlich innerhalb der persönlichen Lernzeiten aus den Einstellungen. Verwende keine anderen Tage oder Uhrzeiten. - Wenn die persönlichen Lernzeiten nicht für den gesamten empfohlenen Plan reichen, plane nur so viel wie in diese Lernzeiten passt. +- Wenn ein empfohlener Lernblock länger als eine verfügbare Lernzeit wäre, teile ihn gedanklich auf oder kürze ihn. Der Kalender darf kurze Alternativ-Sessions enthalten. - Nutze dayOffsetBeforeExam relativ zum Prüfungstag: 1 = einen Tag vor der Prüfung. - Verteile mehrere Sessions auf unterschiedliche Kalendertage, solange genug Tage verfügbar sind. Wiederhole dayOffsetBeforeExam nicht, wenn eine Alternative möglich ist. - Wenn Antworten nur Platzhalter oder Unsinn enthalten, erstelle trotzdem einen remedialen Grundlagenplan und setze strengths auf []. @@ -946,9 +1059,10 @@ MVP-Vorgabe: } userContent.push(...fileParts); - const result = await withStructuredOutputErrorHandling( - () => - generateText({ + let generatedPlan: GeneratedPlan; + let usedFallbackPlan = false; + try { + const result = await generateText({ model: model(MODEL_ID), temperature: 0.25, maxOutputTokens: 3_200, @@ -958,27 +1072,44 @@ MVP-Vorgabe: system: "Du bist ein strenger, praxisnaher Lernplaner. Plane nur realistische, kalendereignete Lernslots und antworte ausschließlich im vorgegebenen JSON-Schema.", messages: [{ role: "user", content: userContent }], - }), - "Aus diesen Antworten konnte kein stabiler Lernplan erstellt werden. Ergänze mindestens ein paar konkrete Stichworte zu deinem Wissenstand und versuche es erneut.", - ); + }); + generatedPlan = result.output; + } catch (error) { + if (!NoObjectGeneratedError.isInstance(error)) { + throw error; + } + + console.warn("AI plan structured output validation failed", { + finishReason: error.finishReason, + text: error.text?.slice(0, 500), + cause: error.cause, + }); + generatedPlan = buildFallbackGeneratedPlan(context, args.answers); + usedFallbackPlan = true; + } const { sessions, planningHint } = normalizeSessions( context.plan.examDateKey, availableDays, - result.output.sessions, + generatedPlan.sessions, context.learningTimes, + context.occupiedEntries, ); await ctx.runMutation(internal.learningPlans.replaceGeneratedSessions, { learningPlanId: args.learningPlanId, knowledgeAnswersJson: JSON.stringify(args.answers), - sourceSummary: formatGermanUiText(result.output.sourceSummary), + sourceSummary: formatGermanUiText(generatedPlan.sourceSummary), insight: { - summary: formatGermanUiText(result.output.insight.summary), - strengths: result.output.insight.strengths.map(formatGermanUiText), - gaps: result.output.insight.gaps.map(formatGermanUiText), + summary: formatGermanUiText(generatedPlan.insight.summary), + strengths: generatedPlan.insight.strengths.map(formatGermanUiText), + gaps: generatedPlan.insight.gaps.map(formatGermanUiText), }, - planningHint, + planningHint: + planningHint ?? + (usedFallbackPlan + ? "Es wurden alternative Lernblöcke vorgeschlagen." + : undefined), sessions, }); diff --git a/convex/learningPlans.test.ts b/convex/learningPlans.test.ts index 12b54c3..a96417d 100644 --- a/convex/learningPlans.test.ts +++ b/convex/learningPlans.test.ts @@ -2,7 +2,7 @@ import { convexTest } from "convex-test"; import { afterEach, beforeEach, expect, test, vi } from "vitest"; -import { api } from "./_generated/api"; +import { api, internal } from "./_generated/api"; import schema from "./schema"; const modules = import.meta.glob("./**/*.ts"); @@ -118,3 +118,131 @@ test("updating a learning plan session at its own synced time is allowed", async }), ).resolves.toBeNull(); }); + +test("generated draft sessions are not synced as calendar entries before acceptance", async () => { + const t = convexTest(schema, modules).withIdentity(user); + const learningPlanId = await createPlan(t); + + await expect( + t.mutation(internal.learningPlans.replaceGeneratedSessions, { + learningPlanId, + knowledgeAnswersJson: "[]", + sourceSummary: "Testmaterial", + insight: { summary: "Bereit zum Lernen.", strengths: [], gaps: [] }, + sessions: [ + { + phase: "practice", + title: "Üben", + dateKey: "2026-06-05", + dateLabel: "5. Juni 2026", + startTime: "09:00", + durationMinutes: 30, + goal: "Kurz wiederholen.", + tasks: ["Begriffe prüfen"], + expectedOutcome: "Du bist vorbereitet.", + }, + ], + }), + ).resolves.toBeNull(); + + 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(); +}); + +test("AI context includes occupied entries during the plan scheduling window", async () => { + const t = convexTest(schema, modules).withIdentity(user); + const learningPlanId = await createPlan(t); + + await t.mutation(api.dayEntries.create, { + dayKey: "2026-06-01", + title: "Informatik Grundlagen IT-Systeme", + time: "17:00", + kind: "Leistungskontrolle", + plannedDateLabel: "1. Juni 2026", + durationMinutes: 30, + examTypeLabel: "Test", + }); + + const context = await t.query(internal.learningPlans.getAiContext, { + learningPlanId, + }); + + expect(context.occupiedEntries).toContainEqual({ + dayKey: "2026-06-01", + time: "17:00", + durationMinutes: 30, + }); +}); + +test("review sessions are only synced after the plan is accepted", async () => { + const t = convexTest(schema, modules).withIdentity(user); + const learningPlanId = await createPlan(t); + + await t.mutation(internal.learningPlans.replaceGeneratedSessions, { + learningPlanId, + knowledgeAnswersJson: "[]", + sourceSummary: "Testmaterial", + insight: { summary: "Bereit zum Lernen.", strengths: [], gaps: [] }, + sessions: [ + { + phase: "practice", + title: "Üben", + dateKey: "2026-06-04", + dateLabel: "4. Juni 2026", + startTime: "17:00", + durationMinutes: 30, + goal: "Kurz wiederholen.", + tasks: ["Begriffe prüfen"], + expectedOutcome: "Du bist vorbereitet.", + }, + ], + }); + + await expect( + t.mutation(api.learningPlans.syncSessionsToCalendar, { learningPlanId }), + ).rejects.toThrow("Bestätige den Lernplan zuerst."); + + const beforeAccept = await t.query(api.dayEntries.listByDayKeys, { + dayKeys: ["2026-06-04"], + }); + expect(beforeAccept["2026-06-04"]).toHaveLength(0); + + await t.mutation(api.learningPlans.acceptPlan, { learningPlanId }); + + const afterAccept = await t.query(api.dayEntries.listByDayKeys, { + dayKeys: ["2026-06-04"], + }); + expect(afterAccept["2026-06-04"]).toHaveLength(1); + expect(afterAccept["2026-06-04"]?.[0]?.kind).toBe("Lernen"); +}); + +test("generated plans can advance to review without available sessions", async () => { + const t = convexTest(schema, modules).withIdentity(user); + const learningPlanId = await createPlan(t); + + await t.mutation(internal.learningPlans.replaceGeneratedSessions, { + learningPlanId, + knowledgeAnswersJson: "[]", + sourceSummary: "Testmaterial", + insight: { summary: "Bereit zum Lernen.", strengths: [], gaps: [] }, + planningHint: "Keine freie Lernzeit gefunden.", + sessions: [], + }); + + const snapshot = await t.query(api.learningPlans.getSnapshot, { + id: learningPlanId, + }); + + expect(snapshot?.plan.status).toBe("generated"); + expect(snapshot?.sessions).toHaveLength(0); + expect(snapshot?.plan.planningHint).toBe("Keine freie Lernzeit gefunden."); +}); diff --git a/convex/learningPlans.ts b/convex/learningPlans.ts index f92a387..73c84c1 100644 --- a/convex/learningPlans.ts +++ b/convex/learningPlans.ts @@ -10,6 +10,7 @@ import { type QueryCtx, query, } from "./_generated/server"; +import { getDayKeyQueryVariants } from "./dayKeyVariants"; import { deleteManagedFile, getConfiguredStorageProvider, @@ -144,6 +145,28 @@ const formatDateLabel = (date: Date) => const getDateKey = (date: Date) => startOfDay(date).toISOString(); +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)); +}; + +const getLearningPlanCalendarDayKeys = (examDateKey: string) => { + const availableDays = getAvailableDays(examDateKey); + const date = new Date(examDateKey); + if (Number.isNaN(date.getTime())) return []; + + const dayKeys = []; + for (let offset = availableDays; offset >= 0; offset -= 1) { + const nextDate = new Date(date); + nextDate.setUTCDate(nextDate.getUTCDate() - offset); + dayKeys.push(nextDate.toISOString().slice(0, 10)); + } + + return dayKeys; +}; + const getSessionDayEntryTitle = ( plan: Doc<"learningPlans">, session: Pick, "title">, @@ -231,6 +254,22 @@ const syncSessionDayEntry = async ( return session.dayEntryId; }; +const clearSessionDayEntry = async ( + ctx: MutationCtx, + session: Doc<"learningPlanSessions">, +) => { + if (!session.dayEntryId) return; + + const dayEntry = await ctx.db.get("dayEntries", session.dayEntryId); + if (dayEntry?.ownerTokenIdentifier === session.ownerTokenIdentifier) { + await ctx.db.delete("dayEntries", session.dayEntryId); + } + await ctx.db.patch("learningPlanSessions", session._id, { + dayEntryId: undefined, + updatedAt: Date.now(), + }); +}; + export const start = mutation({ args: { examDayEntryId: v.id("dayEntries"), @@ -629,11 +668,40 @@ export const getAiContext = internalQuery({ q.eq("ownerTokenIdentifier", identity.tokenIdentifier), ) .take(7); + const occupiedEntries: Array<{ + dayKey: string; + time?: string; + durationMinutes?: number; + }> = []; + const seenEntryIds = new Set(); + 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, + }); + } + } + } return { plan, documents, learningTimes, + occupiedEntries, accessKey: buildPlanAccessKey(args.learningPlanId), }; }, @@ -718,7 +786,7 @@ export const replaceGeneratedSessions = internalMutation({ const now = Date.now(); for (const [index, session] of args.sessions.entries()) { - const sessionId = await ctx.db.insert("learningPlanSessions", { + await ctx.db.insert("learningPlanSessions", { ownerTokenIdentifier: plan.ownerTokenIdentifier, learningPlanId: args.learningPlanId, ...session, @@ -726,13 +794,6 @@ export const replaceGeneratedSessions = internalMutation({ createdAt: now, updatedAt: now, }); - const createdSession = await ctx.db.get( - "learningPlanSessions", - sessionId, - ); - if (createdSession) { - await syncSessionDayEntry(ctx, plan, createdSession); - } } await ctx.db.patch("learningPlans", args.learningPlanId, { @@ -787,8 +848,10 @@ export const updateSession = mutation({ updatedAt: Date.now(), }); const updatedSession = await ctx.db.get("learningPlanSessions", args.id); - if (updatedSession) { + if (updatedSession && plan.status === "accepted") { await syncSessionDayEntry(ctx, plan, updatedSession); + } else if (updatedSession) { + await clearSessionDayEntry(ctx, updatedSession); } }, }); @@ -860,7 +923,7 @@ export const addSession = mutation({ updatedAt: now, }); const createdSession = await ctx.db.get("learningPlanSessions", sessionId); - if (createdSession) { + if (createdSession && plan.status === "accepted") { await syncSessionDayEntry(ctx, plan, createdSession); } return sessionId; @@ -878,6 +941,9 @@ export const syncSessionsToCalendar = mutation({ if (!plan || plan.ownerTokenIdentifier !== ownerTokenIdentifier) { throw new Error("Lernplan nicht gefunden."); } + if (plan.status !== "accepted") { + throw new Error("Bestätige den Lernplan zuerst."); + } const sessions = await ctx.db .query("learningPlanSessions") diff --git a/src/app/(app)/settings.tsx b/src/app/(app)/settings.tsx index 04884cf..e4243a6 100644 --- a/src/app/(app)/settings.tsx +++ b/src/app/(app)/settings.tsx @@ -10,7 +10,7 @@ import { Switch, View, } from "react-native"; -import { Bell, Logout, Settings } from "~/components/ui/icon"; +import {Bell, Logout, Settings, Timer} from "~/components/ui/icon"; import { ListRow } from "~/components/ui/list-row"; import { Screen, ScreenScroll } from "~/components/ui/screen"; import { useAuth } from "~/context/AuthContext"; diff --git a/src/app/learning-plans/[planId]/review.tsx b/src/app/learning-plans/[planId]/review.tsx index 886f55b..fabad5d 100644 --- a/src/app/learning-plans/[planId]/review.tsx +++ b/src/app/learning-plans/[planId]/review.tsx @@ -1,7 +1,7 @@ import { useConvexAuth, useMutation, useQuery } from "convex/react"; import { Stack, useLocalSearchParams, useRouter } from "expo-router"; import { StatusBar } from "expo-status-bar"; -import { useCallback, useEffect, useRef, useState } from "react"; +import { useCallback, useEffect, useState } from "react"; import { ActivityIndicator, ScrollView, @@ -40,10 +40,6 @@ export default function LearningPlanReviewScreen() { const { isAuthenticated: isConvexAuthenticated } = useConvexAuth(); const addSession = useMutation(api.learningPlans.addSession); const acceptPlan = useMutation(api.learningPlans.acceptPlan); - const syncSessionsToCalendar = useMutation( - api.learningPlans.syncSessionsToCalendar, - ); - const syncedPlanIds = useRef(new Set()); const [isBusy, setIsBusy] = useState(false); const [errorMessage, setErrorMessage] = useState(null); @@ -65,22 +61,6 @@ export default function LearningPlanReviewScreen() { } }, [planId, router, snapshot]); - useEffect(() => { - if (!planId || !snapshot?.sessions.length) return; - if (syncedPlanIds.current.has(planId)) return; - - syncedPlanIds.current.add(planId); - void syncSessionsToCalendar({ learningPlanId: planId }).catch((error) => { - syncedPlanIds.current.delete(planId); - setErrorMessage( - getErrorMessage( - error, - "Die Lernblöcke konnten nicht in den Kalender eingetragen werden.", - ), - ); - }); - }, [planId, snapshot?.sessions.length, syncSessionsToCalendar]); - const runWithErrorHandling = async ( fallback: string, task: () => Promise, @@ -172,6 +152,23 @@ export default function LearningPlanReviewScreen() { onEdit={() => openEdit(session)} /> ))} + {snapshot && snapshot.sessions.length === 0 ? ( + + + Lernzeit vollständig belegt + + + Aktuell sind alle hinterlegten Lernzeiten durch andere Termine belegt. + Passe deine Lernzeiten an oder verschiebe bestehende Termine. + + + ) : null} {errorMessage ? ( diff --git a/src/app/learning-times/index.tsx b/src/app/learning-times/index.tsx index 9d49c99..875117c 100644 --- a/src/app/learning-times/index.tsx +++ b/src/app/learning-times/index.tsx @@ -101,7 +101,7 @@ export default function LearningTimesOverviewScreen() { const goBack = () => { if (router.canGoBack()) { - router.back(); + router.push("/settings"); return; } @@ -180,21 +180,8 @@ export default function LearningTimesOverviewScreen() { -