Add study activity tracking and refresh class header UI
All checks were successful
Automated Container Build / build-and-push (push) Successful in 54s
All checks were successful
Automated Container Build / build-and-push (push) Successful in 54s
This commit is contained in:
parent
95af276d2e
commit
dad62c9914
21 changed files with 1598 additions and 44 deletions
6
.gitignore
vendored
6
.gitignore
vendored
|
|
@ -13,6 +13,12 @@
|
||||||
# testing
|
# testing
|
||||||
/coverage
|
/coverage
|
||||||
|
|
||||||
|
# local SQLite study data
|
||||||
|
/dev.db
|
||||||
|
/dev.db-journal
|
||||||
|
/dev.db-shm
|
||||||
|
/dev.db-wal
|
||||||
|
|
||||||
# next.js
|
# next.js
|
||||||
/.next/
|
/.next/
|
||||||
/out/
|
/out/
|
||||||
|
|
|
||||||
BIN
dev.db
BIN
dev.db
Binary file not shown.
|
|
@ -0,0 +1,9 @@
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "StudyActivity" (
|
||||||
|
"id" TEXT NOT NULL PRIMARY KEY,
|
||||||
|
"type" TEXT NOT NULL,
|
||||||
|
"occurredAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "StudyActivity_occurredAt_idx" ON "StudyActivity"("occurredAt");
|
||||||
|
|
@ -141,6 +141,14 @@ model Setting {
|
||||||
value String
|
value String
|
||||||
}
|
}
|
||||||
|
|
||||||
|
model StudyActivity {
|
||||||
|
id String @id @default(uuid())
|
||||||
|
type String // "FLASHCARD" | "QUIZ_QUESTION"
|
||||||
|
occurredAt DateTime @default(now())
|
||||||
|
|
||||||
|
@@index([occurredAt])
|
||||||
|
}
|
||||||
|
|
||||||
model MaterialGroup {
|
model MaterialGroup {
|
||||||
id String @id @default(uuid())
|
id String @id @default(uuid())
|
||||||
classId String
|
classId String
|
||||||
|
|
|
||||||
|
|
@ -12,8 +12,8 @@ export default async function ClassLayout(props: LayoutProps<"/[classSlug]">) {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="app-page">
|
<div className="app-page">
|
||||||
{/* Dynamic Header & Tabs */}
|
{/* Dynamic class header */}
|
||||||
<ClassHeader classSlug={classSlug} className={classData.name} />
|
<ClassHeader className={classData.name} />
|
||||||
|
|
||||||
{/* Page content */}
|
{/* Page content */}
|
||||||
{props.children}
|
{props.children}
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@
|
||||||
|
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
|
import { ActivityBanner } from "@/components/activity/ActivityBanner";
|
||||||
|
|
||||||
interface ClassItem {
|
interface ClassItem {
|
||||||
id: string;
|
id: string;
|
||||||
|
|
@ -56,33 +57,9 @@ export default function HomePage() {
|
||||||
} finally { setSavingEdit(false); }
|
} finally { setSavingEdit(false); }
|
||||||
}
|
}
|
||||||
|
|
||||||
const deckTotal = classes.reduce((sum, item) => sum + item._count.decks, 0);
|
|
||||||
const quizTotal = classes.reduce((sum, item) => sum + item._count.quizSets, 0);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="app-page">
|
<div className="app-page">
|
||||||
<section className="relative mb-10 overflow-hidden rounded-[2rem] bg-[#172033] px-6 py-8 text-white shadow-[var(--shadow-card)] sm:px-9 sm:py-10 dark:bg-[#111827]">
|
<ActivityBanner onNewClass={() => setShowCreate(true)} />
|
||||||
<div className="absolute -right-16 -top-24 h-64 w-64 rounded-full bg-primary/70 blur-3xl" />
|
|
||||||
<div className="absolute -bottom-28 right-1/3 h-52 w-52 rounded-full bg-accent/35 blur-3xl" />
|
|
||||||
<div className="relative flex flex-col gap-8 lg:flex-row lg:items-end lg:justify-between">
|
|
||||||
<div>
|
|
||||||
<p className="mb-3 text-xs font-bold uppercase tracking-[0.22em] text-white/55">Personal library</p>
|
|
||||||
<h1 className="editorial-title max-w-2xl text-4xl leading-[1.02] sm:text-5xl">What are we learning today?</h1>
|
|
||||||
<p className="mt-4 max-w-xl text-sm leading-6 text-white/65 sm:text-base">Pick up where you left off, sharpen a weak topic, or build something new.</p>
|
|
||||||
</div>
|
|
||||||
<button onClick={() => setShowCreate(true)} className="inline-flex min-h-12 items-center justify-center gap-2 self-start rounded-xl bg-white px-5 text-sm font-bold text-[#172033] shadow-lg transition-transform hover:-translate-y-0.5 lg:self-auto">
|
|
||||||
<span className="text-xl leading-none">+</span> New class
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<div className="relative mt-8 grid max-w-xl grid-cols-3 gap-3 border-t border-white/12 pt-6">
|
|
||||||
{[[classes.length, "Classes"], [deckTotal, "Decks"], [quizTotal, "Quizzes"]].map(([value, label]) => (
|
|
||||||
<div key={label}>
|
|
||||||
<div className="text-2xl font-extrabold sm:text-3xl">{value}</div>
|
|
||||||
<div className="mt-1 text-xs font-semibold text-white/50">{label}</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
{showCreate && (
|
{showCreate && (
|
||||||
<section className="animate-slide-up mb-8 rounded-2xl border border-primary/20 bg-bg-surface p-5 shadow-[var(--shadow-card)] sm:p-6">
|
<section className="animate-slide-up mb-8 rounded-2xl border border-primary/20 bg-bg-surface p-5 shadow-[var(--shadow-card)] sm:p-6">
|
||||||
|
|
|
||||||
17
src/app/api/activity/route.ts
Normal file
17
src/app/api/activity/route.ts
Normal file
|
|
@ -0,0 +1,17 @@
|
||||||
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
import { studyActivitySchema } from "@/lib/validation/activitySchemas";
|
||||||
|
import * as activityService from "@/services/activityService";
|
||||||
|
|
||||||
|
export async function GET() {
|
||||||
|
return NextResponse.json(await activityService.getActivitySummary());
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(request: NextRequest) {
|
||||||
|
const parsed = studyActivitySchema.safeParse(await request.json().catch(() => null));
|
||||||
|
if (!parsed.success) {
|
||||||
|
return NextResponse.json({ error: "Invalid activity type" }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
await activityService.recordActivity(parsed.data.type);
|
||||||
|
return NextResponse.json({ success: true }, { status: 201 });
|
||||||
|
}
|
||||||
199
src/components/activity/ActivityBanner.tsx
Normal file
199
src/components/activity/ActivityBanner.tsx
Normal file
|
|
@ -0,0 +1,199 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useMemo, useRef, useState } from "react";
|
||||||
|
|
||||||
|
interface DailyActivity {
|
||||||
|
date: string;
|
||||||
|
flashcards: number;
|
||||||
|
questions: number;
|
||||||
|
total: number;
|
||||||
|
level: 0 | 1 | 2 | 3 | 4;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ActivitySummary {
|
||||||
|
days: DailyActivity[];
|
||||||
|
today: string;
|
||||||
|
activeDays: number;
|
||||||
|
flashcards: number;
|
||||||
|
questions: number;
|
||||||
|
currentStreak: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const levelClasses = [
|
||||||
|
"bg-white/10",
|
||||||
|
"bg-primary/45",
|
||||||
|
"bg-primary",
|
||||||
|
"bg-accent",
|
||||||
|
"bg-[#fbbf24]",
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export function ActivityBanner({ onNewClass }: { onNewClass: () => void }) {
|
||||||
|
const [summary, setSummary] = useState<ActivitySummary | null>(null);
|
||||||
|
const [error, setError] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetch("/api/activity")
|
||||||
|
.then((response) => {
|
||||||
|
if (!response.ok) throw new Error("Failed to load activity");
|
||||||
|
return response.json();
|
||||||
|
})
|
||||||
|
.then(setSummary)
|
||||||
|
.catch(() => setError(true));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="relative mb-10 overflow-hidden rounded-[2rem] bg-[#172033] px-5 py-7 text-white shadow-[var(--shadow-card)] sm:px-8 sm:py-9 dark:bg-[#111827]">
|
||||||
|
<div className="absolute -right-16 -top-24 h-64 w-64 rounded-full bg-primary/70 blur-3xl" />
|
||||||
|
<div className="absolute -bottom-28 right-1/3 h-52 w-52 rounded-full bg-accent/35 blur-3xl" />
|
||||||
|
<div className="relative mb-6 flex items-center justify-between gap-4">
|
||||||
|
<div>
|
||||||
|
<p className="text-xs font-bold uppercase tracking-[0.22em] text-white/55">Personal library</p>
|
||||||
|
<h1 className="editorial-title mt-1 text-3xl text-white sm:text-4xl">Study activity</h1>
|
||||||
|
</div>
|
||||||
|
<button onClick={onNewClass} className="inline-flex min-h-11 shrink-0 items-center justify-center gap-2 rounded-xl bg-white px-4 text-sm font-bold text-[#172033] shadow-lg transition-transform hover:-translate-y-0.5 sm:min-h-12 sm:px-5">
|
||||||
|
<span className="text-xl leading-none">+</span> New class
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="relative rounded-xl border border-white/15 bg-white/8 px-4 py-5 text-sm text-white/70" role="alert">
|
||||||
|
Study activity could not be loaded. Your library is still available below.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{!summary && !error && <div className="relative h-52 animate-subtle-pulse rounded-2xl bg-white/8" />}
|
||||||
|
{summary && (
|
||||||
|
<div className="relative grid gap-6 xl:grid-cols-[minmax(0,1fr)_10rem] xl:items-center">
|
||||||
|
<div className="min-w-0">
|
||||||
|
<ActivityHeatmap days={summary.days} today={summary.today} />
|
||||||
|
<div className="mt-5 grid grid-cols-3 gap-3 border-t border-white/12 pt-5">
|
||||||
|
<SummaryStat value={summary.activeDays} label="Active days" />
|
||||||
|
<SummaryStat value={summary.flashcards} label="Flashcards" />
|
||||||
|
<SummaryStat value={summary.questions} label="Questions" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<StreakDisplay streak={summary.currentStreak} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ActivityHeatmap({ days, today }: { days: DailyActivity[]; today: string }) {
|
||||||
|
const scrollRef = useRef<HTMLDivElement>(null);
|
||||||
|
const [activeDay, setActiveDay] = useState<DailyActivity | null>(null);
|
||||||
|
const weeks = useMemo(
|
||||||
|
() => Array.from({ length: 53 }, (_, index) => days.slice(index * 7, index * 7 + 7)),
|
||||||
|
[days]
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const container = scrollRef.current;
|
||||||
|
if (container) container.scrollLeft = container.scrollWidth;
|
||||||
|
}, [days]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div className="relative mb-2 h-12">
|
||||||
|
<p className="absolute bottom-0 left-0 text-xs font-semibold text-white/55">Last 53 weeks · Arizona time</p>
|
||||||
|
<div className="absolute right-0 top-0">
|
||||||
|
{activeDay && <DayTooltip day={activeDay} />}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div ref={scrollRef} className="overflow-x-auto pb-2 [scrollbar-color:rgba(255,255,255,.25)_transparent]">
|
||||||
|
<div className="grid w-max grid-cols-[2rem_auto] gap-2">
|
||||||
|
<div className="pt-6 text-[10px] font-semibold leading-[15px] text-white/45" aria-hidden>
|
||||||
|
<div className="h-[15px]" />
|
||||||
|
<div>Mon</div>
|
||||||
|
<div className="h-[15px]" />
|
||||||
|
<div>Wed</div>
|
||||||
|
<div className="h-[15px]" />
|
||||||
|
<div>Fri</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="mb-2 flex h-4 gap-[3px] text-[10px] font-semibold text-white/45" aria-hidden>
|
||||||
|
{weeks.map((week, index) => (
|
||||||
|
<div key={week[0]?.date ?? index} className="w-3">
|
||||||
|
{getMonthLabel(week, weeks[index - 1])}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-[3px]" role="group" aria-label="Study activity over the last 53 weeks">
|
||||||
|
{weeks.map((week, weekIndex) => (
|
||||||
|
<div key={week[0]?.date ?? weekIndex} className="grid grid-rows-7 gap-[3px]">
|
||||||
|
{week.map((day) => day.date > today ? (
|
||||||
|
<span key={day.date} aria-hidden className="h-3 w-3" />
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
key={day.date}
|
||||||
|
type="button"
|
||||||
|
aria-label={getDayAriaLabel(day)}
|
||||||
|
onMouseEnter={() => setActiveDay(day)}
|
||||||
|
onMouseLeave={() => setActiveDay(null)}
|
||||||
|
onFocus={() => setActiveDay(day)}
|
||||||
|
onBlur={() => setActiveDay(null)}
|
||||||
|
onClick={() => setActiveDay((current) => current?.date === day.date ? null : day)}
|
||||||
|
className={`h-3 w-3 rounded-[3px] border border-white/8 ${levelClasses[day.level]} focus-visible:z-10 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-white`}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="mt-2 flex items-center justify-end gap-1.5 text-[10px] font-semibold text-white/45" aria-label="Activity intensity from less to more">
|
||||||
|
<span>Less</span>
|
||||||
|
{levelClasses.map((className, index) => <span key={index} className={`h-3 w-3 rounded-[3px] border border-white/8 ${className}`} />)}
|
||||||
|
<span>More</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function DayTooltip({ day }: { day: DailyActivity }) {
|
||||||
|
const formatted = new Date(`${day.date}T12:00:00Z`).toLocaleDateString("en-US", {
|
||||||
|
timeZone: "America/Phoenix",
|
||||||
|
month: "short",
|
||||||
|
day: "numeric",
|
||||||
|
year: "numeric",
|
||||||
|
});
|
||||||
|
return (
|
||||||
|
<div role="tooltip" className="rounded-lg border border-white/15 bg-[#0d1422] px-3 py-2 text-right text-xs shadow-xl">
|
||||||
|
<div className="font-bold text-white">{formatted} · {day.total} total</div>
|
||||||
|
<div className="mt-0.5 text-white/60">{day.flashcards} flashcards · {day.questions} questions</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function StreakDisplay({ streak }: { streak: number }) {
|
||||||
|
const size = 24 + Math.min(streak, 30) * 0.6;
|
||||||
|
const color = streak === 0 ? "text-white/30" : streak < 7 ? "text-accent" : streak < 30 ? "text-[#ff7a36]" : "text-[#fbbf24]";
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center gap-4 rounded-2xl border border-white/12 bg-white/7 px-5 py-4 xl:flex-col xl:gap-2 xl:text-center">
|
||||||
|
<svg viewBox="0 0 24 24" aria-hidden className={`shrink-0 fill-current ${color}`} style={{ width: size, height: size }}>
|
||||||
|
<path d="M13.5 2.1c.4 3.1-1.4 4.5-2.6 6.1-1 1.3-1.5 2.4-.8 4.1.5-1.4 1.5-2.3 2.5-3.2 1.8 1.6 3.4 3.6 3.4 6.3 0 2.5-1.7 4.6-4 4.6s-4-2-4-4.6c0-1.2.4-2.3 1-3.3-2.4 1.3-4 3.7-4 6.3 0 3.1 2.8 5.6 7 5.6s7-2.7 7-6.7c0-5.6-3.4-10.3-5.5-15.2Z" />
|
||||||
|
</svg>
|
||||||
|
<div>
|
||||||
|
<div className="text-3xl font-extrabold">{streak}</div>
|
||||||
|
<div className="text-xs font-semibold text-white/55">day streak</div>
|
||||||
|
<div className="mt-1 text-[10px] text-white/40">20 activities per day</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SummaryStat({ value, label }: { value: number; label: string }) {
|
||||||
|
return <div><div className="text-xl font-extrabold sm:text-2xl">{value}</div><div className="mt-1 text-xs font-semibold text-white/50">{label}</div></div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getMonthLabel(week: DailyActivity[], previousWeek?: DailyActivity[]) {
|
||||||
|
const firstOfMonth = week.find((day) => Number(day.date.slice(8, 10)) <= 7);
|
||||||
|
if (!firstOfMonth) return "";
|
||||||
|
const month = firstOfMonth.date.slice(5, 7);
|
||||||
|
if (previousWeek?.some((day) => day.date.slice(5, 7) === month)) return "";
|
||||||
|
return new Date(`${firstOfMonth.date}T12:00:00Z`).toLocaleDateString("en-US", { month: "short", timeZone: "America/Phoenix" });
|
||||||
|
}
|
||||||
|
|
||||||
|
function getDayAriaLabel(day: DailyActivity) {
|
||||||
|
return `${day.date}: ${day.flashcards} flashcards and ${day.questions} questions, ${day.total} total activities`;
|
||||||
|
}
|
||||||
|
|
@ -3,6 +3,7 @@
|
||||||
import { useState, useEffect, useCallback, useRef } from "react";
|
import { useState, useEffect, useCallback, useRef } from "react";
|
||||||
import ReactMarkdown from "react-markdown";
|
import ReactMarkdown from "react-markdown";
|
||||||
import remarkGfm from "remark-gfm";
|
import remarkGfm from "remark-gfm";
|
||||||
|
import { recordStudyActivity } from "@/lib/activityClient";
|
||||||
|
|
||||||
interface Card {
|
interface Card {
|
||||||
id: string;
|
id: string;
|
||||||
|
|
@ -55,6 +56,7 @@ export function FlashcardViewer({ cards, deckId, isShared = false, initialProgre
|
||||||
);
|
);
|
||||||
const [swipeClass, setSwipeClass] = useState("");
|
const [swipeClass, setSwipeClass] = useState("");
|
||||||
const cardRef = useRef<HTMLDivElement>(null);
|
const cardRef = useRef<HTMLDivElement>(null);
|
||||||
|
const gradingRef = useRef(false);
|
||||||
|
|
||||||
// Touch/drag state
|
// Touch/drag state
|
||||||
const dragRef = useRef({ startX: 0, currentX: 0, isDragging: false });
|
const dragRef = useRef({ startX: 0, currentX: 0, isDragging: false });
|
||||||
|
|
@ -120,7 +122,12 @@ export function FlashcardViewer({ cards, deckId, isShared = false, initialProgre
|
||||||
// Grade the current card
|
// Grade the current card
|
||||||
const gradeCard = useCallback(
|
const gradeCard = useCallback(
|
||||||
(grade: CardResult) => {
|
(grade: CardResult) => {
|
||||||
if (!currentCard || !hasFlippedOnce) return;
|
if (!currentCard || !hasFlippedOnce || gradingRef.current) return;
|
||||||
|
gradingRef.current = true;
|
||||||
|
|
||||||
|
if (!isShared) {
|
||||||
|
recordStudyActivity("FLASHCARD").catch(() => {});
|
||||||
|
}
|
||||||
|
|
||||||
setToastMessage(null);
|
setToastMessage(null);
|
||||||
const newResults = { ...results, [currentCard.id]: grade };
|
const newResults = { ...results, [currentCard.id]: grade };
|
||||||
|
|
@ -130,6 +137,7 @@ export function FlashcardViewer({ cards, deckId, isShared = false, initialProgre
|
||||||
setSwipeClass(grade === "correct" ? "animate-swipe-right" : "animate-swipe-left");
|
setSwipeClass(grade === "correct" ? "animate-swipe-right" : "animate-swipe-left");
|
||||||
|
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
|
gradingRef.current = false;
|
||||||
setSwipeClass("");
|
setSwipeClass("");
|
||||||
setIsFlipped(false);
|
setIsFlipped(false);
|
||||||
setHasFlippedOnce(false);
|
setHasFlippedOnce(false);
|
||||||
|
|
@ -144,7 +152,7 @@ export function FlashcardViewer({ cards, deckId, isShared = false, initialProgre
|
||||||
}
|
}
|
||||||
}, 350);
|
}, 350);
|
||||||
},
|
},
|
||||||
[currentCard, hasFlippedOnce, results, currentIndex, order, isShuffled]
|
[currentCard, hasFlippedOnce, results, currentIndex, order, isShuffled, isShared]
|
||||||
);
|
);
|
||||||
|
|
||||||
// Keyboard shortcuts
|
// Keyboard shortcuts
|
||||||
|
|
@ -236,6 +244,7 @@ export function FlashcardViewer({ cards, deckId, isShared = false, initialProgre
|
||||||
|
|
||||||
// Restart handlers
|
// Restart handlers
|
||||||
function restartFullSet() {
|
function restartFullSet() {
|
||||||
|
gradingRef.current = false;
|
||||||
let newOrder: string[];
|
let newOrder: string[];
|
||||||
if (isShuffled) {
|
if (isShuffled) {
|
||||||
newOrder = [...cards.map((c) => c.id)].sort(() => Math.random() - 0.5);
|
newOrder = [...cards.map((c) => c.id)].sort(() => Math.random() - 0.5);
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,11 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState, useEffect } from "react";
|
import { useState, useEffect, useRef } from "react";
|
||||||
import ReactMarkdown from "react-markdown";
|
import ReactMarkdown from "react-markdown";
|
||||||
import remarkGfm from "remark-gfm";
|
import remarkGfm from "remark-gfm";
|
||||||
import { scoreQuestion } from "@/lib/scoring";
|
import { scoreQuestion } from "@/lib/scoring";
|
||||||
import { QuizResults } from "./QuizResults";
|
import { QuizResults } from "./QuizResults";
|
||||||
|
import { recordStudyActivity } from "@/lib/activityClient";
|
||||||
|
|
||||||
interface Option {
|
interface Option {
|
||||||
id: string;
|
id: string;
|
||||||
|
|
@ -39,6 +40,7 @@ interface QuizViewerProps {
|
||||||
}
|
}
|
||||||
|
|
||||||
export function QuizViewer({ quiz, retakeIds, isShared = false, onFinished }: QuizViewerProps) {
|
export function QuizViewer({ quiz, retakeIds, isShared = false, onFinished }: QuizViewerProps) {
|
||||||
|
const submittingQuestionRef = useRef<string | null>(null);
|
||||||
const [order, setOrder] = useState<string[]>([]);
|
const [order, setOrder] = useState<string[]>([]);
|
||||||
const [currentIndex, setCurrentIndex] = useState(0);
|
const [currentIndex, setCurrentIndex] = useState(0);
|
||||||
const [answers, setAnswers] = useState<Record<string, string[]>>({});
|
const [answers, setAnswers] = useState<Record<string, string[]>>({});
|
||||||
|
|
@ -194,7 +196,11 @@ export function QuizViewer({ quiz, retakeIds, isShared = false, onFinished }: Qu
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleSubmit() {
|
function handleSubmit() {
|
||||||
if (isSubmitted || !currentQId) return;
|
if (isSubmitted || !currentQId || submittingQuestionRef.current === currentQId) return;
|
||||||
|
submittingQuestionRef.current = currentQId;
|
||||||
|
if (!isShared) {
|
||||||
|
recordStudyActivity("QUIZ_QUESTION").catch(() => {});
|
||||||
|
}
|
||||||
const newSubmitted = [...submittedAnswers, currentQId];
|
const newSubmitted = [...submittedAnswers, currentQId];
|
||||||
setSubmittedAnswers(newSubmitted);
|
setSubmittedAnswers(newSubmitted);
|
||||||
saveProgress(currentIndex, answers);
|
saveProgress(currentIndex, answers);
|
||||||
|
|
@ -202,6 +208,7 @@ export function QuizViewer({ quiz, retakeIds, isShared = false, onFinished }: Qu
|
||||||
|
|
||||||
function handleNext() {
|
function handleNext() {
|
||||||
if (currentIndex + 1 < order.length) {
|
if (currentIndex + 1 < order.length) {
|
||||||
|
submittingQuestionRef.current = null;
|
||||||
const nextIdx = currentIndex + 1;
|
const nextIdx = currentIndex + 1;
|
||||||
setCurrentIndex(nextIdx);
|
setCurrentIndex(nextIdx);
|
||||||
saveProgress(nextIdx, answers);
|
saveProgress(nextIdx, answers);
|
||||||
|
|
@ -290,6 +297,7 @@ export function QuizViewer({ quiz, retakeIds, isShared = false, onFinished }: Qu
|
||||||
setCurrentIndex(0);
|
setCurrentIndex(0);
|
||||||
setAnswers({});
|
setAnswers({});
|
||||||
setSubmittedAnswers([]);
|
setSubmittedAnswers([]);
|
||||||
|
submittingQuestionRef.current = null;
|
||||||
setResultsData(null);
|
setResultsData(null);
|
||||||
|
|
||||||
// Re-shuffle options for the new attempt
|
// Re-shuffle options for the new attempt
|
||||||
|
|
|
||||||
|
|
@ -2,14 +2,12 @@
|
||||||
|
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { usePathname } from "next/navigation";
|
import { usePathname } from "next/navigation";
|
||||||
import { ClassTabs } from "./ClassTabs";
|
|
||||||
|
|
||||||
interface ClassHeaderProps {
|
interface ClassHeaderProps {
|
||||||
classSlug: string;
|
|
||||||
className: string;
|
className: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ClassHeader({ classSlug, className }: ClassHeaderProps) {
|
export function ClassHeader({ className }: ClassHeaderProps) {
|
||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
|
|
||||||
// If we are deeper than /[classSlug]/[tab], hide this header to save space
|
// If we are deeper than /[classSlug]/[tab], hide this header to save space
|
||||||
|
|
@ -23,8 +21,7 @@ export function ClassHeader({ classSlug, className }: ClassHeaderProps) {
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<div className="mb-8 flex flex-col gap-4 border-b border-border-light pb-7 sm:flex-row sm:items-end sm:justify-between">
|
||||||
<div className="mb-7 flex flex-col gap-4 sm:flex-row sm:items-end sm:justify-between">
|
|
||||||
<div>
|
<div>
|
||||||
<Link
|
<Link
|
||||||
href="/"
|
href="/"
|
||||||
|
|
@ -40,8 +37,5 @@ export function ClassHeader({ classSlug, className }: ClassHeaderProps) {
|
||||||
</div>
|
</div>
|
||||||
<p className="max-w-sm text-sm leading-6 text-text-secondary">Choose a mode, then settle in for a focused study session.</p>
|
<p className="max-w-sm text-sm leading-6 text-text-secondary">Choose a mode, then settle in for a focused study session.</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<ClassTabs classSlug={classSlug} />
|
|
||||||
</>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -72,6 +72,11 @@ export type AuthSecurity = Prisma.AuthSecurityModel
|
||||||
*
|
*
|
||||||
*/
|
*/
|
||||||
export type Setting = Prisma.SettingModel
|
export type Setting = Prisma.SettingModel
|
||||||
|
/**
|
||||||
|
* Model StudyActivity
|
||||||
|
*
|
||||||
|
*/
|
||||||
|
export type StudyActivity = Prisma.StudyActivityModel
|
||||||
/**
|
/**
|
||||||
* Model MaterialGroup
|
* Model MaterialGroup
|
||||||
*
|
*
|
||||||
|
|
|
||||||
|
|
@ -96,6 +96,11 @@ export type AuthSecurity = Prisma.AuthSecurityModel
|
||||||
*
|
*
|
||||||
*/
|
*/
|
||||||
export type Setting = Prisma.SettingModel
|
export type Setting = Prisma.SettingModel
|
||||||
|
/**
|
||||||
|
* Model StudyActivity
|
||||||
|
*
|
||||||
|
*/
|
||||||
|
export type StudyActivity = Prisma.StudyActivityModel
|
||||||
/**
|
/**
|
||||||
* Model MaterialGroup
|
* Model MaterialGroup
|
||||||
*
|
*
|
||||||
|
|
|
||||||
File diff suppressed because one or more lines are too long
|
|
@ -395,6 +395,7 @@ export const ModelName = {
|
||||||
ShareLink: 'ShareLink',
|
ShareLink: 'ShareLink',
|
||||||
AuthSecurity: 'AuthSecurity',
|
AuthSecurity: 'AuthSecurity',
|
||||||
Setting: 'Setting',
|
Setting: 'Setting',
|
||||||
|
StudyActivity: 'StudyActivity',
|
||||||
MaterialGroup: 'MaterialGroup'
|
MaterialGroup: 'MaterialGroup'
|
||||||
} as const
|
} as const
|
||||||
|
|
||||||
|
|
@ -411,7 +412,7 @@ export type TypeMap<ExtArgs extends runtime.Types.Extensions.InternalArgs = runt
|
||||||
omit: GlobalOmitOptions
|
omit: GlobalOmitOptions
|
||||||
}
|
}
|
||||||
meta: {
|
meta: {
|
||||||
modelProps: "class" | "deck" | "flashcard" | "quizSet" | "question" | "answerOption" | "studyProgress" | "quizAttempt" | "shareLink" | "authSecurity" | "setting" | "materialGroup"
|
modelProps: "class" | "deck" | "flashcard" | "quizSet" | "question" | "answerOption" | "studyProgress" | "quizAttempt" | "shareLink" | "authSecurity" | "setting" | "studyActivity" | "materialGroup"
|
||||||
txIsolationLevel: TransactionIsolationLevel
|
txIsolationLevel: TransactionIsolationLevel
|
||||||
}
|
}
|
||||||
model: {
|
model: {
|
||||||
|
|
@ -1229,6 +1230,80 @@ export type TypeMap<ExtArgs extends runtime.Types.Extensions.InternalArgs = runt
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
StudyActivity: {
|
||||||
|
payload: Prisma.$StudyActivityPayload<ExtArgs>
|
||||||
|
fields: Prisma.StudyActivityFieldRefs
|
||||||
|
operations: {
|
||||||
|
findUnique: {
|
||||||
|
args: Prisma.StudyActivityFindUniqueArgs<ExtArgs>
|
||||||
|
result: runtime.Types.Utils.PayloadToResult<Prisma.$StudyActivityPayload> | null
|
||||||
|
}
|
||||||
|
findUniqueOrThrow: {
|
||||||
|
args: Prisma.StudyActivityFindUniqueOrThrowArgs<ExtArgs>
|
||||||
|
result: runtime.Types.Utils.PayloadToResult<Prisma.$StudyActivityPayload>
|
||||||
|
}
|
||||||
|
findFirst: {
|
||||||
|
args: Prisma.StudyActivityFindFirstArgs<ExtArgs>
|
||||||
|
result: runtime.Types.Utils.PayloadToResult<Prisma.$StudyActivityPayload> | null
|
||||||
|
}
|
||||||
|
findFirstOrThrow: {
|
||||||
|
args: Prisma.StudyActivityFindFirstOrThrowArgs<ExtArgs>
|
||||||
|
result: runtime.Types.Utils.PayloadToResult<Prisma.$StudyActivityPayload>
|
||||||
|
}
|
||||||
|
findMany: {
|
||||||
|
args: Prisma.StudyActivityFindManyArgs<ExtArgs>
|
||||||
|
result: runtime.Types.Utils.PayloadToResult<Prisma.$StudyActivityPayload>[]
|
||||||
|
}
|
||||||
|
create: {
|
||||||
|
args: Prisma.StudyActivityCreateArgs<ExtArgs>
|
||||||
|
result: runtime.Types.Utils.PayloadToResult<Prisma.$StudyActivityPayload>
|
||||||
|
}
|
||||||
|
createMany: {
|
||||||
|
args: Prisma.StudyActivityCreateManyArgs<ExtArgs>
|
||||||
|
result: BatchPayload
|
||||||
|
}
|
||||||
|
createManyAndReturn: {
|
||||||
|
args: Prisma.StudyActivityCreateManyAndReturnArgs<ExtArgs>
|
||||||
|
result: runtime.Types.Utils.PayloadToResult<Prisma.$StudyActivityPayload>[]
|
||||||
|
}
|
||||||
|
delete: {
|
||||||
|
args: Prisma.StudyActivityDeleteArgs<ExtArgs>
|
||||||
|
result: runtime.Types.Utils.PayloadToResult<Prisma.$StudyActivityPayload>
|
||||||
|
}
|
||||||
|
update: {
|
||||||
|
args: Prisma.StudyActivityUpdateArgs<ExtArgs>
|
||||||
|
result: runtime.Types.Utils.PayloadToResult<Prisma.$StudyActivityPayload>
|
||||||
|
}
|
||||||
|
deleteMany: {
|
||||||
|
args: Prisma.StudyActivityDeleteManyArgs<ExtArgs>
|
||||||
|
result: BatchPayload
|
||||||
|
}
|
||||||
|
updateMany: {
|
||||||
|
args: Prisma.StudyActivityUpdateManyArgs<ExtArgs>
|
||||||
|
result: BatchPayload
|
||||||
|
}
|
||||||
|
updateManyAndReturn: {
|
||||||
|
args: Prisma.StudyActivityUpdateManyAndReturnArgs<ExtArgs>
|
||||||
|
result: runtime.Types.Utils.PayloadToResult<Prisma.$StudyActivityPayload>[]
|
||||||
|
}
|
||||||
|
upsert: {
|
||||||
|
args: Prisma.StudyActivityUpsertArgs<ExtArgs>
|
||||||
|
result: runtime.Types.Utils.PayloadToResult<Prisma.$StudyActivityPayload>
|
||||||
|
}
|
||||||
|
aggregate: {
|
||||||
|
args: Prisma.StudyActivityAggregateArgs<ExtArgs>
|
||||||
|
result: runtime.Types.Utils.Optional<Prisma.AggregateStudyActivity>
|
||||||
|
}
|
||||||
|
groupBy: {
|
||||||
|
args: Prisma.StudyActivityGroupByArgs<ExtArgs>
|
||||||
|
result: runtime.Types.Utils.Optional<Prisma.StudyActivityGroupByOutputType>[]
|
||||||
|
}
|
||||||
|
count: {
|
||||||
|
args: Prisma.StudyActivityCountArgs<ExtArgs>
|
||||||
|
result: runtime.Types.Utils.Optional<Prisma.StudyActivityCountAggregateOutputType> | number
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
MaterialGroup: {
|
MaterialGroup: {
|
||||||
payload: Prisma.$MaterialGroupPayload<ExtArgs>
|
payload: Prisma.$MaterialGroupPayload<ExtArgs>
|
||||||
fields: Prisma.MaterialGroupFieldRefs
|
fields: Prisma.MaterialGroupFieldRefs
|
||||||
|
|
@ -1470,6 +1545,15 @@ export const SettingScalarFieldEnum = {
|
||||||
export type SettingScalarFieldEnum = (typeof SettingScalarFieldEnum)[keyof typeof SettingScalarFieldEnum]
|
export type SettingScalarFieldEnum = (typeof SettingScalarFieldEnum)[keyof typeof SettingScalarFieldEnum]
|
||||||
|
|
||||||
|
|
||||||
|
export const StudyActivityScalarFieldEnum = {
|
||||||
|
id: 'id',
|
||||||
|
type: 'type',
|
||||||
|
occurredAt: 'occurredAt'
|
||||||
|
} as const
|
||||||
|
|
||||||
|
export type StudyActivityScalarFieldEnum = (typeof StudyActivityScalarFieldEnum)[keyof typeof StudyActivityScalarFieldEnum]
|
||||||
|
|
||||||
|
|
||||||
export const MaterialGroupScalarFieldEnum = {
|
export const MaterialGroupScalarFieldEnum = {
|
||||||
id: 'id',
|
id: 'id',
|
||||||
classId: 'classId',
|
classId: 'classId',
|
||||||
|
|
@ -1659,6 +1743,7 @@ export type GlobalOmitConfig = {
|
||||||
shareLink?: Prisma.ShareLinkOmit
|
shareLink?: Prisma.ShareLinkOmit
|
||||||
authSecurity?: Prisma.AuthSecurityOmit
|
authSecurity?: Prisma.AuthSecurityOmit
|
||||||
setting?: Prisma.SettingOmit
|
setting?: Prisma.SettingOmit
|
||||||
|
studyActivity?: Prisma.StudyActivityOmit
|
||||||
materialGroup?: Prisma.MaterialGroupOmit
|
materialGroup?: Prisma.MaterialGroupOmit
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -62,6 +62,7 @@ export const ModelName = {
|
||||||
ShareLink: 'ShareLink',
|
ShareLink: 'ShareLink',
|
||||||
AuthSecurity: 'AuthSecurity',
|
AuthSecurity: 'AuthSecurity',
|
||||||
Setting: 'Setting',
|
Setting: 'Setting',
|
||||||
|
StudyActivity: 'StudyActivity',
|
||||||
MaterialGroup: 'MaterialGroup'
|
MaterialGroup: 'MaterialGroup'
|
||||||
} as const
|
} as const
|
||||||
|
|
||||||
|
|
@ -209,6 +210,15 @@ export const SettingScalarFieldEnum = {
|
||||||
export type SettingScalarFieldEnum = (typeof SettingScalarFieldEnum)[keyof typeof SettingScalarFieldEnum]
|
export type SettingScalarFieldEnum = (typeof SettingScalarFieldEnum)[keyof typeof SettingScalarFieldEnum]
|
||||||
|
|
||||||
|
|
||||||
|
export const StudyActivityScalarFieldEnum = {
|
||||||
|
id: 'id',
|
||||||
|
type: 'type',
|
||||||
|
occurredAt: 'occurredAt'
|
||||||
|
} as const
|
||||||
|
|
||||||
|
export type StudyActivityScalarFieldEnum = (typeof StudyActivityScalarFieldEnum)[keyof typeof StudyActivityScalarFieldEnum]
|
||||||
|
|
||||||
|
|
||||||
export const MaterialGroupScalarFieldEnum = {
|
export const MaterialGroupScalarFieldEnum = {
|
||||||
id: 'id',
|
id: 'id',
|
||||||
classId: 'classId',
|
classId: 'classId',
|
||||||
|
|
|
||||||
|
|
@ -19,5 +19,6 @@ export type * from './models/QuizAttempt'
|
||||||
export type * from './models/ShareLink'
|
export type * from './models/ShareLink'
|
||||||
export type * from './models/AuthSecurity'
|
export type * from './models/AuthSecurity'
|
||||||
export type * from './models/Setting'
|
export type * from './models/Setting'
|
||||||
|
export type * from './models/StudyActivity'
|
||||||
export type * from './models/MaterialGroup'
|
export type * from './models/MaterialGroup'
|
||||||
export type * from './commonInputTypes'
|
export type * from './commonInputTypes'
|
||||||
1091
src/generated/prisma/models/StudyActivity.ts
Normal file
1091
src/generated/prisma/models/StudyActivity.ts
Normal file
File diff suppressed because it is too large
Load diff
9
src/lib/activityClient.ts
Normal file
9
src/lib/activityClient.ts
Normal file
|
|
@ -0,0 +1,9 @@
|
||||||
|
import type { StudyActivityType } from "@/lib/validation/activitySchemas";
|
||||||
|
|
||||||
|
export function recordStudyActivity(type: StudyActivityType) {
|
||||||
|
return fetch("/api/activity", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ type }),
|
||||||
|
});
|
||||||
|
}
|
||||||
7
src/lib/validation/activitySchemas.ts
Normal file
7
src/lib/validation/activitySchemas.ts
Normal file
|
|
@ -0,0 +1,7 @@
|
||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
|
export const studyActivitySchema = z.object({
|
||||||
|
type: z.enum(["FLASHCARD", "QUIZ_QUESTION"]),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type StudyActivityType = z.infer<typeof studyActivitySchema>["type"];
|
||||||
104
src/services/activityService.ts
Normal file
104
src/services/activityService.ts
Normal file
|
|
@ -0,0 +1,104 @@
|
||||||
|
import { prisma } from "@/lib/db";
|
||||||
|
import type { StudyActivityType } from "@/lib/validation/activitySchemas";
|
||||||
|
|
||||||
|
const ARIZONA_OFFSET_MS = 7 * 60 * 60 * 1000;
|
||||||
|
const WEEKS_TO_DISPLAY = 53;
|
||||||
|
const DAYS_TO_DISPLAY = WEEKS_TO_DISPLAY * 7;
|
||||||
|
const STREAK_THRESHOLD = 20;
|
||||||
|
|
||||||
|
export interface DailyActivity {
|
||||||
|
date: string;
|
||||||
|
flashcards: number;
|
||||||
|
questions: number;
|
||||||
|
total: number;
|
||||||
|
level: 0 | 1 | 2 | 3 | 4;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ActivitySummary {
|
||||||
|
days: DailyActivity[];
|
||||||
|
today: string;
|
||||||
|
activeDays: number;
|
||||||
|
flashcards: number;
|
||||||
|
questions: number;
|
||||||
|
currentStreak: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
function toArizonaDateKey(date: Date): string {
|
||||||
|
return new Date(date.getTime() - ARIZONA_OFFSET_MS).toISOString().slice(0, 10);
|
||||||
|
}
|
||||||
|
|
||||||
|
function dateKeyToUtcDate(dateKey: string): Date {
|
||||||
|
return new Date(`${dateKey}T00:00:00.000Z`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function addDays(dateKey: string, days: number): string {
|
||||||
|
const date = dateKeyToUtcDate(dateKey);
|
||||||
|
date.setUTCDate(date.getUTCDate() + days);
|
||||||
|
return date.toISOString().slice(0, 10);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getLevel(total: number): 0 | 1 | 2 | 3 | 4 {
|
||||||
|
if (total === 0) return 0;
|
||||||
|
if (total < 20) return 1;
|
||||||
|
if (total < 50) return 2;
|
||||||
|
if (total < 100) return 3;
|
||||||
|
return 4;
|
||||||
|
}
|
||||||
|
|
||||||
|
function emptyDay(date: string): DailyActivity {
|
||||||
|
return { date, flashcards: 0, questions: 0, total: 0, level: 0 };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function recordActivity(type: StudyActivityType) {
|
||||||
|
return prisma.studyActivity.create({ data: { type } });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getActivitySummary(now = new Date()): Promise<ActivitySummary> {
|
||||||
|
const today = toArizonaDateKey(now);
|
||||||
|
const todayDate = dateKeyToUtcDate(today);
|
||||||
|
const dayOfWeek = todayDate.getUTCDay();
|
||||||
|
const startDate = addDays(today, -(dayOfWeek + (WEEKS_TO_DISPLAY - 1) * 7));
|
||||||
|
const endDate = addDays(startDate, DAYS_TO_DISPLAY - 1);
|
||||||
|
|
||||||
|
const activities = await prisma.studyActivity.findMany({
|
||||||
|
select: { type: true, occurredAt: true },
|
||||||
|
orderBy: { occurredAt: "asc" },
|
||||||
|
});
|
||||||
|
const totals = new Map<string, DailyActivity>();
|
||||||
|
|
||||||
|
for (const activity of activities) {
|
||||||
|
const date = toArizonaDateKey(activity.occurredAt);
|
||||||
|
const day = totals.get(date) ?? emptyDay(date);
|
||||||
|
if (activity.type === "FLASHCARD") day.flashcards += 1;
|
||||||
|
if (activity.type === "QUIZ_QUESTION") day.questions += 1;
|
||||||
|
day.total += 1;
|
||||||
|
day.level = getLevel(day.total);
|
||||||
|
totals.set(date, day);
|
||||||
|
}
|
||||||
|
|
||||||
|
const days = Array.from({ length: DAYS_TO_DISPLAY }, (_, index) => {
|
||||||
|
const date = addDays(startDate, index);
|
||||||
|
return totals.get(date) ?? emptyDay(date);
|
||||||
|
});
|
||||||
|
const visibleDays = days.filter((day) => day.date <= today && day.date <= endDate);
|
||||||
|
const flashcards = visibleDays.reduce((sum, day) => sum + day.flashcards, 0);
|
||||||
|
const questions = visibleDays.reduce((sum, day) => sum + day.questions, 0);
|
||||||
|
|
||||||
|
let streakDate = (totals.get(today)?.total ?? 0) >= STREAK_THRESHOLD
|
||||||
|
? today
|
||||||
|
: addDays(today, -1);
|
||||||
|
let currentStreak = 0;
|
||||||
|
while ((totals.get(streakDate)?.total ?? 0) >= STREAK_THRESHOLD) {
|
||||||
|
currentStreak += 1;
|
||||||
|
streakDate = addDays(streakDate, -1);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
days,
|
||||||
|
today,
|
||||||
|
activeDays: visibleDays.filter((day) => day.total > 0).length,
|
||||||
|
flashcards,
|
||||||
|
questions,
|
||||||
|
currentStreak,
|
||||||
|
};
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue