Compare commits

..
10 Commits
24 changed files with 845 additions and 111 deletions
Regular → Executable
-77
View File
@@ -1,77 +0,0 @@
# Habit Tracker
Habit Tracker ist eine PHP-Webanwendung, mit der Benutzer eigene Gewohnheiten anlegen, bearbeiten, filtern und taeglich abhaken koennen.
## Funktionen
- Registrierung und Login mit Passwort-Hashing
- Geschuetzter Benutzerbereich per Session
- Habits erstellen, bearbeiten, loeschen und fuer heute abhaken
- Kategorien und Statusfilter im Dashboard
- Tagesprotokoll ueber `habit_logs`
- Wochenfortschritt im Dashboard gegen das persoenliche Wochenziel
- Responsive Oberflaeche fuer Desktop und Smartphone
- Serverseitige Validierung und klare Fehlermeldungen
- Schutz gegen SQL-Injection durch PDO Prepared Statements
- Schutz gegen XSS durch escaped HTML-Ausgabe
- CSRF-Schutz fuer schreibende Formulare
## Technik
- PHP
- HTML/CSS
- SQL
- MySQL/PDO
- MVC-Struktur
## MVC-Struktur
- `public/index.php`: Einstiegspunkt und Routing
- `app/Controllers/`: verarbeitet Anfragen und steuert den Ablauf
- `app/Models/`: kapselt Datenbankzugriffe
- `app/Views/`: erzeugt die HTML-Ausgabe
- `app/Core/`: gemeinsame Kernklassen fuer Datenbank und Views
- `app/helpers.php`: Hilfsfunktionen fuer Escaping, Sessions, Flash-Meldungen und CSRF
- `database/schema.sql`: Datenbankstruktur mit Startdaten
## Datenbank
Die Datenbank besteht aus 5 Tabellen:
- `users`: Benutzerkonten
- `categories`: Habit-Kategorien
- `habits`: Gewohnheiten der Benutzer
- `habit_logs`: taegliche Erledigungen
- `reminders`: vorbereitete Erinnerungen pro Habit
## Sicherheit
- Passwoerter werden mit `password_hash()` gespeichert.
- Login prueft Passwoerter mit `password_verify()`.
- SQL-Abfragen nutzen vorbereitete PDO-Statements.
- Ausgaben werden mit `htmlspecialchars()` escaped.
- Schreibende Formulare nutzen CSRF-Tokens.
- Nach erfolgreichem Login wird die Session-ID erneuert.
- Die echte `config/config.php` wird nicht mit Git hochgeladen.
## Einrichtung
1. `database/schema.sql` in phpMyAdmin importieren.
2. `config/config.example.php` zu `config/config.php` kopieren.
3. Datenbankdaten in `config/config.php` eintragen.
4. Projekt ueber `public/index.php` starten.
## Testablauf
1. Benutzer registrieren.
2. Einloggen.
3. Habit mit Kategorie und Wochenziel erstellen.
4. Habit im Dashboard filtern.
5. Habit fuer heute abhaken.
6. Wochenfortschritt im Dashboard kontrollieren.
7. Habit bearbeiten und loeschen.
8. Seite auf Smartphone-Breite pruefen.
## Bewertungsbezug
Das Projekt zeigt eine klare MVC-Aufteilung, Datenbankarbeit mit mehreren verknuepften Tabellen, serverseitige Validierung, Sicherheitsmassnahmen gegen XSS, SQL-Injection und CSRF sowie eine responsive Benutzeroberflaeche.
+105 -20
View File
@@ -2,69 +2,124 @@
class AuthController
{
// Zeigt das Login-Formular
public function showLogin(): void
{
View::render('auth/login');
View::render('auth/login', [
'values' => ['email' => ''],
'errors' => [],
]);
}
// Prueft die Anmeldedaten und startet die Sitzung
public function login(): void
{
verify_csrf_token();
$email = trim($_POST['email'] ?? '');
$password = $_POST['password'] ?? '';
$email = $this->postString('email');
$password = $this->postPassword();
$errors = [];
if (!filter_var($email, FILTER_VALIDATE_EMAIL) || $password === '') {
flash('error', 'Bitte gib eine gueltige E-Mail und dein Passwort ein.');
redirect('login');
if (!filter_var($email, FILTER_VALIDATE_EMAIL) || mb_strlen($email) > 190) {
$errors['email'] = 'Bitte gib eine gueltige E-Mail-Adresse ein.';
}
$user = User::findByEmail($email);
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'])) {
flash('error', 'E-Mail oder Passwort ist nicht korrekt.');
redirect('login');
$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');
View::render('auth/register', [
'values' => ['name' => '', 'email' => ''],
'errors' => [],
]);
}
// Legt ein neues Konto an
public function register(): void
{
verify_csrf_token();
$name = trim($_POST['name'] ?? '');
$email = trim($_POST['email'] ?? '');
$password = $_POST['password'] ?? '';
$name = $this->postString('name');
$email = $this->postString('email');
$password = $this->postPassword();
$errors = [];
if ($name === '' || !filter_var($email, FILTER_VALIDATE_EMAIL) || strlen($password) < 8) {
flash('error', 'Bitte Name, gueltige E-Mail und ein Passwort mit mindestens 8 Zeichen eingeben.');
redirect('register');
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 (User::findByEmail($email)) {
flash('error', 'Diese E-Mail ist bereits registriert.');
redirect('register');
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;
}
User::create($name, $email, $password);
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']);
@@ -72,4 +127,34 @@ class AuthController
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 : '';
}
}
+17 -1
View File
@@ -2,22 +2,32 @@
class HabitController
{
// Zeigt das Dashboard mit allen Habits des Benutzers
public function dashboard(): void
{
require_login();
$userId = (int) current_user_id();
$filters = [
'category_id' => $_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(current_user_id(), $filters),
'habits' => Habit::allForUser($userId, $filters),
'categories' => Category::all(),
'filters' => $filters,
'remindersByHabit' => $remindersByHabit,
]);
}
// Zeigt das leere Formular zum Anlegen
public function create(): void
{
require_login();
@@ -29,6 +39,7 @@ class HabitController
]);
}
// Speichert einen neuen Habit
public function store(): void
{
require_login();
@@ -50,6 +61,7 @@ class HabitController
redirect('dashboard');
}
// Zeigt das Formular zum Bearbeiten
public function edit(): void
{
require_login();
@@ -68,6 +80,7 @@ class HabitController
]);
}
// Uebernimmt die Aenderungen eines bestehenden Habits
public function update(): void
{
require_login();
@@ -77,6 +90,7 @@ class HabitController
$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(),
@@ -90,6 +104,7 @@ class HabitController
redirect('dashboard');
}
// Loescht einen Habit
public function delete(): void
{
require_login();
@@ -127,6 +142,7 @@ class HabitController
$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.';
}
+193
View File
@@ -0,0 +1,193 @@
<?php
class ReminderController
{
// Zeigt das Formular zum Anlegen einer Erinnerung
public function create(): void
{
require_login();
$habit = Habit::findForUser($this->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) : '';
}
}
Regular → Executable
+2
View File
@@ -1,7 +1,9 @@
<?php
// Stellt die Verbindung zur Datenbank bereit
class Database
{
// Verbindung wird zwischengespeichert damit pro Request nur eine entsteht
private static ?PDO $connection = null;
public static function connection(): PDO
Regular → Executable
View File
Regular → Executable
View File
Regular → Executable
+1
View File
@@ -49,6 +49,7 @@ class Habit
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)
+99
View File
@@ -0,0 +1,99 @@
<?php
class Reminder
{
public static function findForUser(int $id, 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 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]);
}
}
Regular → Executable
+2
View File
@@ -2,6 +2,7 @@
class User
{
// Legt einen neuen Benutzer an
public static function create(string $name, string $email, string $password): bool
{
$sql = 'INSERT INTO users (name, email, password_hash) VALUES (:name, :email, :password_hash)';
@@ -14,6 +15,7 @@ class User
]);
}
// 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');
+35 -2
View File
@@ -1,12 +1,45 @@
<?php
$values = $values ?? ['email' => ''];
$errors = $errors ?? [];
?>
<section class="auth-panel">
<h1>Anmelden</h1>
<?php if (!empty($errors['form'])): ?>
<p class="field-error" role="alert"><?= e($errors['form']) ?></p>
<?php endif; ?>
<form method="post" action="index.php?route=login" class="form">
<?= csrf_field() ?>
<label for="email">E-Mail</label>
<input id="email" name="email" type="email" autocomplete="email" required>
<input
id="email"
name="email"
type="email"
maxlength="190"
autocomplete="email"
value="<?= e($values['email'] ?? '') ?>"
class="<?= !empty($errors['email']) ? 'input-error' : '' ?>"
<?= !empty($errors['email']) ? 'aria-invalid="true" aria-describedby="email-error"' : '' ?>
required
>
<?php if (!empty($errors['email'])): ?>
<small id="email-error" class="field-error"><?= e($errors['email']) ?></small>
<?php endif; ?>
<label for="password">Passwort</label>
<input id="password" name="password" type="password" autocomplete="current-password" required>
<input
id="password"
name="password"
type="password"
autocomplete="current-password"
class="<?= !empty($errors['password']) ? 'input-error' : '' ?>"
<?= !empty($errors['password']) ? 'aria-invalid="true" aria-describedby="password-error"' : '' ?>
required
>
<?php if (!empty($errors['password'])): ?>
<small id="password-error" class="field-error"><?= e($errors['password']) ?></small>
<?php endif; ?>
<button type="submit">Einloggen</button>
</form>
+51 -4
View File
@@ -1,16 +1,63 @@
<?php
$values = $values ?? ['name' => '', 'email' => ''];
$errors = $errors ?? [];
?>
<section class="auth-panel">
<h1>Registrieren</h1>
<?php if (!empty($errors['form'])): ?>
<p class="field-error" role="alert"><?= e($errors['form']) ?></p>
<?php endif; ?>
<form method="post" action="index.php?route=register" class="form">
<?= csrf_field() ?>
<label for="name">Name</label>
<input id="name" name="name" type="text" autocomplete="name" required>
<input
id="name"
name="name"
type="text"
maxlength="100"
autocomplete="name"
value="<?= e($values['name'] ?? '') ?>"
class="<?= !empty($errors['name']) ? 'input-error' : '' ?>"
<?= !empty($errors['name']) ? 'aria-invalid="true" aria-describedby="name-error"' : '' ?>
required
>
<?php if (!empty($errors['name'])): ?>
<small id="name-error" class="field-error"><?= e($errors['name']) ?></small>
<?php endif; ?>
<label for="email">E-Mail</label>
<input id="email" name="email" type="email" autocomplete="email" required>
<input
id="email"
name="email"
type="email"
maxlength="190"
autocomplete="email"
value="<?= e($values['email'] ?? '') ?>"
class="<?= !empty($errors['email']) ? 'input-error' : '' ?>"
<?= !empty($errors['email']) ? 'aria-invalid="true" aria-describedby="email-error"' : '' ?>
required
>
<?php if (!empty($errors['email'])): ?>
<small id="email-error" class="field-error"><?= e($errors['email']) ?></small>
<?php endif; ?>
<label for="password">Passwort</label>
<input id="password" name="password" type="password" minlength="8" autocomplete="new-password" required>
<small>Mindestens 8 Zeichen.</small>
<input
id="password"
name="password"
type="password"
minlength="8"
autocomplete="new-password"
class="<?= !empty($errors['password']) ? 'input-error' : '' ?>"
<?= !empty($errors['password']) ? 'aria-invalid="true" aria-describedby="password-error password-help"' : 'aria-describedby="password-help"' ?>
required
>
<small id="password-help">Mindestens 8 Zeichen.</small>
<?php if (!empty($errors['password'])): ?>
<small id="password-error" class="field-error"><?= e($errors['password']) ?></small>
<?php endif; ?>
<button type="submit">Konto erstellen</button>
</form>
Regular → Executable
+54
View File
@@ -1,3 +1,16 @@
<?php
$weekdayLabels = [
1 => 'Montag',
2 => 'Dienstag',
3 => 'Mittwoch',
4 => 'Donnerstag',
5 => 'Freitag',
6 => 'Samstag',
7 => 'Sonntag',
];
?>
<section class="dashboard-head">
<div>
<p class="eyebrow">Heute</p>
@@ -35,6 +48,7 @@
<section class="habit-list">
<?php foreach ($habits as $habit): ?>
<?php $habitReminders = $remindersByHabit[(int) $habit['id']] ?? []; ?>
<article class="habit-card <?= $habit['done_today'] ? 'done' : '' ?>">
<div class="habit-main">
<span class="category-dot" style="background-color: <?= e($habit['category_color'] ?? '#9ca3af') ?>"></span>
@@ -61,6 +75,46 @@
<button class="danger" type="submit">Loeschen</button>
</form>
</div>
<section class="habit-reminders" aria-labelledby="reminders-<?= (int) $habit['id'] ?>">
<div class="reminder-heading">
<h3 id="reminders-<?= (int) $habit['id'] ?>">Erinnerungen</h3>
<a href="index.php?route=reminders/create&habit_id=<?= (int) $habit['id'] ?>">Erinnerung hinzufuegen</a>
</div>
<?php if (empty($habitReminders)): ?>
<p class="reminder-empty">Noch keine Erinnerung eingerichtet.</p>
<?php else: ?>
<ul class="reminder-list">
<?php foreach ($habitReminders as $reminder): ?>
<?php
$weekday = $reminder['weekday'] === null
? 'Taeglich'
: ($weekdayLabels[(int) $reminder['weekday']] ?? 'Unbekannt');
$isActive = (int) $reminder['is_active'] === 1;
?>
<li class="reminder-item <?= $isActive ? '' : 'paused' ?>">
<strong class="reminder-time"><?= e(substr($reminder['reminder_time'], 0, 5)) ?></strong>
<span><?= e($weekday) ?></span>
<span class="reminder-status"><?= $isActive ? 'Aktiv' : 'Pausiert' ?></span>
<div class="reminder-actions">
<form method="post" action="index.php?route=reminders/toggle">
<?= csrf_field() ?>
<input type="hidden" name="id" value="<?= (int) $reminder['id'] ?>">
<button type="submit"><?= $isActive ? 'Pausieren' : 'Aktivieren' ?></button>
</form>
<a href="index.php?route=reminders/edit&id=<?= (int) $reminder['id'] ?>">Bearbeiten</a>
<form method="post" action="index.php?route=reminders/delete" onsubmit="return confirm('Diese Erinnerung wirklich loeschen?');">
<?= csrf_field() ?>
<input type="hidden" name="id" value="<?= (int) $reminder['id'] ?>">
<button class="danger" type="submit">Loeschen</button>
</form>
</div>
</li>
<?php endforeach; ?>
</ul>
<?php endif; ?>
</section>
</article>
<?php endforeach; ?>
</section>
Regular → Executable
View File
+69
View File
@@ -0,0 +1,69 @@
<?php
$isEdit = !empty($reminder['id']);
$selectedWeekday = (string) ($reminder['weekday'] ?? '');
$selectedActive = (string) ($reminder['is_active'] ?? '1');
$time = substr((string) ($reminder['reminder_time'] ?? ''), 0, 5);
$weekdays = [
'1' => 'Montag',
'2' => 'Dienstag',
'3' => 'Mittwoch',
'4' => 'Donnerstag',
'5' => 'Freitag',
'6' => 'Samstag',
'7' => 'Sonntag',
];
?>
<section class="form-panel">
<h1><?= $isEdit ? 'Erinnerung bearbeiten' : 'Erinnerung erstellen' ?></h1>
<p class="form-help">Habit: <strong><?= e($habit['title']) ?></strong></p>
<form method="post" action="index.php?route=<?= $isEdit ? 'reminders/update' : 'reminders/store' ?>" class="form">
<?= csrf_field() ?>
<?php if ($isEdit): ?>
<input type="hidden" name="id" value="<?= (int) $reminder['id'] ?>">
<?php else: ?>
<input type="hidden" name="habit_id" value="<?= (int) $habit['id'] ?>">
<?php endif; ?>
<label for="reminder_time">Uhrzeit</label>
<input
id="reminder_time"
name="reminder_time"
type="time"
value="<?= e($time) ?>"
class="<?= !empty($errors['reminder_time']) ? 'input-error' : '' ?>"
required
aria-describedby="reminder-time-error"
>
<?php if (!empty($errors['reminder_time'])): ?>
<small id="reminder-time-error" class="field-error"><?= e($errors['reminder_time']) ?></small>
<?php endif; ?>
<label for="weekday">Wochentag</label>
<select id="weekday" name="weekday" class="<?= !empty($errors['weekday']) ? 'input-error' : '' ?>" aria-describedby="weekday-help weekday-error">
<option value="">Taeglich</option>
<?php foreach ($weekdays as $value => $label): ?>
<option value="<?= $value ?>" <?= $selectedWeekday === $value ? 'selected' : '' ?>><?= $label ?></option>
<?php endforeach; ?>
</select>
<small id="weekday-help">Ohne Auswahl wird die Erinnerung taeglich angezeigt.</small>
<?php if (!empty($errors['weekday'])): ?>
<small id="weekday-error" class="field-error"><?= e($errors['weekday']) ?></small>
<?php endif; ?>
<label for="is_active">Status</label>
<select id="is_active" name="is_active" class="<?= !empty($errors['is_active']) ? 'input-error' : '' ?>" aria-describedby="status-error">
<option value="1" <?= $selectedActive === '1' ? 'selected' : '' ?>>Aktiv</option>
<option value="0" <?= $selectedActive === '0' ? 'selected' : '' ?>>Pausiert</option>
</select>
<?php if (!empty($errors['is_active'])): ?>
<small id="status-error" class="field-error"><?= e($errors['is_active']) ?></small>
<?php endif; ?>
<button type="submit"><?= $isEdit ? 'Speichern' : 'Erinnerung erstellen' ?></button>
</form>
<p><a href="index.php?route=dashboard">Zurueck zum Dashboard</a></p>
</section>
Regular → Executable
View File
+89
View File
@@ -0,0 +1,89 @@
<svg xmlns="http://www.w3.org/2000/svg" width="1600" height="1000" viewBox="0 0 1600 1000" role="img" aria-labelledby="title desc">
<title id="title">ER-Diagramm des Habit Trackers</title>
<desc id="desc">Datenmodell mit den Tabellen users, categories, habits, habit_logs und reminders.</desc>
<style>
.canvas { fill: #f6f7f9; }
.heading { font: 700 38px Arial, Helvetica, sans-serif; fill: #172033; }
.subheading { font: 400 19px Arial, Helvetica, sans-serif; fill: #657086; }
.relation { stroke: #8290a4; stroke-width: 3; fill: none; marker-end: url(#arrow); }
.relation-label { font: 700 18px Arial, Helvetica, sans-serif; fill: #657086; }
.table { fill: #ffffff; stroke: #cfd7e3; stroke-width: 2; }
.table-head { fill: #176b5b; }
.table-title { font: 700 24px Arial, Helvetica, sans-serif; fill: #ffffff; }
.field { font: 18px "Courier New", monospace; fill: #172033; }
.key { font-weight: 700; fill: #0f4c40; }
.note { font: 16px Arial, Helvetica, sans-serif; fill: #657086; }
</style>
<defs>
<marker id="arrow" markerWidth="10" markerHeight="10" refX="8" refY="3" orient="auto" markerUnits="strokeWidth">
<path d="M0,0 L0,6 L9,3 z" fill="#8290a4" />
</marker>
</defs>
<rect class="canvas" width="1600" height="1000" rx="24" />
<text class="heading" x="80" y="78">Habit Tracker — ER-Diagramm</text>
<text class="subheading" x="80" y="112">Fünf Tabellen mit klaren Fremdschlüssel-Beziehungen</text>
<path class="relation" d="M430 350 C505 350, 500 385, 575 385" />
<text class="relation-label" x="468" y="331">1 : n</text>
<path class="relation" d="M1160 350 C1085 350, 1095 425, 1020 425" />
<text class="relation-label" x="1064" y="330">1 : n</text>
<path class="relation" d="M800 585 C800 645, 430 635, 430 705" />
<text class="relation-label" x="588" y="647">1 : n</text>
<path class="relation" d="M800 585 C800 645, 1170 635, 1170 705" />
<text class="relation-label" x="988" y="647">1 : n</text>
<g transform="translate(100,190)">
<rect class="table" width="330" height="320" rx="10" />
<rect class="table-head" width="330" height="58" rx="10" />
<text class="table-title" x="24" y="38">users</text>
<text class="field key" x="24" y="96">PK id</text>
<text class="field" x="24" y="132">name</text>
<text class="field" x="24" y="168">email UNIQUE</text>
<text class="field" x="24" y="204">password_hash</text>
<text class="field" x="24" y="240">created_at</text>
<text class="note" x="24" y="288">Ein Konto besitzt mehrere Habits.</text>
</g>
<g transform="translate(1160,190)">
<rect class="table" width="330" height="270" rx="10" />
<rect class="table-head" width="330" height="58" rx="10" />
<text class="table-title" x="24" y="38">categories</text>
<text class="field key" x="24" y="96">PK id</text>
<text class="field" x="24" y="132">name</text>
<text class="field" x="24" y="168">color</text>
<text class="note" x="24" y="226">Kategorie eines Habits ist optional.</text>
</g>
<g transform="translate(575,260)">
<rect class="table" width="450" height="350" rx="10" />
<rect class="table-head" width="450" height="58" rx="10" />
<text class="table-title" x="24" y="38">habits</text>
<text class="field key" x="24" y="96">PK id</text>
<text class="field key" x="24" y="132">FK user_id → users.id</text>
<text class="field key" x="24" y="168">FK category_id → categories.id</text>
<text class="field" x="24" y="204">title · description</text>
<text class="field" x="24" y="240">target_per_week</text>
<text class="field" x="24" y="276">created_at</text>
<text class="note" x="24" y="322">Zentrale Tabelle der Anwendung.</text>
</g>
<g transform="translate(100,705)">
<rect class="table" width="430" height="230" rx="10" />
<rect class="table-head" width="430" height="58" rx="10" />
<text class="table-title" x="24" y="38">habit_logs</text>
<text class="field key" x="24" y="96">PK id</text>
<text class="field key" x="24" y="132">FK habit_id → habits.id</text>
<text class="field" x="24" y="168">completed_on · created_at</text>
<text class="note" x="24" y="210">UNIQUE: habit_id + completed_on</text>
</g>
<g transform="translate(950,705)">
<rect class="table" width="430" height="230" rx="10" />
<rect class="table-head" width="430" height="58" rx="10" />
<text class="table-title" x="24" y="38">reminders</text>
<text class="field key" x="24" y="96">PK id</text>
<text class="field key" x="24" y="132">FK habit_id → habits.id</text>
<text class="field" x="24" y="168">reminder_time · weekday · is_active</text>
<text class="note" x="24" y="210">Erinnerung gehört zu genau einem Habit.</text>
</g>
</svg>

After

Width:  |  Height:  |  Size: 4.7 KiB

+27
View File
@@ -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`.
Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

+87 -1
View File
@@ -114,7 +114,8 @@ a {
}
h1,
h2 {
h2,
h3 {
line-height: 1.2;
margin: 0;
}
@@ -127,6 +128,10 @@ h2 {
font-size: 20px;
}
h3 {
font-size: 16px;
}
.button,
button {
background: var(--primary);
@@ -212,6 +217,7 @@ input.input-error {
border: 1px solid var(--line);
border-radius: 8px;
display: flex;
flex-wrap: wrap;
gap: 18px;
justify-content: space-between;
padding: 18px;
@@ -223,6 +229,7 @@ input.input-error {
.habit-main {
display: flex;
flex: 1 1 480px;
gap: 14px;
}
@@ -247,6 +254,71 @@ input.input-error {
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,
@@ -271,5 +343,19 @@ input.input-error {
.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;
}
}
+8
View File
@@ -15,8 +15,10 @@ 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'];
@@ -28,6 +30,8 @@ $routes = [
'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'],
@@ -37,6 +41,10 @@ $routes = [
'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'],
],
];