This commit is contained in:
2025-10-13 16:19:17 +02:00
parent b83fe9c5c4
commit 7f6db80195
30 changed files with 12203 additions and 0 deletions

51
.gitignore vendored Normal file
View File

@@ -0,0 +1,51 @@
# Node.js
node_modules/
# Build output
/dist
/build
# Vite
.vite
# Logs
npm-debug.log*
yarn-debug.log*
yarn-error.log*
# OS
.DS_Store
Thumbs.db
# IDE
.vscode/
.idea/
*.suo
*.ntvs*
*.njsproj
*.sln
# Env
.env
.env.local
.env.development.local
.env.test.local
.env.production.local
# Misc
*.log
# Tailwind
.tailwind*
# Coverage
coverage/
# Others
*.tgz
# Optional: lock files
# Uncomment if you want to ignore lock files
# package-lock.json
# yarn.lock
# pnpm-lock.yaml

15
index.html Normal file
View File

@@ -0,0 +1,15 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Habit Tracker App</title>
<meta name="description" content="Track your habits and visualize your progress with HabitGrid." />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<link rel="stylesheet" href="/src/index.css" />
</head>
<body class="bg-slate-50 dark:bg-slate-950 text-slate-900 dark:text-slate-100">
<div id="root"></div>
<script type="module" src="/src/main.jsx"></script>
</body>
</html>

10145
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

51
package.json Normal file
View File

@@ -0,0 +1,51 @@
{
"name": "habitgrid",
"type": "module",
"version": "0.0.0",
"private": true,
"scripts": {
"dev": "vite --host :: --port 3000",
"build": "node tools/generate-llms.js || true && vite build",
"preview": "vite preview --host :: --port 3000"
},
"dependencies": {
"@radix-ui/react-alert-dialog": "^1.0.5",
"@radix-ui/react-avatar": "^1.0.3",
"@radix-ui/react-checkbox": "^1.0.4",
"@radix-ui/react-dialog": "^1.0.5",
"@radix-ui/react-dropdown-menu": "^2.0.5",
"@radix-ui/react-label": "^2.0.2",
"@radix-ui/react-slider": "^1.1.2",
"@radix-ui/react-slot": "^1.2.3",
"@radix-ui/react-switch": "^1.0.3",
"@radix-ui/react-tabs": "^1.0.4",
"@radix-ui/react-toast": "^1.1.5",
"class-variance-authority": "^0.7.1",
"clsx": "^2.0.0",
"framer-motion": "^10.16.4",
"lucide-react": "^0.285.0",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-helmet": "^6.1.0",
"react-router-dom": "^6.16.0",
"tailwind-merge": "^1.14.0",
"tailwindcss-animate": "^1.0.7"
},
"devDependencies": {
"@babel/generator": "^7.27.0",
"@babel/parser": "^7.27.0",
"@babel/traverse": "^7.27.0",
"@babel/types": "^7.27.0",
"@types/node": "^20.8.3",
"@types/react": "^18.2.15",
"@types/react-dom": "^18.2.7",
"@vitejs/plugin-react": "^4.0.3",
"autoprefixer": "^10.4.16",
"eslint": "^8.57.1",
"eslint-config-react-app": "^7.0.1",
"postcss": "^8.4.31",
"tailwindcss": "^3.3.3",
"terser": "^5.39.0",
"vite": "^7.1.9"
}
}

6
postcss.config.js Normal file
View File

@@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
};

31
src/App.jsx Normal file
View File

@@ -0,0 +1,31 @@
import React from 'react';
import { BrowserRouter as Router, Routes, Route } from 'react-router-dom';
import { Helmet } from 'react-helmet';
import HomePage from './pages/HomePage';
import HabitDetailPage from './pages/HabitDetailPage';
import AddEditHabitPage from './pages/AddEditHabitPage';
import SettingsPage from './pages/SettingsPage';
import { Toaster } from './components/ui/toaster';
function App() {
return (
<Router>
<Helmet>
<title>HabitGrid - Commit to yourself, one square at a time</title>
<meta name="description" content="Track your habits with a beautiful GitHub-style contribution grid. Build streaks, visualize progress, and commit to yourself daily." />
</Helmet>
<div className="min-h-screen bg-background">
<Routes>
<Route path="/" element={<HomePage />} />
<Route path="/habit/:id" element={<HabitDetailPage />} />
<Route path="/add" element={<AddEditHabitPage />} />
<Route path="/edit/:id" element={<AddEditHabitPage />} />
<Route path="/settings" element={<SettingsPage />} />
</Routes>
<Toaster />
</div>
</Router>
);
}
export default App;

View File

@@ -0,0 +1,17 @@
import React from 'react';
import { motion } from 'framer-motion';
const CallToAction = () => {
return (
<motion.p
className='text-md text-white max-w-lg mx-auto'
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ duration: 0.5, delay: 0.8 }}
>
Let's turn your ideas into reality.
</motion.p>
);
};
export default CallToAction;

View File

@@ -0,0 +1,65 @@
import React from 'react';
import { motion } from 'framer-motion';
import { Check } from 'lucide-react';
const PRESET_COLORS = [
'#22c55e', // green
'#3b82f6', // blue
'#8b5cf6', // purple
'#ec4899', // pink
'#f59e0b', // amber
'#ef4444', // red
'#06b6d4', // cyan
'#10b981', // emerald
'#6366f1', // indigo
'#f97316', // orange
];
const ColorPicker = ({ selectedColor, onColorChange }) => {
return (
<div className="space-y-3">
<div className="grid grid-cols-5 gap-2">
{PRESET_COLORS.map((color) => (
<motion.button
key={color}
whileHover={{ scale: 1.1 }}
whileTap={{ scale: 0.95 }}
onClick={() => onColorChange(color)}
className="relative w-12 h-12 rounded-xl transition-all"
style={{
backgroundColor: color,
boxShadow: selectedColor === color
? `0 0 0 3px ${color}40`
: 'none',
}}
>
{selectedColor === color && (
<motion.div
initial={{ scale: 0 }}
animate={{ scale: 1 }}
className="absolute inset-0 flex items-center justify-center"
>
<Check className="w-6 h-6 text-white drop-shadow-lg" />
</motion.div>
)}
</motion.button>
))}
</div>
<div className="flex items-center gap-3">
<input
type="color"
value={selectedColor}
onChange={(e) => onColorChange(e.target.value)}
className="w-12 h-12 rounded-xl cursor-pointer border-2 border-slate-200 dark:border-slate-700"
/>
<div className="flex-1">
<p className="text-sm font-medium">Custom Color</p>
<p className="text-xs text-muted-foreground">{selectedColor}</p>
</div>
</div>
</div>
);
};
export default ColorPicker;

View File

@@ -0,0 +1,34 @@
import React from 'react';
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from './ui/alert-dialog';
const DeleteHabitDialog = ({ open, onOpenChange, onConfirm, habitName }) => {
return (
<AlertDialog open={open} onOpenChange={onOpenChange}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Delete Habit?</AlertDialogTitle>
<AlertDialogDescription>
Are you sure you want to delete "{habitName}"? This will permanently remove all progress and cannot be undone.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction onClick={onConfirm} className="bg-destructive text-destructive-foreground hover:bg-destructive/90">
Delete
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
);
};
export default DeleteHabitDialog;

View File

