From a03c92dc82ad527d7da6bbaa3c43000e2e5f0e69 Mon Sep 17 00:00:00 2001 From: polwex Date: Thu, 29 May 2025 16:25:31 +0700 Subject: better better --- bun.lock | 3 + package.json | 1 + src/actions/logintest/funcs.ts | 24 ++ src/actions/tones.ts | 47 ++-- src/components/logintest/Form.tsx | 53 ++++ src/components/logintest/ServerForm.tsx | 67 +++++ src/components/ui/badge.tsx | 36 +++ src/components/ui/tabs.tsx | 64 +++++ src/lib/db/index.ts | 106 ++++++- src/lib/server/cookie.ts | 14 +- src/lib/services/srs_study.ts | 21 +- src/pages.gen.ts | 5 +- src/pages/logintest/Form.tsx | 53 ---- src/pages/logintest/ServerForm.tsx | 67 ----- src/pages/logintest/funcs.ts | 24 -- src/pages/logintest/index.tsx | 6 +- src/pages/study.tsx | 480 ++++++++++++++++++++++++++++++++ 17 files changed, 879 insertions(+), 192 deletions(-) create mode 100644 src/actions/logintest/funcs.ts create mode 100644 src/components/logintest/Form.tsx create mode 100644 src/components/logintest/ServerForm.tsx create mode 100644 src/components/ui/badge.tsx create mode 100644 src/components/ui/tabs.tsx delete mode 100644 src/pages/logintest/Form.tsx delete mode 100644 src/pages/logintest/ServerForm.tsx delete mode 100644 src/pages/logintest/funcs.ts create mode 100644 src/pages/study.tsx diff --git a/bun.lock b/bun.lock index 92e40f2..aca40e3 100644 --- a/bun.lock +++ b/bun.lock @@ -13,6 +13,7 @@ "@radix-ui/react-progress": "^1.1.7", "@radix-ui/react-select": "^2.2.4", "@radix-ui/react-slot": "^1.2.2", + "@radix-ui/react-tabs": "^1.1.12", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "cookie": "^1.0.2", @@ -242,6 +243,8 @@ "@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="], + "@radix-ui/react-tabs": ["@radix-ui/react-tabs@1.1.12", "", { "dependencies": { "@radix-ui/primitive": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-presence": "1.1.4", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-roving-focus": "1.1.10", "@radix-ui/react-use-controllable-state": "1.2.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-GTVAlRVrQrSw3cEARM0nAx73ixrWDPNZAruETn3oHCNP6SbZ/hNxdxp+u7VkIEv3/sFoLq1PfcHrl7Pnp0CDpw=="], + "@radix-ui/react-use-callback-ref": ["@radix-ui/react-use-callback-ref@1.1.1", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-FkBMwD+qbGQeMu1cOHnuGB6x4yzPjho8ap5WtbEJ26umhgqVXbhekKUQO+hZEL1vU92a3wHwdp0HAcqAUF5iDg=="], "@radix-ui/react-use-controllable-state": ["@radix-ui/react-use-controllable-state@1.2.2", "", { "dependencies": { "@radix-ui/react-use-effect-event": "0.0.2", "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-BjasUjixPFdS+NKkypcyyN5Pmg83Olst0+c6vGov0diwTEo6mgdqVR6hxcEgFuh4QrAs7Rc+9KuGJ9TVCj0Zzg=="], diff --git a/package.json b/package.json index 7d9d795..291dd1b 100644 --- a/package.json +++ b/package.json @@ -18,6 +18,7 @@ "@radix-ui/react-progress": "^1.1.7", "@radix-ui/react-select": "^2.2.4", "@radix-ui/react-slot": "^1.2.2", + "@radix-ui/react-tabs": "^1.1.12", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "cookie": "^1.0.2", diff --git a/src/actions/logintest/funcs.ts b/src/actions/logintest/funcs.ts new file mode 100644 index 0000000..4ffd5ef --- /dev/null +++ b/src/actions/logintest/funcs.ts @@ -0,0 +1,24 @@ +import { readFile, writeFile } from "node:fs/promises"; +import { unstable_rerenderRoute } from "waku/router/server"; + +export const getMessage = async () => { + const data = await readFile("./message.txt", "utf8"); + return data; +}; + +export const greet = async (formData: FormData) => { + "use server"; + // simulate a slow server response + await new Promise((resolve) => setTimeout(resolve, 1000)); + const currentData = await getMessage(); + await writeFile( + "./message.txt", + currentData + "\n" + formData.get("name") + " from server!", + ); + unstable_rerenderRoute("/"); +}; + +export const increment = async (count: number) => { + "use server"; + return count + 1; +}; diff --git a/src/actions/tones.ts b/src/actions/tones.ts index 8089453..7d85d1c 100644 --- a/src/actions/tones.ts +++ b/src/actions/tones.ts @@ -1,31 +1,31 @@ -'use server'; +"use server"; -import db from '@/lib/db'; -import { WordData } from '@/zoom/logic/types'; +import db from "@/lib/db"; +import { WordData } from "@/zoom/logic/types"; // Helper to extract tone from prosody - assuming prosody is an array of objects like [{tone: number}, ...] const getTonesFromProsody = (prosody: any): number[] | null => { if (Array.isArray(prosody) && prosody.length > 0) { - return prosody.map(p => p.tone).filter(t => typeof t === 'number'); + return prosody.map((p) => p.tone).filter((t) => typeof t === "number"); } return null; }; export async function fetchWordsByToneAndSyllables( syllableCount: number, - tones: (number | null)[] // Array of tones, one for each syllable. null means any tone. + tones: (number | null)[], // Array of tones, one for each syllable. null means any tone. ): Promise { if (syllableCount !== tones.length) { console.error("Syllable count and tones array length mismatch"); return null; } - const queryParams: (string | number)[] = ['th', syllableCount, syllableCount]; // lang, syllables (for WHERE), syllables (for json_array_length) + const queryParams: (string | number)[] = ["th", syllableCount, syllableCount]; // lang, syllables (for WHERE), syllables (for json_array_length) let toneConditions = ""; const toneClauses: string[] = []; tones.forEach((tone, index) => { - if (tone !== null && typeof tone === 'number') { + if (tone !== null && typeof tone === "number") { // Assumes SQLite's json_extract function is available and prosody is like: [{"tone": 1}, {"tone": 3}, ...] // Path for first syllable's tone: '$[0].tone' toneClauses.push(`json_extract(prosody, '$[${index}].tone') = ?`); @@ -34,11 +34,20 @@ export async function fetchWordsByToneAndSyllables( }); if (toneClauses.length > 0) { - toneConditions = `AND ${toneClauses.join(' AND ')}`; + toneConditions = `AND ${toneClauses.join(" AND ")}`; } const queryString = ` - SELECT id, spelling, prosody, syllables, lang, type, frequency, confidence, ipa, senses_array + SELECT id, spelling, prosody, syllables, lang, type, frequency, confidence, ipa, + (SELECT + json_group_array(json_object( + 'pos', pos, + 'senses', s.senses, + 'forms', forms, + 'etymology', etymology, + 'related', related) + ) FROM senses s WHERE s.parent_id = expressions.id + ) as senses_array FROM expressions WHERE lang = ? AND syllables = ? @@ -52,7 +61,7 @@ export async function fetchWordsByToneAndSyllables( try { const query = db.db.query(queryString); - const row = query.get(...queryParams) as any; + const row = query.get(...queryParams) as any; if (!row) return null; @@ -69,13 +78,17 @@ export async function fetchWordsByToneAndSyllables( confidence: row.confidence, ipa: row.ipa ? JSON.parse(row.ipa) : [], // Senses parsing is simplified here. Adjust if full sense data is needed. - senses: row.senses_array ? JSON.parse(row.senses_array).map((s: any) => ({ - pos: s.pos, - senses: typeof s.senses === 'string' ? JSON.parse(s.senses) : s.senses, - forms: typeof s.forms === 'string' ? JSON.parse(s.forms) : s.forms, - etymology: s.etymology, - related: typeof s.related === 'string' ? JSON.parse(s.related) : s.related, - })) : [], + senses: row.senses_array + ? JSON.parse(row.senses_array).map((s: any) => ({ + pos: s.pos, + senses: + typeof s.senses === "string" ? JSON.parse(s.senses) : s.senses, + forms: typeof s.forms === "string" ? JSON.parse(s.forms) : s.forms, + etymology: s.etymology, + related: + typeof s.related === "string" ? JSON.parse(s.related) : s.related, + })) + : [], }; return word; } catch (error) { diff --git a/src/components/logintest/Form.tsx b/src/components/logintest/Form.tsx new file mode 100644 index 0000000..a593acb --- /dev/null +++ b/src/components/logintest/Form.tsx @@ -0,0 +1,53 @@ +"use client"; + +import { useFormStatus } from "react-dom"; + +const SubmitButton = () => { + const { pending } = useFormStatus(); + return ( + <> + + + ); +}; + +export const Form = ({ + message, + greet, +}: { + message: Promise; + greet: (formData: FormData) => Promise; +}) => ( +
+

{message}

+
+
+
+ Name:{" "} + +
+
+ Email:{" "} + +
+ +
+
+

This is a client component.

+
+); diff --git a/src/components/logintest/ServerForm.tsx b/src/components/logintest/ServerForm.tsx new file mode 100644 index 0000000..8e629b8 --- /dev/null +++ b/src/components/logintest/ServerForm.tsx @@ -0,0 +1,67 @@ +async function submitUserProfile(formData: FormData) { + "use server"; + const name = formData.get("name"); + const age = formData.get("age"); + const favoriteColor = formData.get("favoriteColor"); + const hobby = formData.get("hobby"); + const isSubscribed = formData.get("newsletter") === "on"; + + console.log({ + name, + age, + favoriteColor, + hobby, + isSubscribed, + }); +} + +export const ServerForm = () => { + return ( +
+
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ +
+ + +
+ ); +}; diff --git a/src/components/ui/badge.tsx b/src/components/ui/badge.tsx new file mode 100644 index 0000000..12daad7 --- /dev/null +++ b/src/components/ui/badge.tsx @@ -0,0 +1,36 @@ +import * as React from "react" +import { cva, type VariantProps } from "class-variance-authority" + +import { cn } from "@/lib/utils" + +const badgeVariants = cva( + "inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2", + { + variants: { + variant: { + default: + "border-transparent bg-primary text-primary-foreground hover:bg-primary/80", + secondary: + "border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80", + destructive: + "border-transparent bg-destructive text-destructive-foreground hover:bg-destructive/80", + outline: "text-foreground", + }, + }, + defaultVariants: { + variant: "default", + }, + } +) + +export interface BadgeProps + extends React.HTMLAttributes, + VariantProps {} + +function Badge({ className, variant, ...props }: BadgeProps) { + return ( +
+ ) +} + +export { Badge, badgeVariants } \ No newline at end of file diff --git a/src/components/ui/tabs.tsx b/src/components/ui/tabs.tsx new file mode 100644 index 0000000..3d6f3ac --- /dev/null +++ b/src/components/ui/tabs.tsx @@ -0,0 +1,64 @@ +import * as React from "react" +import * as TabsPrimitive from "@radix-ui/react-tabs" + +import { cn } from "@/lib/utils" + +function Tabs({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function TabsList({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function TabsTrigger({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function TabsContent({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +export { Tabs, TabsList, TabsTrigger, TabsContent } diff --git a/src/lib/db/index.ts b/src/lib/db/index.ts index 5c13f85..2212583 100644 --- a/src/lib/db/index.ts +++ b/src/lib/db/index.ts @@ -3,7 +3,7 @@ import { getDBOffset, wordFactorial } from "../utils"; import type { AddSense, AddWord, Result, State } from "../types"; import { DEFAULT_SRS } from "../services/srs"; import { DBWord, WordData } from "@/zoom/logic/types"; -import { DeckResponse } from "../types/cards"; +import { CardResponse, DeckResponse } from "../types/cards"; const PAGE_SIZE = 100; @@ -506,6 +506,110 @@ class DatabaseHandler { `); return query.all({ cid, userid }); } + + /** + * Fetch a single card by ID with all related data + * @param cardId Card ID + * @param userId User ID + * @returns Card data with progress and expression information + */ + fetchCardById(cardId: number, userId: number): Result { + console.time("fetchCardById-total"); + + // Query to fetch card with expression and progress data + const query = this.db.query(` + SELECT + cards.id as cid, cards.text, cards.note, + up.id as upid, + up.repetition_count, + up.ease_factor, + up.interval, + up.next_review_date, + up.last_reviewed, + up.is_mastered, + e.id as eid, + e.*, + (CASE WHEN bm.word_id IS NULL THEN 0 ELSE 1 END) as is_bookmarked, + (SELECT + json_group_array(json_object( + 'pos', pos, + 'senses', s.senses, + 'forms', forms, + 'etymology', etymology, + 'related', related) + ) + FROM senses s WHERE s.parent_id = e.id + ) as senses_array + FROM cards + JOIN cards_expressions ce ON cards.id = ce.card_id + JOIN expressions e ON e.id = ce.expression_id + LEFT JOIN user_progress up ON up.card_id = cards.id AND up.user_id = ? + LEFT JOIN bookmarks bm ON bm.word_id = e.id AND bm.user_id = ? + WHERE cards.id = ? + `); + + const result = query.get(userId, userId, cardId); + + if (!result) { + console.timeEnd("fetchCardById-total"); + return { error: "Card not found" }; + } + + console.time("fetchCardById-json-processing"); + + // Process the row into a CardResponse object + const row: any = result; + + // Process sense data + const sense_array = JSON.parse(row.senses_array); + const senses = sense_array.map((s: any) => { + const senses = JSON.parse(s.senses); + const related = JSON.parse(s.related); + const forms = JSON.parse(s.forms); + return { ...s, senses, related, forms }; + }); + + // Create expression object + const expression = { + isBookmarked: row.is_bookmarked > 0, + ipa: JSON.parse(row.ipa), + prosody: JSON.parse(row.prosody), + syllables: row.syllables, + frequency: row.frequency, + type: row.type, + lang: row.lang, + spelling: row.spelling, + id: row.eid, + confidence: row.confidence, + senses, + }; + + // Create progress object (default if not exists) + const progress = !row.upid + ? DEFAULT_SRS + : { + repetitionCount: row.repetition_count, + easeFactor: row.ease_factor, + interval: row.interval, + nextReviewDate: row.next_review_date, + lastReviewed: row.last_reviewed, + isMastered: row.is_mastered, + }; + + // Create card response + const card = { + text: row.text, + note: row.note, + id: row.cid, + expression, + progress, + }; + + console.timeEnd("fetchCardById-json-processing"); + console.timeEnd("fetchCardById-total"); + + return { ok: card }; + } // // write // diff --git a/src/lib/server/cookie.ts b/src/lib/server/cookie.ts index 32894b9..80f8f9f 100644 --- a/src/lib/server/cookie.ts +++ b/src/lib/server/cookie.ts @@ -7,16 +7,14 @@ const cookieMiddleware: Middleware = () => { // Parse incoming cookies const cookies = cookie.parse(ctx.req.headers.cookie || ""); const coki = cookies.sorlang; - + // If cookie exists, fetch user data and set in context if (coki) { const userRow = db.fetchCookie(coki); - if (userRow) { - ctx.data.user = { id: userRow.id, name: userRow.name }; - console.log("User authenticated:", userRow.name); - } + if (userRow) ctx.data.user = { id: userRow.id, name: userRow.name }; + // console.log("User authenticated:", userRow.name); } - + // Uncomment to enable redirection for unauthenticated users /* if (!ctx.data.user && ctx.req.url.pathname !== "/login") { @@ -27,9 +25,9 @@ const cookieMiddleware: Middleware = () => { return; } */ - + await next(); - + // Cookie setting is now handled by setCookieMiddleware }; }; diff --git a/src/lib/services/srs_study.ts b/src/lib/services/srs_study.ts index 9223722..0930795 100644 --- a/src/lib/services/srs_study.ts +++ b/src/lib/services/srs_study.ts @@ -309,27 +309,14 @@ export class SRSStudyService { // Record the attempt this.recordAttempt(userId, cardId, accuracy > 0.6 ? 1 : 0, reviewTime); - // Fetch updated card data - const updatedDeckResponse = this.db.fetchLesson({ - userId, - lessonId: 0, // We don't care about lesson context here - count: 1, - page: 1, - }); + // Fetch updated card data using the fetchCardById method + const updatedCardResponse = this.db.fetchCardById(cardId, userId); - if ("error" in updatedDeckResponse) { + if ("error" in updatedCardResponse) { return { error: "Failed to fetch updated card data" }; } - // Find the updated card - const updatedCard = updatedDeckResponse.ok.cards.find( - (c) => c.id === cardId, - ); - if (!updatedCard) { - return { error: "Failed to retrieve updated card" }; - } - - return updatedCard; + return updatedCardResponse.ok; } /** diff --git a/src/pages.gen.ts b/src/pages.gen.ts index 6695ceb..2d0d34e 100644 --- a/src/pages.gen.ts +++ b/src/pages.gen.ts @@ -18,6 +18,8 @@ import type { getConfig as File_Logout_getConfig } from './pages/logout'; // prettier-ignore import type { getConfig as File_Db_getConfig } from './pages/db'; // prettier-ignore +import type { getConfig as File_Study_getConfig } from './pages/study'; +// prettier-ignore import type { getConfig as File_Form_getConfig } from './pages/form'; // prettier-ignore import type { getConfig as File_Tones_getConfig } from './pages/tones'; @@ -40,6 +42,7 @@ type Page = | ({ path: '/parse' } & GetConfigResponse) | ({ path: '/logout' } & GetConfigResponse) | ({ path: '/db' } & GetConfigResponse) +| ({ path: '/study' } & GetConfigResponse) | { path: '/test/client-modal'; render: 'dynamic' } | { path: '/test/trigger-modal-button'; render: 'dynamic' } | { path: '/test'; render: 'dynamic' } @@ -48,9 +51,7 @@ type Page = | ({ path: '/picker' } & GetConfigResponse) | { path: '/lessons'; render: 'dynamic' } | ({ path: '/about' } & GetConfigResponse) -| { path: '/logintest/Form'; render: 'dynamic' } | ({ path: '/logintest' } & GetConfigResponse) -| { path: '/logintest/ServerForm'; render: 'dynamic' } | ({ path: '/' } & GetConfigResponse); // prettier-ignore diff --git a/src/pages/logintest/Form.tsx b/src/pages/logintest/Form.tsx deleted file mode 100644 index a593acb..0000000 --- a/src/pages/logintest/Form.tsx +++ /dev/null @@ -1,53 +0,0 @@ -"use client"; - -import { useFormStatus } from "react-dom"; - -const SubmitButton = () => { - const { pending } = useFormStatus(); - return ( - <> - - - ); -}; - -export const Form = ({ - message, - greet, -}: { - message: Promise; - greet: (formData: FormData) => Promise; -}) => ( -
-

{message}

-
-
-
- Name:{" "} - -
-
- Email:{" "} - -
- -
-
-

This is a client component.

-
-); diff --git a/src/pages/logintest/ServerForm.tsx b/src/pages/logintest/ServerForm.tsx deleted file mode 100644 index 8e629b8..0000000 --- a/src/pages/logintest/ServerForm.tsx +++ /dev/null @@ -1,67 +0,0 @@ -async function submitUserProfile(formData: FormData) { - "use server"; - const name = formData.get("name"); - const age = formData.get("age"); - const favoriteColor = formData.get("favoriteColor"); - const hobby = formData.get("hobby"); - const isSubscribed = formData.get("newsletter") === "on"; - - console.log({ - name, - age, - favoriteColor, - hobby, - isSubscribed, - }); -} - -export const ServerForm = () => { - return ( -
-
- - -
- -
- - -
- -
- - -
- -
- - -
- -
- -
- - -
- ); -}; diff --git a/src/pages/logintest/funcs.ts b/src/pages/logintest/funcs.ts deleted file mode 100644 index 4ffd5ef..0000000 --- a/src/pages/logintest/funcs.ts +++ /dev/null @@ -1,24 +0,0 @@ -import { readFile, writeFile } from "node:fs/promises"; -import { unstable_rerenderRoute } from "waku/router/server"; - -export const getMessage = async () => { - const data = await readFile("./message.txt", "utf8"); - return data; -}; - -export const greet = async (formData: FormData) => { - "use server"; - // simulate a slow server response - await new Promise((resolve) => setTimeout(resolve, 1000)); - const currentData = await getMessage(); - await writeFile( - "./message.txt", - currentData + "\n" + formData.get("name") + " from server!", - ); - unstable_rerenderRoute("/"); -}; - -export const increment = async (count: number) => { - "use server"; - return count + 1; -}; diff --git a/src/pages/logintest/index.tsx b/src/pages/logintest/index.tsx index df8bc08..5707c69 100644 --- a/src/pages/logintest/index.tsx +++ b/src/pages/logintest/index.tsx @@ -1,6 +1,6 @@ -import { Form } from "./Form"; -import { getMessage, greet } from "./funcs"; -import { ServerForm } from "./ServerForm"; +import { Form } from "../../components/logintest/Form"; +import { getMessage, greet } from "../../actions/logintest/funcs"; +import { ServerForm } from "../../components/logintest/ServerForm"; export default function HomePage() { return ( diff --git a/src/pages/study.tsx b/src/pages/study.tsx new file mode 100644 index 0000000..f9450a7 --- /dev/null +++ b/src/pages/study.tsx @@ -0,0 +1,480 @@ +import { getContextData } from "waku/middleware/context"; +import { Link } from "waku"; +import { getUserLessons, getUserStudyStats } from "@/actions/srs"; +import { + BookOpen, + GraduationCap, + Clock, + Star, + ChevronRight, + BrainCircuit, + Flame, + Layers, + CalendarDays +} from "lucide-react"; + +import { Button } from "@/components/ui/button"; +import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from "@/components/ui/card"; +import { Progress } from "@/components/ui/progress"; +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; +import { Badge } from "@/components/ui/badge"; +import Navbar from "@/components/Navbar"; + +// This is a server component that gets the initial data +export default async function StudyPage() { + const { user } = getContextData() as any; + + // Redirect to login if not authenticated + if (!user) { + return ( +
+ +
+ +

Login Required

+

+ You need to be logged in to access your study dashboard. +

+
+ + +
+
+
+
+ ); + } + + // Fetch user study data + let userStats; + let userLessons; + + try { + // Get user stats and lessons in parallel + [userStats, userLessons] = await Promise.all([ + getUserStudyStats(user.id), + getUserLessons(user.id) + ]); + } catch (error) { + console.error("Error fetching study data:", error); + userStats = { + totalCards: 0, + masteredCards: 0, + dueCards: 0, + averageEaseFactor: 2.5, + successRate: 0, + streakDays: 0 + }; + userLessons = []; + } + + // Calculate overall progress + const overallProgress = userStats.totalCards > 0 + ? Math.round((userStats.masteredCards / userStats.totalCards) * 100) + : 0; + + // Sort lessons by different criteria + const dueLessons = [...userLessons].sort((a, b) => b.dueCards - a.dueCards).filter(l => l.dueCards > 0); + const inProgressLessons = userLessons.filter(lesson => lesson.progress > 0 && lesson.progress < 100); + const recentLessons = [...userLessons].sort((a, b) => b.id - a.id).slice(0, 4); + + return ( +
+ + +
+ {/* Dashboard Header */} +
+

Study Dashboard

+

+ Track your progress, review due cards, and continue your language learning journey +

+
+ + {/* Stats Overview */} +
+ + +
+
+ +
+ + Total + +
+

{userStats.totalCards}

+

Total Cards

+
+
+ + + +
+
+ +
+ + {Math.round(userStats.masteredCards / Math.max(userStats.totalCards, 1) * 100)}% + +
+

{userStats.masteredCards}

+

Mastered Cards

+
+
+ + + +
+
+ +
+ 0 ? "bg-red-100 text-red-800" : "bg-gray-100"}> + {userStats.dueCards > 0 ? "Due Today" : "All Caught Up"} + +
+

{userStats.dueCards}

+

Cards Due for Review

+
+
+ + + +
+
+ +
+ + Streak + +
+

{userStats.streakDays}

+

Days in a Row

+
+
+
+ + {/* Main Content Tabs */} + + + All Lessons + + Due for Review {dueLessons.length > 0 && `(${dueLessons.length})`} + + In Progress + Study Stats + + + {/* All Lessons Tab */} + +
+ {userLessons.length > 0 ? ( + userLessons.map((lesson) => ( + + )) + ) : ( +
+

No lessons available yet

+

+ Start your language learning journey by adding lessons +

+ +
+ )} +
+
+ + {/* Due for Review Tab */} + + {dueLessons.length > 0 ? ( +
+ {dueLessons.map((lesson) => ( + + ))} +
+ ) : ( + + +
+ +
+

All caught up!

+

+ You don't have any cards due for review right now. Check back later or start a new lesson. +

+ +
+
+ )} +
+ + {/* In Progress Tab */} + + {inProgressLessons.length > 0 ? ( +
+ {inProgressLessons.map((lesson) => ( + + ))} +
+ ) : ( + + +
+ +
+

No lessons in progress

+

+ Start learning by selecting a lesson from the All Lessons tab. +

+ +
+
+ )} +
+ + {/* Stats Tab */} + +
+ + + Overall Progress + Your language learning journey progress + + +
+
+ Progress + {overallProgress}% +
+ +
+ +
+
+ Success Rate + {Math.round(userStats.successRate * 100)}% +
+
+ Average Ease + {userStats.averageEaseFactor.toFixed(1)} +
+
+ Total Cards + {userStats.totalCards} +
+
+ Mastered + {userStats.masteredCards} +
+
+
+
+ + + + Study Streak + Keep the momentum going! + + +
+
+ +
+
{userStats.streakDays}
+
Days in a row
+ +
+
+
+ + + + Recent Activity + Your latest learning progress + + +
+ {recentLessons.map(lesson => ( +
+
+
+ +
+
+
{lesson.name}
+
+ {lesson.description || `Lesson ${lesson.id}`} +
+
+
+
+
+
{Math.round(lesson.progress)}%
+
+ {lesson.masteredCards} / {lesson.totalCards} cards +
+
+ +
+
+ ))} +
+
+
+
+
+
+ + {/* Quick Access Section */} +
+

Quick Access

+
+ + +
+ +
+
+

Start New Lesson

+

Add text to study

+
+ +
+
+ + + +
+ +
+
+

Review Due Cards

+

{userStats.dueCards} cards waiting

+
+ +
+
+ + + +
+ +
+
+

Track Progress

+

View detailed statistics

+
+ +
+
+
+
+
+
+ ); +} + +// Lesson Card Component +function LessonCard({ + lesson, + showDueCardsBadge = false, + showProgress = false +}: { + lesson: any; + showDueCardsBadge?: boolean; + showProgress?: boolean; +}) { + return ( + + +
+
+ {lesson.name} + + {lesson.description || `Lesson ${lesson.id}`} + +
+ {showDueCardsBadge && lesson.dueCards > 0 && ( + + {lesson.dueCards} due + + )} +
+
+ +
+ {lesson.masteredCards} of {lesson.totalCards} mastered + {Math.round(lesson.progress)}% complete +
+ + + {showProgress && ( +
+
+ Due Cards + {lesson.dueCards} +
+
+ Mastered + {lesson.masteredCards} +
+
+ )} +
+ + + +
+ ); +} + +export const getConfig = async () => { + return { + render: "dynamic", + } as const; +}; -- cgit v1.2.3