From b8aa1a442a935558a54b87c46b01314605fa9fe4 Mon Sep 17 00:00:00 2001 From: Greg Date: Sat, 9 Aug 2025 23:14:36 +0200 Subject: [PATCH] Initial version of the app --- app/globals.css | 5 + app/layout.tsx | 15 ++ app/page.tsx | 462 +++++++++++++++++++++++++++++++++++++ components/ui/button.tsx | 30 +++ components/ui/card.tsx | 15 ++ components/ui/input.tsx | 15 ++ components/ui/label.tsx | 6 + components/ui/progress.tsx | 11 + components/ui/switch.tsx | 14 ++ components/ui/tabs.tsx | 45 ++++ components/ui/tooltip.tsx | 15 ++ next-env.d.ts | 5 + next.config.js | 1 + package.json | 31 +++ postcss.config.js | 6 + tailwind.config.js | 12 + tsconfig.json | 34 +++ 17 files changed, 722 insertions(+) create mode 100644 app/globals.css create mode 100644 app/layout.tsx create mode 100644 app/page.tsx create mode 100644 components/ui/button.tsx create mode 100644 components/ui/card.tsx create mode 100644 components/ui/input.tsx create mode 100644 components/ui/label.tsx create mode 100644 components/ui/progress.tsx create mode 100644 components/ui/switch.tsx create mode 100644 components/ui/tabs.tsx create mode 100644 components/ui/tooltip.tsx create mode 100644 next-env.d.ts create mode 100644 next.config.js create mode 100644 package.json create mode 100644 postcss.config.js create mode 100644 tailwind.config.js create mode 100644 tsconfig.json 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 ( +
+
+
+
+

Study Tracker

+

Split your pages, track your progress, and finish on time.

+
+
+ + +
+
+ + + + + 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 🎉 +
+ )} +
+ ); + })} +
+
+
+ +
+ Data is stored locally in your browser. Coming next: user accounts and cloud sync. +
+
+
+ ); +} + +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}🔥
+
+
+
+
Best streak
+
{best}
+
+
+ ); +} \ 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) => ( + + ); +} \ 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