@@ -0,0 +1,54 @@
import React from 'react';
import { useNavigate } from 'react-router-dom';
import { motion } from 'framer-motion';
import { ChevronRight, Flame } from 'lucide-react';
import { Button } from './ui/button';
import MiniGrid from './MiniGrid';
const HabitCard = ({ habit, onUpdate }) => {
const navigate = useNavigate();
return (
<motion.div
whileHover={{ scale: 1.01 }}
whileTap={{ scale: 0.99 }}
className="bg-white dark:bg-slate-800 rounded-2xl p-5 shadow-sm border border-slate-200 dark:border-slate-700 cursor-pointer transition-all hover:shadow-md"
onClick={() => navigate(`/habit/${habit.id}`)}
>
<div className="flex items-start justify-between mb-4">
<div className="flex-1">
<div className="flex items-center gap-2 mb-1">
<div
className="w-3 h-3 rounded"
style={{ backgroundColor: habit.color }}
/>
<h3 className="font-semibold text-lg">{habit.name}</h3>
</div>
<div className="flex items-center gap-4 text-sm text-muted-foreground">
<div className="flex items-center gap-1">
<Flame className="w-4 h-4 text-orange-500" />
<span>{habit.currentStreak || 0} day streak</span>
</div>
<span></span>
<span>Personal Record: {habit.longestStreak || 0} days</span>
</div>
</div>
<Button
variant="ghost"
size="icon"
className="rounded-full"
onClick={(e) => {
e.stopPropagation();
navigate(`/habit/${habit.id}`);
}}
>
<ChevronRight className="w-5 h-5" />
</Button>
</div>
<MiniGrid habit={habit} onUpdate={onUpdate} />
</motion.div>
);
};
export default HabitCard;

View File

@@ -0,0 +1,120 @@
import React, { useMemo } from 'react';
import { motion } from 'framer-motion';
import { getColorIntensity, isToday, formatDate, getWeekdayLabel } from '../lib/utils-habit';
import { toggleCompletion } from '../lib/storage';
const HabitGrid = ({ habit, onUpdate, fullView = false }) => {
const weeks = useMemo(() => {
const today = new Date();
// Find the Monday of the current week
const todayDay = today.getDay(); // 0=Sun, 1=Mon, ...
const daysSinceMonday = (todayDay + 6) % 7; // 0=Mon, 1=Tue, ..., 6=Sun
const mondayThisWeek = new Date(today);
mondayThisWeek.setDate(today.getDate() - daysSinceMonday);
const weeksArray = [];
const totalWeeks = fullView ? 52 : 12;
for (let week = totalWeeks - 1; week >= 0; week--) {
const weekDays = [];
// For each week, calculate Monday, then add 0..6 days for each row
const monday = new Date(mondayThisWeek);
monday.setDate(mondayThisWeek.getDate() - week * 7);
for (let day = 0; day < 7; day++) {
const date = new Date(monday);
date.setDate(monday.getDate() + day);
weekDays.push(date);
}
weeksArray.push(weekDays);
}
return weeksArray;
}, [fullView]);
const handleCellClick = (date) => {
toggleCompletion(habit.id, formatDate(date));
onUpdate();
};
return (
<div className="bg-white dark:bg-slate-800 rounded-2xl p-6 shadow-sm border border-slate-200 dark:border-slate-700">
<div className="mb-4">
<h2 className="text-lg font-semibold mb-1">Activity Calendar</h2>
<p className="text-sm text-muted-foreground">
Tap any day to mark it as complete
</p>
</div>
<div className="overflow-x-auto grid-scroll">
<div className="inline-flex gap-1">
{/* Weekday labels: Monday (top) to Sunday (bottom) */}
<div className="flex flex-col gap-1 mr-2">
<div className="h-3" />
{[1, 2, 3, 4, 5, 6, 0].map((day) => (
<div
key={day}
className="h-3 flex items-center justify-end text-xs text-muted-foreground pr-1"
>
{getWeekdayLabel(day)}
</div>
))}
</div>
{/* Grid: Monday (top) to Sunday (bottom) */}
{weeks.map((week, weekIndex) => (
<div key={weekIndex} className="flex flex-col gap-1">
{/* Month label */}
<div className="h-3 text-xs text-muted-foreground text-center">
{weekIndex % 4 === 0 && week[0].toLocaleDateString('en-US', { month: 'short' })}
</div>
{/* Days: Monday (top) to Sunday (bottom) */}
{week.map((date, dayIndex) => {
const dateStr = formatDate(date);
const isCompleted = habit.completions.includes(dateStr);
const intensity = isCompleted ? getColorIntensity(habit.completions, dateStr) : 0;
const isTodayCell = isToday(date);
const isFuture = date > new Date();
return (
<motion.button
key={dayIndex}
whileHover={{ scale: 1.15 }}
whileTap={{ scale: 0.9 }}
onClick={() => handleCellClick(date)}
className="habit-cell w-3 h-3 rounded-sm"
style={{
backgroundColor: isCompleted ? habit.color : 'transparent',
opacity: isFuture ? 0 : (isCompleted ? 0.3 + (intensity * 0.7) : 1),
border: isTodayCell ? `2px solid ${habit.color}` : `1px solid ${habit.color}20`,
pointerEvents: isFuture ? 'none' : 'auto',
visibility: isFuture ? 'hidden' : 'visible',
}}
title={`${dateStr}${isCompleted ? ' ✓' : ''}`}
/>
);
})}
</div>
))}
</div>
</div>
{/* Legend */}
<div className="flex items-center gap-2 mt-4 text-xs text-muted-foreground">
<span>Less</span>
<div className="flex gap-1">
{[0, 0.25, 0.5, 0.75, 1].map((intensity, i) => (
<div
key={i}
className="w-3 h-3 rounded-sm"
style={{
backgroundColor: habit.color,
opacity: 0.3 + (intensity * 0.7),
border: `1px solid ${habit.color}20`,
}}
/>
))}
</div>
<span>More</span>
</div>
</div>
);
};
export default HabitGrid;

View File

@@ -0,0 +1,54 @@
import React from 'react';
import { motion } from 'framer-motion';
import { getColorIntensity, isToday, formatDate } from '../lib/utils-habit';
import { toggleCompletion } from '../lib/storage';
const MiniGrid = ({ habit, onUpdate }) => {
const today = new Date();
const days = [];
for (let i = 27; i >= 0; i--) {
const date = new Date(today);
date.setDate(date.getDate() - i);
days.push(date);
}
const handleCellClick = (e, date) => {
e.stopPropagation();
toggleCompletion(habit.id, formatDate(date));
onUpdate();
};
return (
<div className="flex gap-1 overflow-x-auto grid-scroll pb-2">
{days.map((date, index) => {
const dateStr = formatDate(date);
const isCompleted = habit.completions.includes(dateStr);
const intensity = isCompleted ? getColorIntensity(habit.completions, dateStr) : 0;
const isTodayCell = isToday(date);
return (
<motion.button
key={index}
whileHover={{ scale: 0.9 }}
whileTap={{ scale: 0.5 }}
onClick={(e) => handleCellClick(e, date)}
className="habit-cell flex w-8 h-8 rounded-2xl transition-all"
style={{
backgroundColor: isCompleted
? habit.color
: 'transparent',
opacity: isCompleted ? 0.3 + (intensity * 0.7) : 1,
border: isTodayCell
? `2px solid ${habit.color}`
: `1px solid ${habit.color}20`,
}}
title={dateStr}
/>
);
})}
</div>
);
};
export default MiniGrid;

View File

