Initial habit tracker project

This commit is contained in:
2026-07-10 16:18:31 +02:00
commit 23416ee382
20 changed files with 1157 additions and 0 deletions
+75
View File
@@ -0,0 +1,75 @@
<?php
class AuthController
{
public function showLogin(): void
{
View::render('auth/login');
}
public function login(): void
{
verify_csrf_token();
$email = trim($_POST['email'] ?? '');
$password = $_POST['password'] ?? '';
if (!filter_var($email, FILTER_VALIDATE_EMAIL) || $password === '') {
flash('error', 'Bitte gib eine gueltige E-Mail und dein Passwort ein.');
redirect('login');
}
$user = User::findByEmail($email);
if (!$user || !password_verify($password, $user['password_hash'])) {
flash('error', 'E-Mail oder Passwort ist nicht korrekt.');
redirect('login');
}
session_regenerate_id(true);
$_SESSION['user_id'] = (int) $user['id'];
$_SESSION['user_name'] = $user['name'];
redirect('dashboard');
}
public function showRegister(): void
{
View::render('auth/register');
}
public function register(): void
{
verify_csrf_token();
$name = trim($_POST['name'] ?? '');
$email = trim($_POST['email'] ?? '');
$password = $_POST['password'] ?? '';
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 (User::findByEmail($email)) {
flash('error', 'Diese E-Mail ist bereits registriert.');
redirect('register');
}
User::create($name, $email, $password);
flash('success', 'Registrierung erfolgreich. Du kannst dich jetzt anmelden.');
redirect('login');
}
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');
}
}
+137
View File
@@ -0,0 +1,137 @@
<?php
class HabitController
{
public function dashboard(): void
{
require_login();
$filters = [
'category_id' => $_GET['category_id'] ?? '',
'status' => $_GET['status'] ?? '',
];
View::render('habits/dashboard', [
'habits' => Habit::allForUser(current_user_id(), $filters),
'categories' => Category::all(),
'filters' => $filters,
]);
}
public function create(): void
{
require_login();
View::render('habits/form', [
'habit' => null,
'categories' => Category::all(),
'errors' => [],
]);
}
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');
}
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' => [],
]);
}
public function update(): void
{
require_login();
verify_csrf_token();
$id = (int) ($_POST['id'] ?? 0);
$data = $this->validatedData();
if (!empty($data['errors'])) {
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');
}
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.';
}
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,
];
}
}
+35
View File
@@ -0,0 +1,35 @@
<?php
use PDO;
use PDOException;
class Database
{
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;
}
}
+15
View File
@@ -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';
}
}
+11
View File
@@ -0,0 +1,11 @@
<?php
class Category
{
public static function all(): array
{
return Database::connection()
->query('SELECT id, name, color FROM categories ORDER BY name')
->fetchAll();
}
}
+110
View File
@@ -0,0 +1,110 @@
<?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
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;
}
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]);
}
}
+25
View File
@@ -0,0 +1,25 @@
<?php
class User
{
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),
]);
}
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;
}
}
+15
View File
@@ -0,0 +1,15 @@
<section class="auth-panel">
<h1>Anmelden</h1>
<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>
<label for="password">Passwort</label>
<input id="password" name="password" type="password" autocomplete="current-password" required>
<button type="submit">Einloggen</button>
</form>
<p>Noch kein Konto? <a href="index.php?route=register">Jetzt registrieren</a></p>
</section>
+19
View File
@@ -0,0 +1,19 @@
<section class="auth-panel">
<h1>Registrieren</h1>
<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>
<label for="email">E-Mail</label>
<input id="email" name="email" type="email" autocomplete="email" required>
<label for="password">Passwort</label>
<input id="password" name="password" type="password" minlength="8" autocomplete="new-password" required>
<small>Mindestens 8 Zeichen.</small>
<button type="submit">Konto erstellen</button>
</form>
<p>Schon registriert? <a href="index.php?route=login">Zum Login</a></p>
</section>
+67
View File
@@ -0,0 +1,67 @@
<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): ?>
<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') ?> |
Ziel: <?= (int) $habit['target_per_week'] ?>x pro Woche
</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>
</article>
<?php endforeach; ?>
</section>
+58
View File
@@ -0,0 +1,58 @@
<?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"
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"><?= e($habit['description'] ?? '') ?></textarea>
<label for="category_id">Kategorie</label>
<select id="category_id" name="category_id">
<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>
<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>
+3
View File
@@ -0,0 +1,3 @@
</main>
</body>
</html>
+34
View File
@@ -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; ?>
+82
View File
@@ -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;
}
}