diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..358fc5b --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +config/config.php +.DS_Store +Thumbs.db diff --git a/README.md b/README.md index 65a599a..e69de29 100644 --- a/README.md +++ b/README.md @@ -1,34 +0,0 @@ -# Habit Tracker - -## Projektziel - -Der Habit Tracker ist eine Webanwendung, mit der Benutzer eigene Gewohnheiten anlegen, verwalten und täglich abhaken können. Ziel ist es, Fortschritte sichtbar zu machen und Benutzer beim Aufbau guter Routinen zu unterstützen. - -## Geplante Hauptfunktionen - -- Registrierung und Login -- Dashboard mit heutigen Habits -- Habits erstellen, bearbeiten und löschen -- Habits täglich abhaken -- Übersicht aller Habits mit Filter und Sortierung -- Detailansicht mit Verlauf, Erfolgsquote und Streak -- Kategorien für Habits -- Responsive Oberfläche für Desktop und Smartphone - -## Geplante Datenbanktabellen - -- users -- habits -- habit_logs -- categories -- reminders - -## Technische Anforderungen - -- MVC-Struktur -- PHP, HTML, CSS, SQL -- Prepared Statements zum Schutz vor SQL-Injections -- htmlspecialchars zum Schutz vor XSS -- Passwortspeicherung mit password_hash -- Session-basierter Login -- Optional: Fetch API zum Abhaken ohne Neuladen der Seite \ No newline at end of file diff --git a/app/Controllers/AuthController.php b/app/Controllers/AuthController.php new file mode 100755 index 0000000..35f4c4a --- /dev/null +++ b/app/Controllers/AuthController.php @@ -0,0 +1,160 @@ + ['email' => ''], + 'errors' => [], + ]); + } + + // Prueft die Anmeldedaten und startet die Sitzung + public function login(): void + { + verify_csrf_token(); + + $email = $this->postString('email'); + $password = $this->postPassword(); + $errors = []; + + if (!filter_var($email, FILTER_VALIDATE_EMAIL) || mb_strlen($email) > 190) { + $errors['email'] = 'Bitte gib eine gueltige E-Mail-Adresse ein.'; + } + + if ($password === '') { + $errors['password'] = 'Bitte gib dein Passwort ein.'; + } + + if (!empty($errors)) { + $this->renderLogin($email, $errors); + return; + } + + try { + $user = User::findByEmail($email); + } catch (PDOException $exception) { + $this->renderLogin($email, ['form' => 'Die Anmeldung ist gerade nicht verfuegbar. Bitte versuche es spaeter erneut.']); + return; + } + + if (!$user || !password_verify($password, $user['password_hash'])) { + $this->renderLogin($email, ['form' => 'E-Mail oder Passwort ist nicht korrekt.']); + return; + } + + session_regenerate_id(true); + $_SESSION['user_id'] = (int) $user['id']; + $_SESSION['user_name'] = $user['name']; + unset($_SESSION['csrf_token']); + redirect('dashboard'); + } + + // Zeigt das Registrierungsformular + public function showRegister(): void + { + View::render('auth/register', [ + 'values' => ['name' => '', 'email' => ''], + 'errors' => [], + ]); + } + + // Legt ein neues Konto an + public function register(): void + { + verify_csrf_token(); + + $name = $this->postString('name'); + $email = $this->postString('email'); + $password = $this->postPassword(); + $errors = []; + + if ($name === '') { + $errors['name'] = 'Bitte gib deinen Namen ein.'; + } elseif (mb_strlen($name) > 100) { + $errors['name'] = 'Der Name darf maximal 100 Zeichen lang sein.'; + } + + if (!filter_var($email, FILTER_VALIDATE_EMAIL)) { + $errors['email'] = 'Bitte gib eine gueltige E-Mail-Adresse ein.'; + } elseif (mb_strlen($email) > 190) { + $errors['email'] = 'Die E-Mail-Adresse darf maximal 190 Zeichen lang sein.'; + } + + if (strlen($password) < 8) { + $errors['password'] = 'Das Passwort muss mindestens 8 Zeichen lang sein.'; + } + + if (!empty($errors)) { + $this->renderRegister($name, $email, $errors); + return; + } + + try { + if (User::findByEmail($email)) { + $this->renderRegister($name, $email, ['email' => 'Diese E-Mail ist bereits registriert.']); + return; + } + + $created = User::create($name, $email, $password); + } catch (PDOException $exception) { + $this->renderRegister($name, $email, ['form' => 'Das Konto konnte nicht erstellt werden. Bitte versuche es spaeter erneut.']); + return; + } + + if (!$created) { + $this->renderRegister($name, $email, ['form' => 'Das Konto konnte nicht erstellt werden. Bitte versuche es spaeter erneut.']); + return; + } + + flash('success', 'Registrierung erfolgreich. Du kannst dich jetzt anmelden.'); + redirect('login'); + } + + // Beendet die Sitzung + public function logout(): void + { + verify_csrf_token(); + + $_SESSION = []; + + if (ini_get('session.use_cookies')) { + $params = session_get_cookie_params(); + setcookie(session_name(), '', time() - 42000, $params['path'], $params['domain'], $params['secure'], $params['httponly']); + } + session_destroy(); + redirect('login'); + } + + private function renderLogin(string $email, array $errors): void + { + View::render('auth/login', [ + 'values' => ['email' => $email], + 'errors' => $errors, + ]); + } + + private function renderRegister(string $name, string $email, array $errors): void + { + View::render('auth/register', [ + 'values' => ['name' => $name, 'email' => $email], + 'errors' => $errors, + ]); + } + + private function postString(string $key): string + { + $value = $_POST[$key] ?? ''; + + return is_string($value) ? trim($value) : ''; + } + + private function postPassword(): string + { + $value = $_POST['password'] ?? ''; + + return is_string($value) ? $value : ''; + } +} \ No newline at end of file diff --git a/app/Controllers/HabitController.php b/app/Controllers/HabitController.php new file mode 100755 index 0000000..4ac4a25 --- /dev/null +++ b/app/Controllers/HabitController.php @@ -0,0 +1,162 @@ + $_GET['category_id'] ?? '', + 'status' => $_GET['status'] ?? '', + ]; + + $remindersByHabit = []; + foreach (Reminder::allForUser($userId) as $reminder) { + $remindersByHabit[(int) $reminder['habit_id']][] = $reminder; + } + + View::render('habits/dashboard', [ + 'habits' => Habit::allForUser($userId, $filters), + 'categories' => Category::all(), + 'filters' => $filters, + 'remindersByHabit' => $remindersByHabit, + ]); + } + + // Zeigt das leere Formular zum Anlegen + public function create(): void + { + require_login(); + + View::render('habits/form', [ + 'habit' => null, + 'categories' => Category::all(), + 'errors' => [], + ]); + } + + // Speichert einen neuen Habit + public function store(): void + { + require_login(); + verify_csrf_token(); + + $data = $this->validatedData(); + + if (!empty($data['errors'])) { + View::render('habits/form', [ + 'habit' => $_POST, + 'categories' => Category::all(), + 'errors' => $data['errors'], + ]); + return; + } + + Habit::create(current_user_id(), $data); + flash('success', 'Habit wurde erstellt.'); + redirect('dashboard'); + } + + // Zeigt das Formular zum Bearbeiten + public function edit(): void + { + require_login(); + + $habit = Habit::findForUser((int) ($_GET['id'] ?? 0), current_user_id()); + + if (!$habit) { + flash('error', 'Habit wurde nicht gefunden.'); + redirect('dashboard'); + } + + View::render('habits/form', [ + 'habit' => $habit, + 'categories' => Category::all(), + 'errors' => [], + ]); + } + + // Uebernimmt die Aenderungen eines bestehenden Habits + public function update(): void + { + require_login(); + verify_csrf_token(); + + $id = (int) ($_POST['id'] ?? 0); + $data = $this->validatedData(); + + if (!empty($data['errors'])) { + // ID mitgeben, sonst zeigt das Formular auf keinen Datensatz mehr + View::render('habits/form', [ + 'habit' => array_merge($_POST, ['id' => $id]), + 'categories' => Category::all(), + 'errors' => $data['errors'], + ]); + return; + } + + Habit::update($id, current_user_id(), $data); + flash('success', 'Habit wurde gespeichert.'); + redirect('dashboard'); + } + + // Loescht einen Habit + public function delete(): void + { + require_login(); + verify_csrf_token(); + + Habit::delete((int) ($_POST['id'] ?? 0), current_user_id()); + flash('success', 'Habit wurde geloescht.'); + redirect('dashboard'); + } + + public function toggle(): void + { + require_login(); + verify_csrf_token(); + + Habit::toggleToday((int) ($_POST['id'] ?? 0), current_user_id()); + redirect('dashboard'); + } + + private function validatedData(): array + { + $title = trim($_POST['title'] ?? ''); + $description = trim($_POST['description'] ?? ''); + $categoryId = $_POST['category_id'] ?? ''; + $targetPerWeek = (int) ($_POST['target_per_week'] ?? 0); + $errors = []; + + if ($title === '') { + $errors['title'] = 'Bitte gib einen Namen fuer den Habit ein.'; + } elseif (mb_strlen($title) > 120) { + $errors['title'] = 'Der Name darf maximal 120 Zeichen lang sein.'; + } + + if (mb_strlen($description) > 1000) { + $errors['description'] = 'Die Beschreibung darf maximal 1000 Zeichen lang sein.'; + } + + // Gegen die Datenbank pruefen, damit keine erfundene ID durchkommt + if ($categoryId !== '' && !Category::exists((int) $categoryId)) { + $errors['category_id'] = 'Bitte waehle eine gueltige Kategorie aus.'; + } + + if ($targetPerWeek < 1 || $targetPerWeek > 7) { + $errors['target_per_week'] = 'Bitte waehle einen Wert zwischen 1 und 7.'; + } + + return [ + 'title' => $title, + 'description' => $description, + 'category_id' => $categoryId, + 'target_per_week' => $targetPerWeek, + 'errors' => $errors, + ]; + } +} \ No newline at end of file diff --git a/app/Controllers/ReminderController.php b/app/Controllers/ReminderController.php new file mode 100755 index 0000000..a717d6f --- /dev/null +++ b/app/Controllers/ReminderController.php @@ -0,0 +1,193 @@ +queryId('habit_id'), current_user_id()); + + if (!$habit) { + flash('error', 'Der Habit fuer die Erinnerung wurde nicht gefunden.'); + redirect('dashboard'); + } + + View::render('reminders/form', [ + 'habit' => $habit, + 'reminder' => ['habit_id' => $habit['id'], 'is_active' => 1], + 'errors' => [], + ]); + } + + // Speichert eine neue Erinnerung + public function store(): void + { + require_login(); + verify_csrf_token(); + + $habit = Habit::findForUser($this->postId('habit_id'), current_user_id()); + + if (!$habit) { + flash('error', 'Der Habit fuer die Erinnerung wurde nicht gefunden.'); + redirect('dashboard'); + } + + $data = $this->validatedData(); + + if (!empty($data['errors'])) { + View::render('reminders/form', [ + 'habit' => $habit, + 'reminder' => array_merge($_POST, $data), + 'errors' => $data['errors'], + ]); + return; + } + + if (!Reminder::createForHabit((int) $habit['id'], current_user_id(), $data)) { + flash('error', 'Die Erinnerung konnte nicht gespeichert werden.'); + redirect('dashboard'); + } + + flash('success', 'Erinnerung wurde erstellt.'); + redirect('dashboard'); + } + + // Zeigt das Formular zum Bearbeiten + public function edit(): void + { + require_login(); + + $reminder = Reminder::findForUser($this->queryId('id'), current_user_id()); + + if (!$reminder) { + flash('error', 'Erinnerung wurde nicht gefunden.'); + redirect('dashboard'); + } + + $habit = Habit::findForUser((int) $reminder['habit_id'], current_user_id()); + + View::render('reminders/form', [ + 'habit' => $habit, + 'reminder' => $reminder, + 'errors' => [], + ]); + } + + public function update(): void + { + require_login(); + verify_csrf_token(); + + $reminder = Reminder::findForUser($this->postId('id'), current_user_id()); + + if (!$reminder) { + flash('error', 'Erinnerung wurde nicht gefunden.'); + redirect('dashboard'); + } + + $habit = Habit::findForUser((int) $reminder['habit_id'], current_user_id()); + $data = $this->validatedData(); + + if (!empty($data['errors'])) { + View::render('reminders/form', [ + 'habit' => $habit, + 'reminder' => array_merge($reminder, $data), + 'errors' => $data['errors'], + ]); + return; + } + + if (!Reminder::update((int) $reminder['id'], current_user_id(), $data)) { + flash('error', 'Die Erinnerung konnte nicht gespeichert werden.'); + redirect('dashboard'); + } + + flash('success', 'Erinnerung wurde gespeichert.'); + redirect('dashboard'); + } + + // Loescht eine Erinnerung + public function delete(): void + { + require_login(); + verify_csrf_token(); + + $reminder = Reminder::findForUser($this->postId('id'), current_user_id()); + + if (!$reminder || !Reminder::delete((int) $reminder['id'], current_user_id())) { + flash('error', 'Die Erinnerung konnte nicht geloescht werden.'); + redirect('dashboard'); + } + + flash('success', 'Erinnerung wurde geloescht.'); + redirect('dashboard'); + } + + public function toggle(): void + { + require_login(); + verify_csrf_token(); + + $reminder = Reminder::findForUser($this->postId('id'), current_user_id()); + + if (!$reminder || !Reminder::toggle((int) $reminder['id'], current_user_id())) { + flash('error', 'Der Status der Erinnerung konnte nicht geaendert werden.'); + redirect('dashboard'); + } + + flash('success', $reminder['is_active'] ? 'Erinnerung pausiert.' : 'Erinnerung aktiviert.'); + redirect('dashboard'); + } + + private function validatedData(): array + { + $time = $this->postString('reminder_time'); + $weekdayValue = $this->postString('weekday'); + $activeValue = $this->postString('is_active'); + $errors = []; + + // Uhrzeit muss dem Format HH:MM zwischen 00:00 und 23:59 + if (!preg_match('/^(?:[01]\d|2[0-3]):[0-5]\d$/', $time)) { + $errors['reminder_time'] = 'Bitte gib eine Uhrzeit im Format HH:MM ein.'; + } + + // Leerer Wochentag ist erlaubt und bedeutet taeglich + if ($weekdayValue !== '' && !preg_match('/^[1-7]$/', $weekdayValue)) { + $errors['weekday'] = 'Bitte waehle einen gueltigen Wochentag aus.'; + } + + if (!in_array($activeValue, ['0', '1'], true)) { + $errors['is_active'] = 'Bitte waehle einen gueltigen Status aus.'; + } + + return [ + 'reminder_time' => $time, + 'weekday' => $weekdayValue === '' ? null : (int) $weekdayValue, + 'is_active' => $activeValue === '0' ? 0 : 1, + 'errors' => $errors, + ]; + } + + private function queryId(string $key): int + { + $value = $_GET[$key] ?? ''; + + return is_string($value) && preg_match('/^[1-9]\d*$/', $value) ? (int) $value : 0; + } + + private function postId(string $key): int + { + $value = $_POST[$key] ?? ''; + + return is_string($value) && preg_match('/^[1-9]\d*$/', $value) ? (int) $value : 0; + } + + private function postString(string $key): string + { + $value = $_POST[$key] ?? ''; + + return is_string($value) ? trim($value) : ''; + } +} \ No newline at end of file diff --git a/app/Core/Database.php b/app/Core/Database.php new file mode 100755 index 0000000..f7e7f49 --- /dev/null +++ b/app/Core/Database.php @@ -0,0 +1,34 @@ + PDO::ERRMODE_EXCEPTION, + PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC, + ]); + } catch (PDOException $exception) { + throw new PDOException('Datenbankverbindung fehlgeschlagen.'); + } + + return self::$connection; + } +} \ No newline at end of file diff --git a/app/Core/View.php b/app/Core/View.php new file mode 100755 index 0000000..5b0fe82 --- /dev/null +++ b/app/Core/View.php @@ -0,0 +1,15 @@ +query('SELECT id, name, color FROM categories ORDER BY name') + ->fetchAll(); + } + + public static function exists(int $id): bool + { + $statement = Database::connection()->prepare('SELECT id FROM categories WHERE id = :id LIMIT 1'); + $statement->execute(['id' => $id]); + + return (bool) $statement->fetch(); + } +} diff --git a/app/Models/Habit.php b/app/Models/Habit.php new file mode 100755 index 0000000..f867d45 --- /dev/null +++ b/app/Models/Habit.php @@ -0,0 +1,115 @@ + $userId]; + + if (!empty($filters['category_id'])) { + $where[] = 'h.category_id = :category_id'; + $params['category_id'] = (int) $filters['category_id']; + } + + if (($filters['status'] ?? '') === 'done') { + $where[] = 'hl.id IS NOT NULL'; + } elseif (($filters['status'] ?? '') === 'open') { + $where[] = 'hl.id IS NULL'; + } + + $sql = 'SELECT h.*, c.name AS category_name, c.color AS category_color, + CASE WHEN hl.id IS NULL THEN 0 ELSE 1 END AS done_today, + (SELECT COUNT(*) + FROM habit_logs hwl + WHERE hwl.habit_id = h.id + AND YEARWEEK(hwl.completed_on, 1) = YEARWEEK(CURRENT_DATE, 1)) AS completed_this_week + FROM habits h + LEFT JOIN categories c ON c.id = h.category_id + LEFT JOIN habit_logs hl + ON hl.habit_id = h.id + AND hl.completed_on = CURRENT_DATE + WHERE ' . implode(' AND ', $where) . ' + ORDER BY h.created_at DESC'; + + $statement = Database::connection()->prepare($sql); + $statement->execute($params); + + return $statement->fetchAll(); + } + + public static function findForUser(int $id, int $userId): ?array + { + $statement = Database::connection()->prepare( + 'SELECT * FROM habits WHERE id = :id AND user_id = :user_id LIMIT 1' + ); + $statement->execute(['id' => $id, 'user_id' => $userId]); + + $habit = $statement->fetch(); + return $habit ?: null; + } + + // Legt einen neuen Habit an + public static function create(int $userId, array $data): bool + { + $sql = 'INSERT INTO habits (user_id, category_id, title, description, target_per_week) + VALUES (:user_id, :category_id, :title, :description, :target_per_week)'; + + return Database::connection()->prepare($sql)->execute([ + 'user_id' => $userId, + 'category_id' => $data['category_id'] ?: null, + 'title' => $data['title'], + 'description' => $data['description'], + 'target_per_week' => (int) $data['target_per_week'], + ]); + } + + public static function update(int $id, int $userId, array $data): bool + { + $sql = 'UPDATE habits + SET category_id = :category_id, + title = :title, + description = :description, + target_per_week = :target_per_week + WHERE id = :id AND user_id = :user_id'; + + return Database::connection()->prepare($sql)->execute([ + 'id' => $id, + 'user_id' => $userId, + 'category_id' => $data['category_id'] ?: null, + 'title' => $data['title'], + 'description' => $data['description'], + 'target_per_week' => (int) $data['target_per_week'], + ]); + } + + public static function delete(int $id, int $userId): bool + { + $statement = Database::connection()->prepare('DELETE FROM habits WHERE id = :id AND user_id = :user_id'); + + return $statement->execute(['id' => $id, 'user_id' => $userId]); + } + + public static function toggleToday(int $id, int $userId): bool + { + $habit = self::findForUser($id, $userId); + + if (!$habit) { + return false; + } + + $db = Database::connection(); + $check = $db->prepare( + 'SELECT id FROM habit_logs WHERE habit_id = :habit_id AND completed_on = CURRENT_DATE LIMIT 1' + ); + $check->execute(['habit_id' => $id]); + $log = $check->fetch(); + + if ($log) { + return $db->prepare('DELETE FROM habit_logs WHERE id = :id')->execute(['id' => $log['id']]); + } + + $insert = $db->prepare('INSERT INTO habit_logs (habit_id, completed_on) VALUES (:habit_id, CURRENT_DATE)'); + return $insert->execute(['habit_id' => $id]); + } +} \ No newline at end of file diff --git a/app/Models/Reminder.php b/app/Models/Reminder.php new file mode 100755 index 0000000..a35111a --- /dev/null +++ b/app/Models/Reminder.php @@ -0,0 +1,99 @@ +prepare( + 'SELECT r.*, h.title AS habit_title + FROM reminders r + INNER JOIN habits h ON h.id = r.habit_id + WHERE r.id = :id AND h.user_id = :user_id + LIMIT 1' + ); + $statement->execute(['id' => $id, 'user_id' => $userId]); + + $reminder = $statement->fetch(); + return $reminder ?: null; + } + + public static function allForUser(int $userId): array + { + $statement = Database::connection()->prepare( + 'SELECT r.*, h.title AS habit_title + FROM reminders r + INNER JOIN habits h ON h.id = r.habit_id + WHERE h.user_id = :user_id + ORDER BY h.title ASC, r.is_active DESC, r.weekday ASC, r.reminder_time ASC' + ); + $statement->execute(['user_id' => $userId]); + + return $statement->fetchAll(); + } + + // Legt eine Erinnerung an + public static function createForHabit(int $habitId, int $userId, array $data): bool + { + $statement = Database::connection()->prepare( + 'INSERT INTO reminders (habit_id, reminder_time, weekday, is_active) + SELECT id, :reminder_time, :weekday, :is_active + FROM habits + WHERE id = :habit_id AND user_id = :user_id' + ); + $statement->execute([ + 'habit_id' => $habitId, + 'user_id' => $userId, + 'reminder_time' => $data['reminder_time'], + 'weekday' => $data['weekday'], + 'is_active' => $data['is_active'], + ]); + + return $statement->rowCount() === 1; + } + + // Aktualisiert eine Erinnerung + public static function update(int $id, int $userId, array $data): bool + { + $statement = Database::connection()->prepare( + 'UPDATE reminders r + INNER JOIN habits h ON h.id = r.habit_id + SET r.reminder_time = :reminder_time, + r.weekday = :weekday, + r.is_active = :is_active + WHERE r.id = :id AND h.user_id = :user_id' + ); + + return $statement->execute([ + 'id' => $id, + 'user_id' => $userId, + 'reminder_time' => $data['reminder_time'], + 'weekday' => $data['weekday'], + 'is_active' => $data['is_active'], + ]); + } + + public static function delete(int $id, int $userId): bool + { + $statement = Database::connection()->prepare( + 'DELETE r + FROM reminders r + INNER JOIN habits h ON h.id = r.habit_id + WHERE r.id = :id AND h.user_id = :user_id' + ); + + return $statement->execute(['id' => $id, 'user_id' => $userId]); + } + + // Schaltet zwischen aktiv und pausiert um + public static function toggle(int $id, int $userId): bool + { + $statement = Database::connection()->prepare( + 'UPDATE reminders r + INNER JOIN habits h ON h.id = r.habit_id + SET r.is_active = IF(r.is_active = 1, 0, 1) + WHERE r.id = :id AND h.user_id = :user_id' + ); + + return $statement->execute(['id' => $id, 'user_id' => $userId]); + } +} \ No newline at end of file diff --git a/app/Models/User.php b/app/Models/User.php new file mode 100755 index 0000000..eaa8bd5 --- /dev/null +++ b/app/Models/User.php @@ -0,0 +1,27 @@ +prepare($sql); + + return $statement->execute([ + 'name' => $name, + 'email' => $email, + 'password_hash' => password_hash($password, PASSWORD_DEFAULT), + ]); + } + + // Sucht einen Benutzer anhand der E-Mail + public static function findByEmail(string $email): ?array + { + $statement = Database::connection()->prepare('SELECT * FROM users WHERE email = :email LIMIT 1'); + $statement->execute(['email' => $email]); + + $user = $statement->fetch(); + return $user ?: null; + } +} \ No newline at end of file diff --git a/app/Views/auth/login.php b/app/Views/auth/login.php new file mode 100644 index 0000000..b49bc84 --- /dev/null +++ b/app/Views/auth/login.php @@ -0,0 +1,48 @@ + '']; +$errors = $errors ?? []; +?> + +
+