@@ -0,0 +1,103 @@
import React from 'react';
import * as AlertDialogPrimitive from '@radix-ui/react-alert-dialog';
import { cn } from '../../lib/utils';
import { buttonVariants } from './button';
const AlertDialog = AlertDialogPrimitive.Root;
const AlertDialogTrigger = AlertDialogPrimitive.Trigger;
const AlertDialogPortal = ({ ...props }) => (
<AlertDialogPrimitive.Portal {...props} />
);
AlertDialogPortal.displayName = AlertDialogPrimitive.Portal.displayName;
const AlertDialogOverlay = React.forwardRef(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Overlay
className={cn(
'fixed inset-0 z-50 bg-background/80 backdrop-blur-sm data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0',
className
)}
{...props}
ref={ref}
/>
));
AlertDialogOverlay.displayName = AlertDialogPrimitive.Overlay.displayName;
const AlertDialogContent = React.forwardRef(({ className, ...props }, ref) => (
<AlertDialogPortal>
<AlertDialogOverlay />
<AlertDialogPrimitive.Content
ref={ref}
className={cn(
'fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg',
className
)}
{...props}
/>
</AlertDialogPortal>
));
AlertDialogContent.displayName = AlertDialogPrimitive.Content.displayName;
const AlertDialogHeader = ({ className, ...props }) => (
<div
className={cn('flex flex-col space-y-2 text-center sm:text-left', className)}
{...props}
/>
);
AlertDialogHeader.displayName = 'AlertDialogHeader';
const AlertDialogFooter = ({ className, ...props }) => (
<div
className={cn('flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2', className)}
{...props}
/>
);
AlertDialogFooter.displayName = 'AlertDialogFooter';
const AlertDialogTitle = React.forwardRef(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Title
ref={ref}
className={cn('text-lg font-semibold', className)}
{...props}
/>
));
AlertDialogTitle.displayName = AlertDialogPrimitive.Title.displayName;
const AlertDialogDescription = React.forwardRef(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Description
ref={ref}
className={cn('text-sm text-muted-foreground', className)}
{...props}
/>
));
AlertDialogDescription.displayName = AlertDialogPrimitive.Description.displayName;
const AlertDialogAction = React.forwardRef(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Action
ref={ref}
className={cn(buttonVariants(), className)}
{...props}
/>
));
AlertDialogAction.displayName = AlertDialogPrimitive.Action.displayName;
const AlertDialogCancel = React.forwardRef(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Cancel
ref={ref}
className={cn(buttonVariants({ variant: 'outline' }), 'mt-2 sm:mt-0', className)}
{...props}
/>
));
AlertDialogCancel.displayName = AlertDialogPrimitive.Cancel.displayName;
export {
AlertDialog,
AlertDialogTrigger,
AlertDialogContent,
AlertDialogHeader,
AlertDialogFooter,
AlertDialogTitle,
AlertDialogDescription,
AlertDialogAction,
AlertDialogCancel,
};

View File

@@ -0,0 +1,47 @@
import { cn } from '../../lib/utils';
import { Slot } from '@radix-ui/react-slot';
import { cva } from 'class-variance-authority';
import React from 'react';
const buttonVariants = cva(
'inline-flex items-center justify-center rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50',
{
variants: {
variant: {
default: 'bg-primary text-primary-foreground hover:bg-primary/90',
destructive:
'bg-destructive text-destructive-foreground hover:bg-destructive/90',
outline:
'border border-input bg-background hover:bg-accent hover:text-accent-foreground',
secondary:
'bg-secondary text-secondary-foreground hover:bg-secondary/80',
ghost: 'hover:bg-accent hover:text-accent-foreground',
link: 'text-primary underline-offset-4 hover:underline',
},
size: {
default: 'h-10 px-4 py-2',
sm: 'h-9 rounded-md px-3',
lg: 'h-11 rounded-md px-8',
icon: 'h-10 w-10',
},
},
defaultVariants: {
variant: 'default',
size: 'default',
},
},
);
const Button = React.forwardRef(({ className, variant, size, asChild = false, ...props }, ref) => {
const Comp = asChild ? Slot : 'button';
return (
<Comp
className={cn(buttonVariants({ variant, size, className }))}
ref={ref}
{...props}
/>
);
});
Button.displayName = 'Button';
export { Button, buttonVariants };

View File

@@ -0,0 +1,19 @@
import React from 'react';
import { cn } from '../../lib/utils';
const Input = React.forwardRef(({ className, type, ...props }, ref) => {
return (
<input
type={type}
className={cn(
'flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50',
className
)}
ref={ref}
{...props}
/>
);
});
Input.displayName = 'Input';
export { Input };

View File

@@ -0,0 +1,19 @@
import React from 'react';
import * as LabelPrimitive from '@radix-ui/react-label';
import { cva } from 'class-variance-authority';
import { cn } from '../../lib/utils';
const labelVariants = cva(
'text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70'
);
const Label = React.forwardRef(({ className, ...props }, ref) => (
<LabelPrimitive.Root
ref={ref}
className={cn(labelVariants(), className)}
{...props}
/>
));
Label.displayName = LabelPrimitive.Root.displayName;
export { Label };

View File

@@ -0,0 +1,23 @@
import React from 'react';
import * as SwitchPrimitives from '@radix-ui/react-switch';
import { cn } from '../../lib/utils';
const Switch = React.forwardRef(({ className, ...props }, ref) => (
<SwitchPrimitives.Root
className={cn(
'peer inline-flex h-6 w-11 shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=unchecked]:bg-input',
className
)}
{...props}
ref={ref}
>
<SwitchPrimitives.Thumb
className={cn(
'pointer-events-none block h-5 w-5 rounded-full bg-background shadow-lg ring-0 transition-transform data-[state=checked]:translate-x-5 data-[state=unchecked]:translate-x-0'
)}
/>
</SwitchPrimitives.Root>
));
Switch.displayName = SwitchPrimitives.Root.displayName;
export { Switch };

101
src/components/ui/toast.jsx Normal file
View File

@@ -0,0 +1,101 @@
import { cn } from '../../lib/utils';
import * as ToastPrimitives from '@radix-ui/react-toast';
import { cva } from 'class-variance-authority';
import { X } from 'lucide-react';
import React from 'react';
const ToastProvider = ToastPrimitives.Provider;
const ToastViewport = React.forwardRef(({ className, ...props }, ref) => (
<ToastPrimitives.Viewport
ref={ref}
className={cn(
'fixed top-0 z-[100] flex max-h-screen w-full flex-col-reverse p-4 sm:bottom-0 sm:right-0 sm:top-auto sm:flex-col md:max-w-[420px]',
className,
)}
{...props}
/>
));
ToastViewport.displayName = ToastPrimitives.Viewport.displayName;
const toastVariants = cva(
'data-[swipe=move]:transition-none group relative pointer-events-auto flex w-full items-center justify-between space-x-4 overflow-hidden rounded-md border p-6 pr-8 shadow-lg transition-all data-[swipe=move]:translate-x-[var(--radix-toast-swipe-move-x)] data-[swipe=cancel]:translate-x-0 data-[swipe=end]:translate-x-[var(--radix-toast-swipe-end-x)] data-[state=open]:animate-in data-[state=closed]:animate-out data-[swipe=end]:animate-out data-[state=closed]:fade-out-80 data-[state=open]:slide-in-from-top-full data-[state=open]:sm:slide-in-from-bottom-full data-[state=closed]:slide-out-to-right-full',
{
variants: {
variant: {
default: 'bg-background border',
destructive:
'group destructive border-destructive bg-destructive text-destructive-foreground',
},
},
defaultVariants: {
variant: 'default',
},
},
);
const Toast = React.forwardRef(({ className, variant, ...props }, ref) => {
return (
<ToastPrimitives.Root
ref={ref}
className={cn(toastVariants({ variant }), className)}
{...props}
/>
);
});
Toast.displayName = ToastPrimitives.Root.displayName;
const ToastAction = React.forwardRef(({ className, ...props }, ref) => (
<ToastPrimitives.Action
ref={ref}
className={cn(
'inline-flex h-8 shrink-0 items-center justify-center rounded-md border bg-transparent px-3 text-sm font-medium ring-offset-background transition-colors hover:bg-secondary focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 group-[.destructive]:border-destructive/30 group-[.destructive]:hover:border-destructive/30 group-[.destructive]:hover:bg-destructive group-[.destructive]:hover:text-destructive-foreground group-[.destructive]:focus:ring-destructive',
className,
)}
{...props}
/>
));
ToastAction.displayName = ToastPrimitives.Action.displayName;
const ToastClose = React.forwardRef(({ className, ...props }, ref) => (
<ToastPrimitives.Close
ref={ref}
className={cn(
'absolute right-2 top-2 rounded-md p-1 text-foreground/50 opacity-0 transition-opacity hover:text-foreground focus:opacity-100 focus:outline-none focus:ring-2 group-hover:opacity-100 group-[.destructive]:text-red-300 group-[.destructive]:hover:text-red-50 group-[.destructive]:focus:ring-red-400 group-[.destructive]:focus:ring-offset-red-600',
className,
)}
toast-close=""
{...props}
>
<X className="h-4 w-4" />
</ToastPrimitives.Close>
));
ToastClose.displayName = ToastPrimitives.Close.displayName;
const ToastTitle = React.forwardRef(({ className, ...props }, ref) => (
<ToastPrimitives.Title
ref={ref}
className={cn('text-sm font-semibold', className)}
{...props}
/>
));
ToastTitle.displayName = ToastPrimitives.Title.displayName;
const ToastDescription = React.forwardRef(({ className, ...props }, ref) => (
<ToastPrimitives.Description
ref={ref}
className={cn('text-sm opacity-90', className)}
{...props}
/>
));
ToastDescription.displayName = ToastPrimitives.Description.displayName;
export {
Toast,
ToastAction,
ToastClose,
ToastDescription,
ToastProvider,
ToastTitle,
ToastViewport,
};

View File

@@ -0,0 +1,34 @@
import {
Toast,
ToastClose,
ToastDescription,
ToastProvider,
ToastTitle,
ToastViewport,
} from './toast';
import { useToast } from './use-toast';
import React from 'react';
export function Toaster() {
const { toasts } = useToast();
return (
<ToastProvider>
{toasts.map(({ id, title, description, action, ...props }) => {
return (
<Toast key={id} {...props}>
<div className="grid gap-1">
{title && <ToastTitle>{title}</ToastTitle>}
{description && (
<ToastDescription>{description}</ToastDescription>
)}
</div>
{action}
<ToastClose />
</Toast>
);
})}
<ToastViewport />
</ToastProvider>
);
}

View File

@@ -0,0 +1,103 @@
import { useState, useEffect } from "react"
const TOAST_LIMIT = 1
let count = 0
function generateId() {
count = (count + 1) % Number.MAX_VALUE
return count.toString()
}
const toastStore = {
state: {
toasts: [],
},
listeners: [],
getState: () => toastStore.state,
setState: (nextState) => {
if (typeof nextState === 'function') {
toastStore.state = nextState(toastStore.state)
} else {
toastStore.state = { ...toastStore.state, ...nextState }
}
toastStore.listeners.forEach(listener => listener(toastStore.state))
},
subscribe: (listener) => {
toastStore.listeners.push(listener)
return () => {
toastStore.listeners = toastStore.listeners.filter(l => l !== listener)
}
}
}
export const toast = ({ ...props }) => {
const id = generateId()
const update = (props) =>
toastStore.setState((state) => ({
...state,
toasts: state.toasts.map((t) =>
t.id === id ? { ...t, ...props } : t
),
}))
const dismiss = () => toastStore.setState((state) => ({
...state,
toasts: state.toasts.filter((t) => t.id !== id),
}))
toastStore.setState((state) => ({
...state,
toasts: [
{ ...props, id, dismiss },
...state.toasts,
].slice(0, TOAST_LIMIT),
}))
return {
id,
dismiss,
update,
}
}
export function useToast() {
const [state, setState] = useState(toastStore.getState())
useEffect(() => {
const unsubscribe = toastStore.subscribe((state) => {
setState(state)
})
return unsubscribe
}, [])
useEffect(() => {
const timeouts = []
state.toasts.forEach((toast) => {
if (toast.duration === Infinity) {
return
}
const timeout = setTimeout(() => {
toast.dismiss()
}, toast.duration || 5000)
timeouts.push(timeout)
})
return () => {
timeouts.forEach((timeout) => clearTimeout(timeout))
}
}, [state.toasts])
return {
toast,
toasts: state.toasts,
}
}

97
src/index.css Normal file
View File

@@ -0,0 +1,97 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
@layer base {
:root {
--background: 0 0% 100%;
--foreground: 222.2 84% 4.9%;
--card: 0 0% 100%;
--card-foreground: 222.2 84% 4.9%;
--popover: 0 0% 100%;
--popover-foreground: 222.2 84% 4.9%;
--primary: 142 76% 36%;
--primary-foreground: 355.7 100% 97.3%;
--secondary: 210 40% 96.1%;
--secondary-foreground: 222.2 47.4% 11.2%;
--muted: 210 40% 96.1%;
--muted-foreground: 215.4 16.3% 46.9%;
--accent: 210 40% 96.1%;
--accent-foreground: 222.2 47.4% 11.2%;
--destructive: 0 84.2% 60.2%;
--destructive-foreground: 210 40% 98%;
--border: 214.3 31.8% 91.4%;
--input: 214.3 31.8% 91.4%;
--ring: 142 76% 36%;
--radius: 0.5rem;
}
.dark {
--background: 222.2 30% 8%;
--foreground: 210 40% 98%;
--card: 222.2 30% 8%;
--card-foreground: 210 40% 98%;
--popover: 222.2 30% 8%;
--popover-foreground: 210 40% 98%;
--primary: 142 76% 36%;
--primary-foreground: 355.7 100% 97.3%;
--secondary: 217.2 20% 17.5%;
--secondary-foreground: 210 40% 98%;
--muted: 217.2 20% 17.5%;
--muted-foreground: 215 20.2% 65.1%;
--accent: 217.2 20% 17.5%;
--accent-foreground: 210 40% 98%;
--destructive: 0 62.8% 30.6%;
--destructive-foreground: 210 40% 98%;
--border: 217.2 20% 17.5%;
--input: 217.2 20% 17.5%;
--ring: 142 76% 36%;
}
}
@layer base {
* {
@apply border-border;
}
body {
@apply bg-background text-foreground;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', 'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue', sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
}
.habit-cell {
transition: all 0.1s cubic-bezier(0.4, 0, 0.2, 1);
}
.habit-cell:hover {
transform: scale(1.1);
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06);
}
.habit-cell:active {
transform: scale(0.9);
}
.grid-scroll {
scrollbar-width: thin;
scrollbar-color: rgba(0, 0, 0, 0.2) transparent;
}
.grid-scroll::-webkit-scrollbar {
height: 6px;
}
.grid-scroll::-webkit-scrollbar-track {
background: transparent;
}
.grid-scroll::-webkit-scrollbar-thumb {
background-color: rgba(0, 0, 0, 0.2);
border-radius: 3px;
}
.dark .grid-scroll::-webkit-scrollbar-thumb {
background-color: rgba(255, 255, 255, 0.2);
}

