Compare commits

...
5 Commits
16 changed files with 811 additions and 28 deletions
+99 -20
View File
@@ -4,58 +4,107 @@ class AuthController
{
public function showLogin(): void
{
View::render('auth/login');
View::render('auth/login', [
'values' => ['email' => ''],
'errors' => [],
]);
}
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');
}
public function showRegister(): void
{
View::render('auth/register');
View::render('auth/register', [
'values' => ['name' => '', 'email' => ''],
'errors' => [],
]);
}
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');
}
@@ -72,4 +121,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 : '';
}
}
+9 -1
View File
@@ -6,15 +6,23 @@ class HabitController
{
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,
]);
}
+187
View File
@@ -0,0 +1,187 @@
<?php
class ReminderController
{
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' => [],
]);
}
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');
}
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');
}
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 = [];
if (!preg_match('/^(?:[01]\\d|2[0-3]):[0-5]\\d$/', $time)) {
$errors['reminder_time'] = 'Bitte gib eine Uhrzeit im Format HH:MM ein.';
}
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) : '';
}
}
+96
View File
@@ -0,0 +1,96 @@
<?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();
}
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;
}
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]);
}
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]);
}
}
+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>
+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>
+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>
+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'],
],
];