462 lines
16 KiB
TypeScript
462 lines
16 KiB
TypeScript
"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);
|
||
return d.toLocaleDateString(undefined, { weekday: "short", month: "short", day: "numeric" });
|
||
} 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 today’s 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>
|
||
);
|
||
} |