143
src/lib/storage.js Normal file
View File

@@ -0,0 +1,143 @@
const STORAGE_KEY = 'habitgrid_data';
export const getHabits = () => {
try {
const data = localStorage.getItem(STORAGE_KEY);
return data ? JSON.parse(data) : [];
} catch (error) {
console.error('Error loading habits:', error);
return [];
}
};
export const getHabit = (id) => {
const habits = getHabits();
return habits.find(h => h.id === id);
};
export const saveHabit = (habit) => {
const habits = getHabits();
const newHabit = {
...habit,
id: Date.now().toString(),
};
habits.push(newHabit);
localStorage.setItem(STORAGE_KEY, JSON.stringify(habits));
return newHabit;
};
export const updateHabit = (id, updates) => {
const habits = getHabits();
const index = habits.findIndex(h => h.id === id);
if (index !== -1) {
habits[index] = { ...habits[index], ...updates };
localStorage.setItem(STORAGE_KEY, JSON.stringify(habits));
}
};
export const deleteHabit = (id) => {
const habits = getHabits();
const filtered = habits.filter(h => h.id !== id);
localStorage.setItem(STORAGE_KEY, JSON.stringify(filtered));
};
export const toggleCompletion = (habitId, dateStr) => {
const habits = getHabits();
const habit = habits.find(h => h.id === habitId);
if (!habit) return;
const completions = habit.completions || [];
const index = completions.indexOf(dateStr);
if (index > -1) {
completions.splice(index, 1);
} else {
completions.push(dateStr);
}
const { currentStreak, longestStreak } = calculateStreaks(completions);
updateHabit(habitId, {
completions,
currentStreak,
longestStreak: Math.max(longestStreak, habit.longestStreak || 0),
});
};
const calculateStreaks = (completions) => {
if (completions.length === 0) {
return { currentStreak: 0, longestStreak: 0 };
}
const sortedDates = completions
.map(d => new Date(d))
.sort((a, b) => b - a);
let currentStreak = 0;
let longestStreak = 0;
let tempStreak = 1;
const today = new Date();
today.setHours(0, 0, 0, 0);
const yesterday = new Date(today);
yesterday.setDate(yesterday.getDate() - 1);
const mostRecent = sortedDates[0];
mostRecent.setHours(0, 0, 0, 0);
if (mostRecent.getTime() === today.getTime() || mostRecent.getTime() === yesterday.getTime()) {
currentStreak = 1;
for (let i = 1; i < sortedDates.length; i++) {
const current = new Date(sortedDates[i]);
current.setHours(0, 0, 0, 0);
const previous = new Date(sortedDates[i - 1]);
previous.setHours(0, 0, 0, 0);
const diffDays = Math.floor((previous - current) / (1000 * 60 * 60 * 24));
if (diffDays === 1) {
currentStreak++;
tempStreak++;
} else {
break;
}
}
}
tempStreak = 1;
for (let i = 1; i < sortedDates.length; i++) {
const current = new Date(sortedDates[i]);
current.setHours(0, 0, 0, 0);
const previous = new Date(sortedDates[i - 1]);
previous.setHours(0, 0, 0, 0);
const diffDays = Math.floor((previous - current) / (1000 * 60 * 60 * 24));
if (diffDays === 1) {
tempStreak++;
longestStreak = Math.max(longestStreak, tempStreak);
} else {
tempStreak = 1;
}
}
longestStreak = Math.max(longestStreak, currentStreak, 1);
return { currentStreak, longestStreak };
};
export const exportData = () => {
const habits = getHabits();
return JSON.stringify(habits, null, 2);
};
export const importData = (jsonString) => {
const habits = JSON.parse(jsonString);
localStorage.setItem(STORAGE_KEY, JSON.stringify(habits));
};
export const clearAllData = () => {
localStorage.removeItem(STORAGE_KEY);
};

42
src/lib/utils-habit.js Normal file
View File

@@ -0,0 +1,42 @@
export const formatDate = (date) => {
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, '0');
const day = String(date.getDate()).padStart(2, '0');
return `${year}-${month}-${day}`;
};
export const isToday = (date) => {
const today = new Date();
return (
date.getDate() === today.getDate() &&
date.getMonth() === today.getMonth() &&
date.getFullYear() === today.getFullYear()
);
};
export const getColorIntensity = (completions, dateStr) => {
const index = completions.indexOf(dateStr);
if (index === -1) return 0;
let streak = 1;
const date = new Date(dateStr);
for (let i = 1; i <= 10; i++) {
const prevDate = new Date(date);
prevDate.setDate(prevDate.getDate() - i);
const prevDateStr = formatDate(prevDate);
if (completions.includes(prevDateStr)) {
streak++;
} else {
break;
}
}
return Math.min(streak / 10, 1);
};
export const getWeekdayLabel = (dayIndex) => {
const labels = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
return labels[dayIndex];
};

6
src/lib/utils.js Normal file
View File

@@ -0,0 +1,6 @@
import { clsx } from 'clsx';
import { twMerge } from 'tailwind-merge';
export function cn(...inputs) {
return twMerge(clsx(inputs));
}

8
src/main.jsx Normal file
View File

@@ -0,0 +1,8 @@
import React from 'react';
import ReactDOM from 'react-dom/client';
import App from './App';
import './index.css';
ReactDOM.createRoot(document.getElementById('root')).render(
<App />
);

View File

