24 Commits

Author SHA1 Message Date
eff5be49c4 Edited: docker-compose.yaml 2024-12-31 10:46:32 +01:00
d13c490efb Added: Habits dashboard 2024-12-30 14:35:16 +01:00
d98c208df9 Edited: README.md - start local server in correct folder 2024-12-28 22:27:24 +01:00
85af85b1ee Edited: Dashboard links and global button styles, added new color variables 2024-12-27 17:07:09 +01:00
1ff7fc454f Edited: small changes in header 2024-12-27 15:26:08 +01:00
e0dfc7120f Edited: Removed colons from labels in habit creation form 2024-12-27 15:12:54 +01:00
9c90710bf3 Added: No-header layout, signup/signin link enhancements, and header design updates 2024-12-27 15:05:55 +01:00
4b8ee90d8a Added: environment.php.example 2024-12-27 14:18:38 +01:00
59246decd7 Edited: docker-compose - added profiles for dev and prod 2024-12-27 13:38:50 +01:00
147e3b1499 Edited: meta tags in default layout 2024-12-27 02:52:58 +01:00
daec4ec1c9 Edited: README.md 2024-12-27 02:36:34 +01:00
3c6ecfb5e2 Added: .gitignore 2024-12-27 02:35:40 +01:00
b4e08f28ca Edited: README.md 2024-12-27 02:35:01 +01:00
c4366edb29 Added: Dockerfile and docker support 2024-12-27 02:29:07 +01:00
d9f632da26 Fix: check if the session is set before using it 2024-12-27 02:28:03 +01:00
2847231376 Added: favicon, Dashboard, Habits list, some styles, dashboard redirect 2024-12-27 02:06:32 +01:00
d33d233f0f Edited: Login and Signup pages are now styled, Added: App logo 2024-12-26 23:25:34 +01:00
43960ddcb9 Added: Habit creation logic 2024-12-26 18:47:00 +01:00
85209ff134 Added: Router route groups 2024-12-26 17:47:42 +01:00
4c44dac115 Auth logic is completed (signin, signup, logout), Added: Middlewares, RequireAuth middleware 2024-12-26 14:44:40 +01:00
01b057986e README.md translated from czech to english 2024-12-26 02:13:03 +01:00
ecb2a6b74b Added: TODO.md 2024-12-26 02:03:18 +01:00
5bca9b12aa Added: Signin Up, connection to the DB, validator, base controller, base view, environment file, User model 2024-12-26 02:00:33 +01:00
d7b8aba072 Added: docker-compose for mariadb and phpmyadmin 2024-12-25 23:37:07 +01:00
40 changed files with 1272 additions and 131 deletions

53
.docker/nginx.conf Normal file
View File

@@ -0,0 +1,53 @@
# User and worker process settings
user www-data;
worker_processes auto;
pid /run/nginx.pid;
events {
worker_connections 1024;
}
http {
# General settings
sendfile on;
tcp_nopush on;
tcp_nodelay on;
keepalive_timeout 65;
types_hash_max_size 2048;
include /etc/nginx/mime.types;
default_type application/octet-stream;
# Logging
access_log /var/log/nginx/access.log;
error_log /var/log/nginx/error.log;
# Gzip compression
gzip on;
gzip_disable "msie6";
# Server configuration
server {
listen 80;
server_name localhost;
root /var/www/html/public;
index index.php index.html;
location / {
try_files $uri $uri/ /index.php?$query_string;
}
location ~ \\.php$ {
include fastcgi_params;
fastcgi_pass 127.0.0.1:9000;
fastcgi_index index.php;
fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
}
location ~ /\.ht {
deny all;
}
}
}

22
.dockerignore Normal file
View File

