first commit

This commit is contained in:
Harivansh Rathi 2024-11-17 13:40:06 -05:00
commit de1634d659
22 changed files with 6105 additions and 0 deletions

3
.bolt/config.json Normal file
View file

@ -0,0 +1,3 @@
{
"template": "bolt-vite-react-ts"
}

8
.bolt/prompt Normal file
View file

@ -0,0 +1,8 @@
For all designs I ask you to make, have them be beautiful, not cookie cutter. Make webpages that are fully featured and worthy for production.
By default, this template supports JSX syntax with Tailwind CSS classes, React hooks, and Lucide React for icons. Do not install other packages for UI themes, icons, etc unless absolutely necessary or I request them.
Use icons from lucide-react for logos.
Use stock photos from unsplash where appropriate, only valid URLs you know exist. Do not download the images, only link to them in image tags.

24
.gitignore vendored Normal file
View file

@ -0,0 +1,24 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?

28
eslint.config.js Normal file
View file

@ -0,0 +1,28 @@
import js from '@eslint/js';
import globals from 'globals';
import reactHooks from 'eslint-plugin-react-hooks';
import reactRefresh from 'eslint-plugin-react-refresh';
import tseslint from 'typescript-eslint';
export default tseslint.config(
{ ignores: ['dist'] },
{
extends: [js.configs.recommended, ...tseslint.configs.recommended],
files: ['**/*.{ts,tsx}'],
languageOptions: {
ecmaVersion: 2020,
globals: globals.browser,
},
plugins: {
'react-hooks': reactHooks,
'react-refresh': reactRefresh,
},
rules: {
...reactHooks.configs.recommended.rules,
'react-refresh/only-export-components': [
'warn',
{ allowConstantExport: true },
],
},
}
);

BIN
habits.db Normal file

Binary file not shown.

16
index.html Normal file
View file

@ -0,0 +1,16 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Habit Tracker</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

5287
package-lock.json generated Normal file

File diff suppressed because it is too large Load diff

39
package.json Normal file
View file

@ -0,0 +1,39 @@
{
"name": "habit-tracker-fullstack",
"private": true,
"version": "1.0.0",
"type": "module",
"scripts": {
"dev": "concurrently \"vite\" \"node server.js\"",
"build": "vite build",
"lint": "eslint .",
"preview": "vite preview"
},
"dependencies": {
"cors": "^2.8.5",
"express": "^4.18.3",
"lucide-react": "^0.344.0",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"sql.js": "^1.10.2"
},
"devDependencies": {
"@eslint/js": "^9.9.1",
"@types/express": "^4.17.21",
"@types/react": "^18.3.5",
"@types/react-dom": "^18.3.0",
"@types/sql.js": "^1.4.9",
"@vitejs/plugin-react": "^4.3.1",
"autoprefixer": "^10.4.18",
"concurrently": "^8.2.2",
"eslint": "^9.9.1",
"eslint-plugin-react-hooks": "^5.1.0-rc.0",
"eslint-plugin-react-refresh": "^0.4.11",
"globals": "^15.9.0",
"postcss": "^8.4.35",
"tailwindcss": "^3.4.1",
"typescript": "^5.5.3",
"typescript-eslint": "^8.3.0",
"vite": "^5.4.2"
}
}

6
postcss.config.js Normal file
View file

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

129
server.js Normal file
View file