@@ -0,0 +1,182 @@
import React, { useState, useEffect } from 'react';
import { useParams, useNavigate } from 'react-router-dom';
import { motion } from 'framer-motion';
import { ArrowLeft, Save } from 'lucide-react';
import { Button } from '../components/ui/button';
import { Input } from '../components/ui/input';
import { Label } from '../components/ui/label';
import { useToast } from '../components/ui/use-toast';
import ColorPicker from '../components/ColorPicker';
import { getHabit, saveHabit, updateHabit } from '../lib/storage';
const AddEditHabitPage = () => {
const { id } = useParams();
const navigate = useNavigate();
const { toast } = useToast();
const isEdit = !!id;
const [name, setName] = useState('');
const [color, setColor] = useState('#22c55e');
useEffect(() => {
if (isEdit) {
const habit = getHabit(id);
if (habit) {
setName(habit.name);
setColor(habit.color);
} else {
toast({
title: "Habit not found",
description: "This habit doesn't exist or was deleted.",
variant: "destructive",
});
navigate('/');
}
}
}, [id, isEdit, navigate, toast]);
const handleSubmit = (e) => {
e.preventDefault();
if (!name.trim()) {
toast({
title: "Name required",
description: "Please enter a habit name.",
variant: "destructive",
});
return;
}
if (isEdit) {
updateHabit(id, { name: name.trim(), color });
toast({
title: "✅ Habit updated",
description: "Your habit has been updated successfully.",
});
} else {
saveHabit({
name: name.trim(),
color,
completions: [],
currentStreak: 0,
longestStreak: 0,
createdAt: new Date().toISOString(),
});
toast({
title: "✅ Habit created",
description: "Your new habit is ready to track!",
});
}
navigate('/');
};
return (
<div className="min-h-screen bg-gradient-to-br from-slate-50 via-white to-slate-50 dark:from-slate-950 dark:via-slate-900 dark:to-slate-950">
<div className="max-w-2xl mx-auto px-4 py-6 sm:px-6 lg:px-8">
{/* Header */}
<motion.div
initial={{ opacity: 0, y: -20 }}
animate={{ opacity: 1, y: 0 }}
className="flex items-center gap-3 mb-8"
>
<Button
variant="ghost"
size="icon"
onClick={() => navigate(-1)}
className="rounded-full"
>
<ArrowLeft className="w-5 h-5" />
</Button>
<div>
<h1 className="text-2xl font-bold">{isEdit ? 'Edit Habit' : 'Create New Habit'}</h1>
<p className="text-sm text-muted-foreground">
{isEdit ? 'Update your habit details' : 'Start building a new habit'}
</p>
</div>
</motion.div>
{/* Form */}
<motion.form
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.1 }}
onSubmit={handleSubmit}
className="space-y-6"
>
<div className="bg-white dark:bg-slate-800 rounded-2xl p-6 shadow-sm border border-slate-200 dark:border-slate-700 space-y-6">
{/* Name Input */}
<div className="space-y-2">
<Label htmlFor="name">Habit Name</Label>
<Input
id="name"
placeholder="e.g., Morning Exercise, Read 30 Minutes, Meditate"
value={name}
onChange={(e) => setName(e.target.value)}
className="text-lg"
maxLength={50}
/>
<p className="text-xs text-muted-foreground">
{name.length}/50 characters
</p>
</div>
{/* Color Picker */}
<div className="space-y-2">
<Label>Habit Color</Label>
<ColorPicker selectedColor={color} onColorChange={setColor} />
</div>
{/* Preview */}
<div className="space-y-2">
<Label>Preview</Label>
<div className="bg-slate-50 dark:bg-slate-900 rounded-xl p-4 border border-slate-200 dark:border-slate-700">
<div className="flex items-center gap-3 mb-3">
<div
className="w-4 h-4 rounded"
style={{ backgroundColor: color }}
/>
<span className="font-medium">{name || 'Your Habit Name'}</span>
</div>
<div className="flex gap-1">
{[...Array(14)].map((_, i) => (
<div
key={i}
className="w-3 h-3 rounded-sm"
style={{
backgroundColor: i < 7 ? color : 'transparent',
opacity: i < 7 ? 0.3 + (i * 0.1) : 1,
border: `1px solid ${color}20`,
}}
/>
))}
</div>
</div>
</div>
</div>
{/* Action Buttons */}
<div className="flex gap-3">
<Button
type="button"
variant="outline"
onClick={() => navigate(-1)}
className="flex-1 rounded-xl"
>
Cancel
</Button>
<Button
type="submit"
className="flex-1 rounded-xl"
>
<Save className="w-4 h-4 mr-2" />
{isEdit ? 'Update Habit' : 'Create Habit'}
</Button>
</div>
</motion.form>
</div>
</div>
);
};
export default AddEditHabitPage;

View File

@@ -0,0 +1,167 @@
import React, { useState, useEffect } from 'react';
import { useParams, useNavigate } from 'react-router-dom';
import { motion } from 'framer-motion';
import { ArrowLeft, Edit2, Trash2, TrendingUp, Target, Calendar } from 'lucide-react';
import { Button } from '../components/ui/button';
import { useToast } from '../components/ui/use-toast';
import HabitGrid from '../components/HabitGrid';
import DeleteHabitDialog from '../components/DeleteHabitDialog';
import { getHabit, deleteHabit } from '../lib/storage';
const HabitDetailPage = () => {
const { id } = useParams();
const navigate = useNavigate();
const { toast } = useToast();
const [habit, setHabit] = useState(null);
const [showDeleteDialog, setShowDeleteDialog] = useState(false);
useEffect(() => {
loadHabit();
}, [id]);
const loadHabit = () => {
const loadedHabit = getHabit(id);
if (!loadedHabit) {
toast({
title: "Habit not found",
description: "This habit doesn't exist or was deleted.",
variant: "destructive",
});
navigate('/');
return;
}
setHabit(loadedHabit);
};
const handleDelete = () => {
deleteHabit(id);
toast({
title: "✅ Habit deleted",
description: "Your habit has been removed successfully.",
});
navigate('/');
};
if (!habit) {
return (
<div className="min-h-screen flex items-center justify-center">
<div className="animate-pulse text-muted-foreground">Loading...</div>
</div>
);
}
// Find the oldest completion date
let oldestDate = new Date();
if (habit.completions.length > 0) {
oldestDate = new Date(habit.completions.reduce((min, d) => d < min ? d : min, habit.completions[0]));
}
const completionRate = habit.completions.length > 0
? Math.round((habit.completions.length / Math.max(1, Math.ceil((Date.now() - oldestDate.getTime()) / (1000 * 60 * 60 * 24)))) * 100)
: 0;
return (
<div className="min-h-screen bg-gradient-to-br from-slate-50 via-white to-slate-50 dark:from-slate-950 dark:via-slate-900 dark:to-slate-950">
<div className="max-w-6xl mx-auto px-4 py-6 sm:px-6 lg:px-8">
{/* Header */}
<motion.div
initial={{ opacity: 0, y: -20 }}
animate={{ opacity: 1, y: 0 }}
className="flex items-center justify-between mb-6"
>
<div className="flex items-center gap-3">
<Button
variant="ghost"
size="icon"
onClick={() => navigate('/')}
className="rounded-full"
>
<ArrowLeft className="w-5 h-5" />
</Button>
<div>
<h1 className="text-2xl font-bold">{habit.name}</h1>
<p className="text-sm text-muted-foreground">Track your daily progress</p>
</div>
</div>
<div className="flex gap-2">
<Button
variant="ghost"
size="icon"
onClick={() => navigate(`/edit/${id}`)}
className="rounded-full"
>
<Edit2 className="w-5 h-5" />
</Button>
<Button
variant="ghost"
size="icon"
onClick={() => setShowDeleteDialog(true)}
className="rounded-full text-destructive hover:text-destructive"
>
<Trash2 className="w-5 h-5" />
</Button>
</div>
</motion.div>
{/* Stats Cards */}
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.1 }}
className="grid grid-cols-1 sm:grid-cols-3 gap-4 mb-8"
>
<div className="bg-white dark:bg-slate-800 rounded-2xl p-6 shadow-sm border border-slate-200 dark:border-slate-700">
<div className="flex items-center gap-3 mb-3">
<div className="w-10 h-10 rounded-xl bg-gradient-to-br from-orange-400 to-red-500 flex items-center justify-center">
<TrendingUp className="w-5 h-5 text-white" />
</div>
<span className="text-sm font-medium text-muted-foreground">Current Streak</span>
</div>
<p className="text-3xl font-bold">{habit.currentStreak || 0}</p>
<p className="text-xs text-muted-foreground mt-1">days in a row</p>
</div>
<div className="bg-white dark:bg-slate-800 rounded-2xl p-6 shadow-sm border border-slate-200 dark:border-slate-700">
<div className="flex items-center gap-3 mb-3">
<div className="w-10 h-10 rounded-xl bg-gradient-to-br from-purple-400 to-pink-500 flex items-center justify-center">
<Target className="w-5 h-5 text-white" />
</div>
<span className="text-sm font-medium text-muted-foreground">Longest Streak</span>
</div>
<p className="text-3xl font-bold">{habit.longestStreak || 0}</p>
<p className="text-xs text-muted-foreground mt-1">personal best</p>
</div>
<div className="bg-white dark:bg-slate-800 rounded-2xl p-6 shadow-sm border border-slate-200 dark:border-slate-700">
<div className="flex items-center gap-3 mb-3">
<div className="w-10 h-10 rounded-xl bg-gradient-to-br from-blue-400 to-cyan-500 flex items-center justify-center">
<Calendar className="w-5 h-5 text-white" />
</div>
<span className="text-sm font-medium text-muted-foreground">Consistency Score!</span>
</div>
<p className="text-3xl font-bold">{completionRate}%</p>
<p className="text-xs text-muted-foreground mt-1">overall progress</p>
</div>
</motion.div>
{/* Habit Grid */}
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.2 }}
>
<HabitGrid habit={habit} onUpdate={loadHabit} fullView />
</motion.div>
</div>
<DeleteHabitDialog
open={showDeleteDialog}
onOpenChange={setShowDeleteDialog}
onConfirm={handleDelete}
habitName={habit.name}
/>
</div>
);
};
export default HabitDetailPage;