@@ -0,0 +1,22 @@
.git
.gitignore
storage/logs/*
!storage/logs/.gitignore
Dockerfile
docker-compose.yaml
*.swp
*.swo
*.idea/
*.vscode/
*.DS_Store
# Exclude sensitive config files (uncomment if you don't want to include environment config)
# config/environment.php
README.md
TODO.md
LICENSE

1
.gitignore vendored Normal file
View File

@@ -0,0 +1 @@
*.swp

18
Dockerfile Normal file
View File

@@ -0,0 +1,18 @@
FROM php:8.2-fpm-alpine
WORKDIR /var/www/html
RUN apk add --no-cache nginx curl \
&& docker-php-ext-install mysqli
COPY . /var/www/html
RUN chown -R www-data:www-data /var/www/html \
&& chmod -R 755 /var/www/html
COPY .docker/nginx.conf /etc/nginx/nginx.conf
EXPOSE 80
CMD php-fpm & nginx -g "daemon off;"

110
README.md
View File

@@ -1,83 +1,55 @@
# Habit Tracker # Habit Tracker
Aplikace pro sledování návyků a motivaci k dosažení osobních cílů. An app for tracking habits and motivation to achieve personal goals
## Funkce ## Used technologies
- **Uživatelská autentizace:** Možnost registrace a přihlášení. - **Frontend:** HTML, CSS, JavaScript
- **Správa návyků:** Přidávání, úprava a mazání návyků.
- **Denní kontrola:** Označování splněných návyků každý den.
- **Gamifikace:** Získávání bodů za splněné návyky, odemykání odznaků a postup na vyšší úrovně.
- **Přehled:** Statistiky o pokroku a dosažených úspěších.
- **(Volitelné) Žebříček:** Srovnání bodů mezi uživateli.
## Použité technologie
- **Backend:** PHP (OOP) - **Backend:** PHP (OOP)
- **Databáze:** MySQL - **Database:** MariaDB
- **Frontend:** HTML, CSS, JavaScript (Bootstrap pro responzivní design)
## Instalace ## How to build
1. Klonujte tento repozitář:
### Build using docker
Run the container using docker-compose
```bash ```bash
git clone https://github.com/vase-repozitar/habit-tracker.git docker-compose up
``` ```
2. Importujte databázovou strukturu ze souboru `database.sql` do MySQL.
3. Nakonfigurujte připojení k databázi v souboru `config.php`: The app should be available at http://localhost:8000
PhpMyAdmin should be available at http://localhost:8080
### Build manually
1. Clone the repo
```bash
git clone https://git.filiprojek.cz/fr/habit-tracker.git
```
2. Create `config/environment.php`
- It should have following structure:
```php ```php
define('DB_HOST', 'localhost'); <?php
define('DB_USER', 'uzivatel');
define('DB_PASS', 'heslo'); define('DB_HOST', 'your db host');
define('DB_NAME', 'habit_tracker'); define('DB_USER', 'your db username');
define('DB_PASS', 'your db password');
define('DB_NAME', 'your db name');
``` ```
4. Spusťte lokální server (např. pomocí XAMPP nebo WAMP) a přistupte k aplikaci přes prohlížeč. - For the database, you can use included `docker-compose.yaml` which have both MariaDB and PhpMyAdmin
## Struktura databáze 3. Start an local web server
- You can use php's integrated server by running this:
```bash
php -S localhost:8000 -t ./public
```
- You can use any host and any port you want.
### Tabulka `users` (uživatelé) ## Usage
| Sloupec | Typ | Popis | 1. Register and Login to the app.
|---------------|-------------|----------------------------| 2. Add your habits.
| id | INT | Primární klíč | 3. Mark your habits when you're done doing them.
| username | VARCHAR(50) | Uživatelské jméno | 4. Earn point and unlock achievements by completing you're habits!
| email | VARCHAR(100)| Email |
| password | VARCHAR(255)| Heslo (hashované) |
| points | INT | Celkový počet bodů |
| created_at | TIMESTAMP | Datum registrace |
### Tabulka `habits` (návyků)
| Sloupec | Typ | Popis |
|---------------|-------------|----------------------------|
| id | INT | Primární klíč |
| user_id | INT | ID uživatele (cizí klíč) |
| title | VARCHAR(100)| Název návyku |
| frequency | VARCHAR(50) | Frekvence (denní/týdenní) |
| reward_points | INT | Počet bodů za splnění |
| created_at | TIMESTAMP | Datum vytvoření |
### Tabulka `progress` (pokrok)
| Sloupec | Typ | Popis |
|---------------|-------------|----------------------------|
| id | INT | Primární klíč |
| user_id | INT | ID uživatele (cizí klíč) |
| habit_id | INT | ID návyku (cizí klíč) |
| date | DATE | Datum splnění |
| status | ENUM | Stav (např. 'Done') |
### Tabulka `achievements` (odznaky)
| Sloupec | Typ | Popis |
|---------------|-------------|----------------------------|
| id | INT | Primární klíč |
| user_id | INT | ID uživatele (cizí klíč) |
| achievement | VARCHAR(100)| Název odznaku |
| unlocked_at | TIMESTAMP | Datum odemknutí |
## Použití
1. Zaregistrujte se a přihlaste do aplikace.
2. Přidejte své návyky pomocí intuitivního rozhraní.
3. Každý den označte splněné návyky a sledujte svůj pokrok.
4. Sbírejte body a odemykejte odznaky za svou vytrvalost!
## Autor
Tento projekt byl vytvořen jako součást školního zadání.
## Licence ## Licence
Tento projekt je licencován pod GNU GENERAL PUBLIC LICENSE verze 3. Více informací v souboru `LICENSE`. This project is licensed under GPL3.0 and later. More information is availabe in `LICENSE` file.

18
TODO.md Normal file
View File

@@ -0,0 +1,18 @@
# TODO
## Auth and User stuff
- [x] signup
- [x] signin
- [ ] edit user data - change password, mail...
## Core of the app
- [ ] header and navbar
- [ ] dashboard
- [x] css
- [ ] its just plain
- [ ] graphs
- [x] Habits list
- [ ] css
- [ ] Habits create
- [ ] validate cron input
- [ ] Habits track

View File

@@ -1,19 +1,85 @@
<?php <?php
class AuthController { class AuthController extends Controller {
public function signin() { public function signin() {
$view = new View(); if($_SERVER['REQUEST_METHOD'] === 'POST') {
$data = [ $email = $_POST['email'] ?? '';
'title' => 'Log In' $password = $_POST['password'] ?? '';
];
$view->render('auth/signin', $data); $validator = new Validator();
$validator->required('email', $email);
$validator->email('email', $email);
$validator->required('password', $password);
if (!$validator->passes()) {
$this->view('auth/signup', [
'error' => 'Please correct the errors below.',
'validationErrors' => $validator->errors() ?: [],
]);
return;
}
$user = new User();
$result = $user->login($email, $password);
if($result === true) {
$this->redirect('/dashboard');
} else {
$this->view('auth/signin', ['error' => $result], 'noheader');
}
} else {
$this->view('auth/signin', ['title' => 'Log In'], 'noheader');
}
} }
public function signup() { public function signup() {
$view = new View(); if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$data = [ $username = $_POST['username'] ?? '';
'title' => 'Register' $email = $_POST['email'] ?? '';
]; $password = $_POST['password'] ?? '';
$view->render('auth/signup', $data); $password2 = $_POST['password-2'] ?? '';
$validator = new Validator();
$validator->required('username', $username);
$validator->email('email', $email);
$validator->required('password', $password);
$validator->minLength('password', $password, 8);
$validator->alphanumeric('password', $password);
if ($password !== $password2) {
$validator->errors()['password_confirmation'] = 'Passwords do not match.';
}
if (!$validator->passes()) {
$this->view('auth/signup', [
'error' => 'Please correct the errors below.',
'validationErrors' => $validator->errors() ?: [],
], 'noheader');
return;
}
$user = new User();
$result = $user->register($username, $email, $password);
if ($result === true) {
$this->redirect('/auth/signin');
} else {
$this->view('auth/signup', [
'error' => $result,
'validationErrors' => [],
], 'noheader');
}
} else {
$this->view('auth/signup', [
'title' => 'Register',
'validationErrors' => [],
], 'noheader');
}
}
public function logout() {
session_unset();
session_destroy();
$this->redirect('/auth/signin');
} }
} }

View File

@@ -0,0 +1,16 @@
<?php
class DashboardController extends Controller {
public function index() {
$habit = new Habit();
$habits = $habit->getHabitsByUser($_SESSION['user']['id']);
$this->view('dashboard/index', [
'title' => 'Dashboard',
'habits' => $habits,
]);
}
public function reroute(){
$this->redirect('/dashboard');
}
}

View File

@@ -0,0 +1,57 @@
<?php
class HabitController extends Controller {
public function index() {
$habit = new Habit();
$habits = $habit->getHabitsByUser($_SESSION['user']['id']);
$this->view('habits/index', ['title' => 'Habits', 'habits' => $habits]);
}
public function create() {
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$name = $_POST['name'] ?? '';
$frequency = $_POST['frequency'] ?? 'Daily';
$customFrequency = null;
if (empty($name)) {
$this->view('habits/create', ['error' => 'Habit name is required.']);
return;
}
if ($frequency === 'Custom') {
$daysOfWeek = $_POST['days_of_week'] ?? [];
$daysOfMonth = $_POST['days_of_month'] ?? '*';
$months = $_POST['months'] ?? '*';
// Combine into crontab-like string
$customFrequency = implode(',', $daysOfWeek) . " $daysOfMonth $months";
}
$habit = new Habit();
$result = $habit->create([
'name' => $name,
'frequency' => $frequency,
'custom_frequency' => $customFrequency,
'reward_points' => intval($_POST['difficulty'] ?? 1),
'user_id' => $_SESSION['user']['id'],
]);
if ($result) {
$this->redirect('/habits');
} else {
$this->view('habits/create', ['error' => 'Failed to create habit.']);
}
} else {
$this->view('habits/create', ['title' => 'Create Habit']);
}
}
public function edit() {
// Edit habit (to be implemented later)
}
public function delete() {
// Delete habit (to be implemented later)
}
}

View File

@@ -1,22 +1,18 @@
<?php <?php
class HomeController { class HomeController extends Controller {
//private function render($view) {
// ob_start();
// require_once views . $view;
// $content = ob_get_clean();
// require_once views . 'layouts/base.php';
//}
public function index() { public function index() {
$view = new View();
$data = [ $data = [
'title' => 'Home' 'title' => 'Home'
]; ];
$view->render('home/index', $data); $this->view('home/index', $data);
//require_once views . 'home/index.php';
} }
public function home() { public function home() {
$this->index(); $this->index();
} }
public function dashboard() {
$this->view("dashboard/index");
}
} }

46
app/models/Habit.php Normal file
View File

@@ -0,0 +1,46 @@
<?php
class Habit {
private $db;
public function __construct() {
$this->db = Database::getInstance()->getConnection();
}
public function create($data) {
$stmt = $this->db->prepare("
INSERT INTO habits (user_id, title, frequency, custom_frequency, reward_points, created_at)
VALUES (?, ?, ?, ?, ?, NOW())
");
$stmt->bind_param(
"isssi", // Bind types: int, string, string, string, int
$data['user_id'],
$data['name'],
$data['frequency'],
$data['custom_frequency'], // Bind the custom_frequency field
$data['reward_points']
);
if ($stmt->execute()) {
return true;
} else {
error_log("Failed to create habit: " . $stmt->error);
return false;
}
}
public function getHabitsByUser($userId) {
$stmt = $this->db->prepare("SELECT id, title, frequency, custom_frequency, reward_points, created_at FROM habits WHERE user_id = ?");
$stmt->bind_param("i", $userId);
$stmt->execute();
$result = $stmt->get_result();
$habits = [];
while ($row = $result->fetch_assoc()) {
$habits[] = $row;
}
return $habits;
}
}

65
app/models/User.php Normal file
View File

@@ -0,0 +1,65 @@
<?php
require_once '../core/Database.php';
class User {
private $db;
public function __construct() {
$this->db = Database::getInstance()->getConnection();
if ($this->db) {
error_log("Database connection established successfully.");
} else {
error_log("Failed to connect to the database.");
}
}
public function register($username, $email, $password) {
// Check if email already exists
$stmt = $this->db->prepare("SELECT id FROM users WHERE email = ?");
$stmt->bind_param("s", $email);
$stmt->execute();
$result = $stmt->get_result();
$stmt->close();
if ($result->num_rows > 0) {
return "Email is already registered";
}
$hashedPassword = password_hash($password, PASSWORD_BCRYPT);
$stmt = $this->db->prepare("INSERT INTO users (username, email, password, points, created_at) VALUES (?, ?, ?, 0, NOW())");
$stmt->bind_param("sss", $username, $email, $hashedPassword);
if ($stmt->execute()) {
return true;
} else {
return "Error: " . $stmt->error;
}
}
public function login($email, $password) {
$hashedPassword = password_hash($password, PASSWORD_BCRYPT);
$stmt = $this->db->prepare("SELECT id, username, password FROM users WHERE email = ?");
$stmt->bind_param("s", $email);
$stmt->execute();
$result = $stmt->get_result();
$stmt->close();
if ($result->num_rows === 1) {
$user = $result->fetch_assoc();
if (password_verify($password, $user['password'])) {
$_SESSION['user'] = [
'id' => $user['id'],
'username' => $user['username'],
'email' => $email,
];
return true;
}
}
return "Incorrect username or password.";
}
}

View File

@@ -1,9 +1,33 @@
<section class="signin"> <link rel="stylesheet" href="/css/login.css">
<form> <link rel="stylesheet" href="/css/form.css">
<label for="username">Username</label> <section class="form signin">
<input type="text" name="username" id="username"> <div class="header-form">
<img src="/img/logo.jpg" alt="Habit Tracker Logo">
<h1>Sign in to Habit Tracker</h1>
</div>
<?php if ($this->get('error')): ?>
<div class="error"><?= $this->get('error') ?></div>
<?php endif; ?>
<form action="/auth/signin" method="POST">
<label for="email">Email</label>
<input type="email" name="email" id="email" value="<?= htmlspecialchars($_POST['email'] ?? '') ?>" required>
<?php if (isset($this->get('validationErrors')['email'])): ?>
<small class="error"><?= $this->get('validationErrors')['email'] ?></small>
<?php endif; ?>
<label for="password">Password</label> <label for="password">Password</label>
<input type="password" name="password" id="password"> <input type="password" name="password" id="password" required>
<input type="submit" value="Sign In" id="submit-signin"> <?php if (isset($this->get('validationErrors')['password'])): ?>
<small class="error"><?= $this->get('validationErrors')['password'] ?></small>
<?php endif; ?>
<input type="submit" value="Sign In">
</form> </form>
<div class="bordered">
<p>New to Habit Tracker?</p>
<a href="/auth/signup">Create an account</a>
</div>
</section> </section>

View File

@@ -1,11 +1,45 @@
<section class="signup"> <link rel="stylesheet" href="/css/login.css">
<form> <link rel="stylesheet" href="/css/form.css">
<section class="form signup">
<div class="header-form">
<img src="/img/logo.jpg" alt="Habit Tracker Logo">
<h1>Sign up to Habit Tracker</h1>
</div>
<?php if ($this->get('error')): ?>
<div class="error"><?= $this->get('error') ?></div>
<?php endif; ?>
<form action="/auth/signup" method="POST">
<label for="username">Username</label> <label for="username">Username</label>
<input type="text" name="username" id="username"> <input type="text" name="username" id="username" value="<?= htmlspecialchars($_POST['username'] ?? '') ?>" required>
<?php if (isset($this->get('validationErrors')['username'])): ?>
<small class="error"><?= $this->get('validationErrors')['username'] ?></small>
<?php endif; ?>
<label for="email">Email</label>
<input type="email" name="email" id="email" value="<?= htmlspecialchars($_POST['email'] ?? '') ?>" required>
<?php if (isset($this->get('validationErrors')['email'])): ?>
<small class="error"><?= $this->get('validationErrors')['email'] ?></small>
<?php endif; ?>
<label for="password">Password</label> <label for="password">Password</label>
<input type="password" name="password" id="password"> <input type="password" name="password" id="password" required>
<?php if (isset($this->get('validationErrors')['password'])): ?>
<small class="error"><?= $this->get('validationErrors')['password'] ?></small>
<?php endif; ?>
<label for="password-2">Password again</label> <label for="password-2">Password again</label>
<input type="password" name="password-2" id="password-2"> <input type="password" name="password-2" id="password-2" required>
<input type="submit" value="Sign Up" id="submit-signup"> <?php if (isset($this->get('validationErrors')['password_confirmation'])): ?>
<small class="error"><?= $this->get('validationErrors')['password_confirmation'] ?></small>
<?php endif; ?>
<input type="submit" value="Sign Up">
</form> </form>
<div class="bordered">
<p>Already have an account?</p>
<a href="/auth/signin">Sign in</a>
</div>
</section> </section>

View File

@@ -0,0 +1,47 @@
<link rel="stylesheet" href="/css/dashboard.css">
<section class="dashboard">
<h1>Welcome, <?= htmlspecialchars($_SESSION['user']['username']) ?>!</h1>
<div>
<a href="/habits/create" class="btn-green">Create new habit!</a>
<a href="/habits" class="btn-primary">List all habits</a>
</div>
<div class="card-wrapper">
<section class="card upcoming">
<h2>Upcoming</h2>
<div class="habit">
<b>Habit Title</b>
<p>Frequency</p>
<p>Reward points</p>
</div>
</section>
<section class="card recent">
<h2>Recent</h2>
<div class="habit">
<b>Habit Title</b>
<p>Frequency</p>
<p>Reward points</p>
</div>
</section>
<section class="card missed">
<h2>Missed</h2>
<div class="habit">
<b>Habit Title</b>
<p>Frequency</p>
<p>Reward points</p>
</div>
</section>
<section class="card history-graph">
<h2>Graph of History</h2>
</section>
<section class="card streak">
<h2>Streak</h2>
<p>You're current streak is 123 days</p>
<p>Good job!</p>
</section>
</div>
</section>

View File

@@ -0,0 +1,70 @@
<link rel="stylesheet" href="/css/form.css">
<link rel="stylesheet" href="/css/habits_create.css">
<section class="form habit-create">
<h1 class="header-form"><?= $this->get('title', 'Create Habit') ?></h1>
<?php if ($this->get('error')): ?>
<div class="error" style="color: red; margin-bottom: 1rem;">
<?= htmlspecialchars($this->get('error')) ?>
</div>
<?php endif; ?>
<form method="POST" action="/habits/create">
<label for="name">Habit Name</label>
<input type="text" name="name" id="name" required value="<?= htmlspecialchars($_POST['name'] ?? '') ?>">
<label for="frequency">Frequency</label>
<select name="frequency" id="frequency" onchange="toggleCustomFrequency(this.value)">
<option value="Daily">Daily</option>
<option value="Weekly">Weekly</option>
<option value="Custom">Custom</option>
</select>
<div id="custom-frequency" style="display: none;">
<label id="lbl_dow">Days of the Week</label>
<div class="dow_chb_wrapper">
<label for="dow_mon">Monday</label>
<input type="checkbox" name="days_of_week[]" id="dow_mon" value="1">
</div>
<div class="dow_chb_wrapper">
<label for="dow_tue">Tuesday</label>
<input type="checkbox" name="days_of_week[]" id="dow_tue" value="2">
</div>
<div class="dow_chb_wrapper">
<label for="dow_wed">Wednesday</label>
<input type="checkbox" name="days_of_week[]" id="dow_wed" value="3">
</div>
<div class="dow_chb_wrapper">
<label for="dow_thu">Thursday</label>
<input type="checkbox" name="days_of_week[]" id="dow_thu" value="4">
</div>
<div class="dow_chb_wrapper">
<label for="dow_fri">Friday</label>
<input type="checkbox" name="days_of_week[]" id="dow_fri" value="5">
</div>
<div class="dow_chb_wrapper">
<label for="dow_sat">Saturday</label>
<input type="checkbox" name="days_of_week[]" id="dow_sat" value="6">
</div>
<div class="dow_chb_wrapper">
<label for="dow_sun">Sunday</label>
<input type="checkbox" name="days_of_week[]" id="dow_sun" value="7">
</div>
<label for="days_of_month" id="lbl_dom">Days of the Month</label>
<input type="text" name="days_of_month" id="days_of_month" placeholder="1,15 (comma-separated)">
<label for="months">Months</label>
<input type="text" name="months" id="months" placeholder="1,7,12 (comma-separated)">
</div>
<input type="submit" value="Create Habit">
</form>
</section>
<script>
function toggleCustomFrequency(value) {
const customFrequencyDiv = document.getElementById('custom-frequency');
customFrequencyDiv.style.display = value === 'Custom' ? 'flex' : 'none';
}
</script>

View File

@@ -0,0 +1,24 @@
<link rel="stylesheet" href="/css/habits_dashboard.css">
<section class="habits">
<?php if (empty($this->get('habits'))): ?>
<p>No habits yet. <a href="/habits/create">Create your first habit</a>.</p>
<?php else: ?>
<div class="habits-wrapper">
<?php foreach ($this->get('habits') as $habit): ?>
<div class="habit bordered">
<b><?= htmlspecialchars($habit['title']) ?></b>
<p>Frequency: <?= htmlspecialchars($habit['frequency']) ?></p>
<?php if (isset($habit['custom_frequency'])): ?>
<p><?= htmlspecialchars($habit['custom_frequency'] ?? 'N/A') ?></p>
<?php endif; ?>
<p><?= htmlspecialchars($habit['reward_points']) ?></p>
<p><?= htmlspecialchars($habit['created_at']) ?></p>
<a href="/habits/done">Mark as done</a> |
<a href="/habits/edit?id=<?= $habit['id'] ?>">Edit</a> |
<a href="/habits/delete?id=<?= $habit['id'] ?>" onclick="return confirm('Are you sure you want to delete this habit?')">Delete</a>
</div>
<?php endforeach; ?>
</div>
<a href="/habits/create" class="btn-green">Create new habit!</a>
<?php endif; ?>
</section>

View File

@@ -1,16 +1,35 @@
<!doctype html> <!doctype html>
<html> <html>
<head> <head>
<meta charset="utf-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="author" content="Filip Rojek | http://filiprojek.cz">
<meta name="email" content="webmaster(@)fofrweb.com">
<meta name="copyright" content="(c) filiprojek.cz">
<title>Habit Tracker | <?= $data['title'] ?></title> <title>Habit Tracker | <?= $data['title'] ?></title>
<link rel="stylesheet" href="/css/main.css">
<link rel="stylesheet" href="/css/global.css">
<link rel="stylesheet" href="/css/vars.css">
<link rel="stylesheet" href="/css/header.css">
<link rel="icon" type="image/x-icon" href="/img/favicon.ico">
</head> </head>
<body> <body>
<header> <header>
<div id="hd-left">
<a href="/"><img src="/img/logo.jpg" alt="home"></a>
<label><?= $data['title'] ?></label>
</div>
<div id="hd-right">
<?php if (!isset($_SESSION['user'])): ?>
<a href="/auth/signin">Log In</a> <a href="/auth/signin">Log In</a>
<a href="/auth/signup">Sign Up</a> <a href="/auth/signup">Sign Up</a>
<?php else: ?>
<a href="/auth/logout" class="btn-secondary">Sign out</a>
<?php endif; ?>
</div>
</header> </header>
<section class="content"> <main class="content">
<?= $content ?> <?= $content ?>
</section> </main>
</body> </body>
</html> </html>

View File

@@ -0,0 +1,20 @@
<!doctype html>
<html>
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="author" content="Filip Rojek | http://filiprojek.cz">
<meta name="email" content="webmaster(@)fofrweb.com">
<meta name="copyright" content="(c) filiprojek.cz">
<title>Habit Tracker | <?= $data['title'] ?></title>
<link rel="stylesheet" href="/css/main.css">
<link rel="stylesheet" href="/css/global.css">
<link rel="stylesheet" href="/css/vars.css">
<link rel="icon" type="image/x-icon" href="/img/favicon.ico">
</head>
<body>
<main class="content">
<?= $content ?>
</main>
</body>
</html>

View File

@@ -1,4 +0,0 @@
<header>
<a href="/signin">Log In</a>
<a href="/signup">Sign Up</a>
</header>

1
config/.gitignore vendored Normal file
View File

@@ -0,0 +1 @@
environment.php

View File

@@ -0,0 +1,6 @@
<?php
define('DB_HOST', '0.0.0.0');
define('DB_NAME', 'habit_tracker');
define('DB_USER', 'username');
define('DB_PASS', 'password');

View File

@@ -0,0 +1,23 @@
<?php
class Controller {
/**
* Redirect to a given URL
*
* @param string $url
*/
public function redirect($url) {
header("Location: $url");
exit();
}
/**
* Render a view
*
* @param string $viewName
* @param array $data
*/
public function view($viewName, $data = [], $layout = "base") {
$view = new View();
$view->render($viewName, $data, $layout);
}
}

116
core/Database.php Normal file
View File

@@ -0,0 +1,116 @@
<?php
class Database {
private static $instance = null;
private $connection;
private function __construct() {
$this->initializeConnection();
$this->checkAndCreateDatabase();
$this->checkAndCreateTables();
}
/**
* Get the singleton instance of the Database
* @return Database
*/
public static function getInstance() {
if (self::$instance === null) {
self::$instance = new Database();
}
return self::$instance;
}
/**
* Get the active database connection
* @return mysqli
*/
public function getConnection() {
return $this->connection;
}
/**
* Initialize the connection to the database
*/
private function initializeConnection() {
$this->connection = new mysqli(DB_HOST, DB_USER, DB_PASS);
if ($this->connection->connect_error) {
die("Database connection failed: " . $this->connection->connect_error);
}
}
/**
* Check and create the database if it doesn't exist
*/
private function checkAndCreateDatabase() {
$query = "CREATE DATABASE IF NOT EXISTS `" . DB_NAME . "`";
if (!$this->connection->query($query)) {
die("Failed to create or access database: " . $this->connection->error);
}
$this->connection->select_db(DB_NAME);
}
/**
* Check and create required tables if they don't exist
*/
private function checkAndCreateTables() {
// Create users table
$usersTableQuery = "CREATE TABLE IF NOT EXISTS users (
id INT AUTO_INCREMENT PRIMARY KEY,
username VARCHAR(50) NOT NULL,
email VARCHAR(100) NOT NULL UNIQUE,
password VARCHAR(255) NOT NULL,
points INT DEFAULT 0,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
) ENGINE=InnoDB;";
if (!$this->connection->query($usersTableQuery)) {
die("Failed to create users table: " . $this->connection->error);
}
// Create progress table
$progressTableQuery = "CREATE TABLE IF NOT EXISTS progress (
id INT AUTO_INCREMENT PRIMARY KEY,
user_id INT NOT NULL,
habit_id INT NOT NULL,
date DATE NOT NULL,
status ENUM('Done', 'Pending') DEFAULT 'Pending',
FOREIGN KEY (user_id) REFERENCES users(id)
) ENGINE=InnoDB;";
if (!$this->connection->query($progressTableQuery)) {
die("Failed to create progress table: " . $this->connection->error);
}
// Create habits table
$habitsTableQuery = "CREATE TABLE IF NOT EXISTS habits (
id INT AUTO_INCREMENT PRIMARY KEY,
user_id INT NOT NULL,
title VARCHAR(100) NOT NULL,
frequency ENUM('Daily', 'Weekly', 'Custom') NOT NULL,
custom_frequency VARCHAR(255) DEFAULT NULL, -- Store crontab-like string
reward_points INT DEFAULT 1,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
) ENGINE=InnoDB;";
if (!$this->connection->query($habitsTableQuery)) {
die("Failed to create habits table: " . $this->connection->error);
}
}
/**
* Prevent cloning of the singleton instance
*/
private function __clone() {}
/**
* Prevent unserialization of the singleton instance
*/
public function __wakeup() {}
}

