Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
189 changes: 189 additions & 0 deletions convex/learningPlans.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,13 @@ const user = {
tokenIdentifier: "test:user",
};

const createKnowledgeQuestions = () =>
Array.from({ length: 5 }, (_, index) => ({
id: `q${index + 1}`,
prompt: `Frage ${index + 1}`,
targetInsight: `Erkenntnis ${index + 1}`,
}));

const createPlan = async (t: TestBackend) => {
const examDayEntryId = await t.mutation(api.dayEntries.create, {
dayKey: "2026-06-05",
Expand Down Expand Up @@ -77,6 +84,188 @@ const createAcceptedPlanWithSession = async (
return { learningPlanId, session };
};

test("list overview exposes unfinished creation progress and its first unanswered question", async () => {
const t = convexTest(schema, modules).withIdentity(user);
const learningPlanId = await createPlan(t);
const questions = createKnowledgeQuestions();

await t.mutation(internal.learningPlans.storeKnowledgeQuestions, {
learningPlanId,
questions,
sourceSummary: "Testmaterial",
});
await t.mutation(api.learningPlans.saveKnowledgeAnswer, {
learningPlanId,
questionId: "q1",
answer: "Antwort 1",
});
await t.mutation(api.learningPlans.saveKnowledgeAnswer, {
learningPlanId,
questionId: "q3",
answer: "Antwort 3",
});

const overviews = await t.query(api.learningPlans.listOverview, {});

expect(overviews).toHaveLength(1);
expect(overviews[0]).toMatchObject({
id: learningPlanId,
status: "questionsReady",
creationProgress: {
questionCount: 5,
answeredQuestionCount: 2,
firstUnansweredQuestionIndex: 1,
},
});
});

test("list overview reports when every creation question has been answered", async () => {
const t = convexTest(schema, modules).withIdentity(user);
const learningPlanId = await createPlan(t);
const questions = createKnowledgeQuestions();

await t.mutation(internal.learningPlans.storeKnowledgeQuestions, {
learningPlanId,
questions,
sourceSummary: "Testmaterial",
});
for (const question of questions) {
await t.mutation(api.learningPlans.saveKnowledgeAnswer, {
learningPlanId,
questionId: question.id,
answer: `Antwort ${question.id}`,
});
}

const overviews = await t.query(api.learningPlans.listOverview, {});

expect(overviews[0]).toMatchObject({
id: learningPlanId,
creationProgress: {
questionCount: 5,
answeredQuestionCount: 5,
firstUnansweredQuestionIndex: null,
},
});
});

test("list overview finds current answers after stale question generations", async () => {
const t = convexTest(schema, modules).withIdentity(user);
const learningPlanId = await createPlan(t);
const questions = createKnowledgeQuestions();

await t.mutation(internal.learningPlans.storeKnowledgeQuestions, {
learningPlanId,
questions,
sourceSummary: "Testmaterial",
});
await t.run(async (ctx) => {
for (let index = 0; index < 21; index += 1) {
await ctx.db.insert("learningPlanAnswers", {
ownerTokenIdentifier: user.tokenIdentifier,
learningPlanId,
questionId: `stale-${index}`,
answer: `Alte Antwort ${index}`,
createdAt: index,
updatedAt: index,
});
}
});
await t.mutation(api.learningPlans.saveKnowledgeAnswer, {
learningPlanId,
questionId: "q1",
answer: "Aktuelle Antwort",
});

const overviews = await t.query(api.learningPlans.listOverview, {});

expect(overviews[0]).toMatchObject({
creationProgress: {
answeredQuestionCount: 1,
firstUnansweredQuestionIndex: 1,
},
});
});

test("list overview keeps unfinished creation progress scoped to its owner", async () => {
const t = convexTest(schema, modules);
const mine = t.withIdentity(user);
const other = t.withIdentity({ tokenIdentifier: "test:other-user" });
const myLearningPlanId = await createPlan(mine);
const otherLearningPlanId = await createPlan(other);
const questions = [
{
id: "q1",
prompt: "Frage 1",
targetInsight: "Erkenntnis 1",
},
];

await mine.mutation(internal.learningPlans.storeKnowledgeQuestions, {
learningPlanId: myLearningPlanId,
questions,
sourceSummary: "Meine Unterlagen",
});
await other.mutation(internal.learningPlans.storeKnowledgeQuestions, {
learningPlanId: otherLearningPlanId,
questions,
sourceSummary: "Andere Unterlagen",
});

const overviews = await mine.query(api.learningPlans.listOverview, {});

expect(overviews).toHaveLength(1);
expect(overviews[0]?.id).toBe(myLearningPlanId);
expect(overviews[0]?.id).not.toBe(otherLearningPlanId);
});

test("list overview keeps an unfinished creation visible alongside fifty accepted plans", async () => {
const t = convexTest(schema, modules).withIdentity(user);
const creationPlanId = await createPlan(t);

await t.mutation(internal.learningPlans.storeKnowledgeQuestions, {
learningPlanId: creationPlanId,
questions: [
{
id: "q1",
prompt: "Frage 1",
targetInsight: "Erkenntnis 1",
},
],
sourceSummary: "Testmaterial",
});
await t.run(async (ctx) => {
for (let index = 0; index < 50; index += 1) {
await ctx.db.insert("learningPlans", {
ownerTokenIdentifier: user.tokenIdentifier,
subject: `Fach ${index + 1}`,
examTypeLabel: "Klausur",
examDateKey: "2026-06-05",
examDateLabel: "5. Juni 2026",
examTime: "09:00",
durationMinutes: 90,
topicDescription: "Testthema",
status: "accepted",
createdAt: index,
updatedAt: index,
});
}
});

const overviews = await t.query(api.learningPlans.listOverview, {});

expect(overviews).toHaveLength(51);
expect(overviews).toContainEqual(
expect.objectContaining({
id: creationPlanId,
status: "questionsReady",
}),
);
expect(
overviews.filter((overview) => overview.status === "accepted"),
).toHaveLength(50);
});

beforeEach(() => {
vi.useFakeTimers();
vi.setSystemTime(new Date("2026-05-30T10:00:00.000Z"));
Expand Down
53 changes: 52 additions & 1 deletion convex/learningPlans.ts
Original file line number Diff line number Diff line change
Expand Up @@ -601,7 +601,7 @@ export const listOverview = query({
args: {},
handler: async (ctx) => {
const ownerTokenIdentifier = await requireOwnerTokenIdentifier(ctx);
const plans = await ctx.db
const acceptedPlans = await ctx.db
.query("learningPlans")
.withIndex("by_ownerTokenIdentifier_and_status", (q) =>
q
Expand All @@ -610,6 +610,18 @@ export const listOverview = query({
)
.order("desc")
.take(50);
const creationPlans = await ctx.db
.query("learningPlans")
.withIndex("by_ownerTokenIdentifier_and_status", (q) =>
q
.eq("ownerTokenIdentifier", ownerTokenIdentifier)
.eq("status", "questionsReady"),
)
.order("desc")
.take(50);
const plans = [...acceptedPlans, ...creationPlans].sort(
(left, right) => right.updatedAt - left.updatedAt,
);

const overviews = [];
for (const plan of plans) {
Expand All @@ -630,6 +642,44 @@ export const listOverview = query({
sessions.length > 0
? Math.round((completedCount / sessions.length) * 100)
: 0;
let creationProgress: {
questionCount: number;
answeredQuestionCount: number;
firstUnansweredQuestionIndex: number | null;
} | null = null;
if (plan.status === "questionsReady") {
const questions = plan.knowledgeQuestions ?? [];
const answers = await Promise.all(
questions.map((question) =>
ctx.db
.query("learningPlanAnswers")
.withIndex("by_learningPlanId_and_questionId", (q) =>
q.eq("learningPlanId", plan._id).eq("questionId", question.id),
)
.unique(),
),
);
const answeredQuestionIds = new Set(
answers
.filter(
(answer): answer is NonNullable<typeof answer> =>
answer !== null && answer.answer.trim().length > 0,
)
.map((answer) => answer.questionId),
);
const firstUnansweredQuestionIndex = questions.findIndex(
(question) => !answeredQuestionIds.has(question.id),
);

creationProgress = {
questionCount: questions.length,
answeredQuestionCount: answeredQuestionIds.size,
firstUnansweredQuestionIndex:
firstUnansweredQuestionIndex >= 0
? firstUnansweredQuestionIndex
: null,
};
}

overviews.push({
id: plan._id,
Expand All @@ -653,6 +703,7 @@ export const listOverview = query({
completed: currentSession.completed === true,
}
: null,
creationProgress,
updatedAt: plan.updatedAt,
});
}
Expand Down
3 changes: 3 additions & 0 deletions docs/contexts/mobile-app/CONTEXT.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,9 @@ _Avoid_: Called by client, Server Error, raw stack trace

- Capture app architecture, routing, UI, and native behavior decisions here.
- Put mobile-app ADRs in `docs/contexts/mobile-app/adr/`.
- Keep `expo-constants` as a direct dependency while Expo Router requires it as
a native peer. The generic Expo-upgrade cleanup rule for implicit packages
does not apply when `expo-doctor` reports the package as required.
- NativeWind is part of the build pipeline, not only runtime styling. Release
build issues around Metro, Tailwind generation, or `expo-updates` should check
`metro.config.js` and the NativeWind patch documentation before changing EAS
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,8 @@ The module:
changes;
- exposes a synchronous `getColorScheme(): "light" | "dark"` method and an
`onChange` event;
- refreshes the value when the app becomes active;
- retries observation when a window becomes key and refreshes the value when
the app becomes active;
- enables React Native's key-window appearance mode and refreshes
`RCTAppearance` during module creation and foreground activation;
- is discovered through Expo Autolinking rather than hand-edited into the
Expand Down Expand Up @@ -116,7 +117,9 @@ Costs and risks:
notification behavior, which are not part of the documented JavaScript API
contract and may change during upgrades.
- Active-window lookup assumes Dayova's current single-window app model.
- Observer installation requires a window when the first listener attaches.
- Observer installation requires an active window; key-window and foreground
activation events retry an initial race and rehome the observer when the
window changed.
- Initialization, trait changes, and foreground refreshes can emit duplicate
state values, so consumers must remain idempotent.
- The module's iOS 16.4 compatibility is compile-tested but cannot be launched
Expand Down
4 changes: 4 additions & 0 deletions docs/contexts/platform/CONTEXT.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,10 @@ _Avoid_: User-facing message, learner error text
- `EXPO_PUBLIC_POSTHOG_API_KEY` enables PostHog analytics in the Expo app. Leave it empty to disable analytics locally.
- `EXPO_PUBLIC_POSTHOG_HOST` defaults to `https://eu.i.posthog.com`; use a Dayova-owned reverse proxy only if event delivery or privacy requirements justify the extra infrastructure.
- Analytics events must go through `src/lib/analytics.ts`. PostHog autocapture, lifecycle events, anonymous pre-auth tracking, and session replay are intentionally disabled for the validation phase.
- Validation event capture begins only after the Clerk user is available and
remains fail-open while the matching Convex user query is loading. Convex
activity marking and student-code enrichment are best effort; do not delay or
discard one-shot product interactions while waiting for that enrichment.
- PostHog identity properties are limited to validation-relevant IDs and coarse student context. Do not send names, email addresses, birth dates, avatar URLs, raw notes, uploaded content, or learner answers as analytics properties.
- Capture platform conventions, release processes, and environment decisions here.
- Put platform ADRs in `docs/contexts/platform/adr/`.
Expand Down
10 changes: 7 additions & 3 deletions docs/contexts/product/CONTEXT.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,16 +6,20 @@ Confluence is the current cross-functional documentation hub. Keep this file foc

## Language

**Lernplan-Erstellung**:
The user-facing setup flow that collects the learner's exam details, optional learning material, and answers to five short questions before a learning plan exists.
_Avoid_: Quiz, completed learning-plan steps, Wissensanalyse

**Persönlicher Lernplan**:
The user-facing learning-plan creation flow and accepted study path. It frames the five short pre-plan questions and optional learning material as the setup for a personalized, ordered plan whose sessions build toward exam readiness.
_Avoid_: Wissensanalyse, Quiz
The generated and accepted study path whose ordered sessions build toward exam readiness. It exists only after the `Lernplan-Erstellung` is complete.
_Avoid_: The setup questions or their completion progress, Wissensanalyse, Quiz

**Nächster Lernschritt**:
The next unfinished session in a `Persönlicher Lernplan`. It is the recommended continuation point, while later sessions can remain visible for flexibility.
_Avoid_: Hard lock, hidden future sessions

**Pre-plan diagnostic step**:
The internal name for the five-question diagnostic part of `Persönlicher Lernplan`, used when distinguishing it from post-session `Wissensanalyse`.
The internal name for the five-question diagnostic part of `Lernplan-Erstellung`, used when distinguishing it from post-session `Wissensanalyse`.
_Avoid_: User-facing copy

**Wissensanalyse**:
Expand Down
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added docs/evidence/day-169/android-resume-flow.mp4
Binary file not shown.
Binary file not shown.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added docs/evidence/day-169/ios-pause-confirmation.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added docs/evidence/day-169/ios-resume-flow.mp4
Binary file not shown.
Binary file added docs/evidence/day-169/ios-unfinished-card.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
9 changes: 5 additions & 4 deletions docs/styling.md
Original file line number Diff line number Diff line change
Expand Up @@ -83,10 +83,11 @@ With that configuration:

- When adding or removing custom `fontSize` tokens in `tailwind.config.ts`, update the `theme.text` list in `src/lib/utils.ts`.
- Use Tailwind's standard spacing scale. Spacing must stay on a 4px rhythm: `gap-1` is 4px, `gap-2` is 8px, `gap-3` is 12px, `gap-4` is 16px, and so on. Do not redefine spacing keys so class numbers mean raw pixels.
- The app supports light, dark, and system theme preferences. Keep palette
changes centralized in `src/global.css`, `src/lib/theme.ts`, and
`src/lib/theme-preference.ts` so NativeWind classes, React Navigation, and
native-only color props stay aligned.
- The app supports light, dark, and system theme preferences. Keep CSS-variable
palette values canonical in `src/global.css`; `pnpm theme:generate` derives
the native runtime map in `src/lib/theme-variables.ts`, and `pnpm check`
rejects a stale generated map. Keep navigation and native-only colors in
`src/lib/theme.ts`, and preference behavior in `src/lib/theme-preference.ts`.
- The current typography baseline is Poppins: Regular for body copy and
SemiBold for headings and highlighted text. Figma records that baseline but
is not an approval gate for app changes; deliberate changes must update the
Expand Down
Loading