Compare commits
10
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0a23a18218 | ||
|
|
9eb8b1de23 | ||
|
|
f91feadedf | ||
|
|
6e1dcd9d1c | ||
|
|
b4811f4bd0 | ||
|
|
f09fdfa25e | ||
|
|
3d48595ee9 | ||
|
|
81ad4b82c9 | ||
|
|
dc8407767e | ||
|
|
a055eb2b0a |
@@ -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.
|
||||
|
||||
Regular → Executable
+103
-18
@@ -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.';
|
||||
}
|
||||
|
||||
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 (!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)) {
|
||||
flash('error', 'Diese E-Mail ist bereits registriert.');
|
||||
redirect('register');
|
||||
$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 : '';
|
||||
}
|
||||
}
|
||||
Regular → Executable
+17
-1
@@ -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.';
|
||||
}
|
||||
|
||||
Executable
+193
@@ -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
@@ -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
Regular → Executable
Regular → Executable
+1
@@ -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)
|
||||
|
||||
Executable
+99
@@ -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
@@ -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');
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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 |
@@ -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 |
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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'],
|
||||
],
|
||||
];
|
||||
|
||||
|
||||
Reference in New Issue
Block a user