View File

@@ -2,17 +2,73 @@
class Router { class Router {
private $routes = []; private $routes = [];
private $middlewares = [];
private $groupPrefix = '';
private $groupMiddlewares = [];
public function add($route, $action) { /**
$this->routes[$route] = $action; * Add a route with a specific action and optional middleware
*
* @param string $route
* @param string $action
* @param array $middlewares Optional middlewares for this route
*/
public function add($route, $action, $middlewares = []) {
$route = $this->groupPrefix . $route;
$middlewares = array_merge($this->groupMiddlewares, $middlewares);
$this->routes[$route] = ['action' => $action, 'middlewares' => $middlewares];
} }
/**
* Define a group of routes with shared prefix and middlewares
*
* @param string $prefix
* @param array $middlewares
* @param callable $callback
*/
public function group($prefix, $middlewares, $callback) {
// Save the current state
$previousPrefix = $this->groupPrefix;
$previousMiddlewares = $this->groupMiddlewares;
// Set new group prefix and middlewares
$this->groupPrefix = $previousPrefix . $prefix;
$this->groupMiddlewares = array_merge($this->groupMiddlewares, $middlewares);
// Execute the callback to define routes in the group
$callback($this);
// Restore the previous state
$this->groupPrefix = $previousPrefix;
$this->groupMiddlewares = $previousMiddlewares;
}
/**
* Dispatch the current request to the correct route and execute middlewares
*/
public function dispatch() { public function dispatch() {
$uri = $_SERVER['REQUEST_URI']; $uri = $_SERVER['REQUEST_URI'];
$uri = parse_url($uri, PHP_URL_PATH); $uri = parse_url($uri, PHP_URL_PATH);
// Normalize the URI by removing trailing slash (except for root "/")
if ($uri !== '/' && substr($uri, -1) === '/') {
$uri = rtrim($uri, '/');
}
if (array_key_exists($uri, $this->routes)) { if (array_key_exists($uri, $this->routes)) {
$action = $this->routes[$uri]; $route = $this->routes[$uri];
$middlewares = $route['middlewares'];
// Execute middlewares
foreach ($middlewares as $middleware) {
$middlewareInstance = new $middleware();
if (!$middlewareInstance->handle()) {
return; // Stop execution if middleware fails
}
}
// Execute the route's controller and method
$action = $route['action'];
list($controllerName, $methodName) = explode('@', $action); list($controllerName, $methodName) = explode('@', $action);
require_once controllers . "{$controllerName}.php"; require_once controllers . "{$controllerName}.php";
@@ -22,7 +78,7 @@ class Router {
} else { } else {
http_response_code(404); http_response_code(404);
$view = new View(); $view = new View();
$view->render('errors/404'); $view->render('errors/404', [], 'noheader');
} }
} }
} }

55
core/Validator.php Normal file
View File

@@ -0,0 +1,55 @@
<?php
class Validator {
private $errors = [];
/**
* Check if a field is not empty
*/
public function required($field, $value, $message = null) {
if (empty(trim($value))) {
$this->errors[$field] = $message ?? "$field is required.";
}
}
/**
* Check if a field meets minimum length
*/
public function minLength($field, $value, $length, $message = null) {
if (strlen($value) < $length) {
$this->errors[$field] = $message ?? "$field must be at least $length characters.";
}
}
/**
* Check if a field contains letters and numbers
*/
public function alphanumeric($field, $value, $message = null) {
if (!preg_match('/^(?=.*[a-zA-Z])(?=.*\d).+$/', $value)) {
$this->errors[$field] = $message ?? "$field must contain letters and numbers.";
}
}
/**
* Validate an email
*/
public function email($field, $value, $message = null) {
if (!filter_var($value, FILTER_VALIDATE_EMAIL)) {
$this->errors[$field] = $message ?? "Invalid email format.";
}
}
/**
* Get validation errors
*/
public function errors() {
return $this->errors;
}
/**
* Check if validation passed
*/
public function passes() {
return empty($this->errors);
}
}

View File

@@ -1,10 +1,11 @@
<?php <?php
class View class View
{ {
public function render($view, $data = [], $layout = 'base') private $data = [];
{
// Extract variables to be accessible in the view public function render($view, $data = [], $layout = 'base') {
extract($data); // Store the data
$this->data = $data;
// Capture the view content // Capture the view content
ob_start(); ob_start();
@@ -14,4 +15,11 @@ class View
// Include the base layout and inject the view content // Include the base layout and inject the view content
require_once views . "layouts/$layout.php"; require_once views . "layouts/$layout.php";
} }
/**
* Safely get a value from the data array
*/
public function get($key) {
return $this->data[$key] ?? null;
}
} }