162
src/pages/HomePage.jsx Normal file
View File

@@ -0,0 +1,162 @@
import React, { useState, useEffect } from 'react';
import { useNavigate } from 'react-router-dom';
import { motion, AnimatePresence } from 'framer-motion';
import { Plus, Settings, TrendingUp, Flame, Calendar } from 'lucide-react';
import { Button } from '../components/ui/button';
import { useToast } from '../components/ui/use-toast';
import HabitCard from '../components/HabitCard';
import { getHabits } from '../lib/storage';
const HomePage = () => {
const navigate = useNavigate();
const { toast } = useToast();
const [habits, setHabits] = useState([]);
const [isPremium] = useState(false);
useEffect(() => {
loadHabits();
}, []);
const loadHabits = () => {
const loadedHabits = getHabits();
setHabits(loadedHabits);
};
const handleAddHabit = () => {
if (!isPremium && habits.length >= 1000) {
toast({
title: "🔒 Premium Feature",
description: "Free tier limited to 1000 habits. Upgrade to unlock unlimited habits!",
duration: 4000,
});
return;
}
navigate('/add');
};
return (
<div className="min-h-screen bg-gradient-to-br from-slate-50 via-white to-slate-50 dark:from-slate-950 dark:via-slate-900 dark:to-slate-950">
<div className="max-w-6xl mx-auto px-4 py-6 sm:px-6 lg:px-8">
{/* Header */}
<motion.div
initial={{ opacity: 0, y: -20 }}
animate={{ opacity: 1, y: 0 }}
className="flex items-center justify-between mb-8"
>
<div className="flex items-center gap-3">
<div className="w-12 h-12 bg-gradient-to-br from-green-400 to-emerald-600 rounded-xl flex items-center justify-center shadow-lg">
<div className="grid grid-cols-4 gap-0.5">
{[...Array(11)].map((_, i) => (
<div key={i} className="w-1.5 h-1.5 bg-white rounded-sm opacity-90" />
))}
</div>
</div>
<div>
<h1 className="text-3xl font-bold bg-gradient-to-r from-slate-900 to-slate-700 dark:from-white dark:to-slate-300 bg-clip-text text-transparent">
HabitGrid
</h1>
<p className="text-sm text-muted-foreground">Commit to yourself, one square at a time</p>
</div>
</div>
<Button
variant="ghost"
size="icon"
onClick={() => navigate('/settings')}
className="rounded-full"
>
<Settings className="w-5 h-5" />
</Button>
</motion.div>
{/* Stats Overview */}
{habits.length > 0 && (
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.1 }}
className="grid grid-cols-2 sm:grid-cols-2 gap-4 mb-8"
>
<div className="bg-white dark:bg-slate-800 rounded-2xl p-4 shadow-sm border border-slate-200 dark:border-slate-700">
<div className="flex items-center gap-2 mb-2">
<Calendar className="w-4 h-4 text-blue-500" />
<span className="text-xs font-medium text-muted-foreground">Active Habits</span>
</div>
<p className="text-2xl font-bold">{habits.length}</p>
</div>
<div className="bg-white dark:bg-slate-800 rounded-2xl p-4 shadow-sm border border-slate-200 dark:border-slate-700">
<div className="flex items-center gap-2 mb-2">
<TrendingUp className="w-4 h-4 text-green-500" />
<span className="text-xs font-medium text-muted-foreground">Total Streaks</span>
</div>
<p className="text-2xl font-bold">
{habits.reduce((sum, h) => sum + (h.currentStreak || 0), 0)}
</p>
</div>
</motion.div>
)}
{/* Habits List */}
<div className="space-y-4">
<AnimatePresence mode="popLayout">
{habits.map((habit, index) => (
<motion.div
key={habit.id}
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, scale: 0.95 }}
transition={{ delay: index * 0.05 }}
>
<HabitCard habit={habit} onUpdate={loadHabits} />
</motion.div>
))}
</AnimatePresence>
</div>
{/* Empty State */}
{habits.length === 0 && (
<motion.div
initial={{ opacity: 0, scale: 0.95 }}
animate={{ opacity: 1, scale: 1 }}
className="text-center py-16"
>
<div className="w-24 h-24 bg-gradient-to-br from-green-100 to-emerald-100 dark:from-green-900/20 dark:to-emerald-900/20 rounded-3xl flex items-center justify-center mx-auto mb-6">
<Flame className="w-12 h-12 text-green-600 dark:text-green-400" />
</div>
<h2 className="text-2xl font-bold mb-2">Create your grid!</h2>
<p className="text-muted-foreground mb-8 max-w-md mx-auto">
Create your first habit and watch your progress every day as you fill in the squares. Small steps lead to big changes!
</p>
<Button
onClick={handleAddHabit}
size="lg"
className="rounded-full shadow-lg hover:shadow-xl transition-shadow"
>
<Plus className="w-5 h-5 mr-2" />
Create First Habit
</Button>
</motion.div>
)}
{/* Add Button */}
{habits.length > 0 && (
<motion.div
initial={{ opacity: 0, scale: 0.8 }}
animate={{ opacity: 1, scale: 1 }}
transition={{ delay: 0.3 }}
className="fixed bottom-6 right-6"
>
<Button
onClick={handleAddHabit}
size="lg"
className="rounded-full w-14 h-14 shadow-2xl hover:shadow-3xl transition-all hover:scale-110"
>
<Plus className="w-6 h-6" />
</Button>
</motion.div>
)}
</div>
</div>
);
};
export default HomePage;

228
src/pages/SettingsPage.jsx Normal file
View File

