diff --git a/convex/_generated/api.d.ts b/convex/_generated/api.d.ts index b33ccc4..0d517ac 100644 --- a/convex/_generated/api.d.ts +++ b/convex/_generated/api.d.ts @@ -16,6 +16,7 @@ import type * as generatedGermanText from "../generatedGermanText.js"; import type * as generatedGermanTextRepair from "../generatedGermanTextRepair.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 scheduleConflicts from "../scheduleConflicts.js"; import type * as users from "../users.js"; @@ -34,6 +35,7 @@ declare const fullApi: ApiFromModules<{ generatedGermanTextRepair: typeof generatedGermanTextRepair; learningPlanAi: typeof learningPlanAi; learningPlans: typeof learningPlans; + learningTimes: typeof learningTimes; scheduleConflicts: typeof scheduleConflicts; users: typeof users; }>; diff --git a/convex/dayEntries.test.ts b/convex/dayEntries.test.ts index da6c71f..2dac780 100644 --- a/convex/dayEntries.test.ts +++ b/convex/dayEntries.test.ts @@ -86,3 +86,27 @@ test("manual timed entry adjacent to an existing entry is allowed", async () => expect(createdId).toBeTruthy(); }); + +test("retrying the same create returns the existing entry instead of conflicting with itself", async () => { + const t = convexTest(schema, modules).withIdentity(user); + + const payload = { + dayKey: "2026-06-15", + title: "Informatik Mündliche Prüfung", + time: "12:00", + kind: "Leistungskontrolle", + plannedDateLabel: "Montag, 15. Juni", + durationMinutes: 30, + examTypeLabel: "Mündliche Prüfung", + }; + + const createdId = await t.mutation(api.dayEntries.create, payload); + const retriedId = await t.mutation(api.dayEntries.create, payload); + + expect(retriedId).toBe(createdId); + + const entries = await t.query(api.dayEntries.listByDayKeys, { + dayKeys: ["2026-06-15"], + }); + expect(entries["2026-06-15"]).toHaveLength(1); +}); diff --git a/convex/dayEntries.ts b/convex/dayEntries.ts index cc62989..69136f6 100644 --- a/convex/dayEntries.ts +++ b/convex/dayEntries.ts @@ -89,6 +89,60 @@ const getRequestedDayKey = ( return berlinDayKey ? queryKeyToRequestedDayKey.get(berlinDayKey) : undefined; }; +const optionalValuesMatch = ( + left: TValue | undefined, + right: TValue | undefined, +) => (left ?? undefined) === (right ?? undefined); + +const isSameCreatePayload = ( + entry: Doc<"dayEntries">, + args: OptionalEntryFields & { title: string }, +) => + entry.title === args.title && + optionalValuesMatch(entry.time, args.time) && + optionalValuesMatch(entry.kind, args.kind) && + optionalValuesMatch(entry.notes, args.notes) && + optionalValuesMatch(entry.dueDateKey, args.dueDateKey) && + optionalValuesMatch(entry.dueDateLabel, args.dueDateLabel) && + optionalValuesMatch(entry.plannedDateLabel, args.plannedDateLabel) && + optionalValuesMatch(entry.durationMinutes, args.durationMinutes) && + optionalValuesMatch(entry.examTypeLabel, args.examTypeLabel) && + optionalValuesMatch(entry.completed, args.completed) && + optionalValuesMatch(entry.relatedLearningPlanId, args.relatedLearningPlanId) && + optionalValuesMatch( + entry.relatedLearningPlanSessionId, + args.relatedLearningPlanSessionId, + ); + +const findExistingSameEntry = async ( + ctx: QueryCtx | MutationCtx, + { + ownerTokenIdentifier, + dayKey, + args, + }: { + ownerTokenIdentifier: string; + dayKey: string; + args: OptionalEntryFields & { title: string }; + }, +) => { + for (const queryDayKey of getDayKeyQueryVariants(dayKey)) { + const entries = await ctx.db + .query("dayEntries") + .withIndex("by_ownerTokenIdentifier_and_dayKey", (q) => + q + .eq("ownerTokenIdentifier", ownerTokenIdentifier) + .eq("dayKey", queryDayKey), + ) + .take(100); + + const existing = entries.find((entry) => isSameCreatePayload(entry, args)); + if (existing) return existing; + } + + return null; +}; + const entryFields = { title: v.string(), time: v.optional(v.string()), @@ -179,7 +233,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] ?? []), @@ -217,6 +277,15 @@ export const create = mutation({ if (!title) { throw new Error("Titel darf nicht leer sein."); } + const existingSameEntry = await findExistingSameEntry(ctx, { + ownerTokenIdentifier, + dayKey: args.dayKey, + args: { ...args, title }, + }); + if (existingSameEntry) { + return existingSameEntry._id; + } + await assertNoScheduleConflict(ctx, { ownerTokenIdentifier, dayKey: args.dayKey, diff --git a/convex/learningPlanAi.ts b/convex/learningPlanAi.ts index 5ff221e..409a1a1 100644 --- a/convex/learningPlanAi.ts +++ b/convex/learningPlanAi.ts @@ -23,6 +23,10 @@ const MAX_SESSION_TITLE_CHARS = 28; const LLM_GENERATION_TIMEOUT_MS = 60_000; const MAX_GENERATED_TEXT_ATTEMPTS = 3; const MODEL_ID = "gemini-3-flash-preview"; +const MIN_LEARNING_SLOT_MINUTES = 10; +const MAX_GENERATED_SESSIONS = 20; +const ALTERNATIVE_SLOT_MINUTES = 30; +const MAX_ALTERNATIVE_SESSIONS = 6; const GERMAN_UI_TEXT_RULE = "All visible German UI text must use correct umlauts and ß, not ae/oe/ue/ss substitutions."; const GERMAN_TEXT_SHADOW_RULE = @@ -223,6 +227,16 @@ type LearningPlanAiContext = { fileType: string; fileSizeBytes: number; }>; + learningTimes: Array<{ + dayOfWeek: number; + startTime: string; + endTime: string; + }>; + occupiedEntries: Array<{ + dayKey: string; + time?: string; + durationMinutes?: number; + }>; accessKey: string; }; @@ -326,6 +340,21 @@ const normalizeAiGeneratedGermanText = (value: GeneratedGermanText) => repairGeneratedGermanTextFromAsciiShadow(value.text, value.asciiShadow), ); +const toAsciiShadow = (value: string) => + value + .replace(/Ä/g, "Ae") + .replace(/Ö/g, "Oe") + .replace(/Ü/g, "Ue") + .replace(/ä/g, "ae") + .replace(/ö/g, "oe") + .replace(/ü/g, "ue") + .replace(/ß/g, "ss"); + +const generatedGermanText = (text: string): GeneratedGermanText => ({ + text, + asciiShadow: toAsciiShadow(text), +}); + const compactText = (value: string, maxChars: number) => { const normalized = value .replace(/\r/g, "") @@ -492,6 +521,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, @@ -504,6 +578,216 @@ const buildDateFromOffset = ( return date; }; +type LearningTimeWindow = LearningPlanAiContext["learningTimes"][number]; +type OccupiedEntry = LearningPlanAiContext["occupiedEntries"][number]; + +type LearningSlot = { + date: Date; + dateKey: string; + startMinutes: number; + endMinutes: number; + isAlternative?: boolean; +}; + +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 overlapsInterval = ( + first: { start: number; end: number }, + second: { start: number; end: number }, +) => first.start < second.end && first.end > second.start; + +const buildAlternativeLearningSlots = ( + examDateKey: string, + availableDays: number, + windowsByDay: Map, + occupiedIntervalsByDay: Map>, + neededMinutes: number, +) => { + const slots: LearningSlot[] = []; + let remainingMinutes = neededMinutes; + if (remainingMinutes < MIN_LEARNING_SLOT_MINUTES) return slots; + + for (let offset = availableDays; offset >= 1; offset -= 1) { + const date = buildDateFromOffset(examDateKey, offset); + const dateKey = formatDateKey(date); + const windows = windowsByDay.get(getBerlinDayOfWeek(date)) ?? []; + const occupiedIntervals = occupiedIntervalsByDay.get(dateKey) ?? []; + + for (const window of windows) { + const startMinutes = parseTimeToMinutes(window.startTime); + const endMinutes = parseTimeToMinutes(window.endTime); + if (startMinutes === null || endMinutes === null || endMinutes <= startMinutes) { + continue; + } + + const blockingIntervals = occupiedIntervals.filter((interval) => + overlapsInterval(interval, { start: startMinutes, end: endMinutes }), + ); + let candidateStart = Math.max( + endMinutes, + ...blockingIntervals.map((interval) => interval.end), + ); + while ( + remainingMinutes >= MIN_LEARNING_SLOT_MINUTES && + slots.length < MAX_ALTERNATIVE_SESSIONS + ) { + const durationMinutes = Math.min( + ALTERNATIVE_SLOT_MINUTES, + remainingMinutes, + ); + const candidate = { + start: candidateStart, + end: candidateStart + durationMinutes, + }; + if (candidate.end > 22 * 60) break; + if ( + occupiedIntervals.some((interval) => + overlapsInterval(candidate, interval), + ) + ) { + candidateStart += ALTERNATIVE_SLOT_MINUTES; + continue; + } + + slots.push({ + date, + dateKey, + startMinutes: candidate.start, + endMinutes: candidate.end, + isAlternative: true, + }); + remainingMinutes -= durationMinutes; + candidateStart = candidate.end; + } + if ( + remainingMinutes < MIN_LEARNING_SLOT_MINUTES || + slots.length >= MAX_ALTERNATIVE_SESSIONS + ) { + return slots; + } + } + } + + return slots; +}; + +const buildLearningSlots = ( + examDateKey: string, + availableDays: number, + learningTimes: LearningTimeWindow[], + occupiedEntries: OccupiedEntry[], + requestedMinutes: number, +) => { + 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 occupiedIntervalsByDay = getOccupiedIntervalsByDay(occupiedEntries); + const slots: LearningSlot[] = []; + for (let offset = availableDays; offset >= 1; offset -= 1) { + const date = buildDateFromOffset(examDateKey, offset); + const dateKey = formatDateKey(date); + const windows = windowsByDay.get(getBerlinDayOfWeek(date)) ?? []; + + for (const window of windows) { + const startMinutes = parseTimeToMinutes(window.startTime); + const endMinutes = parseTimeToMinutes(window.endTime); + if (startMinutes === null || endMinutes === null || endMinutes <= startMinutes) { + continue; + } + + for (const interval of subtractOccupiedIntervals( + startMinutes, + endMinutes, + occupiedIntervalsByDay.get(dateKey) ?? [], + )) { + slots.push({ + date, + dateKey, + startMinutes: interval.start, + endMinutes: interval.end, + }); + } + } + } + + const plannedLearningTimeMinutes = slots.reduce( + (total, slot) => total + (slot.endMinutes - slot.startMinutes), + 0, + ); + const remainingMinutes = requestedMinutes - plannedLearningTimeMinutes; + if (remainingMinutes >= MIN_LEARNING_SLOT_MINUTES) { + slots.push( + ...buildAlternativeLearningSlots( + examDateKey, + availableDays, + windowsByDay, + occupiedIntervalsByDay, + remainingMinutes, + ), + ); + } + + return slots + .filter((slot) => slot.endMinutes - slot.startMinutes >= MIN_LEARNING_SLOT_MINUTES) + .sort( + (left, right) => + left.dateKey.localeCompare(right.dateKey) || + left.startMinutes - right.startMinutes, + ); +}; + const distributeSessionOffsets = ( availableDays: number, sessions: z.infer["sessions"], @@ -548,35 +832,168 @@ const normalizeSessions = ( examDateKey: string, availableDays: number, sessions: z.infer["sessions"], + learningTimes: LearningTimeWindow[], + occupiedEntries: OccupiedEntry[], ) => { + const requestedMinutes = sessions.reduce( + (total, session) => total + session.durationMinutes, + 0, + ); + const slots = buildLearningSlots( + examDateKey, + availableDays, + learningTimes, + occupiedEntries, + requestedMinutes, + ); + const availableLearningTimeMinutes = slots + .filter((slot) => !slot.isAlternative) + .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 + .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( - normalizeAiGeneratedGermanText(session.title), + slot.isAlternative + ? `Alternative: ${ + source + ? normalizeAiGeneratedGermanText(source.title) + : fallbackTitleByPhase[phase] + }` + : source + ? normalizeAiGeneratedGermanText(source.title) + : fallbackTitleByPhase[phase], MAX_SESSION_TITLE_CHARS, - ) || fallbackTitleByPhase[session.phase], - dateKey: date.toISOString(), - dateLabel: formatDateLabel(date), - startTime: session.startTime, - durationMinutes: session.durationMinutes, - goal: normalizeAiGeneratedGermanText(session.goal).trim(), - tasks: session.tasks - .map((task) => normalizeAiGeneratedGermanText(task).trim()) - .filter(Boolean), - expectedOutcome: normalizeAiGeneratedGermanText( - session.expectedOutcome, - ).trim(), + ) || fallbackTitleByPhase[phase], + dateKey: slot.date.toISOString(), + dateLabel: formatDateLabel(slot.date), + startTime: formatTimeFromMinutes(slot.startMinutes), + durationMinutes, + goal: + (source ? normalizeAiGeneratedGermanText(source.goal).trim() : "") || + "Nutze diese Lernzeit, um dich gezielt auf die Prüfung vorzubereiten.", + tasks: + source?.tasks + .map((task) => normalizeAiGeneratedGermanText(task).trim()) + .filter(Boolean) ?? [ + "Wiederhole die wichtigsten Begriffe.", + "Löse eine passende Übungsaufgabe.", + ], + expectedOutcome: + (source + ? normalizeAiGeneratedGermanText(source.expectedOutcome).trim() + : "") || + "Du hast einen 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 totalLearningTimeMinutes = learningTimes.reduce((total, learningTime) => { + const start = parseTimeToMinutes(learningTime.startTime); + const end = parseTimeToMinutes(learningTime.endTime); + return start === null || end === null || end <= start ? total : total + end - start; + }, 0); + const busyLearningTimeMinutes = Math.max( + 0, + totalLearningTimeMinutes - availableLearningTimeMinutes, + ); + const hasAlternativeSessions = normalizedSessions.some((session) => + session.title.startsWith("Alternative:"), + ); + const availabilityHint = + learningTimes.length === 0 + ? "Keine Lernzeiten hinterlegt." + : hasAlternativeSessions + ? "Lernzeiten belegt. Alternativen vorgeschlagen." + : busyLearningTimeMinutes > 0 + ? "Belegte Zeiten ausgelassen." + : undefined; + const capacityHint = + plannedMinutes < requestedMinutes + ? `${plannedMinutes}/${requestedMinutes} Min. geplant.` + : undefined; + const hints = [availabilityHint, capacityHint].filter(Boolean); + + return { + sessions: normalizedSessions, + planningHint: hints.length > 0 ? hints.join(" ") : undefined, + }; +}; + +const buildFallbackGeneratedPlan = ( + context: LearningPlanAiContext, + answers: Array<{ questionId: string; answer: string }>, +): z.infer => { + const answeredCount = answers.filter((answer) => answer.answer.trim()).length; + return { + sourceSummary: generatedGermanText( + "Der Lernplan basiert auf deinen Antworten und den verfügbaren Lernzeiten.", + ), + insight: { + summary: generatedGermanText( + `Du hast ${answeredCount} Antworten gegeben. Daraus wird ein vorsichtiger Grundlagenplan erstellt.`, + ), + strengths: [], + gaps: [ + generatedGermanText( + "Nutze die vorgeschlagenen kurzen Einheiten gezielt zur Wiederholung.", + ), + ], + }, + sessions: [ + { + phase: "practice", + title: generatedGermanText("Kurz üben"), + dayOffsetBeforeExam: Math.min( + Math.max(getAvailableDays(context.plan.examDateKey), 1), + 120, + ), + startTime: "17:00", + durationMinutes: 30, + goal: generatedGermanText( + "Wiederhole die wichtigsten Punkte aus deinen Antworten in einem kurzen Lernblock.", + ), + tasks: [ + generatedGermanText("Markiere die wichtigsten Begriffe."), + generatedGermanText("Löse eine kurze passende Übungsaufgabe."), + ], + expectedOutcome: generatedGermanText( + "Du hast eine konkrete Wiederholung abgeschlossen.", + ), + }, + ], + }; +}; + +export const __testOnlyLearningPlanAi = { + normalizeSessions, }; const buildBaseContext = (context: LearningPlanAiContext) => { @@ -594,6 +1011,29 @@ const buildBaseContext = (context: LearningPlanAiContext) => { .join("\n"); }; +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"); +}; + export const generateKnowledgeQuestions = action({ args: { learningPlanId: v.id("learningPlans"), @@ -712,6 +1152,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 } @@ -721,6 +1162,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} @@ -737,6 +1180,7 @@ 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 fachlich sinnvolle Lernblöcke, aber die finale Kalenderplatzierung erfolgt ausschließlich innerhalb der persönlichen Lernzeiten. Verwende keine anderen Tage oder Uhrzeiten als Empfehlung. - 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 []. @@ -752,53 +1196,73 @@ MVP-Vorgabe: } userContent.push(...fileParts); - const generatedPlan = await withGeneratedTextRetry(async (attempt) => { - const result = await withLlmTimeout((abortSignal) => - generateText({ - model: model(MODEL_ID), - temperature: 0.25, - maxOutputTokens: 4_800, - abortSignal, - providerOptions: vertexProviderOptions, - output: Output.object({ schema: generatedPlanSchema }), - system: `Du bist ein strenger, praxisnaher Lernplaner. Plane nur realistische, kalendereignete Lernslots und antworte ausschließlich im vorgegebenen JSON-Schema.${generatedTextRetrySystemInstruction(attempt)}`, - messages: [{ role: "user", content: userContent }], - }), - ); - - const sessions = normalizeSessions( + const normalizeGeneratedPlan = ( + output: z.infer, + extraPlanningHint?: string, + ) => { + const normalized = normalizeSessions( context.plan.examDateKey, availableDays, - result.output.sessions, + output.sessions, + context.learningTimes, + context.occupiedEntries, ); - if (sessions.length === 0) { - throw new Error("Die KI hat keine nutzbaren Lerntage erzeugt."); - } return { - sourceSummary: normalizeAiGeneratedGermanText( - result.output.sourceSummary, - ), + sourceSummary: normalizeAiGeneratedGermanText(output.sourceSummary), insight: { - summary: normalizeAiGeneratedGermanText( - result.output.insight.summary, - ), - strengths: result.output.insight.strengths.map((strength) => + summary: normalizeAiGeneratedGermanText(output.insight.summary), + strengths: output.insight.strengths.map((strength) => normalizeAiGeneratedGermanText(strength), ), - gaps: result.output.insight.gaps.map((gap) => + gaps: output.insight.gaps.map((gap) => normalizeAiGeneratedGermanText(gap), ), }, - sessions, + sessions: normalized.sessions, + planningHint: [extraPlanningHint, normalized.planningHint] + .filter(Boolean) + .join(" "), }; - }, "Aus diesen Antworten konnte kein stabiler Lernplan erstellt werden. Ergänze mindestens ein paar konkrete Stichworte zu deinem Wissenstand und versuche es erneut."); + }; + + const planFallbackMessage = + "Aus diesen Antworten konnte kein stabiler Lernplan erstellt werden. Ergänze mindestens ein paar konkrete Stichworte zu deinem Wissenstand und versuche es erneut."; + let generatedPlan: ReturnType; + try { + generatedPlan = await withGeneratedTextRetry(async (attempt) => { + const result = await withLlmTimeout((abortSignal) => + generateText({ + model: model(MODEL_ID), + temperature: 0.25, + maxOutputTokens: 4_800, + abortSignal, + providerOptions: vertexProviderOptions, + output: Output.object({ schema: generatedPlanSchema }), + system: `Du bist ein strenger, praxisnaher Lernplaner. Plane nur realistische, kalendereignete Lernslots und antworte ausschließlich im vorgegebenen JSON-Schema.${generatedTextRetrySystemInstruction(attempt)}`, + messages: [{ role: "user", content: userContent }], + }), + ); + + return normalizeGeneratedPlan(result.output); + }, planFallbackMessage); + } catch (error) { + if (!(error instanceof Error) || error.message !== planFallbackMessage) { + throw error; + } + + generatedPlan = normalizeGeneratedPlan( + buildFallbackGeneratedPlan(context, args.answers), + "Alternativplan erstellt.", + ); + } await ctx.runMutation(internal.learningPlans.replaceGeneratedSessions, { learningPlanId: args.learningPlanId, knowledgeAnswersJson: JSON.stringify(args.answers), sourceSummary: generatedPlan.sourceSummary, insight: generatedPlan.insight, + planningHint: generatedPlan.planningHint, sessions: generatedPlan.sessions, }); diff --git a/convex/learningPlanAiScheduling.test.ts b/convex/learningPlanAiScheduling.test.ts new file mode 100644 index 0000000..5a3bbc5 --- /dev/null +++ b/convex/learningPlanAiScheduling.test.ts @@ -0,0 +1,176 @@ +import { describe, expect, test } from "vitest"; +import { __testOnlyLearningPlanAi } from "./learningPlanAi"; + +const germanText = (text: string) => ({ + text, + asciiShadow: text + .replace(/ä/g, "ae") + .replace(/ö/g, "oe") + .replace(/ü/g, "ue") + .replace(/Ä/g, "Ae") + .replace(/Ö/g, "Oe") + .replace(/Ü/g, "Ue") + .replace(/ß/g, "ss"), +}); + +describe("learning plan AI scheduling", () => { + test("uses only free stored learning times and hints when needed time is busy", () => { + const result = __testOnlyLearningPlanAi.normalizeSessions( + "2026-06-05", + 5, + [ + { + phase: "theory", + title: germanText("Grundlagen"), + dayOffsetBeforeExam: 3, + startTime: "15:00", + durationMinutes: 90, + goal: germanText("Wiederhole wichtige Grundlagen für die Prüfung."), + tasks: [ + germanText("Markiere die wichtigsten Begriffe."), + germanText("Schreibe kurze Beispiele auf."), + ], + expectedOutcome: germanText("Du kennst die wichtigsten Grundlagen."), + }, + { + phase: "practice", + title: germanText("Üben"), + dayOffsetBeforeExam: 2, + startTime: "15:00", + durationMinutes: 120, + goal: germanText("Übe die Aufgaben, die dir noch schwerfallen."), + tasks: [ + germanText("Löse passende Übungsaufgaben."), + germanText("Prüfe deine Lösungswege."), + ], + expectedOutcome: germanText("Du hast zentrale Aufgaben geübt."), + }, + { + phase: "rehearsal", + title: germanText("Generalprobe"), + dayOffsetBeforeExam: 1, + startTime: "10:00", + durationMinutes: 60, + goal: germanText("Bearbeite eine kurze Generalprobe zur Prüfung."), + tasks: [ + germanText("Löse einen kompakten Probetest."), + germanText("Notiere offene Fragen."), + ], + expectedOutcome: germanText("Du weißt, was du noch wiederholen musst."), + }, + ], + [ + { dayOfWeek: 2, startTime: "16:00", endTime: "17:30" }, + { dayOfWeek: 3, startTime: "15:00", endTime: "17:00" }, + { dayOfWeek: 4, startTime: "10:00", endTime: "11:00" }, + ], + [ + { + dayKey: "2026-06-03", + time: "15:00", + durationMinutes: 270, + }, + ], + ); + + expect(result.sessions).toHaveLength(6); + expect(result.sessions).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + dateKey: "2026-06-02T00:00:00.000Z", + startTime: "16:00", + durationMinutes: 90, + }), + expect.objectContaining({ + dateKey: "2026-06-04T00:00:00.000Z", + startTime: "10:00", + durationMinutes: 60, + }), + ]), + ); + expect( + result.sessions.filter((session) => + session.title.startsWith("Alternative:"), + ), + ).toHaveLength(4); + expect(result.planningHint).toContain("Lernzeiten belegt"); + expect(result.planningHint).not.toContain("Min. geplant"); + }); + + test("fills missing recommended time with alternative slots", () => { + const result = __testOnlyLearningPlanAi.normalizeSessions( + "2026-06-05", + 5, + [ + { + phase: "practice", + title: germanText("Adressberechnung üben"), + dayOffsetBeforeExam: 3, + startTime: "16:00", + durationMinutes: 180, + goal: germanText("Übe Adressberechnung für die Prüfung."), + tasks: [ + germanText("Berechne Netzwerkadressen."), + germanText("Prüfe Broadcast-Adressen."), + ], + expectedOutcome: germanText("Du kannst Adressen sicher berechnen."), + }, + ], + [{ dayOfWeek: 2, startTime: "16:00", endTime: "17:30" }], + [], + ); + + expect(result.sessions).toHaveLength(4); + expect(result.sessions[0]).toMatchObject({ + startTime: "16:00", + durationMinutes: 90, + }); + expect( + result.sessions.filter((session) => + session.title.startsWith("Alternative:"), + ), + ).toHaveLength(3); + expect(result.planningHint).toContain("Alternativen vorgeschlagen"); + expect(result.planningHint).not.toContain("90/180"); + }); + + test("creates alternative suggestions when every stored learning slot is busy", () => { + const result = __testOnlyLearningPlanAi.normalizeSessions( + "2026-06-05", + 5, + [ + { + phase: "practice", + title: germanText("Subnetting üben"), + dayOffsetBeforeExam: 2, + startTime: "15:00", + durationMinutes: 90, + goal: germanText("Übe Subnetting-Aufgaben für die Prüfung."), + tasks: [ + germanText("Berechne passende Subnetze."), + germanText("Kontrolliere deine Rechenschritte."), + ], + expectedOutcome: germanText("Du kannst Subnetze sicher berechnen."), + }, + ], + [{ dayOfWeek: 3, startTime: "15:00", endTime: "17:00" }], + [ + { + dayKey: "2026-06-03", + time: "15:00", + durationMinutes: 270, + }, + ], + ); + + expect(result.sessions).toHaveLength(3); + expect(result.sessions[0]).toMatchObject({ + dateKey: "2026-06-03T00:00:00.000Z", + startTime: "19:30", + durationMinutes: 30, + }); + expect(result.sessions[0]?.title).toContain("Alternative:"); + expect(result.planningHint).toContain("Lernzeiten belegt"); + expect(result.planningHint).not.toContain("Min. geplant"); + }); +}); diff --git a/convex/learningPlans.test.ts b/convex/learningPlans.test.ts index 9dda7b3..50b4536 100644 --- a/convex/learningPlans.test.ts +++ b/convex/learningPlans.test.ts @@ -119,6 +119,134 @@ 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."); +}); + test("generated knowledge questions reject malformed control characters before storage", async () => { const t = convexTest(schema, modules).withIdentity(user); const learningPlanId = await createPlan(t); diff --git a/convex/learningPlans.ts b/convex/learningPlans.ts index be916e2..057e8c5 100644 --- a/convex/learningPlans.ts +++ b/convex/learningPlans.ts @@ -15,6 +15,7 @@ import { getConfiguredStorageProvider, getR2ConfigOrThrow, } from "./fileStorage"; +import { getDayKeyQueryVariants } from "./dayKeyVariants"; import { normalizeGeneratedGermanText } from "./generatedGermanText"; import { assertNoScheduleConflict } from "./scheduleConflicts"; @@ -145,6 +146,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">, @@ -232,6 +255,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"), @@ -361,6 +400,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), @@ -623,10 +663,46 @@ 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); + 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), }; }, @@ -690,6 +766,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) => { @@ -736,7 +813,7 @@ export const replaceGeneratedSessions = internalMutation({ const now = Date.now(); for (const [index, session] of normalizedSessions.entries()) { - const sessionId = await ctx.db.insert("learningPlanSessions", { + await ctx.db.insert("learningPlanSessions", { ownerTokenIdentifier: plan.ownerTokenIdentifier, learningPlanId: args.learningPlanId, ...session, @@ -744,17 +821,11 @@ 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, { knowledgeAnswersJson: args.knowledgeAnswersJson, + planningHint: args.planningHint, sourceSummary: normalizedSourceSummary, insight: normalizedInsight, status: "generated", @@ -804,8 +875,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); } }, }); @@ -877,7 +950,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; @@ -895,6 +968,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/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/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 14849a5..e4243a6 100644 --- a/src/app/(app)/settings.tsx +++ b/src/app/(app)/settings.tsx @@ -3,8 +3,14 @@ import { useRouter } from "expo-router"; import * as SecureStore from "expo-secure-store"; import { StatusBar } from "expo-status-bar"; import { useEffect, useState } from "react"; -import { Alert, Linking, Platform, Switch, View } from "react-native"; -import { Bell, Logout, Settings } from "~/components/ui/icon"; +import { + Alert, + Linking, + Platform, + Switch, + View, +} from "react-native"; +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"; @@ -168,6 +174,11 @@ export default function SettingsScreen() { /> } /> + router.push("/learning-times")} + /> diff --git a/src/app/entry/new.tsx b/src/app/entry/new.tsx index 82c7595..f97775a 100644 --- a/src/app/entry/new.tsx +++ b/src/app/entry/new.tsx @@ -51,6 +51,10 @@ import { Textarea } from "~/components/ui/textarea"; import { useAuth } from "~/context/AuthContext"; import { getErrorMessage } from "~/features/learning-plans/utils"; import { getDayKey, parseDayKey, startOfLocalDay } from "~/lib/day-key"; +import { + getDurationBetweenTimes, + shiftEndTimeForStartChange, +} from "~/lib/entry-time"; import { goBackOrReplace, useBackIntent } from "~/lib/navigation"; import { ROUTES } from "~/lib/routes"; @@ -129,15 +133,6 @@ const formatCompactDate = (date: Date) => year: "numeric", }).format(date); -const getMinutesSinceStartOfDay = (date: Date) => - date.getHours() * 60 + date.getMinutes(); - -const getDurationBetweenTimes = (start: Date, end: Date) => { - const startMinutes = getMinutesSinceStartOfDay(start); - const endMinutes = getMinutesSinceStartOfDay(end); - return Math.max(endMinutes - startMinutes, 15); -}; - function HomeworkPillField({ label, value, @@ -289,20 +284,13 @@ export default function NewEntryScreen() { const next = new Date(plannedTime); next.setHours(selectedDate.getHours(), selectedDate.getMinutes(), 0, 0); setPlannedTime(next); - if (isHomework) { - const nextEnd = new Date(plannedEndTime); - const nextEndMinutes = getMinutesSinceStartOfDay(nextEnd); - const nextStartMinutes = getMinutesSinceStartOfDay(next); - if (nextEndMinutes <= nextStartMinutes) { - nextEnd.setHours( - selectedDate.getHours(), - selectedDate.getMinutes() + 30, - 0, - 0, - ); - setPlannedEndTime(nextEnd); - } - } + setPlannedEndTime( + shiftEndTimeForStartChange({ + previousStart: plannedTime, + previousEnd: plannedEndTime, + nextStart: next, + }), + ); } if (pickerTarget === "plannedEndTime") { const next = new Date(plannedEndTime); diff --git a/src/app/learning-plans/[planId]/index.tsx b/src/app/learning-plans/[planId]/index.tsx index 0525f4f..3bc6012 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"; @@ -143,6 +143,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 695e9f2..8de4d0d 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, @@ -13,7 +13,7 @@ import type { Id } from "#convex/_generated/dataModel"; import { ScreenHeader as Header } from "~/components/screen-header"; import { ActionModal } from "~/components/ui/action-modal"; 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,23 +25,21 @@ 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"; +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(); 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); @@ -63,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, @@ -131,8 +113,6 @@ export default function LearningPlanReviewScreen() { return true; }, [planId, router]); - useBackIntent(Boolean(planId), goBack); - return ( @@ -143,7 +123,7 @@ export default function LearningPlanReviewScreen() { flexGrow: 1, paddingHorizontal: 32, paddingTop: 80, - paddingBottom: 60, + paddingBottom: 150, }} keyboardShouldPersistTaps="handled" showsVerticalScrollIndicator={false} @@ -153,6 +133,20 @@ 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) => ( ) : null} - - - - - - + + + + + + + + `${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..875117c --- /dev/null +++ b/src/app/learning-times/index.tsx @@ -0,0 +1,195 @@ +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.push("/settings"); + 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)} + /> + ))} + + + + + + + + ); +} diff --git a/src/features/learning-plans/types.ts b/src/features/learning-plans/types.ts index 0f4c1e4..3267425 100644 --- a/src/features/learning-plans/types.ts +++ b/src/features/learning-plans/types.ts @@ -63,6 +63,7 @@ export type LearningPlanSnapshot = { strengths: string[]; gaps: string[]; }; + planningHint?: string; }; documents: LearningPlanDocument[]; answers: LearningPlanAnswer[]; diff --git a/src/lib/entry-time.test.ts b/src/lib/entry-time.test.ts new file mode 100644 index 0000000..e224091 --- /dev/null +++ b/src/lib/entry-time.test.ts @@ -0,0 +1,36 @@ +import { describe, expect, test } from "vitest"; +import { + getDurationBetweenTimes, + shiftEndTimeForStartChange, +} from "./entry-time"; + +const time = (hours: number, minutes: number) => { + const date = new Date("2026-06-05T00:00:00.000"); + date.setHours(hours, minutes, 0, 0); + return date; +}; + +describe("entry time helpers", () => { + test("keeps the existing duration when the start time changes", () => { + const nextEnd = shiftEndTimeForStartChange({ + previousStart: time(16, 0), + previousEnd: time(16, 30), + nextStart: time(7, 0), + }); + + expect(nextEnd.getHours()).toBe(7); + expect(nextEnd.getMinutes()).toBe(30); + }); + + test("does not turn an end time chosen before the start into a stale long exam", () => { + const nextEnd = shiftEndTimeForStartChange({ + previousStart: time(16, 0), + previousEnd: time(10, 30), + nextStart: time(7, 0), + }); + + expect(getDurationBetweenTimes(time(7, 0), nextEnd)).toBe(15); + expect(nextEnd.getHours()).toBe(7); + expect(nextEnd.getMinutes()).toBe(15); + }); +}); diff --git a/src/lib/entry-time.ts b/src/lib/entry-time.ts new file mode 100644 index 0000000..4ebe7eb --- /dev/null +++ b/src/lib/entry-time.ts @@ -0,0 +1,23 @@ +export const getMinutesSinceStartOfDay = (date: Date) => + date.getHours() * 60 + date.getMinutes(); + +export const getDurationBetweenTimes = (start: Date, end: Date) => { + const startMinutes = getMinutesSinceStartOfDay(start); + const endMinutes = getMinutesSinceStartOfDay(end); + return Math.max(endMinutes - startMinutes, 15); +}; + +export const shiftEndTimeForStartChange = ({ + previousStart, + previousEnd, + nextStart, +}: { + previousStart: Date; + previousEnd: Date; + nextStart: Date; +}) => { + const durationMinutes = getDurationBetweenTimes(previousStart, previousEnd); + const nextEnd = new Date(nextStart); + nextEnd.setMinutes(nextStart.getMinutes() + durationMinutes); + return nextEnd; +};