View File

@@ -0,0 +1,12 @@
<?php
class RequireAuth {
public function handle() {
if (!isset($_SESSION['user'])) {
header('Location: /auth/signin');
exit();
}
return true;
}
}

31
docker-compose.yaml Normal file
View File

@@ -0,0 +1,31 @@
services:
mariadb:
image: mariadb:11.4 # LTS at 25. 12. 2025
restart: on-failure:2
environment:
MARIADB_ROOT_PASSWORD: root
ports:
- 3306:3306
profiles: ["prod", "dev"]
phpmyadmin:
image: phpmyadmin
restart: on-failure:2
ports:
- 8080:80
environment:
- PMA_ARBITRARY=1
profiles: ["dev"]
habittracker:
build:
context: .
dockerfile: Dockerfile
volumes:
- .:/var/www/html
ports:
- 8000:80
depends_on:
- mariadb
restart: on-failure:2
profiles: ["prod"]

21
public/css/dashboard.css Normal file
View File

@@ -0,0 +1,21 @@
.dashboard {
display: flex;
flex-direction: column;
align-items: center;
}
.card-wrapper {
display: flex;
flex-wrap: wrap;
gap: 1rem;
justify-content: center;
margin-top: 2rem;
}
.card {
background-color: var(--clr-secondary);
border-radius: var(--border-radious);
border: var(--borderWidth-thin) solid var(--clr-border);
min-width: 17rem;
padding: 1rem;
}