Anmelden

+ + + +
+ + + + required + > + + + + + + + required + > + + + + + +
+

Noch kein Konto? Jetzt registrieren

+
+ diff --git a/app/Views/auth/register.php b/app/Views/auth/register.php new file mode 100644 index 0000000..14cf9e1 --- /dev/null +++ b/app/Views/auth/register.php @@ -0,0 +1,66 @@ + '', 'email' => '']; +$errors = $errors ?? []; +?> + +
+

Registrieren

+ + + +
+ + + + required + > + + + + + + + required + > + + + + + + + required + > + Mindestens 8 Zeichen. + + + + + +
+

Schon registriert? Zum Login

+
+ diff --git a/app/Views/habits/dashboard.php b/app/Views/habits/dashboard.php new file mode 100755 index 0000000..116dc51 --- /dev/null +++ b/app/Views/habits/dashboard.php @@ -0,0 +1,120 @@ + 'Montag', + 2 => 'Dienstag', + 3 => 'Mittwoch', + 4 => 'Donnerstag', + 5 => 'Freitag', + 6 => 'Samstag', + 7 => 'Sonntag', +]; +?> + +
+
+

Heute

+

Deine Habits

+
+ Habit erstellen +
+ +
+ + + + + + + + + +
+ + +

Noch keine Habits gefunden. Erstelle deinen ersten Habit.

