26 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
3144078860 Added: MVC structure, Router, Views and Controllers for home page and login/signup 2024-12-25 23:02:09 +01:00
7b4b349816 Edited: README.md 2024-12-25 23:00:55 +01:00
44 changed files with 1390 additions and 2 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;"

View File

@@ -1,3 +1,55 @@
# habit-tracker
# Habit Tracker
An app for tracking habits and motivation to achieve personal goals
## Used technologies
- **Frontend:** HTML, CSS, JavaScript
- **Backend:** PHP (OOP)
- **Database:** MariaDB
## How to build
### Build using docker
Run the container using docker-compose
```bash
docker-compose up
```
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', 'your db host');
define('DB_USER', 'your db username');
define('DB_PASS', 'your db password');
define('DB_NAME', 'your db name');
```
- For the database, you can use included `docker-compose.yaml` which have both MariaDB and PhpMyAdmin
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.
## Usage
1. Register and Login to the app.
2. Add your habits.
3. Mark your habits when you're done doing them.
4. Earn point and unlock achievements by completing you're habits!
## Licence
This project is licensed under GPL3.0 and later. More information is availabe in `LICENSE` file.
An app for tracking habits and motivation to achieve personal goals.

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

@@ -0,0 +1,85 @@
<?php
class AuthController extends Controller {
public function signin() {
if($_SERVER['REQUEST_METHOD'] === 'POST') {
$email = $_POST['email'] ?? '';
$password = $_POST['password'] ?? '';
$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() {
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$username = $_POST['username'] ?? '';
$email = $_POST['email'] ?? '';
$password = $_POST['password'] ?? '';
$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

@@ -0,0 +1,18 @@
<?php
class HomeController extends Controller {
public function index() {
$data = [
'title' => 'Home'
];
$this->view('home/index', $data);
}
public function home() {
$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.";
}
}

33
app/views/auth/signin.php Normal file
View File

@@ -0,0 +1,33 @@
<link rel="stylesheet" href="/css/login.css">
<link rel="stylesheet" href="/css/form.css">
<section class="form signin">
<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>
<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; ?>
<input type="submit" value="Sign In">
</form>
<div class="bordered">
<p>New to Habit Tracker?</p>
<a href="/auth/signup">Create an account</a>
</div>
</section>

45
app/views/auth/signup.php Normal file
View File

@@ -0,0 +1,45 @@
<link rel="stylesheet" href="/css/login.css">
<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>
<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>
<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>
<input type="password" name="password-2" id="password-2" required>
<?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>
<div class="bordered">
<p>Already have an account?</p>
<a href="/auth/signin">Sign in</a>
</div>
</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>

13
app/views/errors/404.php Normal file
View File

@@ -0,0 +1,13 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Habit Tracker | Error 494</title>
</head>
<body>
<h1>Error 404 - Page not found</h1>
<a href="/">Go back home</a>
</body>
</html>

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

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>

4
app/views/home/index.php Normal file
View File

@@ -0,0 +1,4 @@
<div>
<h1>Welcome to Habit Tracker!</h1>
<p>Track your habits and achieve your goals.</p>
</div>

View File

@@ -0,0 +1,35 @@
<!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="stylesheet" href="/css/header.css">
<link rel="icon" type="image/x-icon" href="/img/favicon.ico">
</head>
<body>
<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/signup">Sign Up</a>
<?php else: ?>
<a href="/auth/logout" class="btn-secondary">Sign out</a>
<?php endif; ?>
</div>
</header>
<main class="content">
<?= $content ?>
</main>
</body>
</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
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');

23
core/Controller.php Normal file
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() {}
}

84
core/Router.php Normal file
View File

@@ -0,0 +1,84 @@
<?php
class Router {
private $routes = [];
private $middlewares = [];
private $groupPrefix = '';
private $groupMiddlewares = [];
/**
* 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() {
$uri = $_SERVER['REQUEST_URI'];
$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)) {
$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);
require_once controllers . "{$controllerName}.php";
$controller = new $controllerName();
$controller->$methodName();
} else {
http_response_code(404);
$view = new View();
$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);
}
}

25
core/View.php Normal file
View File

@@ -0,0 +1,25 @@
<?php
class View
{
private $data = [];
public function render($view, $data = [], $layout = 'base') {
// Store the data
$this->data = $data;
// Capture the view content
ob_start();
require_once views . $view . '.php';
$content = ob_get_clean();
// Include the base layout and inject the view content
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

51
public/index.php Normal file
View File

@@ -0,0 +1,51 @@
<?php
// Show errors and warnings
session_start();
ini_set('display_errors', '1');
ini_set('log_errors', '1');
ini_set('error_log', '../storage/logs/error_log.log');
define('models', __DIR__ . '/../app/models/');
define('views', __DIR__ . '/../app/views/');
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/View.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
$router = new Router();
if(!isset($_SESSION['user'])) {
$router->add('/', 'HomeController@index');
} else {
$router->add('/', 'DashboardController@reroute', ['RequireAuth']);
}
// auth routes
$router->group('/auth', [], function ($router) {
$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();

1
storage/logs/.gitignore vendored Normal file
View File

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