@@ -0,0 +1,228 @@
import React, { useState, useEffect } from 'react';
import { useNavigate } from 'react-router-dom';
import { motion } from 'framer-motion';
import { ArrowLeft, Moon, Sun, Bell, Download, Upload, Trash2 } from 'lucide-react';
import { Button } from '../components/ui/button';
import { Switch } from '../components/ui/switch';
import { Label } from '../components/ui/label';
import { useToast } from '../components/ui/use-toast';
import { exportData, importData, clearAllData } from '../lib/storage';
const SettingsPage = () => {
const navigate = useNavigate();
const { toast } = useToast();
const [darkMode, setDarkMode] = useState(false);
const [notifications, setNotifications] = useState(false);
useEffect(() => {
const isDark = document.documentElement.classList.contains('dark');
setDarkMode(isDark);
}, []);
const toggleDarkMode = (enabled) => {
setDarkMode(enabled);
if (enabled) {
document.documentElement.classList.add('dark');
localStorage.setItem('theme', 'dark');
} else {
document.documentElement.classList.remove('dark');
localStorage.setItem('theme', 'light');
}
};
const handleExport = () => {
const data = exportData();
const blob = new Blob([data], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `habitgrid-backup-${new Date().toISOString().split('T')[0]}.json`;
a.click();
URL.revokeObjectURL(url);
toast({
title: "✅ Data exported",
description: "Your habits have been exported successfully.",
});
};
const handleImport = () => {
const input = document.createElement('input');
input.type = 'file';
input.accept = 'application/json';
input.onchange = (e) => {
const file = e.target.files[0];
if (file) {
const reader = new FileReader();
reader.onload = (event) => {
try {
importData(event.target.result);
toast({
title: "✅ Data imported",
description: "Your habits have been imported successfully.",
});
setTimeout(() => navigate('/'), 500);
} catch (error) {
toast({
title: "Import failed",
description: "Invalid backup file format.",
variant: "destructive",
});
}
};
reader.readAsText(file);
}
};
input.click();
};
const handleClearData = () => {
if (window.confirm('Are you sure you want to delete all habits? This action cannot be undone.')) {
clearAllData();
toast({
title: "✅ Data cleared",
description: "All habits have been deleted.",
});
setTimeout(() => navigate('/'), 500);
}
};
const handleNotificationToggle = (enabled) => {
setNotifications(enabled);
toast({
title: "🚧 Feature coming soon",
description: "Daily reminders will be available in a future update!",
});
};
return (
<div className="min-h-screen bg-gradient-to-br from-slate-50 via-white to-slate-50 dark:from-gray-900 dark:via-gray-800 dark:to-gray-900">
<div className="max-w-2xl mx-auto px-4 py-6 sm:px-6 lg:px-8">
{/* Header */}
<motion.div
initial={{ opacity: 0, y: -20 }}
animate={{ opacity: 1, y: 0 }}
className="flex items-center gap-3 mb-8"
>
<Button
variant="ghost"
size="icon"
onClick={() => navigate('/')}
className="rounded-full"
>
<ArrowLeft className="w-5 h-5" />
</Button>
<div>
<h1 className="text-2xl font-bold">Settings</h1>
<p className="text-sm text-muted-foreground">Customize your experience</p>
</div>
</motion.div>
<div className="space-y-4">
{/* Appearance */}
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.1 }}
className="bg-white dark:bg-slate-800 rounded-2xl p-6 shadow-sm border border-slate-200 dark:border-slate-700"
>
<h2 className="text-lg font-semibold mb-4">Appearance</h2>
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
{darkMode ? <Moon className="w-5 h-5" /> : <Sun className="w-5 h-5" />}
<div>
<Label htmlFor="dark-mode" className="text-base">Dark Mode</Label>
<p className="text-sm text-muted-foreground">Toggle dark theme</p>
</div>
</div>
<Switch
id="dark-mode"
checked={darkMode}
onCheckedChange={toggleDarkMode}
/>
</div>
</motion.div>
{/* Notifications */}
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.2 }}
className="bg-white dark:bg-slate-800 rounded-2xl p-6 shadow-sm border border-slate-200 dark:border-slate-700"
>
<h2 className="text-lg font-semibold mb-4">Notifications</h2>
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<Bell className="w-5 h-5" />
<div>
<Label htmlFor="notifications" className="text-base">Daily Reminders</Label>
<p className="text-sm text-muted-foreground">Get reminded to track habits</p>
</div>
</div>
<Switch
id="notifications"
checked={notifications}
onCheckedChange={handleNotificationToggle}
/>
</div>
</motion.div>
{/* Data Management */}
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.3 }}
className="bg-white dark:bg-slate-800 rounded-2xl p-6 shadow-sm border border-slate-200 dark:border-slate-700 space-y-3"
>
<h2 className="text-lg font-semibold mb-4">Data Management</h2>
<Button
variant="outline"
className="w-full justify-start"
onClick={handleExport}
>
<Download className="w-4 h-4 mr-2" />
Export Data
</Button>
<Button
variant="outline"
className="w-full justify-start"
onClick={handleImport}
>
<Upload className="w-4 h-4 mr-2" />
Import Data
</Button>
<Button
variant="outline"
className="w-full justify-start text-destructive hover:text-destructive"
onClick={handleClearData}
>
<Trash2 className="w-4 h-4 mr-2" />
Clear All Data
</Button>
</motion.div>
{/* About */}
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.4 }}
className="bg-white dark:bg-slate-800 rounded-2xl p-6 shadow-sm border border-slate-200 dark:border-slate-700"
>
<h2 className="text-lg font-semibold mb-2">About HabitGrid</h2>
<p className="text-sm text-muted-foreground mb-4">
Version 1.0.0 Built with for habit builders
</p>
<p className="text-xs text-muted-foreground">
Track your habits with a beautiful GitHub-style contribution grid.
Build streaks, visualize progress, and commit to yourself daily.
</p>
</motion.div>
</div>
</div>
</div>
);
};
export default SettingsPage;

76
tailwind.config.js Normal file
View File

@@ -0,0 +1,76 @@
/** @type {import('tailwindcss').Config} */
module.exports = {
darkMode: ['class'],
content: [
'./pages/**/*.{js,jsx}',
'./components/**/*.{js,jsx}',
'./app/**/*.{js,jsx}',
'./src/**/*.{js,jsx}',
],
theme: {
container: {
center: true,
padding: '2rem',
screens: {
'2xl': '1400px',
},
},
extend: {
colors: {
border: 'hsl(var(--border))',
input: 'hsl(var(--input))',
ring: 'hsl(var(--ring))',
background: 'hsl(var(--background))',
foreground: 'hsl(var(--foreground))',
primary: {
DEFAULT: 'hsl(var(--primary))',
foreground: 'hsl(var(--primary-foreground))',
},
secondary: {
DEFAULT: 'hsl(var(--secondary))',
foreground: 'hsl(var(--secondary-foreground))',
},
destructive: {
DEFAULT: 'hsl(var(--destructive))',
foreground: 'hsl(var(--destructive-foreground))',
},
muted: {
DEFAULT: 'hsl(var(--muted))',
foreground: 'hsl(var(--muted-foreground))',
},
accent: {
DEFAULT: 'hsl(var(--accent))',
foreground: 'hsl(var(--accent-foreground))',
},
popover: {
DEFAULT: 'hsl(var(--popover))',
foreground: 'hsl(var(--popover-foreground))',
},
card: {
DEFAULT: 'hsl(var(--card))',
foreground: 'hsl(var(--card-foreground))',
},
},
borderRadius: {
lg: 'var(--radius)',
md: 'calc(var(--radius) - 2px)',
sm: 'calc(var(--radius) - 4px)',
},
keyframes: {
'accordion-down': {
from: { height: 0 },
to: { height: 'var(--radix-accordion-content-height)' },
},
'accordion-up': {
from: { height: 'var(--radix-accordion-content-height)' },
to: { height: 0 },
},
},
animation: {
'accordion-down': 'accordion-down 0.2s ease-out',
'accordion-up': 'accordion-up 0.2s ease-out',
},
},
},
plugins: [require('tailwindcss-animate')],
};