+ + +
+ + +
+
+ +
+

+

+ + | + Woche: / erledigt + +
+
+ +
+
+ + + +
+ Bearbeiten +
+ + + +
+
+ +
+
+

Erinnerungen

+ Erinnerung hinzufuegen +
+ + +

Noch keine Erinnerung eingerichtet.

+ +
    + + +
  • + + + +
    +
    + + + +
    + Bearbeiten +
    + + + +
    +
    +
  • + +
+ +
+
+ +
\ No newline at end of file diff --git a/app/Views/habits/form.php b/app/Views/habits/form.php new file mode 100755 index 0000000..e32f02b --- /dev/null +++ b/app/Views/habits/form.php @@ -0,0 +1,64 @@ + + +
+

+ +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
diff --git a/app/Views/layout/footer.php b/app/Views/layout/footer.php new file mode 100644 index 0000000..9765cc0 --- /dev/null +++ b/app/Views/layout/footer.php @@ -0,0 +1,3 @@ + + + diff --git a/app/Views/layout/header.php b/app/Views/layout/header.php new file mode 100644 index 0000000..0e43b3d --- /dev/null +++ b/app/Views/layout/header.php @@ -0,0 +1,34 @@ + + + + + + <?= e(config('app_name')) ?> + + + +
+ Habit Tracker + +
+ +
+ +

