studyGuide/app/page.tsx

467 lines
16 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"use client";
import React, { useEffect, useMemo, useState } from "react";
import { motion } from "framer-motion";
import {
Card,
CardContent,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Progress } from "@/components/ui/progress";
import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/components/ui/tabs";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
import { Switch } from "@/components/ui/switch";
import { CalendarDays, CheckCircle2, Flame, RefreshCcw, Target, TrendingUp } from "lucide-react";
import {
LineChart,
Line,
XAxis,
YAxis,
CartesianGrid,
Tooltip as RTooltip,
ResponsiveContainer,
Legend,
} from "recharts";
interface DayPlan {
date: string;
planned: number;
actual: number;
}
interface PlanState {
totalPages: number;
startDate: string;
endDate: string;
includeWeekends: boolean;
days: DayPlan[];
}
function toYMD(d: Date): string {
const y = d.getFullYear();
const m = String(d.getMonth() + 1).padStart(2, "0");
const day = String(d.getDate()).padStart(2, "0");
return `${y}-${m}-${day}`;
}
function fromYMD(s: string): Date {
const [y, m, d] = s.split("-").map(Number);
return new Date(y, (m || 1) - 1, d || 1);
}
function isWeekend(d: Date): boolean {
const day = d.getDay();
return day === 0 || day === 6;
}
function eachDay(start: Date, end: Date, includeWeekends = true): string[] {
const out: string[] = [];
const cur = new Date(start);
cur.setHours(0, 0, 0, 0);
const last = new Date(end);
last.setHours(0, 0, 0, 0);
while (cur <= last) {
if (includeWeekends || !isWeekend(cur)) {
out.push(toYMD(cur));
}
cur.setDate(cur.getDate() + 1);
}
return out;
}
function distribute(total: number, buckets: number): number[] {
if (buckets <= 0) return [];
const base = Math.floor(total / buckets);
const remainder = total - base * buckets;
const arr = Array.from({ length: buckets }, (_, i) => base + (i < remainder ? 1 : 0));
return arr;
}
function formatPretty(s: string): string {
try {
const d = fromYMD(s);
const weekdays = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"];
const months = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"];
const weekday = weekdays[d.getDay()];
const month = months[d.getMonth()];
const day = d.getDate();
return `${weekday}, ${month} ${day}`;
} catch {
return s;
}
}
const LS_KEY = "study-tracker-plan-v1";
function savePlan(p: PlanState) {
if (typeof window === "undefined") return;
localStorage.setItem(LS_KEY, JSON.stringify(p));
}
function loadPlan(): PlanState | null {
if (typeof window === "undefined") return null;
const raw = localStorage.getItem(LS_KEY);
if (!raw) return null;
try {
const parsed = JSON.parse(raw) as PlanState;
return parsed;
} catch {
return null;
}
}
export default function StudyTrackerApp() {
const todayYMD = toYMD(new Date());
const [totalPages, setTotalPages] = useState<number>(200);
const [endDate, setEndDate] = useState<string>(toYMD(new Date(Date.now() + 7 * 86400000)));
const [includeWeekends, setIncludeWeekends] = useState<boolean>(true);
const [days, setDays] = useState<DayPlan[]>([]);
useEffect(() => {
const stored = loadPlan();
if (stored) {
setTotalPages(stored.totalPages);
setEndDate(stored.endDate);
setIncludeWeekends(stored.includeWeekends);
setDays(stored.days);
} else {
buildPlan();
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
function buildPlan() {
const start = new Date();
start.setHours(0, 0, 0, 0);
const end = fromYMD(endDate);
end.setHours(0, 0, 0, 0);
if (isNaN(totalPages) || totalPages <= 0 || end < start) {
setDays([]);
return;
}
const slots = eachDay(start, end, includeWeekends);
const allocation = distribute(totalPages, slots.length);
const newDays: DayPlan[] = slots.map((ymd, i) => {
const existing = days.find((d) => d.date === ymd);
return {
date: ymd,
planned: allocation[i] || 0,
actual: existing?.actual || 0,
};
});
setDays(newDays);
const state: PlanState = {
totalPages,
startDate: todayYMD,
endDate,
includeWeekends,
days: newDays,
};
savePlan(state);
}
useEffect(() => {
const state: PlanState = {
totalPages,
startDate: todayYMD,
endDate,
includeWeekends,
days,
};
savePlan(state);
}, [days, totalPages, endDate, includeWeekends, todayYMD]);
const stats = useMemo(() => {
const studied = days.reduce((sum, d) => sum + (d.actual || 0), 0);
const planned = days.reduce((sum, d) => sum + (d.planned || 0), 0);
const pct = planned > 0 ? Math.min(100, Math.round((studied / planned) * 100)) : 0;
const remaining = Math.max(0, planned - studied);
const avgPerDay = days.length ? Math.round(planned / days.length) : 0;
return { studied, planned, pct, remaining, avgPerDay };
}, [days]);
const chartData = useMemo(() => {
let cumPlanned = 0;
let cumActual = 0;
return days.map((d) => {
cumPlanned += d.planned;
cumActual += d.actual || 0;
return {
date: formatPretty(d.date),
Planned: cumPlanned,
Actual: cumActual,
};
});
}, [days]);
function updateActual(ymd: string, value: number) {
setDays((prev) => prev.map((d) => (d.date === ymd ? { ...d, actual: Math.max(0, Math.floor(value || 0)) } : d)));
}
function bumpActual(ymd: string, inc: number) {
setDays((prev) => prev.map((d) => (d.date === ymd ? { ...d, actual: Math.max(0, (d.actual || 0) + inc) } : d)));
}
function resetProgress() {
setDays((prev) => prev.map((d) => ({ ...d, actual: 0 })));
}
function resetAll() {
setTotalPages(200);
setEndDate(toYMD(new Date(Date.now() + 7 * 86400000)));
setIncludeWeekends(true);
setDays([]);
if (typeof window !== "undefined") localStorage.removeItem(LS_KEY);
buildPlan();
}
const deadlinePassed = new Date(endDate).setHours(0, 0, 0, 0) < new Date().setHours(0, 0, 0, 0);
return (
<div className="min-h-screen w-full bg-gradient-to-b from-slate-50 to-white p-4 md:p-8">
<div className="mx-auto max-w-6xl space-y-6">
<header className="flex flex-col gap-3 md:flex-row md:items-end md:justify-between">
<div>
<h1 className="text-3xl font-bold tracking-tight">Study Tracker</h1>
<p className="text-slate-600">Split your pages, track your progress, and finish on time.</p>
</div>
<div className="flex items-center gap-2">
<Button variant="secondary" onClick={resetProgress} className="gap-2">
<RefreshCcw className="h-4 w-4" /> Reset progress
</Button>
<Button variant="ghost" onClick={resetAll} className="gap-2">
<RefreshCcw className="h-4 w-4" /> Reset all
</Button>
</div>
</header>
<Card className="shadow-sm">
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Target className="h-5 w-5" /> Plan your study
</CardTitle>
</CardHeader>
<CardContent className="grid gap-4 md:grid-cols-4">
<div className="space-y-2">
<Label htmlFor="totalPages">Total pages</Label>
<Input
id="totalPages"
type="number"
min={1}
value={totalPages}
onChange={(e) => setTotalPages(Math.max(0, Math.floor(Number(e.target.value) || 0)))}
/>
</div>
<div className="space-y-2">
<Label htmlFor="endDate" className="flex items-center gap-2">
<CalendarDays className="h-4 w-4" /> Deadline
</Label>
<Input id="endDate" type="date" value={endDate} onChange={(e) => setEndDate(e.target.value)} />
</div>
<div className="flex items-center justify-between rounded-2xl border p-4">
<div>
<div className="font-medium">Include weekends</div>
<div className="text-sm text-slate-500">Count Saturdays & Sundays in the plan</div>
</div>
<Switch checked={includeWeekends} onCheckedChange={setIncludeWeekends} />
</div>
<div className="flex items-end">
<Button className="w-full" onClick={buildPlan}>
Build / Update plan
</Button>
</div>
{deadlinePassed && (
<div className="md:col-span-4 text-sm text-red-600">The deadline date is in the past. Please choose a future date.</div>
)}
</CardContent>
</Card>
<div className="grid gap-4 md:grid-cols-3">
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2 text-base">
<TrendingUp className="h-4 w-4" /> Overall progress
</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
<div className="flex justify-between text-sm text-slate-600">
<span>
{stats.studied} / {stats.planned} pages
</span>
<span>{stats.pct}%</span>
</div>
<Progress value={stats.pct} />
<div className="text-xs text-slate-500">{stats.remaining} pages remaining</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2 text-base">
<Flame className="h-4 w-4" /> Pace
</CardTitle>
</CardHeader>
<CardContent className="space-y-1 text-sm">
<div>
Average planned per day: <span className="font-semibold">{stats.avgPerDay}</span>
</div>
<div>
Days in plan: <span className="font-semibold">{days.length}</span>
</div>
<div>
Finish by: <span className="font-semibold">{formatPretty(endDate)}</span>
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2 text-base">
<CheckCircle2 className="h-4 w-4" /> Streaks
</CardTitle>
</CardHeader>
<CardContent>
<StreakBadge days={days} />
</CardContent>
</Card>
</div>
<Tabs defaultValue="calendar" className="w-full">
<TabsList>
<TabsTrigger value="calendar">Daily</TabsTrigger>
<TabsTrigger value="chart">Progress chart</TabsTrigger>
</TabsList>
<TabsContent value="chart">
<Card>
<CardHeader>
<CardTitle>Planned vs. Actual (cumulative)</CardTitle>
</CardHeader>
<CardContent className="h-72">
{chartData.length > 0 ? (
<ResponsiveContainer width="100%" height="100%">
<LineChart data={chartData} margin={{ top: 8, right: 16, bottom: 8, left: 0 }}>
<CartesianGrid strokeDasharray="3 3" />
<XAxis dataKey="date" interval={Math.floor(chartData.length / 7)} />
<YAxis allowDecimals={false} />
<RTooltip />
<Legend />
<Line type="monotone" dataKey="Planned" dot={false} strokeWidth={2} />
<Line type="monotone" dataKey="Actual" dot={false} strokeWidth={2} />
</LineChart>
</ResponsiveContainer>
) : (
<div className="text-sm text-slate-500">Create a plan to see the chart.</div>
)}
</CardContent>
</Card>
</TabsContent>
<TabsContent value="calendar">
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
{days.map((d) => {
const met = d.actual >= d.planned && d.planned > 0;
return (
<motion.div
key={d.date}
initial={{ opacity: 0, y: 8 }}
animate={{ opacity: 1, y: 0 }}
className={`rounded-2xl border p-4 shadow-sm ${met ? "ring-2 ring-green-500/40" : ""}`}
>
<div className="mb-2 flex items-center justify-between">
<div className="font-semibold">{formatPretty(d.date)}</div>
<TooltipProvider>
<Tooltip>
<TooltipTrigger className="text-xs text-slate-500">Planned</TooltipTrigger>
<TooltipContent>
<p>Pages planned for this day</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
<div className="mb-3 text-sm text-slate-600">
Target: <span className="font-semibold">{d.planned}</span> pages
</div>
<div className="flex items-end gap-2">
<div className="flex-1 space-y-2">
<Label htmlFor={`a-${d.date}`} className="text-xs">
Pages studied
</Label>
<Input
id={`a-${d.date}`}
type="number"
min={0}
value={d.actual}
onChange={(e) => updateActual(d.date, Number(e.target.value))}
/>
</div>
<div className="flex flex-col gap-2">
<Button size="sm" variant="secondary" onClick={() => bumpActual(d.date, d.planned)}>
+{d.planned}
</Button>
<Button size="sm" variant="outline" onClick={() => bumpActual(d.date, 5)}>
+5
</Button>
</div>
</div>
{met && (
<div className="mt-3 flex items-center gap-2 text-green-700">
<CheckCircle2 className="h-4 w-4" />
<span className="text-sm">Great job! You hit todays target 🎉</span>
</div>
)}
</motion.div>
);
})}
</div>
</TabsContent>
</Tabs>
<footer className="pt-4 text-center text-xs text-slate-500">
Data is stored locally in your browser. Coming next: user accounts and cloud sync.
</footer>
</div>
</div>
);
}
function StreakBadge({ days }: { days: DayPlan[] }) {
const today = toYMD(new Date());
const upToToday = days.filter((d) => d.date <= today);
let streak = 0;
for (let i = upToToday.length - 1; i >= 0; i--) {
const d = upToToday[i];
if (d.actual >= d.planned && d.planned > 0) streak++;
else break;
}
const best = (() => {
let bestStreak = 0;
let cur = 0;
for (const d of upToToday) {
if (d.actual >= d.planned && d.planned > 0) cur++;
else {
bestStreak = Math.max(bestStreak, cur);
cur = 0;
}
}
return Math.max(bestStreak, cur);
})();
return (
<div className="flex items-center justify-between rounded-2xl border p-4">
<div className="space-y-1">
<div className="text-sm text-slate-600">Current streak</div>
<div className="text-2xl font-bold">{streak}🔥</div>
</div>
<div className="h-10 w-px bg-slate-200" />
<div className="space-y-1">
<div className="text-sm text-slate-600">Best streak</div>
<div className="text-2xl font-bold">{best}</div>
</div>
</div>
);
}