78
public/css/form.css Normal file
View File

@@ -0,0 +1,78 @@
.form {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
}
body {
background-color: var(--clr-secondary);
}
.form .header-form {
display: flex;
align-items: center;
flex-direction: column;
}
.form .header-form img {
height: 5rem;
}
.form form {
display: flex;
flex-direction: column;
padding: 1rem;
background-color: var(--clr-tertiary);
gap: .5rem;
border-radius: var(--border-radious);
border: var(--borderWidth-thin) solid var(--clr-border);
}
.form form input,
select {
background-color: var(--clr-secondary);
caret-color: white;
color: white;
padding: .3rem;
border-radius: var(--border-radious);
border: var(--borderWidth-thin) solid var(--clr-border);
width: 15rem;
}
.form form input[type="submit"] {
background-color: var(--clr-green);
color: white;
}
.form .error {
width: 17rem;
padding: 1rem;
background-color: var(--clr-danger-muted);
border-radius: var(--border-radious);
border: var(--borderWidth-thin) solid var(--clr-border-danger);
margin-bottom: 1rem;
color: white;
}
.form small.error {
width: 15rem;
}
.form .bordered {
border-radius: var(--border-radious);
border: var(--borderWidth-thin) solid var(--clr-border);
width: 17rem;
padding: 1rem;
margin-top: 1rem;
text-align: center;
}
.form .bordered a {
text-decoration: none;
color: var(--clr-link-blue);
}
.form .bordered a:hover {
text-decoration: underline;
}