+ + + +

+ diff --git a/app/Views/reminders/form.php b/app/Views/reminders/form.php new file mode 100644 index 0000000..0b6086a --- /dev/null +++ b/app/Views/reminders/form.php @@ -0,0 +1,69 @@ + 'Montag', + '2' => 'Dienstag', + '3' => 'Mittwoch', + '4' => 'Donnerstag', + '5' => 'Freitag', + '6' => 'Samstag', + '7' => 'Sonntag', +]; +?> + +
+

+

Habit:

+ +
+ + + + + + + + + + + + + + + + Ohne Auswahl wird die Erinnerung taeglich angezeigt. + + + + + + + + + + + +
+ +

Zurueck zum Dashboard

+
diff --git a/app/helpers.php b/app/helpers.php new file mode 100755 index 0000000..7be5164 --- /dev/null +++ b/app/helpers.php @@ -0,0 +1,82 @@ +'; +} + +function verify_csrf_token(): void +{ + $token = $_POST['csrf_token'] ?? ''; + + if (!is_string($token) || !hash_equals($_SESSION['csrf_token'] ?? '', $token)) { + http_response_code(419); + echo 'Sicherheitspruefung fehlgeschlagen. Bitte lade die Seite neu.'; + exit; + } +} diff --git a/config/config.example.php b/config/config.example.php new file mode 100644 index 0000000..65ebb17 --- /dev/null +++ b/config/config.example.php @@ -0,0 +1,13 @@ + 'Habit Tracker', + 'base_path' => '', + 'database' => [ + 'host' => 'localhost', + 'dbname' => 'habittracker', + 'user' => 'root', + 'password' => '', + 'charset' => 'utf8mb4', + ], +]; diff --git a/database/schema.sql b/database/schema.sql new file mode 100644 index 0000000..eb9b2a9 --- /dev/null +++ b/database/schema.sql @@ -0,0 +1,55 @@ +CREATE DATABASE IF NOT EXISTS pbbfa24aal_habittracker + CHARACTER SET utf8mb4 + COLLATE utf8mb4_unicode_ci; + +USE pbbfa24aal_habittracker; + +CREATE TABLE users ( + id INT AUTO_INCREMENT PRIMARY KEY, + name VARCHAR(100) NOT NULL, + email VARCHAR(190) NOT NULL UNIQUE, + password_hash VARCHAR(255) NOT NULL, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP +); + +CREATE TABLE categories ( + id INT AUTO_INCREMENT PRIMARY KEY, + name VARCHAR(80) NOT NULL, + color VARCHAR(20) NOT NULL DEFAULT '#176b5b' +); + +CREATE TABLE habits ( + id INT AUTO_INCREMENT PRIMARY KEY, + user_id INT NOT NULL, + category_id INT NULL, + title VARCHAR(120) NOT NULL, + description TEXT NULL, + target_per_week TINYINT UNSIGNED NOT NULL DEFAULT 3, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE, + FOREIGN KEY (category_id) REFERENCES categories(id) ON DELETE SET NULL +); + +CREATE TABLE habit_logs ( + id INT AUTO_INCREMENT PRIMARY KEY, + habit_id INT NOT NULL, + completed_on DATE NOT NULL, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + UNIQUE KEY unique_habit_day (habit_id, completed_on), + FOREIGN KEY (habit_id) REFERENCES habits(id) ON DELETE CASCADE +); + +CREATE TABLE reminders ( + id INT AUTO_INCREMENT PRIMARY KEY, + habit_id INT NOT NULL, + reminder_time TIME NOT NULL, + weekday TINYINT UNSIGNED NULL, + is_active TINYINT(1) NOT NULL DEFAULT 1, + FOREIGN KEY (habit_id) REFERENCES habits(id) ON DELETE CASCADE +); + +INSERT INTO categories (name, color) VALUES + ('Gesundheit', '#176b5b'), + ('Lernen', '#365c9a'), + ('Sport', '#b95c20'), + ('Alltag', '#6b4ba3'); diff --git a/docs/ER-Diagramm.svg b/docs/ER-Diagramm.svg new file mode 100644 index 0000000..3351cb0 --- /dev/null +++ b/docs/ER-Diagramm.svg @@ -0,0 +1,89 @@ + + ER-Diagramm des Habit Trackers + Datenmodell mit den Tabellen users, categories, habits, habit_logs und reminders. + + + + + + + + Habit Tracker — ER-Diagramm + Fünf Tabellen mit klaren Fremdschlüssel-Beziehungen + + + 1 : n + + 1 : n + + 1 : n + + 1 : n + + + + + users + PK id + name + email UNIQUE + password_hash + created_at + Ein Konto besitzt mehrere Habits. + + + + + + categories + PK id + name + color + Kategorie eines Habits ist optional. + + + + + + habits + PK id + FK user_id → users.id + FK category_id → categories.id + title · description + target_per_week + created_at + Zentrale Tabelle der Anwendung. + + + + + + habit_logs + PK id + FK habit_id → habits.id + completed_on · created_at + UNIQUE: habit_id + completed_on + + + + + + reminders + PK id + FK habit_id → habits.id + reminder_time · weekday · is_active + Erinnerung gehört zu genau einem Habit. + + diff --git a/docs/Testprotokoll.md b/docs/Testprotokoll.md new file mode 100644 index 0000000..6bed4ef --- /dev/null +++ b/docs/Testprotokoll.md @@ -0,0 +1,27 @@ +# Testprotokoll — Habit Tracker + +**Projekt:** Habit Tracker +**Teststand:** `develop` +**Ausführung:** Vor der Abgabe jeden Test mit einem aktuellen Screenshot oder einer kurzen Notiz nachweisen. + +| ID | Testfall | Schritte | Erwartetes Ergebnis | Tatsächliches Ergebnis | Status | +| --- | --- | --- | --- | --- | --- | +| T01 | Registrierung mit gültigen Daten | Name, gültige E-Mail und Passwort mit mindestens 8 Zeichen eingeben. | Konto wird angelegt; Hinweis führt zum Login. | | Offen | +| T02 | Registrierung mit ungültigen Daten | Leeren Namen, ungültige E-Mail oder zu kurzes Passwort eingeben. | Verständliche Fehlermeldung; Konto wird nicht angelegt. | | Offen | +| T03 | Doppelte E-Mail-Adresse | Bereits registrierte E-Mail erneut registrieren. | Hinweis, dass die E-Mail bereits verwendet wird. | | Offen | +| T04 | Login mit falschem Passwort | Richtige E-Mail, falsches Passwort eingeben. | Kein Login; neutrale Fehlermeldung. | | Offen | +| T05 | Habit erstellen | Eingeloggt Habit mit Kategorie und Wochenziel erstellen. | Habit erscheint im Dashboard mit Kategorie und Wochenziel. | | Offen | +| T06 | Habit-Validierung | Leeren Namen, mehr als 120 Zeichen oder Wochenziel außerhalb 1–7 eingeben. | Feldbezogene Fehlermeldung; Eingaben bleiben sichtbar. | | Offen | +| T07 | Filter | Kategorie- und Statusfilter nacheinander verwenden. | Nur passende Habits werden angezeigt. | | Offen | +| T08 | Tagesprotokoll und Wochenfortschritt | Habit abhaken und die Seite erneut laden. | Status wechselt; Wochenfortschritt erhöht sich genau einmal. | | Offen | +| T09 | Schutz fremder Habits | Mit zweitem Konto eine fremde Habit-ID für Bearbeiten/Löschen aufrufen. | Fremde Habit ist nicht sichtbar, nicht änderbar und nicht löschbar. | | Offen | +| T10 | CSRF-Schutz | Formular ohne oder mit verändertem CSRF-Token absenden. | Aktion wird abgelehnt; keine Daten werden geändert. | | Offen | +| T11 | Responsive Ansicht | Dashboard und Formular bei Smartphone-Breite prüfen. | Navigation, Formulare und Aktionen bleiben ohne horizontales Scrollen bedienbar. | | Offen | +| T12 | Reminder-Funktion | Nach Umsetzung: Erinnerung mit Uhrzeit und Wochentag anlegen, ändern und löschen. | Erinnerung wird korrekt angezeigt und gespeichert. | | Offen | + +## Abnahme vor der Präsentation + +- Alle Zeilen T01–T11 ausführen und das Ergebnis eintragen. +- Bei Fehlerfällen mindestens einen Screenshot sichern. +- T12 erst nach vollständiger Umsetzung der Reminder-Funktion als bestanden markieren. +- Screenshots im Ordner `docs/screenshots/` eindeutig benennen, zum Beispiel `01-login.png` oder `05-wochenfortschritt.png`. diff --git a/docs/screenshots/01-login-desktop.png b/docs/screenshots/01-login-desktop.png new file mode 100644 index 0000000..85f8767 Binary files /dev/null and b/docs/screenshots/01-login-desktop.png differ diff --git a/docs/screenshots/02-login-mobil.png b/docs/screenshots/02-login-mobil.png new file mode 100644 index 0000000..c34aa8e Binary files /dev/null and b/docs/screenshots/02-login-mobil.png differ diff --git a/docs/screenshots/03-registrierung-desktop.png b/docs/screenshots/03-registrierung-desktop.png new file mode 100644 index 0000000..acdbeef Binary files /dev/null and b/docs/screenshots/03-registrierung-desktop.png differ diff --git a/docs/screenshots/04-registrierung-mobil.png b/docs/screenshots/04-registrierung-mobil.png new file mode 100644 index 0000000..35ee9c3 Binary files /dev/null and b/docs/screenshots/04-registrierung-mobil.png differ diff --git a/index.html b/index.html index 5c1870a..cd42e38 100644 --- a/index.html +++ b/index.html @@ -1,3 +1,11 @@ - - + + + + + + Habit Tracker + + +

