Compare commits
18
Commits
50d8e04063
...
a469cbab1a
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a469cbab1a | ||
|
|
0a23a18218 | ||
|
|
9eb8b1de23 | ||
|
|
f91feadedf | ||
|
|
6e1dcd9d1c | ||
|
|
b4811f4bd0 | ||
|
|
f09fdfa25e | ||
|
|
3d48595ee9 | ||
|
|
81ad4b82c9 | ||
|
|
dc8407767e | ||
|
|
a055eb2b0a | ||
|
|
0945d1d2ba | ||
|
|
9bb3bccd6d | ||
|
|
f469086bc0 | ||
|
|
1cdef43a87 | ||
|
|
23416ee382 | ||
|
|
47fa309885 | ||
|
|
b23a1368a8 |
@@ -0,0 +1,3 @@
|
||||
config/config.php
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
@@ -1,34 +0,0 @@
|
||||
# Habit Tracker
|
||||
|
||||
## Projektziel
|
||||
|
||||
Der Habit Tracker ist eine Webanwendung, mit der Benutzer eigene Gewohnheiten anlegen, verwalten und täglich abhaken können. Ziel ist es, Fortschritte sichtbar zu machen und Benutzer beim Aufbau guter Routinen zu unterstützen.
|
||||
|
||||
## Geplante Hauptfunktionen
|
||||
|
||||
- Registrierung und Login
|
||||
- Dashboard mit heutigen Habits
|
||||
- Habits erstellen, bearbeiten und löschen
|
||||
- Habits täglich abhaken
|
||||
- Übersicht aller Habits mit Filter und Sortierung
|
||||
- Detailansicht mit Verlauf, Erfolgsquote und Streak
|
||||
- Kategorien für Habits
|
||||
- Responsive Oberfläche für Desktop und Smartphone
|
||||
|
||||
## Geplante Datenbanktabellen
|
||||
|
||||
- users
|
||||
- habits
|
||||
- habit_logs
|
||||
- categories
|
||||
- reminders
|
||||
|
||||
## Technische Anforderungen
|
||||
|
||||
- MVC-Struktur
|
||||
- PHP, HTML, CSS, SQL
|
||||
- Prepared Statements zum Schutz vor SQL-Injections
|
||||
- htmlspecialchars zum Schutz vor XSS
|
||||
- Passwortspeicherung mit password_hash
|
||||
- Session-basierter Login
|
||||
- Optional: Fetch API zum Abhaken ohne Neuladen der Seite
|
||||
Executable
+160
@@ -0,0 +1,160 @@
|
||||
<?php
|
||||
|
||||
class AuthController
|
||||
{
|
||||
// Zeigt das Login-Formular
|
||||
public function showLogin(): void
|
||||
{
|
||||
View::render('auth/login', [
|
||||
'values' => ['email' => ''],
|
||||
'errors' => [],
|
||||
]);
|
||||
}
|
||||
|
||||
// Prueft die Anmeldedaten und startet die Sitzung
|
||||
public function login(): void
|
||||
{
|
||||
verify_csrf_token();
|
||||
|
||||
$email = $this->postString('email');
|
||||
$password = $this->postPassword();
|
||||
$errors = [];
|
||||
|
||||
if (!filter_var($email, FILTER_VALIDATE_EMAIL) || mb_strlen($email) > 190) {
|
||||
$errors['email'] = 'Bitte gib eine gueltige E-Mail-Adresse ein.';
|
||||
}
|
||||
|
||||
if ($password === '') {
|
||||
$errors['password'] = 'Bitte gib dein Passwort ein.';
|
||||
}
|
||||
|
||||
if (!empty($errors)) {
|
||||
$this->renderLogin($email, $errors);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
$user = User::findByEmail($email);
|
||||
} catch (PDOException $exception) {
|
||||
$this->renderLogin($email, ['form' => 'Die Anmeldung ist gerade nicht verfuegbar. Bitte versuche es spaeter erneut.']);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!$user || !password_verify($password, $user['password_hash'])) {
|
||||
$this->renderLogin($email, ['form' => 'E-Mail oder Passwort ist nicht korrekt.']);
|
||||
return;
|
||||
}
|
||||
|
||||
session_regenerate_id(true);
|
||||
$_SESSION['user_id'] = (int) $user['id'];
|
||||
$_SESSION['user_name'] = $user['name'];
|
||||
unset($_SESSION['csrf_token']);
|
||||
redirect('dashboard');
|
||||
}
|
||||
|
||||
// Zeigt das Registrierungsformular
|
||||
public function showRegister(): void
|
||||
{
|
||||
View::render('auth/register', [
|
||||
'values' => ['name' => '', 'email' => ''],
|
||||
'errors' => [],
|
||||
]);
|
||||
}
|
||||
|
||||
// Legt ein neues Konto an
|
||||
public function register(): void
|
||||
{
|
||||
verify_csrf_token();
|
||||
|
||||
$name = $this->postString('name');
|
||||
$email = $this->postString('email');
|
||||
$password = $this->postPassword();
|
||||
$errors = [];
|
||||
|
||||
if ($name === '') {
|
||||
$errors['name'] = 'Bitte gib deinen Namen ein.';
|
||||
} elseif (mb_strlen($name) > 100) {
|
||||
$errors['name'] = 'Der Name darf maximal 100 Zeichen lang sein.';
|
||||
}
|
||||
|
||||
if (!filter_var($email, FILTER_VALIDATE_EMAIL)) {
|
||||
$errors['email'] = 'Bitte gib eine gueltige E-Mail-Adresse ein.';
|
||||
} elseif (mb_strlen($email) > 190) {
|
||||
$errors['email'] = 'Die E-Mail-Adresse darf maximal 190 Zeichen lang sein.';
|
||||
}
|
||||
|
||||
if (strlen($password) < 8) {
|
||||
$errors['password'] = 'Das Passwort muss mindestens 8 Zeichen lang sein.';
|
||||
}
|
||||
|
||||
if (!empty($errors)) {
|
||||
$this->renderRegister($name, $email, $errors);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
if (User::findByEmail($email)) {
|
||||
$this->renderRegister($name, $email, ['email' => 'Diese E-Mail ist bereits registriert.']);
|
||||
return;
|
||||
}
|
||||
|
||||
$created = User::create($name, $email, $password);
|
||||
} catch (PDOException $exception) {
|
||||
$this->renderRegister($name, $email, ['form' => 'Das Konto konnte nicht erstellt werden. Bitte versuche es spaeter erneut.']);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!$created) {
|
||||
$this->renderRegister($name, $email, ['form' => 'Das Konto konnte nicht erstellt werden. Bitte versuche es spaeter erneut.']);
|
||||
return;
|
||||
}
|
||||
|
||||
flash('success', 'Registrierung erfolgreich. Du kannst dich jetzt anmelden.');
|
||||
redirect('login');
|
||||
}
|
||||
|
||||
// Beendet die Sitzung
|
||||
public function logout(): void
|
||||
{
|
||||
verify_csrf_token();
|
||||
|
||||
$_SESSION = [];
|
||||
|
||||
if (ini_get('session.use_cookies')) {
|
||||
$params = session_get_cookie_params();
|
||||
setcookie(session_name(), '', time() - 42000, $params['path'], $params['domain'], $params['secure'], $params['httponly']);
|
||||
}
|
||||
session_destroy();
|
||||
redirect('login');
|
||||
}
|
||||
|
||||
private function renderLogin(string $email, array $errors): void
|
||||
{
|
||||
View::render('auth/login', [
|
||||
'values' => ['email' => $email],
|
||||
'errors' => $errors,
|
||||
]);
|
||||
}
|
||||
|
||||
private function renderRegister(string $name, string $email, array $errors): void
|
||||
{
|
||||
View::render('auth/register', [
|
||||
'values' => ['name' => $name, 'email' => $email],
|
||||
'errors' => $errors,
|
||||
]);
|
||||
}
|
||||
|
||||
private function postString(string $key): string
|
||||
{
|
||||
$value = $_POST[$key] ?? '';
|
||||
|
||||
return is_string($value) ? trim($value) : '';
|
||||
}
|
||||
|
||||
private function postPassword(): string
|
||||
{
|
||||
$value = $_POST['password'] ?? '';
|
||||
|
||||
return is_string($value) ? $value : '';
|
||||
}
|
||||
}
|
||||
Executable
+162
@@ -0,0 +1,162 @@
|
||||
<?php
|
||||
|
||||
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($userId, $filters),
|
||||
'categories' => Category::all(),
|
||||
'filters' => $filters,
|
||||
'remindersByHabit' => $remindersByHabit,
|
||||
]);
|
||||
}
|
||||
|
||||
// Zeigt das leere Formular zum Anlegen
|
||||
public function create(): void
|
||||
{
|
||||
require_login();
|
||||
|
||||
View::render('habits/form', [
|
||||
'habit' => null,
|
||||
'categories' => Category::all(),
|
||||
'errors' => [],
|
||||
]);
|
||||
}
|
||||
|
||||
// Speichert einen neuen Habit
|
||||
public function store(): void
|
||||
{
|
||||
require_login();
|
||||
verify_csrf_token();
|
||||
|
||||
$data = $this->validatedData();
|
||||
|
||||
if (!empty($data['errors'])) {
|
||||
View::render('habits/form', [
|
||||
'habit' => $_POST,
|
||||
'categories' => Category::all(),
|
||||
'errors' => $data['errors'],
|
||||
]);
|
||||
return;
|
||||
}
|
||||
|
||||
Habit::create(current_user_id(), $data);
|
||||
flash('success', 'Habit wurde erstellt.');
|
||||
redirect('dashboard');
|
||||
}
|
||||
|
||||
// Zeigt das Formular zum Bearbeiten
|
||||
public function edit(): void
|
||||
{
|
||||
require_login();
|
||||
|
||||
$habit = Habit::findForUser((int) ($_GET['id'] ?? 0), current_user_id());
|
||||
|
||||
if (!$habit) {
|
||||
flash('error', 'Habit wurde nicht gefunden.');
|
||||
redirect('dashboard');
|
||||
}
|
||||
|
||||
View::render('habits/form', [
|
||||
'habit' => $habit,
|
||||
'categories' => Category::all(),
|
||||
'errors' => [],
|
||||
]);
|
||||
}
|
||||
|
||||
// Uebernimmt die Aenderungen eines bestehenden Habits
|
||||
public function update(): void
|
||||
{
|
||||
require_login();
|
||||
verify_csrf_token();
|
||||
|
||||
$id = (int) ($_POST['id'] ?? 0);
|
||||
$data = $this->validatedData();
|
||||
|
||||
if (!empty($data['errors'])) {
|
||||
// ID mitgeben, sonst zeigt das Formular auf keinen Datensatz mehr
|
||||
View::render('habits/form', [
|
||||
'habit' => array_merge($_POST, ['id' => $id]),
|
||||
'categories' => Category::all(),
|
||||
'errors' => $data['errors'],
|
||||
]);
|
||||
return;
|
||||
}
|
||||
|
||||
Habit::update($id, current_user_id(), $data);
|
||||
flash('success', 'Habit wurde gespeichert.');
|
||||
redirect('dashboard');
|
||||
}
|
||||
|
||||
// Loescht einen Habit
|
||||
public function delete(): void
|
||||
{
|
||||
require_login();
|
||||
verify_csrf_token();
|
||||
|
||||
Habit::delete((int) ($_POST['id'] ?? 0), current_user_id());
|
||||
flash('success', 'Habit wurde geloescht.');
|
||||
redirect('dashboard');
|
||||
}
|
||||
|
||||
public function toggle(): void
|
||||
{
|
||||
require_login();
|
||||
verify_csrf_token();
|
||||
|
||||
Habit::toggleToday((int) ($_POST['id'] ?? 0), current_user_id());
|
||||
redirect('dashboard');
|
||||
}
|
||||
|
||||
private function validatedData(): array
|
||||
{
|
||||
$title = trim($_POST['title'] ?? '');
|
||||
$description = trim($_POST['description'] ?? '');
|
||||
$categoryId = $_POST['category_id'] ?? '';
|
||||
$targetPerWeek = (int) ($_POST['target_per_week'] ?? 0);
|
||||
$errors = [];
|
||||
|
||||
if ($title === '') {
|
||||
$errors['title'] = 'Bitte gib einen Namen fuer den Habit ein.';
|
||||
} elseif (mb_strlen($title) > 120) {
|
||||
$errors['title'] = 'Der Name darf maximal 120 Zeichen lang sein.';
|
||||
}
|
||||
|
||||
if (mb_strlen($description) > 1000) {
|
||||
$errors['description'] = 'Die Beschreibung darf maximal 1000 Zeichen lang sein.';
|
||||
}
|
||||
|
||||
// Gegen die Datenbank pruefen, damit keine erfundene ID durchkommt
|
||||
if ($categoryId !== '' && !Category::exists((int) $categoryId)) {
|
||||
$errors['category_id'] = 'Bitte waehle eine gueltige Kategorie aus.';
|
||||
}
|
||||
|
||||
if ($targetPerWeek < 1 || $targetPerWeek > 7) {
|
||||
$errors['target_per_week'] = 'Bitte waehle einen Wert zwischen 1 und 7.';
|
||||
}
|
||||
|
||||
return [
|
||||
'title' => $title,
|
||||
'description' => $description,
|
||||
'category_id' => $categoryId,
|
||||
'target_per_week' => $targetPerWeek,
|
||||
'errors' => $errors,
|
||||
];
|
||||
}
|
||||
}
|
||||
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) : '';
|
||||
}
|
||||
}
|
||||
Executable
+34
@@ -0,0 +1,34 @@
|
||||
<?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
|
||||
{
|
||||
if (self::$connection !== null) {
|
||||
return self::$connection;
|
||||
}
|
||||
|
||||
$db = config('database');
|
||||
$dsn = sprintf(
|
||||
'mysql:host=%s;dbname=%s;charset=%s',
|
||||
$db['host'],
|
||||
$db['dbname'],
|
||||
$db['charset']
|
||||
);
|
||||
|
||||
try {
|
||||
self::$connection = new PDO($dsn, $db['user'], $db['password'], [
|
||||
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
|
||||
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
|
||||
]);
|
||||
} catch (PDOException $exception) {
|
||||
throw new PDOException('Datenbankverbindung fehlgeschlagen.');
|
||||
}
|
||||
|
||||
return self::$connection;
|
||||
}
|
||||
}
|
||||
Executable
+15
@@ -0,0 +1,15 @@
|
||||
<?php
|
||||
|
||||
class View
|
||||
{
|
||||
public static function render(string $view, array $data = []): void
|
||||
{
|
||||
extract($data, EXTR_SKIP);
|
||||
|
||||
$viewPath = __DIR__ . '/../Views/' . $view . '.php';
|
||||
|
||||
require __DIR__ . '/../Views/layout/header.php';
|
||||
require $viewPath;
|
||||
require __DIR__ . '/../Views/layout/footer.php';
|
||||
}
|
||||
}
|
||||
Executable
+19
@@ -0,0 +1,19 @@
|
||||
<?php
|
||||
|
||||
class Category
|
||||
{
|
||||
public static function all(): array
|
||||
{
|
||||
return Database::connection()
|
||||
->query('SELECT id, name, color FROM categories ORDER BY name')
|
||||
->fetchAll();
|
||||
}
|
||||
|
||||
public static function exists(int $id): bool
|
||||
{
|
||||
$statement = Database::connection()->prepare('SELECT id FROM categories WHERE id = :id LIMIT 1');
|
||||
$statement->execute(['id' => $id]);
|
||||
|
||||
return (bool) $statement->fetch();
|
||||
}
|
||||
}
|
||||
Executable
+115
@@ -0,0 +1,115 @@
|
||||
<?php
|
||||
|
||||
class Habit
|
||||
{
|
||||
public static function allForUser(int $userId, array $filters = []): array
|
||||
{
|
||||
$where = ['h.user_id = :user_id'];
|
||||
$params = ['user_id' => $userId];
|
||||
|
||||
if (!empty($filters['category_id'])) {
|
||||
$where[] = 'h.category_id = :category_id';
|
||||
$params['category_id'] = (int) $filters['category_id'];
|
||||
}
|
||||
|
||||
if (($filters['status'] ?? '') === 'done') {
|
||||
$where[] = 'hl.id IS NOT NULL';
|
||||
} elseif (($filters['status'] ?? '') === 'open') {
|
||||
$where[] = 'hl.id IS NULL';
|
||||
}
|
||||
|
||||
$sql = 'SELECT h.*, c.name AS category_name, c.color AS category_color,
|
||||
CASE WHEN hl.id IS NULL THEN 0 ELSE 1 END AS done_today,
|
||||
(SELECT COUNT(*)
|
||||
FROM habit_logs hwl
|
||||
WHERE hwl.habit_id = h.id
|
||||
AND YEARWEEK(hwl.completed_on, 1) = YEARWEEK(CURRENT_DATE, 1)) AS completed_this_week
|
||||
FROM habits h
|
||||
LEFT JOIN categories c ON c.id = h.category_id
|
||||
LEFT JOIN habit_logs hl
|
||||
ON hl.habit_id = h.id
|
||||
AND hl.completed_on = CURRENT_DATE
|
||||
WHERE ' . implode(' AND ', $where) . '
|
||||
ORDER BY h.created_at DESC';
|
||||
|
||||
$statement = Database::connection()->prepare($sql);
|
||||
$statement->execute($params);
|
||||
|
||||
return $statement->fetchAll();
|
||||
}
|
||||
|
||||
public static function findForUser(int $id, int $userId): ?array
|
||||
{
|
||||
$statement = Database::connection()->prepare(
|
||||
'SELECT * FROM habits WHERE id = :id AND user_id = :user_id LIMIT 1'
|
||||
);
|
||||
$statement->execute(['id' => $id, 'user_id' => $userId]);
|
||||
|
||||
$habit = $statement->fetch();
|
||||
return $habit ?: null;
|
||||
}
|
||||
|
||||
// Legt einen neuen Habit an
|
||||
public static function create(int $userId, array $data): bool
|
||||
{
|
||||
$sql = 'INSERT INTO habits (user_id, category_id, title, description, target_per_week)
|
||||
VALUES (:user_id, :category_id, :title, :description, :target_per_week)';
|
||||
|
||||
return Database::connection()->prepare($sql)->execute([
|
||||
'user_id' => $userId,
|
||||
'category_id' => $data['category_id'] ?: null,
|
||||
'title' => $data['title'],
|
||||
'description' => $data['description'],
|
||||
'target_per_week' => (int) $data['target_per_week'],
|
||||
]);
|
||||
}
|
||||
|
||||
public static function update(int $id, int $userId, array $data): bool
|
||||
{
|
||||
$sql = 'UPDATE habits
|
||||
SET category_id = :category_id,
|
||||
title = :title,
|
||||
description = :description,
|
||||
target_per_week = :target_per_week
|
||||
WHERE id = :id AND user_id = :user_id';
|
||||
|
||||
return Database::connection()->prepare($sql)->execute([
|
||||
'id' => $id,
|
||||
'user_id' => $userId,
|
||||
'category_id' => $data['category_id'] ?: null,
|
||||
'title' => $data['title'],
|
||||
'description' => $data['description'],
|
||||
'target_per_week' => (int) $data['target_per_week'],
|
||||
]);
|
||||
}
|
||||
|
||||
public static function delete(int $id, int $userId): bool
|
||||
{
|
||||
$statement = Database::connection()->prepare('DELETE FROM habits WHERE id = :id AND user_id = :user_id');
|
||||
|
||||
return $statement->execute(['id' => $id, 'user_id' => $userId]);
|
||||
}
|
||||
|
||||
public static function toggleToday(int $id, int $userId): bool
|
||||
{
|
||||
$habit = self::findForUser($id, $userId);
|
||||
|
||||
if (!$habit) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$db = Database::connection();
|
||||
$check = $db->prepare(
|
||||
'SELECT id FROM habit_logs WHERE habit_id = :habit_id AND completed_on = CURRENT_DATE LIMIT 1'
|
||||
);
|
||||
$check->execute(['habit_id' => $id]);
|
||||
$log = $check->fetch();
|
||||
|
||||
if ($log) {
|
||||
return $db->prepare('DELETE FROM habit_logs WHERE id = :id')->execute(['id' => $log['id']]);
|
||||
}
|
||||
|
||||
$insert = $db->prepare('INSERT INTO habit_logs (habit_id, completed_on) VALUES (:habit_id, CURRENT_DATE)');
|
||||
return $insert->execute(['habit_id' => $id]);
|
||||
}
|
||||
}
|
||||
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]);
|
||||
}
|
||||
}
|
||||
Executable
+27
@@ -0,0 +1,27 @@
|
||||
<?php
|
||||
|
||||
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)';
|
||||
$statement = Database::connection()->prepare($sql);
|
||||
|
||||
return $statement->execute([
|
||||
'name' => $name,
|
||||
'email' => $email,
|
||||
'password_hash' => password_hash($password, PASSWORD_DEFAULT),
|
||||
]);
|
||||
}
|
||||
|
||||
// Sucht einen Benutzer anhand der E-Mail
|
||||
public static function findByEmail(string $email): ?array
|
||||
{
|
||||
$statement = Database::connection()->prepare('SELECT * FROM users WHERE email = :email LIMIT 1');
|
||||
$statement->execute(['email' => $email]);
|
||||
|
||||
$user = $statement->fetch();
|
||||
return $user ?: null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
<?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"
|
||||
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"
|
||||
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>
|
||||
<p>Noch kein Konto? <a href="index.php?route=register">Jetzt registrieren</a></p>
|
||||
</section>
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
<?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"
|
||||
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"
|
||||
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"
|
||||
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>
|
||||
<p>Schon registriert? <a href="index.php?route=login">Zum Login</a></p>
|
||||
</section>
|
||||
|
||||
Executable
+120
@@ -0,0 +1,120 @@
|
||||
<?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>
|
||||
<h1>Deine Habits</h1>
|
||||
</div>
|
||||
<a class="button" href="index.php?route=habits/create">Habit erstellen</a>
|
||||
</section>
|
||||
|
||||
<form method="get" action="index.php" class="filters">
|
||||
<input type="hidden" name="route" value="dashboard">
|
||||
|
||||
<label for="category_id">Kategorie</label>
|
||||
<select id="category_id" name="category_id">
|
||||
<option value="">Alle Kategorien</option>
|
||||
<?php foreach ($categories as $category): ?>
|
||||
<option value="<?= (int) $category['id'] ?>" <?= (string) $filters['category_id'] === (string) $category['id'] ? 'selected' : '' ?>>
|
||||
<?= e($category['name']) ?>
|
||||
</option>
|
||||
<?php endforeach; ?>
|
||||
</select>
|
||||
|
||||
<label for="status">Status</label>
|
||||
<select id="status" name="status">
|
||||
<option value="">Alle</option>
|
||||
<option value="open" <?= $filters['status'] === 'open' ? 'selected' : '' ?>>Offen</option>
|
||||
<option value="done" <?= $filters['status'] === 'done' ? 'selected' : '' ?>>Erledigt</option>
|
||||
</select>
|
||||
|
||||
<button type="submit">Filtern</button>
|
||||
</form>
|
||||
|
||||
<?php if (empty($habits)): ?>
|
||||
<p class="empty">Noch keine Habits gefunden. Erstelle deinen ersten Habit.</p>
|
||||
<?php endif; ?>
|
||||
|
||||
<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>
|
||||
<div>
|
||||
<h2><?= e($habit['title']) ?></h2>
|
||||
<p><?= e($habit['description']) ?></p>
|
||||
<small>
|
||||
<?= e($habit['category_name'] ?? 'Ohne Kategorie') ?> |
|
||||
Woche: <?= (int) $habit['completed_this_week'] ?> / <?= (int) $habit['target_per_week'] ?> erledigt
|
||||
</small>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="habit-actions">
|
||||
<form method="post" action="index.php?route=habits/toggle">
|
||||
<?= csrf_field() ?>
|
||||
<input type="hidden" name="id" value="<?= (int) $habit['id'] ?>">
|
||||
<button type="submit"><?= $habit['done_today'] ? 'Heute erledigt' : 'Abhaken' ?></button>
|
||||
</form>
|
||||
<a href="index.php?route=habits/edit&id=<?= (int) $habit['id'] ?>">Bearbeiten</a>
|
||||
<form method="post" action="index.php?route=habits/delete" onsubmit="return confirm('Diesen Habit wirklich loeschen?');">
|
||||
<?= csrf_field() ?>
|
||||
<input type="hidden" name="id" value="<?= (int) $habit['id'] ?>">
|
||||
<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>
|
||||
Executable
+64
@@ -0,0 +1,64 @@
|
||||
<?php $isEdit = !empty($habit['id']); ?>
|
||||
|
||||
<section class="form-panel">
|
||||
<h1><?= $isEdit ? 'Habit bearbeiten' : 'Habit erstellen' ?></h1>
|
||||
|
||||
<form method="post" action="index.php?route=<?= $isEdit ? 'habits/update' : 'habits/store' ?>" class="form">
|
||||
<?= csrf_field() ?>
|
||||
<?php if ($isEdit): ?>
|
||||
<input type="hidden" name="id" value="<?= (int) $habit['id'] ?>">
|
||||
<?php endif; ?>
|
||||
|
||||
<label for="title">Name des Habits</label>
|
||||
<input
|
||||
id="title"
|
||||
name="title"
|
||||
type="text"
|
||||
maxlength="120"
|
||||
class="<?= !empty($errors['title']) ? 'input-error' : '' ?>"
|
||||
value="<?= e($habit['title'] ?? '') ?>"
|
||||
required
|
||||
aria-describedby="title-error"
|
||||
>
|
||||
<?php if (!empty($errors['title'])): ?>
|
||||
<small id="title-error" class="field-error"><?= e($errors['title']) ?></small>
|
||||
<?php endif; ?>
|
||||
|
||||
<label for="description">Beschreibung <span>optional</span></label>
|
||||
<textarea id="description" name="description" rows="4" maxlength="1000"><?= e($habit['description'] ?? '') ?></textarea>
|
||||
<?php if (!empty($errors['description'])): ?>
|
||||
<small class="field-error"><?= e($errors['description']) ?></small>
|
||||
<?php endif; ?>
|
||||
|
||||
<label for="category_id">Kategorie</label>
|
||||
<select id="category_id" name="category_id" class="<?= !empty($errors['category_id']) ? 'input-error' : '' ?>">
|
||||
<option value="">Keine Kategorie</option>
|
||||
<?php foreach ($categories as $category): ?>
|
||||
<option value="<?= (int) $category['id'] ?>" <?= (string) ($habit['category_id'] ?? '') === (string) $category['id'] ? 'selected' : '' ?>>
|
||||
<?= e($category['name']) ?>
|
||||
</option>
|
||||
<?php endforeach; ?>
|
||||
</select>
|
||||
<?php if (!empty($errors['category_id'])): ?>
|
||||
<small class="field-error"><?= e($errors['category_id']) ?></small>
|
||||
<?php endif; ?>
|
||||
|
||||
<label for="target_per_week">Ziel pro Woche</label>
|
||||
<input
|
||||
id="target_per_week"
|
||||
name="target_per_week"
|
||||
type="number"
|
||||
class="<?= !empty($errors['target_per_week']) ? 'input-error' : '' ?>"
|
||||
min="1"
|
||||
max="7"
|
||||
value="<?= e((string) ($habit['target_per_week'] ?? '3')) ?>"
|
||||
required
|
||||
aria-describedby="target-error"
|
||||
>
|
||||
<?php if (!empty($errors['target_per_week'])): ?>
|
||||
<small id="target-error" class="field-error"><?= e($errors['target_per_week']) ?></small>
|
||||
<?php endif; ?>
|
||||
|
||||
<button type="submit"><?= $isEdit ? 'Speichern' : 'Erstellen' ?></button>
|
||||
</form>
|
||||
</section>
|
||||
@@ -0,0 +1,3 @@
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,34 @@
|
||||
<!doctype html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title><?= e(config('app_name')) ?></title>
|
||||
<link rel="stylesheet" href="assets/css/style.css">
|
||||
</head>
|
||||
<body>
|
||||
<header class="topbar">
|
||||
<a class="brand" href="index.php?route=dashboard">Habit Tracker</a>
|
||||
<nav class="nav">
|
||||
<?php if (current_user_id()): ?>
|
||||
<a href="index.php?route=dashboard">Dashboard</a>
|
||||
<a href="index.php?route=habits/create">Neuer Habit</a>
|
||||
<form method="post" action="index.php?route=logout" class="logout-form">
|
||||
<?= csrf_field() ?>
|
||||
<button type="submit" class="link-button">Logout</button>
|
||||
</form>
|
||||
<?php else: ?>
|
||||
<a href="index.php?route=login">Login</a>
|
||||
<a href="index.php?route=register">Registrieren</a>
|
||||
<?php endif; ?>
|
||||
</nav>
|
||||
</header>
|
||||
|
||||
<main class="page">
|
||||
<?php if ($message = flash('success')): ?>
|
||||
<p class="notice success"><?= e($message) ?></p>
|
||||
<?php endif; ?>
|
||||
|
||||
<?php if ($message = flash('error')): ?>
|
||||
<p class="notice error"><?= e($message) ?></p>
|
||||
<?php endif; ?>
|
||||
@@ -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>
|
||||
Executable
+82
@@ -0,0 +1,82 @@
|
||||
<?php
|
||||
|
||||
function config(string $key, $default = null)
|
||||
{
|
||||
static $config = null;
|
||||
|
||||
if ($config === null) {
|
||||
$config = require __DIR__ . '/../config/config.php';
|
||||
}
|
||||
|
||||
$parts = explode('.', $key);
|
||||
$value = $config;
|
||||
|
||||
foreach ($parts as $part) {
|
||||
if (!is_array($value) || !array_key_exists($part, $value)) {
|
||||
return $default;
|
||||
}
|
||||
$value = $value[$part];
|
||||
}
|
||||
|
||||
return $value;
|
||||
}
|
||||
|
||||
function e(?string $value): string
|
||||
{
|
||||
return htmlspecialchars($value ?? '', ENT_QUOTES, 'UTF-8');
|
||||
}
|
||||
|
||||
function redirect(string $route): never
|
||||
{
|
||||
header('Location: index.php?route=' . urlencode($route));
|
||||
exit;
|
||||
}
|
||||
|
||||
function current_user_id(): ?int
|
||||
{
|
||||
return $_SESSION['user_id'] ?? null;
|
||||
}
|
||||
|
||||
function require_login(): void
|
||||
{
|
||||
if (!current_user_id()) {
|
||||
redirect('login');
|
||||
}
|
||||
}
|
||||
|
||||
function flash(string $key, ?string $message = null): ?string
|
||||
{
|
||||
if ($message !== null) {
|
||||
$_SESSION['flash'][$key] = $message;
|
||||
return null;
|
||||
}
|
||||
|
||||
$value = $_SESSION['flash'][$key] ?? null;
|
||||
unset($_SESSION['flash'][$key]);
|
||||
return $value;
|
||||
}
|
||||
|
||||
function csrf_token(): string
|
||||
{
|
||||
if (empty($_SESSION['csrf_token'])) {
|
||||
$_SESSION['csrf_token'] = bin2hex(random_bytes(32));
|
||||
}
|
||||
|
||||
return $_SESSION['csrf_token'];
|
||||
}
|
||||
|
||||
function csrf_field(): string
|
||||
{
|
||||
return '<input type="hidden" name="csrf_token" value="' . e(csrf_token()) . '">';
|
||||
}
|
||||
|
||||
function verify_csrf_token(): void
|
||||
{
|
||||
$token = $_POST['csrf_token'] ?? '';
|
||||
|
||||
if (!is_string($token) || !hash_equals($_SESSION['csrf_token'] ?? '', $token)) {
|
||||
http_response_code(419);
|
||||
echo 'Sicherheitspruefung fehlgeschlagen. Bitte lade die Seite neu.';
|
||||
exit;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
<?php
|
||||
|
||||
return [
|
||||
'app_name' => 'Habit Tracker',
|
||||
'base_path' => '',
|
||||
'database' => [
|
||||
'host' => 'localhost',
|
||||
'dbname' => 'habittracker',
|
||||
'user' => 'root',
|
||||
'password' => '',
|
||||
'charset' => 'utf8mb4',
|
||||
],
|
||||
];
|
||||
@@ -0,0 +1,55 @@
|
||||
CREATE DATABASE IF NOT EXISTS pbbfa24aal_habittracker
|
||||
CHARACTER SET utf8mb4
|
||||
COLLATE utf8mb4_unicode_ci;
|
||||
|
||||
USE pbbfa24aal_habittracker;
|
||||
|
||||
CREATE TABLE users (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
name VARCHAR(100) NOT NULL,
|
||||
email VARCHAR(190) NOT NULL UNIQUE,
|
||||
password_hash VARCHAR(255) NOT NULL,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE TABLE categories (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
name VARCHAR(80) NOT NULL,
|
||||
color VARCHAR(20) NOT NULL DEFAULT '#176b5b'
|
||||
);
|
||||
|
||||
CREATE TABLE habits (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
user_id INT NOT NULL,
|
||||
category_id INT NULL,
|
||||
title VARCHAR(120) NOT NULL,
|
||||
description TEXT NULL,
|
||||
target_per_week TINYINT UNSIGNED NOT NULL DEFAULT 3,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (category_id) REFERENCES categories(id) ON DELETE SET NULL
|
||||
);
|
||||
|
||||
CREATE TABLE habit_logs (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
habit_id INT NOT NULL,
|
||||
completed_on DATE NOT NULL,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
UNIQUE KEY unique_habit_day (habit_id, completed_on),
|
||||
FOREIGN KEY (habit_id) REFERENCES habits(id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE TABLE reminders (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
habit_id INT NOT NULL,
|
||||
reminder_time TIME NOT NULL,
|
||||
weekday TINYINT UNSIGNED NULL,
|
||||
is_active TINYINT(1) NOT NULL DEFAULT 1,
|
||||
FOREIGN KEY (habit_id) REFERENCES habits(id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
INSERT INTO categories (name, color) VALUES
|
||||
('Gesundheit', '#176b5b'),
|
||||
('Lernen', '#365c9a'),
|
||||
('Sport', '#b95c20'),
|
||||
('Alltag', '#6b4ba3');
|
||||
@@ -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 |
+11
@@ -0,0 +1,11 @@
|
||||
<!doctype html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta http-equiv="refresh" content="0; url=public/">
|
||||
<title>Habit Tracker</title>
|
||||
</head>
|
||||
<body>
|
||||
<p><a href="public/">Habit Tracker starten</a></p>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,361 @@
|
||||
:root {
|
||||
--bg: #f6f7f9;
|
||||
--panel: #ffffff;
|
||||
--text: #172033;
|
||||
--muted: #657086;
|
||||
--line: #d9dee8;
|
||||
--primary: #176b5b;
|
||||
--primary-dark: #0f4c40;
|
||||
--danger: #b42318;
|
||||
--success-bg: #e8f5ee;
|
||||
--error-bg: #fdecec;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
font-family: Arial, Helvetica, sans-serif;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
a {
|
||||
color: var(--primary);
|
||||
text-decoration-thickness: 2px;
|
||||
}
|
||||
|
||||
.topbar {
|
||||
align-items: center;
|
||||
background: var(--panel);
|
||||
border-bottom: 1px solid var(--line);
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
min-height: 64px;
|
||||
padding: 0 32px;
|
||||
}
|
||||
|
||||
.brand {
|
||||
color: var(--text);
|
||||
font-size: 20px;
|
||||
font-weight: 700;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.nav {
|
||||
display: flex;
|
||||
gap: 18px;
|
||||
}
|
||||
|
||||
.logout-form {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.link-button {
|
||||
background: transparent;
|
||||
color: var(--primary);
|
||||
min-height: 0;
|
||||
padding: 0;
|
||||
text-decoration: underline;
|
||||
text-decoration-thickness: 2px;
|
||||
}
|
||||
|
||||
.link-button:hover {
|
||||
background: transparent;
|
||||
color: var(--primary-dark);
|
||||
}
|
||||
|
||||
.page {
|
||||
margin: 0 auto;
|
||||
max-width: 1080px;
|
||||
padding: 32px 24px;
|
||||
}
|
||||
|
||||
.notice,
|
||||
.empty,
|
||||
.auth-panel,
|
||||
.form-panel {
|
||||
background: var(--panel);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 8px;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.notice.success {
|
||||
background: var(--success-bg);
|
||||
}
|
||||
|
||||
.notice.error {
|
||||
background: var(--error-bg);
|
||||
color: var(--danger);
|
||||
}
|
||||
|
||||
.auth-panel,
|
||||
.form-panel {
|
||||
max-width: 520px;
|
||||
}
|
||||
|
||||
.dashboard-head {
|
||||
align-items: center;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.eyebrow {
|
||||
color: var(--muted);
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
margin: 0 0 4px;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
h1,
|
||||
h2,
|
||||
h3 {
|
||||
line-height: 1.2;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 34px;
|
||||
}
|
||||
|
||||
h2 {
|
||||
font-size: 20px;
|
||||
}
|
||||
|
||||
h3 {
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.button,
|
||||
button {
|
||||
background: var(--primary);
|
||||
border: 0;
|
||||
border-radius: 6px;
|
||||
color: #ffffff;
|
||||
cursor: pointer;
|
||||
display: inline-block;
|
||||
font: inherit;
|
||||
font-weight: 700;
|
||||
min-height: 44px;
|
||||
padding: 10px 16px;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
button:hover,
|
||||
.button:hover {
|
||||
background: var(--primary-dark);
|
||||
}
|
||||
|
||||
button.danger {
|
||||
background: var(--danger);
|
||||
}
|
||||
|
||||
.form,
|
||||
.filters {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.form label,
|
||||
.filters label {
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.form label span {
|
||||
color: var(--muted);
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
input,
|
||||
select,
|
||||
textarea {
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 6px;
|
||||
font: inherit;
|
||||
min-height: 44px;
|
||||
padding: 10px 12px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
textarea {
|
||||
resize: vertical;
|
||||
}
|
||||
|
||||
.field-error {
|
||||
color: var(--danger);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
input.input-error {
|
||||
border-color: var(--danger);
|
||||
}
|
||||
|
||||
.filters {
|
||||
align-items: end;
|
||||
background: var(--panel);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 8px;
|
||||
grid-template-columns: 1fr 1fr auto;
|
||||
margin-bottom: 20px;
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.habit-list {
|
||||
display: grid;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.habit-card {
|
||||
align-items: center;
|
||||
background: var(--panel);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 8px;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 18px;
|
||||
justify-content: space-between;
|
||||
padding: 18px;
|
||||
}
|
||||
|
||||
.habit-card.done {
|
||||
border-color: var(--primary);
|
||||
}
|
||||
|
||||
.habit-main {
|
||||
display: flex;
|
||||
flex: 1 1 480px;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.habit-main p {
|
||||
color: var(--muted);
|
||||
margin: 8px 0;
|
||||
}
|
||||
|
||||
.category-dot {
|
||||
border-radius: 999px;
|
||||
flex: 0 0 14px;
|
||||
height: 14px;
|
||||
margin-top: 5px;
|
||||
width: 14px;
|
||||
}
|
||||
|
||||
.habit-actions {
|
||||
align-items: center;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.habit-reminders {
|
||||
border-top: 1px solid var(--line);
|
||||
flex-basis: 100%;
|
||||
margin-top: 2px;
|
||||
padding-top: 14px;
|
||||
}
|
||||
|
||||
.reminder-heading {
|
||||
align-items: center;
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.reminder-empty,
|
||||
.form-help,
|
||||
.form small {
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.reminder-list {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
list-style: none;
|
||||
margin: 12px 0 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.reminder-item {
|
||||
align-items: center;
|
||||
background: var(--bg);
|
||||
border-radius: 6px;
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
grid-template-columns: 72px minmax(100px, 1fr) auto auto;
|
||||
padding: 10px 12px;
|
||||
}
|
||||
|
||||
.reminder-item.paused {
|
||||
opacity: 0.68;
|
||||
}
|
||||
|
||||
.reminder-time {
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.reminder-status {
|
||||
color: var(--muted);
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.reminder-actions {
|
||||
align-items: center;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.reminder-actions button {
|
||||
min-height: 36px;
|
||||
padding: 6px 10px;
|
||||
}
|
||||
|
||||
@media (max-width: 720px) {
|
||||
.topbar,
|
||||
.dashboard-head,
|
||||
.habit-card {
|
||||
align-items: stretch;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.topbar {
|
||||
gap: 12px;
|
||||
padding: 16px 20px;
|
||||
}
|
||||
|
||||
.nav {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.filters {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.habit-actions {
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
.reminder-item {
|
||||
align-items: start;
|
||||
grid-template-columns: 72px 1fr;
|
||||
}
|
||||
|
||||
.reminder-status,
|
||||
.reminder-actions {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
.reminder-actions {
|
||||
justify-content: flex-start;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
session_set_cookie_params([
|
||||
'httponly' => true,
|
||||
'samesite' => 'Lax',
|
||||
'secure' => !empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off',
|
||||
]);
|
||||
session_start();
|
||||
|
||||
require __DIR__ . '/../app/helpers.php';
|
||||
require __DIR__ . '/../app/Core/Database.php';
|
||||
require __DIR__ . '/../app/Core/View.php';
|
||||
require __DIR__ . '/../app/Models/User.php';
|
||||
require __DIR__ . '/../app/Models/Category.php';
|
||||
require __DIR__ . '/../app/Models/Habit.php';
|
||||
require __DIR__ . '/../app/Models/Reminder.php';
|
||||
require __DIR__ . '/../app/Controllers/AuthController.php';
|
||||
require __DIR__ . '/../app/Controllers/HabitController.php';
|
||||
require __DIR__ . '/../app/Controllers/ReminderController.php';
|
||||
|
||||
$route = $_GET['route'] ?? 'dashboard';
|
||||
$method = $_SERVER['REQUEST_METHOD'];
|
||||
|
||||
$routes = [
|
||||
'GET' => [
|
||||
'login' => [AuthController::class, 'showLogin'],
|
||||
'register' => [AuthController::class, 'showRegister'],
|
||||
'dashboard' => [HabitController::class, 'dashboard'],
|
||||
'habits/create' => [HabitController::class, 'create'],
|
||||
'habits/edit' => [HabitController::class, 'edit'],
|
||||
'reminders/create' => [ReminderController::class, 'create'],
|
||||
'reminders/edit' => [ReminderController::class, 'edit'],
|
||||
],
|
||||
'POST' => [
|
||||
'login' => [AuthController::class, 'login'],
|
||||
'register' => [AuthController::class, 'register'],
|
||||
'logout' => [AuthController::class, 'logout'],
|
||||
'habits/store' => [HabitController::class, 'store'],
|
||||
'habits/update' => [HabitController::class, 'update'],
|
||||
'habits/delete' => [HabitController::class, 'delete'],
|
||||
'habits/toggle' => [HabitController::class, 'toggle'],
|
||||
'reminders/store' => [ReminderController::class, 'store'],
|
||||
'reminders/update' => [ReminderController::class, 'update'],
|
||||
'reminders/delete' => [ReminderController::class, 'delete'],
|
||||
'reminders/toggle' => [ReminderController::class, 'toggle'],
|
||||
],
|
||||
];
|
||||
|
||||
if (!isset($routes[$method][$route])) {
|
||||
http_response_code(404);
|
||||
echo 'Seite nicht gefunden.';
|
||||
exit;
|
||||
}
|
||||
|
||||
[$controllerClass, $action] = $routes[$method][$route];
|
||||
(new $controllerClass())->$action();
|
||||
Reference in New Issue
Block a user