diff --git a/app/globals.css b/app/globals.css
new file mode 100644
index 0000000..7fb786d
--- /dev/null
+++ b/app/globals.css
@@ -0,0 +1,5 @@
+@tailwind base;
+@tailwind components;
+@tailwind utilities;
+
+html, body, #__next { height: 100%; }
\ No newline at end of file
diff --git a/app/layout.tsx b/app/layout.tsx
new file mode 100644
index 0000000..2ed3887
--- /dev/null
+++ b/app/layout.tsx
@@ -0,0 +1,15 @@
+import "./globals.css";
+import type { Metadata } from "next";
+
+export const metadata: Metadata = {
+ title: "Study Tracker",
+ description: "Split your pages, track your progress, and finish on time.",
+};
+
+export default function RootLayout({ children }: { children: React.ReactNode }) {
+ return (
+
+
{children}
+
+ );
+}
\ No newline at end of file
diff --git a/app/page.tsx b/app/page.tsx
new file mode 100644
index 0000000..ecb7231
--- /dev/null
+++ b/app/page.tsx
@@ -0,0 +1,462 @@
+"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(200);
+ const [endDate, setEndDate] = useState(toYMD(new Date(Date.now() + 7 * 86400000)));
+ const [includeWeekends, setIncludeWeekends] = useState(true);
+ const [days, setDays] = useState([]);
+
+ 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 (
+
+
+
+
+
+
+
+ Plan your study
+
+
+
+
+
+ setTotalPages(Math.max(0, Math.floor(Number(e.target.value) || 0)))}
+ />
+
+
+
+ setEndDate(e.target.value)} />
+
+
+
+
Include weekends
+
Count Saturdays & Sundays in the plan
+
+
+
+
+
+
+ {deadlinePassed && (
+ The deadline date is in the past. Please choose a future date.
+ )}
+
+
+
+
+
+
+
+ Overall progress
+
+
+
+
+
+ {stats.studied} / {stats.planned} pages
+
+ {stats.pct}%
+
+
+ {stats.remaining} pages remaining
+
+
+
+
+
+ Pace
+
+
+
+
+ Average planned per day: {stats.avgPerDay}
+
+
+ Days in plan: {days.length}
+
+
+ Finish by: {formatPretty(endDate)}
+
+
+
+
+
+
+ Streaks
+
+
+
+
+
+
+
+
+
+
+ Daily
+ Progress chart
+
+
+
+
+ Planned vs. Actual (cumulative)
+
+
+ {chartData.length > 0 ? (
+
+
+
+
+
+
+
+
+
+
+
+ ) : (
+ Create a plan to see the chart.
+ )}
+
+
+
+
+
+ {days.map((d) => {
+ const met = d.actual >= d.planned && d.planned > 0;
+ return (
+
+
+
{formatPretty(d.date)}
+
+
+ Planned
+
+ Pages planned for this day
+
+
+
+
+
+ Target: {d.planned} pages
+
+
+
+
+ updateActual(d.date, Number(e.target.value))}
+ />
+
+
+
+
+
+
+ {met && (
+
+
+ Great job! You hit today’s target 🎉
+
+ )}
+
+ );
+ })}
+
+
+
+
+
+
+
+ );
+}
+
+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 (
+
+
+
Current streak
+
{streak}🔥
+
+
+
+
+ );
+}
\ No newline at end of file
diff --git a/components/ui/button.tsx b/components/ui/button.tsx
new file mode 100644
index 0000000..926607c
--- /dev/null
+++ b/components/ui/button.tsx
@@ -0,0 +1,30 @@
+import * as React from "react";
+import { twMerge } from "tailwind-merge";
+
+export interface ButtonProps extends React.ButtonHTMLAttributes {
+ variant?: "default" | "secondary" | "ghost" | "outline";
+ size?: "default" | "sm";
+}
+
+const variants: Record = {
+ default: "bg-black text-white hover:bg-black/90",
+ secondary: "bg-slate-100 text-slate-900 hover:bg-slate-200",
+ ghost: "bg-transparent hover:bg-slate-100",
+ outline: "border border-slate-300 hover:bg-slate-50",
+};
+
+const sizes: Record = {
+ default: "h-10 px-4 py-2 rounded-2xl",
+ sm: "h-8 px-3 rounded-xl text-sm",
+};
+
+export const Button = React.forwardRef(
+ ({ className, variant = "default", size = "default", ...props }, ref) => (
+
+ )
+);
+Button.displayName = "Button";
\ No newline at end of file
diff --git a/components/ui/card.tsx b/components/ui/card.tsx
new file mode 100644
index 0000000..2562015
--- /dev/null
+++ b/components/ui/card.tsx
@@ -0,0 +1,15 @@
+import * as React from "react";
+import { twMerge } from "tailwind-merge";
+
+export function Card({ className, ...props }: React.HTMLAttributes) {
+ return ;
+}
+export function CardHeader({ className, ...props }: React.HTMLAttributes) {
+ return ;
+}
+export function CardTitle({ className, ...props }: React.HTMLAttributes) {
+ return ;
+}
+export function CardContent({ className, ...props }: React.HTMLAttributes) {
+ return ;
+}
\ No newline at end of file
diff --git a/components/ui/input.tsx b/components/ui/input.tsx
new file mode 100644
index 0000000..74b350b
--- /dev/null
+++ b/components/ui/input.tsx
@@ -0,0 +1,15 @@
+import * as React from "react";
+import { twMerge } from "tailwind-merge";
+
+export interface InputProps extends React.InputHTMLAttributes {}
+
+export const Input = React.forwardRef(({ className, ...props }, ref) => {
+ return (
+
+ );
+});
+Input.displayName = "Input";
\ No newline at end of file
diff --git a/components/ui/label.tsx b/components/ui/label.tsx
new file mode 100644
index 0000000..46aea8f
--- /dev/null
+++ b/components/ui/label.tsx
@@ -0,0 +1,6 @@
+import * as React from "react";
+import { twMerge } from "tailwind-merge";
+
+export function Label({ className, ...props }: React.LabelHTMLAttributes) {
+ return ;
+}
\ No newline at end of file
diff --git a/components/ui/progress.tsx b/components/ui/progress.tsx
new file mode 100644
index 0000000..fdfd84e
--- /dev/null
+++ b/components/ui/progress.tsx
@@ -0,0 +1,11 @@
+import * as React from "react";
+import { twMerge } from "tailwind-merge";
+
+export function Progress({ value = 0, className }: { value?: number; className?: string }) {
+ const pct = Math.min(100, Math.max(0, value));
+ return (
+
+ );
+}
\ No newline at end of file
diff --git a/components/ui/switch.tsx b/components/ui/switch.tsx
new file mode 100644
index 0000000..0678b83
--- /dev/null
+++ b/components/ui/switch.tsx
@@ -0,0 +1,14 @@
+import * as React from "react";
+
+export function Switch({ checked, onCheckedChange }: { checked: boolean; onCheckedChange: (v: boolean) => void }) {
+ return (
+
+ );
+}
\ No newline at end of file
diff --git a/components/ui/tabs.tsx b/components/ui/tabs.tsx
new file mode 100644
index 0000000..6b775cc
--- /dev/null
+++ b/components/ui/tabs.tsx
@@ -0,0 +1,45 @@
+"use client";
+import * as React from "react";
+import { twMerge } from "tailwind-merge";
+
+type TabsContextValue = {
+ value: string;
+ setValue: (v: string) => void;
+};
+
+const TabsCtx = React.createContext(null);
+
+export function Tabs({ defaultValue, className, children }: { defaultValue: string; className?: string; children: React.ReactNode }) {
+ const [value, setValue] = React.useState(defaultValue);
+ return (
+
+ {children}
+
+ );
+}
+
+export function TabsList({ className, children }: { className?: string; children: React.ReactNode }) {
+ return {children}
;
+}
+
+export function TabsTrigger({ value, children }: { value: string; children: React.ReactNode }) {
+ const ctx = React.useContext(TabsCtx)!;
+ const active = ctx.value === value;
+ return (
+
+ );
+}
+
+export function TabsContent({ value, children }: { value: string; children: React.ReactNode }) {
+ const ctx = React.useContext(TabsCtx)!;
+ if (ctx.value !== value) return null;
+ return {children}
;
+}
\ No newline at end of file
diff --git a/components/ui/tooltip.tsx b/components/ui/tooltip.tsx
new file mode 100644
index 0000000..f3c7f3b
--- /dev/null
+++ b/components/ui/tooltip.tsx
@@ -0,0 +1,15 @@
+import * as React from "react";
+
+export function TooltipProvider({ children }: { children: React.ReactNode }) {
+ return <>{children}>;
+}
+export function Tooltip({ children }: { children: React.ReactNode }) {
+ return <>{children}>;
+}
+export function TooltipTrigger({ children, className }: { children: React.ReactNode; className?: string }) {
+ return {children};
+}
+export function TooltipContent({ children }: { children: React.ReactNode }) {
+ // Minimal: native title attribute on trigger is simpler; content hidden.
+ return <>{children}>;
+}
\ No newline at end of file
diff --git a/next-env.d.ts b/next-env.d.ts
new file mode 100644
index 0000000..4f11a03
--- /dev/null
+++ b/next-env.d.ts
@@ -0,0 +1,5 @@
+///
+///
+
+// NOTE: This file should not be edited
+// see https://nextjs.org/docs/basic-features/typescript for more information.
diff --git a/next.config.js b/next.config.js
new file mode 100644
index 0000000..04b970e
--- /dev/null
+++ b/next.config.js
@@ -0,0 +1 @@
+module.exports = { reactStrictMode: true };
diff --git a/package.json b/package.json
new file mode 100644
index 0000000..43309fd
--- /dev/null
+++ b/package.json
@@ -0,0 +1,31 @@
+{
+ "name": "study-tracker",
+ "private": true,
+ "version": "0.1.0",
+ "scripts": {
+ "dev": "next dev",
+ "build": "next build",
+ "start": "next start",
+ "lint": "next lint"
+ },
+ "dependencies": {
+ "next": "14.2.3",
+ "react": "18.2.0",
+ "react-dom": "18.2.0",
+ "framer-motion": "^11.0.0",
+ "recharts": "^2.8.0",
+ "lucide-react": "^0.441.0",
+ "tailwind-merge": "^2.3.0"
+ },
+ "devDependencies": {
+ "autoprefixer": "^10.4.19",
+ "postcss": "^8.4.38",
+ "tailwindcss": "^3.4.7",
+ "typescript": "^5.4.0",
+ "@types/react": "^18.2.0",
+ "@types/node": "^20.10.6",
+ "@types/react-dom": "^18.2.0",
+ "eslint": "^8.57.0",
+ "eslint-config-next": "^14.2.3"
+ }
+}
\ No newline at end of file
diff --git a/postcss.config.js b/postcss.config.js
new file mode 100644
index 0000000..12a703d
--- /dev/null
+++ b/postcss.config.js
@@ -0,0 +1,6 @@
+module.exports = {
+ plugins: {
+ tailwindcss: {},
+ autoprefixer: {},
+ },
+};
diff --git a/tailwind.config.js b/tailwind.config.js
new file mode 100644
index 0000000..c6dbdc6
--- /dev/null
+++ b/tailwind.config.js
@@ -0,0 +1,12 @@
+/** @type {import('tailwindcss').Config} */
+module.exports = {
+ content: [
+ "./app/**/*.{js,ts,jsx,tsx}",
+ "./components/**/*.{js,ts,jsx,tsx}",
+ "./pages/**/*.{js,ts,jsx,tsx}",
+ ],
+ theme: {
+ extend: {},
+ },
+ plugins: [],
+};
diff --git a/tsconfig.json b/tsconfig.json
new file mode 100644
index 0000000..72356cf
--- /dev/null
+++ b/tsconfig.json
@@ -0,0 +1,34 @@
+{
+ "compilerOptions": {
+ "target": "ES2020",
+ "lib": [
+ "dom",
+ "dom.iterable",
+ "esnext"
+ ],
+ "allowJs": false,
+ "skipLibCheck": true,
+ "strict": true,
+ "noEmit": true,
+ "esModuleInterop": true,
+ "module": "esnext",
+ "moduleResolution": "bundler",
+ "resolveJsonModule": true,
+ "isolatedModules": true,
+ "jsx": "preserve",
+ "baseUrl": ".",
+ "paths": {
+ "@/*": [
+ "./*"
+ ]
+ }
+ },
+ "include": [
+ "next-env.d.ts",
+ "**/*.ts",
+ "**/*.tsx"
+ ],
+ "exclude": [
+ "node_modules"
+ ]
+}
\ No newline at end of file