@ -0,0 +1,129 @@
import express from 'express';
import cors from 'cors';
import initSqlJs from 'sql.js';
import { readFileSync, writeFileSync, existsSync } from 'fs';
import { fileURLToPath } from 'url';
import { dirname, join } from 'path';
const __dirname = dirname(fileURLToPath(import.meta.url));
const dbPath = join(__dirname, 'habits.db');
let db;
let SQL;
async function initializeDatabase() {
SQL = await initSqlJs();
if (existsSync(dbPath)) {
const data = readFileSync(dbPath);
db = new SQL.Database(data);
} else {
db = new SQL.Database();
// Initialize tables
db.run(`
CREATE TABLE habits (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL
);
CREATE TABLE habit_completions (
habit_id INTEGER,
completion_date TEXT,
PRIMARY KEY (habit_id, completion_date),
FOREIGN KEY (habit_id) REFERENCES habits(id) ON DELETE CASCADE
);
`);
saveDatabase();
}
}
function saveDatabase() {
const data = db.export();
writeFileSync(dbPath, Buffer.from(data));
}
const app = express();
app.use(cors());
app.use(express.json());
app.use(express.static(join(__dirname, 'public'))); // Serve static files from the 'public' directory
// Initialize database before starting server
await initializeDatabase();
// Get all habits with their completion dates
app.get('/api/habits', (req, res) => {
const habits = db.exec(`
SELECT
h.id,
h.name,
GROUP_CONCAT(hc.completion_date) as completedDates
FROM habits h
LEFT JOIN habit_completions hc ON h.id = hc.habit_id
GROUP BY h.id
`)[0];
const formattedHabits = habits ? habits.values.map(([id, name, completedDates]) => ({
id,
name,
completedDates: completedDates ? completedDates.split(',') : []
})) : [];
res.json(formattedHabits);
});
// Add new habit
app.post('/api/habits', (req, res) => {
const { name } = req.body;
db.run('INSERT INTO habits (name) VALUES (?)', [name]);
const id = db.exec('SELECT last_insert_rowid()')[0].values[0][0];
saveDatabase();
res.json({ id, name, completedDates: [] });
});
// Toggle habit completion
app.post('/api/habits/:id/toggle', (req, res) => {
const { id } = req.params;
const { date } = req.body;
const existing = db.exec(
'SELECT * FROM habit_completions WHERE habit_id = ? AND completion_date = ?',
[id, date]
)[0];
if (existing) {
db.run(
'DELETE FROM habit_completions WHERE habit_id = ? AND completion_date = ?',
[id, date]
);
} else {
db.run(
'INSERT INTO habit_completions (habit_id, completion_date) VALUES (?, ?)',
[id, date]
);
}
saveDatabase();
res.json({ success: true });
});
// Update habit
app.put('/api/habits/:id', (req, res) => {
const { id } = req.params;
const { name } = req.body;
db.run('UPDATE habits SET name = ? WHERE id = ?', [name, id]);
saveDatabase();
res.json({ success: true });
});
// Delete habit
app.delete('/api/habits/:id', (req, res) => {
const { id } = req.params;
db.run('DELETE FROM habits WHERE id = ?', [id]);
saveDatabase();
res.json({ success: true });
});
const PORT = 5000;
app.listen(PORT, () => {
console.log(`Server running on port ${PORT}`);
});

310
src/App.tsx Normal file
View file

