diff --git a/koala/decks/backfill-decks.ts b/koala/decks/backfill-decks.ts index 26516d9..af531b8 100644 --- a/koala/decks/backfill-decks.ts +++ b/koala/decks/backfill-decks.ts @@ -11,13 +11,16 @@ export async function backfillDecks(userID: string) { }); // Step 2: Group the cards by `langCode` - const cardsByLangCode = cards.reduce((acc, card) => { - if (!acc[card.langCode]) { - acc[card.langCode] = []; - } - acc[card.langCode].push(card); - return acc; - }, {} as Record); + const cardsByLangCode = cards.reduce( + (acc, card) => { + if (!acc[card.langCode]) { + acc[card.langCode] = []; + } + acc[card.langCode].push(card); + return acc; + }, + {} as Record, + ); // Step 3: Iterate through each language group and backfill for (const [langCode, cards] of Object.entries(cardsByLangCode)) { diff --git a/koala/equivalence.ts b/koala/equivalence.ts index 9f70fd5..ac9c9bb 100644 --- a/koala/equivalence.ts +++ b/koala/equivalence.ts @@ -22,11 +22,34 @@ const PASS = { result: "pass", userMessage: "OK", } as const; +const SYSTEM_PROMPT = ` +**System Prompt:** + +You are a translation equivalence evaluator. Your sole task is to determine whether a student’s translation of an English sentence into a target language conveys the same meaning as the original sentence. Do not assess grammar, style, or fluency unless these issues alter the overall meaning. Your evaluation should be based solely on semantic equivalence. Follow these guidelines: + +1. **Core Meaning:** + - Focus on whether the student’s translation preserves the essential information, intent, and context of the English sentence. + - Ignore variations in syntax, word order, or phrasing as long as the meaning remains the same. + +2. **Linguistic Nuance:** + - Recognize that the target language may naturally omit or express elements differently (e.g., pronouns in languages like Korean or differences in tense usage). + - Do not penalize omissions or changes that are linguistically appropriate for the target language and do not alter the core meaning. + +3. **Lexical Variation:** + - Accept synonyms, idiomatic expressions, or alternate phrasings that accurately reflect the original meaning. + - Be cautious if key vocabulary is mistranslated or if essential details are missing, as this can change the meaning. + +4. **Evaluation Outcome:** + - Respond with “YES” if the translation accurately and fully conveys the meaning of the English sentence, or “NO” if it does not. + +Your evaluation should be fair and lenient enough to allow natural language variation while ensuring that any translation marked as equivalent does not reinforce an incorrect meaning. +`; const buildPrompt = (props: GrammarCorrectionProps): string => [ - `### SENTENCE A: "${props.userInput}".`, - `### SENTENCE B: "${props.term}".`, + `### Target Sentence: "${props.term}".`, + `### Example Acceptable answer: "${props.definition}".`, + `### User Input: "${props.userInput}".`, "(YES/NO) Does Sentence A more-or-less mean the same thing as Sentence B?", "Meanings do not need to be 100% exact, just mostly the same.", ].join("\n"); @@ -40,7 +63,10 @@ export const equivalence: QuizEvaluator = async (input) => { const check = async () => { const response = await openai.beta.chat.completions.parse({ - messages: [{ role: "user", content: prompt }], + messages: [ + { role: "system", content: SYSTEM_PROMPT }, + { role: "user", content: prompt }, + ], model, max_tokens: 125, temperature: 0.1, @@ -51,7 +77,7 @@ export const equivalence: QuizEvaluator = async (input) => { }; const gradeResponses = await Promise.all([check(), check()]); - const passing = gradeResponses.find((response) => response?.evaluation === "yes"); + const passing = gradeResponses.map((x) => x?.evaluation).includes("yes"); if (passing) { return PASS; } else { diff --git a/koala/fetch-lesson.ts b/koala/fetch-lesson.ts index 433f925..15fc961 100644 --- a/koala/fetch-lesson.ts +++ b/koala/fetch-lesson.ts @@ -1,12 +1,12 @@ import { prismaClient } from "@/koala/prisma-client"; -import { Quiz, Card } from "@prisma/client"; -import { unique } from "radash"; +import { shuffle, unique } from "radash"; import { getUserSettings } from "./auth-helpers"; import { autoPromoteCards } from "./autopromote"; import { errorReport } from "./error-report"; import { maybeGetCardImageUrl } from "./image"; import { LessonType } from "./shared-types"; import { generateLessonAudio } from "./speech"; +import { Card, Quiz } from "@prisma/client"; type GetLessonInputParams = { userId: string; @@ -14,8 +14,18 @@ type GetLessonInputParams = { now: number; take: number; }; - -type LocalQuiz = Quiz & { Card: Card }; +type CardKeys = "imageBlobId" | "definition" | "langCode" | "gender" | "term"; +type LocalCard = Pick; +type QuizKeys = + | "id" + | "repetitions" + | "lastReview" + | "difficulty" + | "stability" + | "quizType" + | "cardId" + | "lapses"; +type LocalQuiz = Pick & { Card: LocalCard }; function interleave(max: number, ...lists: T[][]): T[] { const result: T[] = []; @@ -123,6 +133,34 @@ async function fetchNewCards( }); } +async function getCardsWithFailures( + userId: string, + take: number, +): Promise { + const cards = await prismaClient.card.findMany({ + take, + where: { + userId: userId, + lastFailure: { not: 0 }, + flagged: { not: true }, + }, + orderBy: { lastFailure: "asc" }, + }); + return cards.map((Card): LocalQuiz => { + return { + id: -1 * Math.round(Math.random() * 1000000), + repetitions: 999, + lastReview: 999, + difficulty: 999, + stability: 999, + quizType: "review", + cardId: Card.id, + lapses: 999, + Card, + }; + }); +} + export async function getLessons(p: GetLessonInputParams) { const { userId, deckId, now, take } = p; await autoPromoteCards(userId); @@ -174,12 +212,18 @@ export async function getLessons(p: GetLessonInputParams) { speakingDueOld, speakingDueNew, ); - const allCards = unique([...newCards, ...oldCards], (q) => q.id); + const failedCards = await getCardsWithFailures(userId, take); + const allCards = unique( + [...failedCards, ...newCards, ...oldCards], + (q) => q.id, + ); const uniqueByCardId = unique(allCards, (q) => q.cardId); - return uniqueByCardId.slice(0, take).map((quiz) => { - const isListening = quiz.quizType === "listening"; - const isNew = (quiz.repetitions || 0) < 1; - const quizType = isListening && isNew ? "dictation" : quiz.quizType; - return buildQuizPayload({ ...quiz, quizType }, speedPercent); - }); + return shuffle(uniqueByCardId) + .slice(0, take) + .map((quiz) => { + const isListening = quiz.quizType === "listening"; + const isNew = (quiz.repetitions || 0) < 1; + const quizType = isListening && isNew ? "dictation" : quiz.quizType; + return buildQuizPayload({ ...quiz, quizType }, speedPercent); + }); } diff --git a/koala/get-lang-name.ts b/koala/get-lang-name.ts index d143926..b78faa9 100644 --- a/koala/get-lang-name.ts +++ b/koala/get-lang-name.ts @@ -2,5 +2,5 @@ import { LangCode, supportedLanguages } from "./shared-types"; export const getLangName = (lang: string) => { const key = lang.slice(0, 2).toLowerCase() as LangCode; - return supportedLanguages[key] || 'target language'; + return supportedLanguages[key] || "target language"; }; diff --git a/koala/grammar-ng.ts b/koala/grammar-ng.ts index 9e2b4e0..d7c9861 100644 --- a/koala/grammar-ng.ts +++ b/koala/grammar-ng.ts @@ -1,17 +1,10 @@ -import { z } from "zod"; import { zodResponseFormat } from "openai/helpers/zod"; -import { prismaClient } from "./prisma-client"; +import { z } from "zod"; +import { getLangName } from "./get-lang-name"; import { openai } from "./openai"; +import { prismaClient } from "./prisma-client"; import { compare } from "./quiz-evaluators/evaluator-utils"; import { QuizEvaluator } from "./quiz-evaluators/types"; -import { getLangName } from "./get-lang-name"; - -// We now allow three possible outcomes: "ok", "edit", or "fail", -// but we still map them internally to "correct" or "incorrect" to avoid breaking existing code. -const zodGradeResponse = z.object({ - grade: z.enum(["ok", "edit", "fail"]), - correctedSentence: z.string().optional(), -}); export type Explanation = z.infer; @@ -27,11 +20,15 @@ type StoreTrainingData = ( exp: Explanation, ) => Promise; +const zodGradeResponse = z.object({ + grade: z.enum(["ok", "edit"]), + correctedSentence: z.string().optional(), +}); + const storeTrainingData: StoreTrainingData = async (props, exp) => { const { term, definition, langCode, userInput } = props; const { grade, correctedSentence } = exp; - // Map "ok" to "correct", otherwise "incorrect" const yesNo = grade === "ok" ? "yes" : "no"; await prismaClient.trainingData.create({ @@ -47,95 +44,55 @@ const storeTrainingData: StoreTrainingData = async (props, exp) => { }, }); }; -const JSON_PROMPT = [ - "Output exactly one JSON object, matching this schema:", - "```json", - "{", - ' "grade": "ok" | "edit" | "fail",', - ' "correctedSentence": "..." (optional if grade is "ok" or "fail")', - "}", - "```", - "", -].join("\n"); -// Build the new multi-lingual minimal-edit prompt: + function systemPrompt() { return [ - "=== EXAMPLES ===", - "", - "Example 1:", - "- English prompt: “It's me who is truly sorry.”", - "- User’s Attempt: “제가 말로 미안해요.”", - "- Corrected Output: “저야말로 미안해요.” (EDIT)", - "", - "Example 2:", - "- English prompt: “The economy will likely improve next year.”", - "- User’s Attempt: “내년에 경제가 개선할 거예요.”", - "- Corrected Output: “내년에 경제가 개선될 거예요.” (EDIT)", - "", - "Example 3:", - "- English prompt: “If the user says something unrelated.”", - "- User’s Attempt: “랄라 무슨 노래 먹고 있어요?”", - "- Corrected Output: “NOT RELATED” (FAIL)", - "", - "Example 4:", - "- English prompt: “I’m hungry now, so I can eat anything.”", - "- User’s Attempt: “이제 배고파서 아무거나 먹을 수 있어요.”", - "- Corrected Output: “이제 배고파서 아무거나 먹을 수 있어요.” (OK)", - "", - "=== INSTRUCTIONS ===", - "You are a grammar and usage corrector for a multi-lingual language-learning app. Follow these rules carefully:", - "1. MINIMAL EDITS", - " - Only fix true grammar errors or unnatural wording.", - " - Do NOT add extra words or forms if the sentence is already correct and idiomatic.", - " - Do NOT force the user’s attempt to match a provided reference if the user’s version is correct.", - "", - "2. POLITENESS & REGISTER", - " - If the user uses informal style, maintain it.", - " - If the user uses formal style, maintain it.", - " - If they mix styles incorrectly, correct to a consistent style.", - "", - "3. DIALECT OR REGIONAL USAGE", - " - If the user employs a regional/dialect form correctly, leave it as is.", - " - If a dialect form is used incorrectly, correct it to a clear variant or standard usage.", - "", - "4. “TOTALLY WRONG” CASE", - ' - If the input is nonsensical or has no meaningful relation to the target phrase, output grade="fail" with no correctedSentence.', - " - This is only reserved for extreme cases. Dont nitpick or search for minor errors.", - "", - "5. NO EXPLANATIONS", - ' - Provide ONLY the final evaluation in JSON: { "grade": "ok|edit|fail", "correctedSentence": "..." }.', - ' - If grade is "ok" or "fail", you may omit correctedSentence entirely.', - "", - "6. EQUIVALENT MEANING", - " - Do not get overly concerned about matching every detail of the English prompt in the user's output.", - " - Focus on correcting usage of the target language. Close enough in meaning is good enough.", - "", - JSON_PROMPT, + "=== GRAMMAR ERROR DETECTION ===", + "=== FOCUS: LEARNER MISTAKES ONLY ===", + "Your task: Identify and correct ONLY common language learner grammatical errors.", + "DO NOT suggest improvements to:", + "- Vocabulary choice/phrasing", + "- Style/nuance", + "- Regional variations", + "- Naturalness of expression", + "ONLY correct these specific error types:", + "Korean: Particle errors (은/는 vs 이/가), incorrect verb endings", + "Japanese: Particle errors (は vs が), verb conjugation mistakes", + "Spanish: Gender agreement errors, ser/estar confusion", + "Evaluation rules:", + "1. If sentence is grammatically correct → RESPOND WITH 'OK'", + "2. If contains learner mistake from listed categories → EDIT (minimal changes)", + "3. If error not in listed categories → 'OK' even if non-ideal", + "4. Preserve user's original words when possible", + "Critical constraints:", + "- NEVER add suggestions/comments", + "- NEVER correct valid regional variations", + "- NEVER edit stylistically different but grammatically correct sentences", + "Examples:", + "- User: 'Él está ingeniero' → 'Él es ingeniero' (ser/estar EDIT)", + "- User: 'Watashi wa gakusei desu' → OK (correct particles)", + "- User: 'She eat rice' → 'She eats rice' (verb conjugation EDIT)", + "- User: 'I consumed breakfast' → OK (different phrasing but correct)", + "Output ONLY 'OK' or edited sentence with minimal corrections.", ].join("\n"); } -function createMessages( - langCode: string, - definition: string, - userInput: string, -) { - return [ - { role: "user" as const, content: systemPrompt() }, +async function run(props: GrammarCorrectionProps): Promise { + const messages = [ + { + role: "user" as const, + content: systemPrompt(), + }, { role: "user" as const, content: [ - `=== TASK ===`, - `Correct the following user input (${getLangName(langCode)}):`, - `English Prompt: "${definition}"`, - `User's Attempt: "${userInput}"`, + `Language: ${getLangName(props.langCode)}`, + `Student Prompt: "${props.definition}"`, + `Student Response: "${props.userInput}"`, ].join("\n"), }, ]; -} -async function runChecks(props: GrammarCorrectionProps): Promise { - const { userInput, langCode } = props; - const messages = createMessages(langCode, props.definition, userInput); const response = await openai.beta.chat.completions.parse({ messages, model: "gpt-4o", @@ -144,46 +101,43 @@ async function runChecks(props: GrammarCorrectionProps): Promise { response_format: zodResponseFormat(zodGradeResponse, "grade_response"), }); - // This is the LLM's structured response (or an error if parsing failed) const gradeResponse = response.choices[0]?.message?.parsed; if (!gradeResponse) { throw new Error("Invalid response format from OpenAI."); } - if (compare(userInput, gradeResponse.correctedSentence || "", 0)) { + if ( + gradeResponse.correctedSentence && + compare(props.userInput, gradeResponse.correctedSentence, 0) + ) { gradeResponse.grade = "ok"; + delete gradeResponse.correctedSentence; } - // Store the final data (with "ok" = correct, others = incorrect) - await storeTrainingData(props, gradeResponse); return gradeResponse; } +async function runAndStore( + props: GrammarCorrectionProps, +): Promise { + const result = await run(props); + storeTrainingData(props, result); + return result; +} + export const grammarCorrectionNG: QuizEvaluator = async ({ userInput, card, }) => { - const check = async (): ReturnType => { - // Not impressed with GPT-O1-Mini. - const resp = await runChecks({ - term: card.term, - definition: card.definition, - langCode: card.langCode, - userInput, - }); - - switch (resp.grade) { - case "ok": - return { result: "pass", userMessage: "" }; - case "edit": - return { result: "fail", userMessage: `✏️${resp.correctedSentence}` }; - case "fail": - return { - result: "fail", - userMessage: "(Failed) " + resp.correctedSentence || "", - }; - } - }; - - return check(); + const chosen = await runAndStore({ ...card, userInput }); + + if (chosen.grade === "ok") { + return { result: "pass", userMessage: "" }; + } else { + // "edit" + return { + result: "fail", + userMessage: `✏️${chosen.correctedSentence || ""}`, + }; + } }; diff --git a/koala/is-approved-user.ts b/koala/is-approved-user.ts index 17eff6f..2a1e0b1 100644 --- a/koala/is-approved-user.ts +++ b/koala/is-approved-user.ts @@ -54,7 +54,7 @@ type User = { const userCleanup = async (user: User) => { const { email, id, lastSeen } = user; const lastSeenDays = calculateDays(lastSeen); - if (lastSeenDays < 60) { + if (lastSeenDays < 28) { const su = userApproval(id, email); const cardCount = await countCards(id); if (cardCount > 1) { diff --git a/koala/quiz-evaluators/index.ts b/koala/quiz-evaluators/index.ts index 197ea9f..b9b722f 100644 --- a/koala/quiz-evaluators/index.ts +++ b/koala/quiz-evaluators/index.ts @@ -13,6 +13,7 @@ const NO_OP: QuizEvaluator = (_: unknown) => { const QUIZ_EVALUATORS: Record = { listening: NO_OP, dictation: NO_OP, + review: NO_OP, speaking, }; diff --git a/koala/remix-button.tsx b/koala/remix-button.tsx index f014047..5d03b07 100644 --- a/koala/remix-button.tsx +++ b/koala/remix-button.tsx @@ -101,10 +101,15 @@ export default function RemixButton(props: RemixButtonProps) { ); // Target phrase must contain at least 4 charaters a one space. - const isDisabled = props.card.term.length < 4 || !props.card.term.includes(" "); + const isDisabled = + props.card.term.length < 4 || !props.card.term.includes(" "); const loadButton = ( - ); @@ -121,7 +126,7 @@ export default function RemixButton(props: RemixButtonProps) { } setOpened(false); }} - title="🧪 Remix Card (!EXPERIMENTAL!)" + title="🧪 Remix Card (SLOW, EXPERIMENTAL)" size="lg" overlayProps={{ opacity: 0.5, blur: 1 }} > @@ -197,9 +202,10 @@ export default function RemixButton(props: RemixButtonProps) { <> + + + + ); +}; diff --git a/koala/review/types.ts b/koala/review/types.ts index fad8165..ab0eec1 100644 --- a/koala/review/types.ts +++ b/koala/review/types.ts @@ -7,7 +7,7 @@ export type Quiz = { definitionAudio: string; imageURL?: string | undefined; langCode: string; - lessonType: "listening" | "speaking" | "dictation"; + lessonType: "listening" | "speaking" | "dictation" | "review"; quizId: number; term: string; termAudio: string; diff --git a/koala/shared-types.ts b/koala/shared-types.ts index de8d6a3..a5b4422 100644 --- a/koala/shared-types.ts +++ b/koala/shared-types.ts @@ -41,7 +41,7 @@ export type LangCode = | "tr" // Turkish | "uk" // Ukrainian | "vi"; // Vietnamese // Thai -export type LessonType = "listening" | "speaking" | "dictation"; +export type LessonType = "listening" | "speaking" | "dictation" | "review"; export type QuizResult = "error" | "fail" | "pass"; export type YesNo = "yes" | "no"; diff --git a/koala/speech.ts b/koala/speech.ts index b7f520c..a22248b 100644 --- a/koala/speech.ts +++ b/koala/speech.ts @@ -5,15 +5,17 @@ import { generateSpeechURL } from "./generate-speech-url"; import { removeParens } from "./quiz-evaluators/evaluator-utils"; type AudioLessonParams = { - card: Card; + card: Pick; lessonType: LessonType | "dictation"; speed?: number; }; +const DICTATION = `{{term}}{{definition}}`; const SSML: Record = { speaking: `{{definition}}`, listening: `{{term}}`, - dictation: `{{term}}{{definition}}`, + dictation: DICTATION, + review: DICTATION, }; export async function generateLessonAudio(params: AudioLessonParams) { diff --git a/koala/trpc-routes/calculate-scheduling-data.ts b/koala/trpc-routes/calculate-scheduling-data.ts index 72f51b8..8eb02dc 100644 --- a/koala/trpc-routes/calculate-scheduling-data.ts +++ b/koala/trpc-routes/calculate-scheduling-data.ts @@ -3,7 +3,7 @@ import { errorReport } from "../error-report"; import { Quiz } from "@prisma/client"; const FSRS = createDeck({ - requestedRetentionRate: 0.87, + requestedRetentionRate: 0.86, }); const DAYS = 24 * 60 * 60 * 1000; diff --git a/koala/trpc-routes/get-next-quizzes.ts b/koala/trpc-routes/get-next-quizzes.ts index 0976bcc..18c181e 100644 --- a/koala/trpc-routes/get-next-quizzes.ts +++ b/koala/trpc-routes/get-next-quizzes.ts @@ -15,6 +15,7 @@ export const Quiz = z.object({ z.literal("listening"), z.literal("speaking"), z.literal("dictation"), + z.literal("review"), ]), definitionAudio: z.string(), termAudio: z.string(), @@ -40,7 +41,7 @@ const QuizInput = z.object({ export async function getLessonMeta(userId: string) { const currentDate = new Date().getTime(); // Current time in milliseconds - const quizzesDue = await prismaClient.quiz.count({ + let quizzesDue = await prismaClient.quiz.count({ where: { Card: { userId: userId, @@ -55,6 +56,14 @@ export async function getLessonMeta(userId: string) { }, }); + const reviewsDue = await prismaClient.card.count({ + where: { + userId: userId, + lastFailure: { not: 0 }, + flagged: { not: true }, + }, + }); + const totalCards = await prismaClient.quiz.count({ where: { Card: { @@ -64,6 +73,8 @@ export async function getLessonMeta(userId: string) { }, }); + quizzesDue += reviewsDue; + // Cards that have no quiz yet: // Count of Quizzes where repetitions and lapses are 0 // by distinct cardID diff --git a/koala/trpc-routes/remix.ts b/koala/trpc-routes/remix.ts index f91fe00..87fe1fc 100644 --- a/koala/trpc-routes/remix.ts +++ b/koala/trpc-routes/remix.ts @@ -5,14 +5,12 @@ import { openai } from "../openai"; import { prismaClient } from "../prisma-client"; import { RemixTypePrompts, RemixTypes } from "../remix-types"; import { procedure } from "../trpc-procedure"; -import { isApprovedUser } from "../is-approved-user"; interface RemixParams { type: RemixTypes; langCode: string; term: string; definition: string; - model: keyof typeof MODELS; } interface Remix { @@ -24,13 +22,6 @@ const LANG_SPECIFIC_PROMPT: Record = { KO: "You will be severely punished for using 'dictionary form' or 'plain form' verbs or the pronouns 그녀, 그, 당신.", }; -const MODELS: Record<"good" | "fast" | "cheap" | "premium", string> = { - premium: "o1", // $10.00 + $10.00 = $20.00 - good: "o1-mini", // $12.00 + $3.00 = $15.00 - fast: "gpt-4o", // $2.50 + $10 = $12.50 - cheap: "gpt-4o-mini", // $0.150 + $0.600 = $0.750 -}; - const JSON_PARSE_PROMPT = [ "You are a JSON parser.", "You must convert the data into valid JSON that matches the following schema:", @@ -92,7 +83,7 @@ const parseJSONWithOpenAI = async ( result: { exampleSentence: string; englishTranslation: string }[]; }> => { const response = await openai.beta.chat.completions.parse({ - model: MODELS.cheap, + model: "o3-mini", messages: [ { role: "system", content: JSON_PARSE_PROMPT }, { role: "user", content: rawText }, @@ -117,10 +108,10 @@ const processRemixes = (parsedData: { // Refactored generateRemixes function const generateRemixes = async (input: RemixParams): Promise => { - const { type, langCode, term, model } = input; + const { type, langCode, term } = input; const prompt = buildRemixPrompt(type, langCode, term); - const rawText = await fetchOpenAIResponse(MODELS[model], [ + const rawText = await fetchOpenAIResponse("o3-mini", [ { role: "user", content: prompt }, ]); @@ -160,14 +151,10 @@ export const remix = procedure throw new Error("Card not found"); } - // Thanks for the free tokens, OpenAI. - // Check if date is prior to Feb 28 2025: - const freeTokens = new Date() < new Date(2025, 1, 28); return generateRemixes({ type: input.type as RemixTypes, langCode: card.langCode, term: card.term, definition: card.definition, - model: freeTokens || isApprovedUser(card.userId) ? "premium" : "good", }); }); diff --git a/koala/trpc-routes/transcribe-audio.ts b/koala/trpc-routes/transcribe-audio.ts index 92581ce..30f0eef 100644 --- a/koala/trpc-routes/transcribe-audio.ts +++ b/koala/trpc-routes/transcribe-audio.ts @@ -1,6 +1,5 @@ import { z } from "zod"; import { getUserSettings } from "../auth-helpers"; -import { errorReport } from "../error-report"; import { transcribeB64 } from "../transcribe"; import { procedure } from "../trpc-procedure"; import { LANG_CODES } from "../shared-types"; @@ -28,7 +27,7 @@ export const transcribeAudio = procedure ); if (result.kind !== "OK") { - return errorReport('result.kind !== "OK"'); + return { result: "Server ERROR" }; // TODO: better error handling } return { result: result.text }; diff --git a/pages/admin.tsx b/pages/admin.tsx new file mode 100644 index 0000000..af2f892 --- /dev/null +++ b/pages/admin.tsx @@ -0,0 +1,102 @@ +import React from "react"; +import { GetServerSidePropsContext, InferGetServerSidePropsType } from "next"; +import { getSession } from "next-auth/react"; +import { prismaClient } from "@/koala/prisma-client"; +import { Container, Table, Title } from "@mantine/core"; +import { Prisma } from "@prisma/client"; + +const ONE_DAY = 24 * 60 * 60 * 1000; + +function daysSince(date: Date | null): number { + if (!date) return 0; + return Math.floor((Date.now() - date.getTime()) / ONE_DAY); +} + +export async function getServerSideProps(context: GetServerSidePropsContext) { + const session = await getSession({ req: context.req }); + const email = session?.user?.email?.toLowerCase() ?? null; + + const superUsers = (process.env.AUTHORIZED_EMAILS || "") + .split(",") + .map((x) => x.trim().toLowerCase()) + .filter((x) => x.includes("@")); + + if (!email || !superUsers.includes(email)) { + return { + redirect: { + destination: "/user", + permanent: false, + }, + }; + } + + type UserWithCount = Prisma.UserGetPayload<{ + include: { + _count: { + select: { + Card: true; + }; + }; + }; + }>; + + const users: UserWithCount[] = await prismaClient.user.findMany({ + orderBy: { + lastSeen: "desc", + }, + include: { + _count: { + select: { Card: true }, + }, + }, + }); + + const userData = users.map((u) => { + return { + id: u.id, + email: u.email ?? "(no email??)", + lastSeen: u.lastSeen?.toISOString() ?? null, + daysSinceLastSeen: daysSince(u.lastSeen), + cardCount: u._count.Card, + isAdmin: !!u.email && superUsers.includes(u.email.toLowerCase()), + }; + }); + + return { + props: { + userData, + }, + }; +} + +type Props = InferGetServerSidePropsType; + +export default function AdminPage({ userData }: Props) { + return ( + + + User Report + + + + + + + + + + + + {userData.map((u) => ( + + + + + + + ))} + +
EmailDays Since# CardsAdmin?
{u.email}{u.daysSinceLastSeen}{u.cardCount}{u.isAdmin ? "Yes" : "No"}
+
+ ); +} diff --git a/pages/create.tsx b/pages/create.tsx index 2907b55..b55193f 100644 --- a/pages/create.tsx +++ b/pages/create.tsx @@ -306,10 +306,10 @@ function InputStep({ state, dispatch, onSubmit, loading }: InputStepProps) { what to learn, try an example by clicking the button. - Koala is built for self-study learners who have a textbook - or language course to follow. If you don't have material of your own, - that's OK. Koala can generate content for you to study. Click the - button below until you find a topic that is interesting to you. + Koala is built for self-study learners who have a textbook or language + course to follow. If you don't have material of your own, that's OK. + Koala can generate content for you to study. Click the button below + until you find a topic that is interesting to you.