47
public/css/global.css Normal file
View File

@@ -0,0 +1,47 @@
@import url("https://fonts.googleapis.com/css2?family=Open+Sans:ital,wght@0,300..800;1,300..800&family=Source+Code+Pro:ital,wght@0,200..900;1,200..900&display=swap");
body {
font-family: "Open Sans", serif;
background-color: var(--clr-primary);
color: white;
font-size: 14px;
}
a {
color: white;
}
h1 {
margin-top: .5rem;
margin-bottom: 1rem;
}
.btn-primary,
.btn-secondary,
.btn-tertiary,
.btn-green,
.btn-danger {
background-color: var(--clr-primary);
padding: .5rem;
text-decoration: none;
cursor: pointer;
border-radius: var(--border-radious);
border: var(--borderWidth-thin) solid var(--clr-border);
}
.btn-secondary {
background-color: var(--clr-secondary);
}
.btn-tertiary {
background-color: var(--clr-tertiary);
}
.btn-green {
background-color: var(--clr-green);
}
.btn-danger {
background-color: var(--clr-danger-muted);
border: var(--borderWidth-thin) solid var(--clr-border-danger);
}

View File

@@ -0,0 +1,21 @@
.form form .dow_chb_wrapper input[type="checkbox"] {
width: 1rem;
}
.form form .dow_chb_wrapper {
display: flex;
justify-content: space-between;
}
#lbl_dow {
margin-bottom: .5rem;
}
#lbl_dom {
margin-top: .5rem;
}
#custom-frequency {
flex-direction: column;
justify-content: space-between;
}