@ -0,0 +1,310 @@
import React, { useState, useEffect } from 'react';
import { Plus, CalendarIcon, SettingsIcon, Moon, Sun, ChevronLeft, ChevronRight } from 'lucide-react';
import { HabitList } from './components/HabitList';
import { Calendar } from './components/Calendar';
import { Habit } from './types';
export default function HabitTracker() {
const [habits, setHabits] = useState<Habit[]>([]);
const [newHabit, setNewHabit] = useState('');
const [currentWeek, setCurrentWeek] = useState<string[]>([]);
const [activeView, setActiveView] = useState<'habits' | 'calendar' | 'settings'>('habits');
const [darkMode, setDarkMode] = useState(() => localStorage.getItem('darkMode') === 'true');
const [currentMonth, setCurrentMonth] = useState(new Date());
const [streakGoal, setStreakGoal] = useState(7);
const [showCompletedHabits, setShowCompletedHabits] = useState(true);
const daysOfWeek = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'];
useEffect(() => {
fetchHabits();
setCurrentWeek(getCurrentWeekDates());
}, []);
useEffect(() => {
if (darkMode) {
document.documentElement.classList.add('dark');
} else {
document.documentElement.classList.remove('dark');
}
localStorage.setItem('darkMode', darkMode.toString());
}, [darkMode]);
const fetchHabits = async () => {
try {
const response = await fetch('http://localhost:5000/api/habits');
const data = await response.json();
setHabits(data);
} catch (error) {
console.error('Error fetching habits:', error);
setHabits([]); // Set empty array on error
}
};
const addHabit = async (e: React.FormEvent) => {
e.preventDefault();
if (newHabit.trim()) {
try {
const response = await fetch('http://localhost:5000/api/habits', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name: newHabit }),
});
if (response.ok) {
setNewHabit('');
fetchHabits();
}
} catch (error) {
console.error('Error adding habit:', error);
}
}
};
const toggleHabit = async (id: number, date: string) => {
try {
await fetch(`http://localhost:5000/api/habits/${id}/toggle`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ date }),
});
fetchHabits();
} catch (error) {
console.error('Error toggling habit:', error);
}
};
const updateHabit = async (id: number, name: string) => {
try {
await fetch(`http://localhost:5000/api/habits/${id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name }),
});
fetchHabits();
} catch (error) {
console.error('Error updating habit:', error);
}
};
const deleteHabit = async (id: number) => {
try {
await fetch(`http://localhost:5000/api/habits/${id}`, {
method: 'DELETE',
});
fetchHabits();
} catch (error) {
console.error('Error deleting habit:', error);
}
};
const getCurrentWeekDates = () => {
const now = new Date();
const dayOfWeek = now.getDay();
const diff = now.getDate() - dayOfWeek + (dayOfWeek === 0 ? -6 : 1);
const monday = new Date(now.setDate(diff));
return Array.from({ length: 7 }, (_, i) => {
const date = new Date(monday);
date.setDate(monday.getDate() + i);
return date.toISOString().split('T')[0];
});
};
const changeWeek = (direction: 'prev' | 'next') => {
const firstDay = new Date(currentWeek[0]);
const newFirstDay = new Date(firstDay.setDate(firstDay.getDate() + (direction === 'prev' ? -7 : 7)));
setCurrentWeek(Array.from({ length: 7 }, (_, i) => {
const date = new Date(newFirstDay);
date.setDate(newFirstDay.getDate() + i);
return date.toISOString().split('T')[0];
}));
};
const changeMonth = (direction: 'prev' | 'next') => {
setCurrentMonth(prevMonth => {
const newMonth = new Date(prevMonth);
newMonth.setMonth(newMonth.getMonth() + (direction === 'prev' ? -1 : 1));
return newMonth;
});
};
const getDaysInMonth = (year: number, month: number) => {
return new Date(year, month + 1, 0).getDate();
};
const getCompletedHabitsForDate = (date: string) => {
return habits.filter(habit => habit.completedDates.includes(date));
};
const getStreakForHabit = (habit: Habit) => {
let streak = 0;
const today = new Date();
for (let i = 0; i < streakGoal; i++) {
const date = new Date(today);
date.setDate(today.getDate() - i);
const dateString = date.toISOString().split('T')[0];
if (habit.completedDates.includes(dateString)) {
streak++;
} else {
break;
}
}
return streak;
};
return (
<div className="min-h-screen bg-white dark:bg-black">
<div className="flex h-screen">
<nav className="w-64 border-r border-gray-200 dark:border-gray-800">
<div className="p-4">
<h1 className="text-2xl font-bold text-gray-900 dark:text-white">Habit Tracker</h1>
</div>
<ul className="space-y-2 p-4">
<li>
<button
onClick={() => setActiveView('habits')}
className={`w-full px-4 py-2 text-left rounded-lg ${
activeView === 'habits'
? 'bg-gray-100 dark:bg-gray-800'
: 'hover:bg-gray-50 dark:hover:bg-gray-800'
} dark:text-white`}
>
<Plus className="inline-block mr-2 h-4 w-4" />
Habits
</button>
</li>
<li>
<button
onClick={() => setActiveView('calendar')}
className={`w-full px-4 py-2 text-left rounded-lg ${
activeView === 'calendar'
? 'bg-gray-100 dark:bg-gray-800'
: 'hover:bg-gray-50 dark:hover:bg-gray-800'
} dark:text-white`}
>
<CalendarIcon className="inline-block mr-2 h-4 w-4" />
Calendar
</button>
</li>
<li>
<button
onClick={() => setActiveView('settings')}
className={`w-full px-4 py-2 text-left rounded-lg ${
activeView === 'settings'
? 'bg-gray-100 dark:bg-gray-800'
: 'hover:bg-gray-50 dark:hover:bg-gray-800'
} dark:text-white`}
>
<SettingsIcon className="inline-block mr-2 h-4 w-4" />
Settings
</button>
</li>
</ul>
</nav>
<main className="flex-1 p-8">
{activeView === 'habits' && (
<div className="space-y-6">
<form onSubmit={addHabit} className="flex gap-2">
<input
type="text"
value={newHabit}
onChange={(e) => setNewHabit(e.target.value)}
placeholder="Add a new habit"
className="flex-grow px-4 py-2 border rounded-lg dark:bg-gray-800 dark:border-gray-700 dark:text-white"
/>
<button
type="submit"
className="px-4 py-2 bg-black text-white rounded-lg dark:bg-white dark:text-black"
>
Add Habit
</button>
</form>
<div className="bg-white dark:bg-gray-900 rounded-lg shadow p-6">
<div className="flex justify-between items-center mb-6">
<h2 className="text-2xl font-bold dark:text-white">Weekly Progress</h2>
<div className="flex space-x-2">
<button
onClick={() => changeWeek('prev')}
className="p-2 hover:bg-gray-100 dark:hover:bg-gray-800 rounded-full"
>
<ChevronLeft className="h-5 w-5 dark:text-white" />
</button>
<button
onClick={() => changeWeek('next')}
className="p-2 hover:bg-gray-100 dark:hover:bg-gray-800 rounded-full"
>
<ChevronRight className="h-5 w-5 dark:text-white" />
</button>
</div>
</div>
<HabitList
habits={habits}
currentWeek={currentWeek}
daysOfWeek={daysOfWeek}
onToggleHabit={toggleHabit}
onUpdateHabit={updateHabit}
onDeleteHabit={deleteHabit}
getStreakForHabit={getStreakForHabit}
/>
</div>
</div>
)}
{activeView === 'calendar' && (
<Calendar
currentMonth={currentMonth}
habits={habits}
onChangeMonth={changeMonth}
getDaysInMonth={getDaysInMonth}
getCompletedHabitsForDate={getCompletedHabitsForDate}
/>
)}
{activeView === 'settings' && (
<div className="bg-white dark:bg-gray-900 rounded-lg shadow p-6">
<h2 className="text-2xl font-bold mb-6 dark:text-white">Settings</h2>
<div className="space-y-6">
<div className="flex items-center justify-between">
<span className="dark:text-white">Dark Mode</span>
<button
onClick={() => setDarkMode(!darkMode)}
className="p-2 rounded-full hover:bg-gray-100 dark:hover:bg-gray-800"
>
{darkMode ? (
<Sun className="h-5 w-5 dark:text-white" />
) : (
<Moon className="h-5 w-5" />
)}
</button>
</div>
<div className="flex items-center justify-between">
<span className="dark:text-white">Streak Goal</span>
<select
value={streakGoal}
onChange={(e) => setStreakGoal(Number(e.target.value))}
className="px-3 py-2 border rounded-lg dark:bg-gray-800 dark:border-gray-700 dark:text-white"
>
{[3, 5, 7, 10, 14, 21, 30].map(days => (
<option key={days} value={days}>{days} days</option>
))}
</select>
</div>
<div className="flex items-center justify-between">
<span className="dark:text-white">Show Completed Habits</span>
<input
type="checkbox"
checked={showCompletedHabits}
onChange={(e) => setShowCompletedHabits(e.target.checked)}
className="w-4 h-4 rounded border-gray-300 dark:border-gray-600"
/>
</div>
</div>
</div>
)}
</main>
</div>
</div>
);
}