Habit Tracker starten

+ \ No newline at end of file diff --git a/public/assets/css/style.css b/public/assets/css/style.css new file mode 100644 index 0000000..d411060 --- /dev/null +++ b/public/assets/css/style.css @@ -0,0 +1,361 @@ +:root { + --bg: #f6f7f9; + --panel: #ffffff; + --text: #172033; + --muted: #657086; + --line: #d9dee8; + --primary: #176b5b; + --primary-dark: #0f4c40; + --danger: #b42318; + --success-bg: #e8f5ee; + --error-bg: #fdecec; +} + +* { + box-sizing: border-box; +} + +body { + margin: 0; + background: var(--bg); + color: var(--text); + font-family: Arial, Helvetica, sans-serif; + line-height: 1.5; +} + +a { + color: var(--primary); + text-decoration-thickness: 2px; +} + +.topbar { + align-items: center; + background: var(--panel); + border-bottom: 1px solid var(--line); + display: flex; + justify-content: space-between; + min-height: 64px; + padding: 0 32px; +} + +.brand { + color: var(--text); + font-size: 20px; + font-weight: 700; + text-decoration: none; +} + +.nav { + display: flex; + gap: 18px; +} + +.logout-form { + margin: 0; +} + +.link-button { + background: transparent; + color: var(--primary); + min-height: 0; + padding: 0; + text-decoration: underline; + text-decoration-thickness: 2px; +} + +.link-button:hover { + background: transparent; + color: var(--primary-dark); +} + +.page { + margin: 0 auto; + max-width: 1080px; + padding: 32px 24px; +} + +.notice, +.empty, +.auth-panel, +.form-panel { + background: var(--panel); + border: 1px solid var(--line); + border-radius: 8px; + padding: 20px; +} + +.notice.success { + background: var(--success-bg); +} + +.notice.error { + background: var(--error-bg); + color: var(--danger); +} + +.auth-panel, +.form-panel { + max-width: 520px; +} + +.dashboard-head { + align-items: center; + display: flex; + justify-content: space-between; + margin-bottom: 24px; +} + +.eyebrow { + color: var(--muted); + font-size: 14px; + font-weight: 700; + margin: 0 0 4px; + text-transform: uppercase; +} + +h1, +h2, +h3 { + line-height: 1.2; + margin: 0; +} + +h1 { + font-size: 34px; +} + +h2 { + font-size: 20px; +} + +h3 { + font-size: 16px; +} + +.button, +button { + background: var(--primary); + border: 0; + border-radius: 6px; + color: #ffffff; + cursor: pointer; + display: inline-block; + font: inherit; + font-weight: 700; + min-height: 44px; + padding: 10px 16px; + text-decoration: none; +} + +button:hover, +.button:hover { + background: var(--primary-dark); +} + +button.danger { + background: var(--danger); +} + +.form, +.filters { + display: grid; + gap: 10px; +} + +.form label, +.filters label { + font-weight: 700; +} + +.form label span { + color: var(--muted); + font-weight: 400; +} + +input, +select, +textarea { + border: 1px solid var(--line); + border-radius: 6px; + font: inherit; + min-height: 44px; + padding: 10px 12px; + width: 100%; +} + +textarea { + resize: vertical; +} + +.field-error { + color: var(--danger); + font-weight: 700; +} + +input.input-error { + border-color: var(--danger); +} + +.filters { + align-items: end; + background: var(--panel); + border: 1px solid var(--line); + border-radius: 8px; + grid-template-columns: 1fr 1fr auto; + margin-bottom: 20px; + padding: 16px; +} + +.habit-list { + display: grid; + gap: 14px; +} + +.habit-card { + align-items: center; + background: var(--panel); + border: 1px solid var(--line); + border-radius: 8px; + display: flex; + flex-wrap: wrap; + gap: 18px; + justify-content: space-between; + padding: 18px; +} + +.habit-card.done { + border-color: var(--primary); +} + +.habit-main { + display: flex; + flex: 1 1 480px; + gap: 14px; +} + +.habit-main p { + color: var(--muted); + margin: 8px 0; +} + +.category-dot { + border-radius: 999px; + flex: 0 0 14px; + height: 14px; + margin-top: 5px; + width: 14px; +} + +.habit-actions { + align-items: center; + display: flex; + flex-wrap: wrap; + gap: 10px; + justify-content: flex-end; +} + +.habit-reminders { + border-top: 1px solid var(--line); + flex-basis: 100%; + margin-top: 2px; + padding-top: 14px; +} + +.reminder-heading { + align-items: center; + display: flex; + gap: 12px; + justify-content: space-between; +} + +.reminder-empty, +.form-help, +.form small { + color: var(--muted); +} + +.reminder-list { + display: grid; + gap: 8px; + list-style: none; + margin: 12px 0 0; + padding: 0; +} + +.reminder-item { + align-items: center; + background: var(--bg); + border-radius: 6px; + display: grid; + gap: 12px; + grid-template-columns: 72px minmax(100px, 1fr) auto auto; + padding: 10px 12px; +} + +.reminder-item.paused { + opacity: 0.68; +} + +.reminder-time { + font-variant-numeric: tabular-nums; +} + +.reminder-status { + color: var(--muted); + font-size: 14px; + font-weight: 700; +} + +.reminder-actions { + align-items: center; + display: flex; + flex-wrap: wrap; + gap: 10px; + justify-content: flex-end; +} + +.reminder-actions button { + min-height: 36px; + padding: 6px 10px; +} + +@media (max-width: 720px) { + .topbar, + .dashboard-head, + .habit-card { + align-items: stretch; + flex-direction: column; + } + + .topbar { + gap: 12px; + padding: 16px 20px; + } + + .nav { + flex-wrap: wrap; + } + + .filters { + grid-template-columns: 1fr; + } + + .habit-actions { + justify-content: flex-start; + } + + .reminder-item { + align-items: start; + grid-template-columns: 72px 1fr; + } + + .reminder-status, + .reminder-actions { + grid-column: 1 / -1; + } + + .reminder-actions { + justify-content: flex-start; + } +} + diff --git a/public/index.php b/public/index.php new file mode 100644 index 0000000..d171ea8 --- /dev/null +++ b/public/index.php @@ -0,0 +1,58 @@ + true, + 'samesite' => 'Lax', + 'secure' => !empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off', +]); +session_start(); + +require __DIR__ . '/../app/helpers.php'; +require __DIR__ . '/../app/Core/Database.php'; +require __DIR__ . '/../app/Core/View.php'; +require __DIR__ . '/../app/Models/User.php'; +require __DIR__ . '/../app/Models/Category.php'; +require __DIR__ . '/../app/Models/Habit.php'; +require __DIR__ . '/../app/Models/Reminder.php'; +require __DIR__ . '/../app/Controllers/AuthController.php'; +require __DIR__ . '/../app/Controllers/HabitController.php'; +require __DIR__ . '/../app/Controllers/ReminderController.php'; + +$route = $_GET['route'] ?? 'dashboard'; +$method = $_SERVER['REQUEST_METHOD']; + +$routes = [ + 'GET' => [ + 'login' => [AuthController::class, 'showLogin'], + 'register' => [AuthController::class, 'showRegister'], + 'dashboard' => [HabitController::class, 'dashboard'], + 'habits/create' => [HabitController::class, 'create'], + 'habits/edit' => [HabitController::class, 'edit'], + 'reminders/create' => [ReminderController::class, 'create'], + 'reminders/edit' => [ReminderController::class, 'edit'], + ], + 'POST' => [ + 'login' => [AuthController::class, 'login'], + 'register' => [AuthController::class, 'register'], + 'logout' => [AuthController::class, 'logout'], + 'habits/store' => [HabitController::class, 'store'], + 'habits/update' => [HabitController::class, 'update'], + 'habits/delete' => [HabitController::class, 'delete'], + 'habits/toggle' => [HabitController::class, 'toggle'], + 'reminders/store' => [ReminderController::class, 'store'], + 'reminders/update' => [ReminderController::class, 'update'], + 'reminders/delete' => [ReminderController::class, 'delete'], + 'reminders/toggle' => [ReminderController::class, 'toggle'], + ], +]; + +if (!isset($routes[$method][$route])) { + http_response_code(404); + echo 'Seite nicht gefunden.'; + exit; +} + +[$controllerClass, $action] = $routes[$method][$route]; +(new $controllerClass())->$action();