View File

@@ -0,0 +1,15 @@
.habits-wrapper {
display: flex;
gap: 1rem;
flex-wrap: wrap;
justify-content: center;
}
.habits .bordered {
border-radius: var(--border-radious);
border: var(--borderWidth-thin) solid var(--clr-border);
width: 17rem;
padding: 1rem;
margin-top: 1rem;
text-align: center;
}

26
public/css/header.css Normal file
View File

@@ -0,0 +1,26 @@
header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 2rem;
width: 100vw;
height: 3rem;
background: var(--clr-secondary);
border-radius: var(--border-radious);
border-bottom: var(--borderWidth-thin) solid var(--clr-border);
}
#hd-left,
#hd-right {
display: flex;
align-items: center;
gap: 1rem;
}
header a img {
height: 2rem;
}
header a {
text-decoration: none;
}

13
public/css/main.css Normal file
View File

@@ -0,0 +1,13 @@
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
main {
display: flex;
flex-direction: column;
margin-top: 2rem;
margin-bottom: 2rem;
padding: 0 var(--container-size);
}

20
public/css/vars.css Normal file
View File

@@ -0,0 +1,20 @@
:root {
--container-size: 5vw;
--clr-primary: #010409;
--clr-secondary: #0d1117;
--clr-tertiary: #151b23;
--clr-green: #238636;
--clr-danger-muted: #f851491a;
--clr-link-blue: #4493f8;
--clr-light-blue: #39a2ae;
--clr-light-green: #71f79f;
--clr-red: #9b1d20;
--clr-orange: #e08e45;
--clr-gray-blue: #627c85;
--border-radious: 5px;
--borderWidth-thin: max(1px, 0.0625rem);
--clr-border: #3d444db3;
--clr-border-danger: #f8514966;
}

BIN
public/img/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

BIN
public/img/logo.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 45 KiB

View File

@@ -1,5 +1,6 @@
<?php <?php
// Show errors and warnings // Show errors and warnings
session_start();
ini_set('display_errors', '1'); ini_set('display_errors', '1');
ini_set('log_errors', '1'); ini_set('log_errors', '1');
ini_set('error_log', '../storage/logs/error_log.log'); ini_set('error_log', '../storage/logs/error_log.log');
@@ -7,17 +8,44 @@ ini_set('error_log', '../storage/logs/error_log.log');
define('models', __DIR__ . '/../app/models/'); define('models', __DIR__ . '/../app/models/');
define('views', __DIR__ . '/../app/views/'); define('views', __DIR__ . '/../app/views/');
define('controllers', __DIR__ . '/../app/controllers/'); define('controllers', __DIR__ . '/../app/controllers/');
define('config', __DIR__ . '/../config/');
require_once config . 'environment.php';
require_once '../core/Validator.php';
require_once '../core/Router.php'; require_once '../core/Router.php';
require_once '../core/View.php'; require_once '../core/View.php';
require_once '../core/Controller.php'; require_once '../core/Controller.php';
require_once '../core/Database.php';
require_once '../core/middlewares/RequireAuth.php';
require_once models . 'User.php';
require_once models . 'Habit.php';
// Initialize router // Initialize router
$router = new Router(); $router = new Router();
if(!isset($_SESSION['user'])) {
$router->add('/', 'HomeController@index'); $router->add('/', 'HomeController@index');
$router->add('/home', 'HomeController@home'); } else {
$router->add('/', 'DashboardController@reroute', ['RequireAuth']);
}
// auth routes // auth routes
$router->add('/auth/signin', 'AuthController@signin'); $router->group('/auth', [], function ($router) {
$router->add('/auth/signup', 'AuthController@signup'); $router->add('/signin', 'AuthController@signin');
$router->add('/signup', 'AuthController@signup');
$router->add('/logout', 'AuthController@logout');
});
// dashboard route
$router->add('/dashboard', 'DashboardController@index', ['RequireAuth']);
// habits routes
$router->group('/habits', ['RequireAuth'], function ($router) {
$router->add('', 'HabitController@index');
$router->add('/create', 'HabitController@create');
$router->add('/edit/{id}', 'HabitController@edit');
$router->add('/delete/{id}', 'HabitController@delete');
});
$router->dispatch(); $router->dispatch();