Initial version of the app

This commit is contained in:
Greg 2025-08-09 23:14:36 +02:00
parent 0299acb4e6
commit b8aa1a442a
17 changed files with 722 additions and 0 deletions

5
app/globals.css Normal file
View File

@ -0,0 +1,5 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
html, body, #__next { height: 100%; }

15
app/layout.tsx Normal file
View File

@ -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 (
<html lang="en">
<body>{children}</body>
</html>
);
}

462
app/page.tsx Normal file
View File

@ -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<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>
);
}

30
components/ui/button.tsx Normal file
View File

@ -0,0 +1,30 @@
import * as React from "react";
import { twMerge } from "tailwind-merge";
export interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
variant?: "default" | "secondary" | "ghost" | "outline";
size?: "default" | "sm";
}
const variants: Record<string, string> = {
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<string, string> = {
default: "h-10 px-4 py-2 rounded-2xl",
sm: "h-8 px-3 rounded-xl text-sm",
};
export const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
({ className, variant = "default", size = "default", ...props }, ref) => (
<button
ref={ref}
className={twMerge("inline-flex items-center justify-center font-medium transition-colors focus:outline-none focus:ring-2 focus:ring-slate-400 focus:ring-offset-2 disabled:opacity-50 disabled:pointer-events-none shadow-sm", variants[variant], sizes[size], className)}
{...props}
/>
)
);
Button.displayName = "Button";

15
components/ui/card.tsx Normal file
View File

@ -0,0 +1,15 @@
import * as React from "react";
import { twMerge } from "tailwind-merge";
export function Card({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) {
return <div className={twMerge("rounded-2xl border bg-white", className)} {...props} />;
}
export function CardHeader({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) {
return <div className={twMerge("p-4 pb-0", className)} {...props} />;
}
export function CardTitle({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) {
return <h3 className={twMerge("text-lg font-semibold", className)} {...props} />;
}
export function CardContent({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) {
return <div className={twMerge("p-4", className)} {...props} />;
}

15
components/ui/input.tsx Normal file
View File

@ -0,0 +1,15 @@
import * as React from "react";
import { twMerge } from "tailwind-merge";
export interface InputProps extends React.InputHTMLAttributes<HTMLInputElement> {}
export const Input = React.forwardRef<HTMLInputElement, InputProps>(({ className, ...props }, ref) => {
return (
<input
ref={ref}
className={twMerge("flex h-10 w-full rounded-2xl border border-slate-300 bg-white px-3 py-2 text-sm ring-offset-white placeholder:text-slate-400 focus:outline-none focus:ring-2 focus:ring-slate-400 disabled:opacity-50", className)}
{...props}
/>
);
});
Input.displayName = "Input";

6
components/ui/label.tsx Normal file
View File

@ -0,0 +1,6 @@
import * as React from "react";
import { twMerge } from "tailwind-merge";
export function Label({ className, ...props }: React.LabelHTMLAttributes<HTMLLabelElement>) {
return <label className={twMerge("text-sm font-medium text-slate-700", className)} {...props} />;
}

View File

@ -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 (
<div className={twMerge("h-3 w-full overflow-hidden rounded-full bg-slate-200", className)}>
<div className="h-full bg-black transition-all" style={{ width: pct + "%" }} />
</div>
);
}

14
components/ui/switch.tsx Normal file
View File

@ -0,0 +1,14 @@
import * as React from "react";
export function Switch({ checked, onCheckedChange }: { checked: boolean; onCheckedChange: (v: boolean) => void }) {
return (
<button
onClick={() => onCheckedChange(!checked)}
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors ${checked ? "bg-black" : "bg-slate-300"}`}
>
<span
className={`inline-block h-5 w-5 transform rounded-full bg-white transition-transform ${checked ? "translate-x-5" : "translate-x-1"}`}
/>
</button>
);
}

45
components/ui/tabs.tsx Normal file
View File

@ -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<TabsContextValue | null>(null);
export function Tabs({ defaultValue, className, children }: { defaultValue: string; className?: string; children: React.ReactNode }) {
const [value, setValue] = React.useState(defaultValue);
return (
<div className={twMerge("w-full", className)}>
<TabsCtx.Provider value={{ value, setValue }}>{children}</TabsCtx.Provider>
</div>
);
}
export function TabsList({ className, children }: { className?: string; children: React.ReactNode }) {
return <div className={twMerge("mb-3 inline-flex items-center gap-2 rounded-2xl border bg-slate-100 p-1", className)}>{children}</div>;
}
export function TabsTrigger({ value, children }: { value: string; children: React.ReactNode }) {
const ctx = React.useContext(TabsCtx)!;
const active = ctx.value === value;
return (
<button
onClick={() => ctx.setValue(value)}
className={twMerge(
"px-4 py-2 rounded-xl text-sm",
active ? "bg-white shadow border" : "text-slate-600 hover:text-slate-900"
)}
>
{children}
</button>
);
}
export function TabsContent({ value, children }: { value: string; children: React.ReactNode }) {
const ctx = React.useContext(TabsCtx)!;
if (ctx.value !== value) return null;
return <div className="mt-2">{children}</div>;
}

15
components/ui/tooltip.tsx Normal file
View File

@ -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 <span className={className} title="">{children}</span>;
}
export function TooltipContent({ children }: { children: React.ReactNode }) {
// Minimal: native title attribute on trigger is simpler; content hidden.
return <>{children}</>;
}

5
next-env.d.ts vendored Normal file
View File

@ -0,0 +1,5 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
// NOTE: This file should not be edited
// see https://nextjs.org/docs/basic-features/typescript for more information.

1
next.config.js Normal file
View File

@ -0,0 +1 @@
module.exports = { reactStrictMode: true };

31
package.json Normal file
View File

@ -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"
}
}

6
postcss.config.js Normal file
View File

@ -0,0 +1,6 @@
module.exports = {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
};

12
tailwind.config.js Normal file
View File

@ -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: [],
};

34
tsconfig.json Normal file
View File

@ -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"
]
}