View file

@ -0,0 +1,78 @@
import React from 'react';
import { ChevronLeft, ChevronRight } from 'lucide-react';
import { Habit } from '../types';
interface CalendarProps {
currentMonth: Date;
habits: Habit[];
onChangeMonth: (direction: 'prev' | 'next') => void;
getDaysInMonth: (year: number, month: number) => number;
getCompletedHabitsForDate: (date: string) => Habit[];
}
export function Calendar({
currentMonth,
habits,
onChangeMonth,
getDaysInMonth,
getCompletedHabitsForDate
}: CalendarProps) {
const daysOfWeek = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'];
return (
<div className="bg-white dark:bg-gray-900 rounded-lg shadow p-6">
<div className="flex items-center justify-between mb-6">
<h2 className="text-2xl font-bold dark:text-white">
{currentMonth.toLocaleString('default', { month: 'long', year: 'numeric' })}
</h2>
<div className="flex space-x-2">
<button
onClick={() => onChangeMonth('prev')}
className="p-2 hover:bg-gray-100 dark:hover:bg-gray-800 rounded-full"
>
<ChevronLeft className="h-5 w-5 dark:text-white" />
</button>
<button
onClick={() => onChangeMonth('next')}
className="p-2 hover:bg-gray-100 dark:hover:bg-gray-800 rounded-full"
>
<ChevronRight className="h-5 w-5 dark:text-white" />
</button>
</div>
</div>
<div className="grid grid-cols-7 gap-2">
{daysOfWeek.map(day => (
<div key={day} className="text-center font-bold dark:text-white">{day}</div>
))}
{Array.from({ length: getDaysInMonth(currentMonth.getFullYear(), currentMonth.getMonth()) }).map((_, index) => {
const date = new Date(
currentMonth.getFullYear(),
currentMonth.getMonth(),
index + 1
).toISOString().split('T')[0];
const completedHabits = getCompletedHabitsForDate(date);
return (
<div
key={date}
className="border dark:border-gray-700 p-2 min-h-[60px] relative"
>
<span className="text-sm dark:text-white">{index + 1}</span>
{completedHabits.length > 0 && (
<div className="absolute bottom-2 left-1/2 transform -translate-x-1/2">
<div className="group relative">
<div className="h-2 w-2 bg-green-500 rounded-full"></div>
<div className="absolute bottom-full left-1/2 transform -translate-x-1/2 mb-2 hidden group-hover:block bg-black text-white text-xs rounded p-2 whitespace-nowrap">
{completedHabits.map(habit => habit.name).join(', ')}
</div>
</div>
</div>
)}
</div>
);
})}
</div>
</div>
);
}

View file

@ -0,0 +1,78 @@
import React from 'react';
import { Trash2 } from 'lucide-react';
import { Habit } from '../types';
interface HabitListProps {
habits: Habit[];
currentWeek: string[];
daysOfWeek: string[];
onToggleHabit: (id: number, date: string) => void;
onUpdateHabit: (id: number, name: string) => void;
onDeleteHabit: (id: number) => void;
getStreakForHabit: (habit: Habit) => number;
}
export function HabitList({
habits,
currentWeek,
daysOfWeek,
onToggleHabit,
onUpdateHabit,
onDeleteHabit,
getStreakForHabit
}: HabitListProps) {
return (
<table className="w-full">
<thead>
<tr>
<th className="px-4 py-2 text-left dark:text-white">Habit</th>
{daysOfWeek.map((day, index) => (
<th key={day} className="px-4 py-2 text-center dark:text-white">
{day}
<div className="text-xs text-gray-500 dark:text-gray-400">
{new Date(currentWeek[index]).getDate()}
</div>
</th>
))}
<th className="px-4 py-2 text-center dark:text-white">Streak</th>
<th className="px-4 py-2 text-center dark:text-white">Actions</th>
</tr>
</thead>
<tbody>
{habits.map((habit) => (
<tr key={habit.id} className="border-t dark:border-gray-700">
<td className="px-4 py-2 dark:text-white">
<input
type="text"
value={habit.name}
onChange={(e) => onUpdateHabit(habit.id, e.target.value)}
className="bg-transparent border-none focus:outline-none focus:ring-2 focus:ring-gray-300 rounded px-2"
/>
</td>
{currentWeek.map((date) => (
<td key={date} className="px-4 py-2 text-center">
<input
type="checkbox"
checked={habit.completedDates.includes(date)}
onChange={() => onToggleHabit(habit.id, date)}
className="w-4 h-4 rounded border-gray-300 dark:border-gray-600"
/>
</td>
))}
<td className="px-4 py-2 text-center dark:text-white">
{getStreakForHabit(habit)}
</td>
<td className="px-4 py-2 text-center">
<button
onClick={() => onDeleteHabit(habit.id)}
className="p-2 text-red-500 hover:bg-red-100 dark:hover:bg-red-900 rounded-full"
>
<Trash2 className="h-4 w-4" />
</button>
</td>
</tr>
))}
</tbody>
</table>
);
}

3
src/index.css Normal file
View file

@ -0,0 +1,3 @@
@tailwind base;
@tailwind components;
@tailwind utilities;

13
src/main.tsx Normal file
View file

@ -0,0 +1,13 @@
import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
import App from './App';
import './index.css';
const rootElement = document.getElementById('root');
if (!rootElement) throw new Error('Failed to find the root element');
createRoot(rootElement).render(
<StrictMode>
<App />
</StrictMode>
);

5
src/types.ts Normal file
View file

@ -0,0 +1,5 @@
export interface Habit {
id: number;
name: string;
completedDates: string[];
}

1
src/vite-env.d.ts vendored Normal file
View file

@ -0,0 +1 @@
/// <reference types="vite/client" />

14
tailwind.config.js Normal file
View file

@ -0,0 +1,14 @@
/** @type {import('tailwindcss').Config} */
export default {
content: ['./index.html', './src/**/*.{js,ts,jsx,tsx}'],
darkMode: 'class',
theme: {
extend: {
colors: {
primary: '#000000',
secondary: '#ffffff',
},
},
},
plugins: [],
};

24
tsconfig.app.json Normal file
View file

@ -0,0 +1,24 @@
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"isolatedModules": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "react-jsx",
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true
},
"include": ["src"]
}

7
tsconfig.json Normal file
View file

@ -0,0 +1,7 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
}

22
tsconfig.node.json Normal file
View file

@ -0,0 +1,22 @@
{
"compilerOptions": {
"target": "ES2022",
"lib": ["ES2023"],
"module": "ESNext",
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"isolatedModules": true,
"moduleDetection": "force",
"noEmit": true,
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true
},
"include": ["vite.config.ts"]
}

10
vite.config.ts Normal file
View file

@ -0,0 +1,10 @@
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
// https://vitejs.dev/config/
export default defineConfig({
plugins: [react()],
optimizeDeps: {
exclude: ['lucide-react'],
},
});