Compare commits

..

26 Commits

Author SHA1 Message Date
3d86f1ae2e Fixed: data validation in offline refuel create form
All checks were successful
Build and Deploy Zola Website / build_and_deploy (push) Successful in 12s
2025-02-03 19:54:58 +01:00
c14bbd1930 Update: diagrams
All checks were successful
Build and Deploy Zola Website / build_and_deploy (push) Successful in 13s
2025-02-03 09:53:19 +01:00
88a78d60e9 Updated: fixed fuel consumption calculation on dashboard
All checks were successful
Build and Deploy Zola Website / build_and_deploy (push) Successful in 12s
2025-02-01 23:35:21 +01:00
4576800e27 Updated: Removed LIMIT for all refuel records, fixed fuel consumption calculation on dashboard
All checks were successful
Build and Deploy Zola Website / build_and_deploy (push) Successful in 12s
2025-02-01 22:56:32 +01:00
652b04bfa1 Updated: Vehicle list hides "Set as default" for default vehicle and improves styling
All checks were successful
Build and Deploy Zola Website / build_and_deploy (push) Successful in 12s
2025-02-01 19:02:24 +01:00
6df1f6574a Updated: Added persistent storage for MariaDB in docker-compose.yaml
All checks were successful
Build and Deploy Zola Website / build_and_deploy (push) Successful in 13s
2025-02-01 18:48:28 +01:00
7517bcb78f Updated: Improved vehicle and refuel data handling on dashboard
All checks were successful
Build and Deploy Zola Website / build_and_deploy (push) Successful in 14s
Updated:
- DashboardController now fetches latest refuel record only for the default vehicle
- VehicleController - first created vehicle is now set as default automatically
- Refuel model - latest_one() now accepts vehicle_id instead of user_id
- Dashboard view - improved handling when no vehicles or refuel records exist
- CSS styles - adjusted dashboard layout and global action padding
2025-02-01 18:38:49 +01:00
f90c707435 Updated: docker-compose.yml networking for proper DB access 2025-02-01 17:35:22 +01:00
60423d37ce Updated: TODO.md
All checks were successful
Build and Deploy Zola Website / build_and_deploy (push) Successful in 13s
2025-01-30 14:39:21 +01:00
5989fba225 Fix: unable to register
All checks were successful
Build and Deploy Zola Website / build_and_deploy (push) Successful in 22s
2025-01-27 02:07:01 +01:00
ba11c41147 Added: mileage, average fuel consumption
All checks were successful
Build and Deploy Zola Website / build_and_deploy (push) Successful in 12s
2025-01-27 01:57:31 +01:00
64c7fd15a1 Added: set default vehicle 2025-01-27 00:38:22 +01:00
ea3afa2507 Updated: README.md added diagrams
All checks were successful
Build and Deploy Zola Website / build_and_deploy (push) Successful in 13s
2025-01-26 23:16:00 +01:00
a5f99788fc Added: Delete vehicle 2025-01-26 23:01:35 +01:00
2201430f59 Partially done
All checks were successful
Build and Deploy Zola Website / build_and_deploy (push) Successful in 16s
2025-01-26 22:22:12 +01:00
18c78e37a4 In progress: offline fuel record creation 2025-01-26 17:21:30 +01:00
ccbb0eac64 Added: Stats in dashboard
All checks were successful
Build and Deploy Zola Website / build_and_deploy (push) Successful in 12s
2025-01-05 21:24:55 +01:00
21c2f4598b Added: Create new refuel record
All checks were successful
Build and Deploy Zola Website / build_and_deploy (push) Successful in 13s
2025-01-05 19:05:30 +01:00
860a20d946 Added: Fuel record create - not complete yet
All checks were successful
Build and Deploy Zola Website / build_and_deploy (push) Successful in 12s
2025-01-03 17:10:08 +01:00
c5955010cb Edited: vehicle list styled
All checks were successful
Build and Deploy Zola Website / build_and_deploy (push) Successful in 13s
2025-01-03 16:16:15 +01:00
fc163431f8 Added: Gitea Workflow
All checks were successful
Build and Deploy Zola Website / build_and_deploy (push) Successful in 12s
2025-01-03 00:45:54 +01:00
15029970d6 Fix: wrong action url in vehicle create 2025-01-03 00:18:29 +01:00
e13edeccfc Edited: vehicle create route renamed from vehicle add 2025-01-02 02:02:14 +01:00
be6b465684 Added: vehicle creation 2024-12-31 15:53:15 +01:00
aded859a79 Added: tables for fuel-stats 2024-12-31 14:21:41 +01:00
c29bd7cbab Edited: App is rebranded to Fuel Stats 2024-12-31 11:34:39 +01:00
42 changed files with 1321 additions and 352 deletions

View File

@ -0,0 +1,36 @@
name: Build and Deploy Zola Website
on:
push:
branches:
- master
env:
HOST: ${{ secrets.SERVER_IP }}
SSH_USERNAME: ${{ secrets.USERNAME }}
SSH_PRIVATE_KEY: ${{ secrets.DEPLOY_KEY }}
DEST_FOLDER: "/srv/www/cz/filiprojek/fuelstats"
jobs:
build_and_deploy:
runs-on: ubuntu-latest
steps:
- name: Checkout Repository
uses: actions/checkout@v4
- name: Deploy
run: |
apt update -y && apt-get install -y --no-install-recommends rsync
eval "$(ssh-agent -s)"
ssh-add - <<< "${SSH_PRIVATE_KEY}"
mkdir -p ~/.ssh/
ssh-keyscan -H ${HOST} >> ~/.ssh/known_hosts
rsync -r --delete-after ./* "${SSH_USERNAME}@${HOST}:${{ env.DEST_FOLDER }}"
- name: Copy environment.php
run: |
eval "$(ssh-agent -s)"
ssh-add - <<< "${SSH_PRIVATE_KEY}"
mkdir -p ~/.ssh/
ssh-keyscan -H ${HOST} >> ~/.ssh/known_hosts
ssh ${SSH_USERNAME}@${HOST} "cp /var/websrvenv/environment.php /srv/www/cz/filiprojek/fuelstats/config/environment.php"

BIN
.screenshots/class.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

BIN
.screenshots/dlm.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 92 KiB

BIN
.screenshots/usecase.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

View File

@ -208,8 +208,8 @@ If you develop a new program, and you want it to be of the greatest possible use
To do so, attach the following notices to the program. It is safest to attach them to the start of each source file to most effectively state the exclusion of warranty; and each file should have at least the “copyright” line and a pointer to where the full notice is found.
habit-tracker
Copyright (C) 2024 fr
Fuel Stats
Copyright (C) 2024 Filip Rojek
This program is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version.
@ -221,7 +221,7 @@ Also add information on how to contact you by electronic and paper mail.
If the program does terminal interaction, make it output a short notice like this when it starts in an interactive mode:
habit-tracker Copyright (C) 2024 fr
Fuel Stats Copyright (C) 2024 Filip Rojek
This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'.
This is free software, and you are welcome to redistribute it under certain conditions; type `show c' for details.

View File

@ -1,32 +1,32 @@
# Habit Tracker
# Fuel Stats
An app for tracking habits and motivation to achieve personal goals
An app for tracking your fuel consumption and optimizing your driving efficiency.
## Used technologies
## Used Technologies
- **Frontend:** HTML, CSS, JavaScript
- **Backend:** PHP (OOP)
- **Database:** MariaDB
## How to build
## How to Build
### Build using docker
Run the container using docker-compose
### Build Using Docker
Run the container using docker-compose:
```bash
docker-compose up
docker-compose --profile <dev|prod> up -d
```
The app should be available at http://localhost:8000
The app should be available at http://localhost:8000.
PhpMyAdmin should be available at http://localhost:8080
PhpMyAdmin should be available at http://localhost:8080.
### Build manually
1. Clone the repo
### Build Manually
1. Clone the repository:
```bash
git clone https://git.filiprojek.cz/fr/habit-tracker.git
git clone https://git.filiprojek.cz/fr/fuel-stats.git
```
2. Create `config/environment.php`
- It should have following structure:
2. Create `config/environment.php`:
- It should have the following structure:
```php
<?php
@ -35,21 +35,31 @@ 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
- For the database, you can use the included `docker-compose.yaml` which includes both MariaDB and PhpMyAdmin.
3. Start an local web server
- You can use php's integrated server by running this:
3. Start a 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.
- You can use any host and any port you prefer.
## 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!
1. Register and log in to the app.
2. Add your vehicles with their details (fuel type, registration, etc.).
3. Record each refueling:
- Select your vehicle.
- Input the number of liters, price per liter, and total cost.
4. Track your fuel consumption and spending through the dashboard.
5. View detailed stats and graphs to analyze your driving habits.
## Licence
This project is licensed under GPL3.0 and later. More information is availabe in `LICENSE` file.
## Use case diagram
![](.screenshots/usecase.png)
## Data logical model
![](.screenshots/dlm.png)
## Class diagram
![](.screenshots/class.png)
## License
This project is licensed under GPL3.0 and later. More information is available in the `LICENSE` file.

46
TODO.md
View File

@ -6,13 +6,39 @@
- [ ] 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
- [ ] intro tutorial when no car exist or just dont show anything
- [x] change/set default car
- [ ] specific car view - charts, fuel records
- [ ] remove/edit fuel record
## Until release
- [x] Sync offline data from locale storage
- [x] Include kilometer state of an car
- [ ] More charts
- [x] Average fuel conusption
- [ ] Kilometer state
- [ ] More cards
- [ ] Average fuel conusption in last 30 days
- [ ] Kilometer state in last 30 days`
- [ ] Offline navigation between dashboard and offline form
## What has to be done
- [x] Vehicle delete
- [ ] intro tutorial when no car exist or just dont show anything
- [x] change/set default car
- [x] hide errors
## Nice to have
- [ ] specific car view - charts, fuel records
- [ ] remove/edit fuel record
- [x] Include kilometer state of an car
- [ ] More charts
- [x] Average fuel conusption
- [ ] Kilometer state
- [ ] More cards
- [ ] Average fuel conusption in last 30 days
- [ ] Kilometer state in last 30 days`
- [ ] Offline navigation between dashboard and offline form
- [ ] Fix vehicle deletion - wrong redirect
- [ ] Update diagrams in README.md

View File

@ -1,12 +1,34 @@
<?php
class DashboardController extends Controller {
public function index() {
$habit = new Habit();
$habits = $habit->getHabitsByUser($_SESSION['user']['id']);
$vehicle = new Vehicle();
$vehicles = $vehicle->getVehiclesByUser($_SESSION['user']['id']);
$default_car = $vehicle->getDefaultVehicle($_SESSION['user']['id']) ?? null;
$refuel = new Refuel();
$data = [
"date" => [],
"price" => [],
"mileage" => [],
"liters" => []
];
$raw_data = $default_car ? $refuel->latest_data($default_car['id'], 0) : [];
foreach($raw_data as $one) {
array_push($data['date'], date('d. m.', strtotime($one['created_at'])));
array_push($data['price'], $one['price_per_liter']);
array_push($data['mileage'], $one['mileage']);
array_push($data['liters'], $one['liters']);
}
$latest_data = $default_car ? $refuel->latest_one($default_car['id']) : [];
$latest_record = !empty($latest_data) ? $latest_data[0] : null;
$this->view('dashboard/index', [
'title' => 'Dashboard',
'habits' => $habits,
'vehicles' => $vehicles,
'date_price_data' => $data,
'default_car' => $default_car,
'latest_record' => $latest_record,
]);
}

View File

@ -1,57 +0,0 @@
<?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,89 @@
<?php
class RefuelController extends Controller {
public function create() {
if($_SERVER['REQUEST_METHOD'] === 'GET'){
$vehicle = new Vehicle();
$vehicles = $vehicle->getVehiclesByUser($_SESSION['user']['id']);
$this->view('refuel/create', [
'title' => "New refuel record",
'vehicles' => $vehicles,
]);
return;
}
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$vehicle_id = $_POST['vehicle'] ?? '';
$fuel_type = $_POST['fuel_type'] ?? '';
$liters = $_POST['liters'] ?? '';
$price_per_liter = $_POST['price_per_liter'] ?? '';
$total_price = $_POST['total_price'] ?? '';
$mileage = $_POST['mileage'] ?? '';
$note = $_POST['note'] ?? '';
$validator = new Validator();
$validator->required('vehicle', $vehicle_id);
$validator->required('fuel_type', $fuel_type);
$validator->required('liters', $liters);
$validator->required('price_per_liter', $price_per_liter);
$validator->required('total_price', $total_price);
$validator->number('liters', $liters);
$validator->number('price_per_liter', $price_per_liter);
$validator->number('total_price', $total_price);
$validator->number('mileage', $mileage);
if (round($liters * $price_per_liter, 2) != $total_price) {
$validator->setErrors(["total_price" => "Price calculation is wrong"]);
}
if($note == "") $note = NULL;
if (!$validator->passes()) {
$vehicle = new Vehicle();
$vehicles = $vehicle->getVehiclesByUser($_SESSION['user']['id']);
$this->view('refuel/create', [
'error' => 'Please correct the errors below.',
'validationErrors' => $validator->errors() ?: [],
'vehicles' => $vehicles,
'title' => 'New refuel record',
'status' => '400'
]);
return;
}
$record = new Refuel();
$result = $record->create([
'user_id' => $_SESSION['user']['id'],
'vehicle_id' => $vehicle_id,
'fuel_type' => $fuel_type,
'note' => $note,
'liters' => $liters,
'price_per_liter' => $price_per_liter,
'total_price' => $total_price,
'mileage' => $mileage,
]);
if ($result === true) {
$this->redirect('/');
} else {
$vehicle = new Vehicle();
$vehicles = $vehicle->getVehiclesByUser($_SESSION['user']['id']);
$this->view('refuel/create', [
'title' => 'New refuel record',
'error' => $result,
'validationErrors' => [],
'vehicles' => $vehicles,
]);
}
return;
}
}
public function edit() {
// Edit refuel record (to be implemented later)
}
public function delete() {
// Delete refuel record (to be implemented later)
}
}

View File

@ -0,0 +1,104 @@
<?php
class VehicleController extends Controller {
public function index() {
$vehicle = new Vehicle();
$vehicles = $vehicle->getVehiclesByUser($_SESSION['user']['id']);
$this->view('vehicles/index', ['title' => 'Vehicles', 'vehicles' => $vehicles]);
}
public function create() {
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$name = $_POST['name'] ?? '';
$registration_plate = $_POST['registration_plate'] ?? '';
$fuel_type = $_POST['fuel_type'] ?? '';
$note = $_POST['note'] ?? '';
$validator = new Validator();
$validator->required('name', $name);
$validator->required('registration_plate', $registration_plate);
$validator->required('fuel_type', $fuel_type);
if($note == "") $note = NULL;
if (!$validator->passes()) {
$this->view('vehicles/create', [
'error' => 'Please correct the errors below.',
'validationErrors' => $validator->errors() ?: [],
]);
return;
}
$vehicle = new Vehicle();
$default_vehicle = $vehicle->getDefaultVehicle($_SESSION['user']['id']);
$is_default = $default_vehicle ? 0 : 1;
$result = $vehicle->create([
'name' => $name,
'registration_plate' => strtoupper($registration_plate),
'fuel_type' => $fuel_type,
'note' => $note,
'user_id' => $_SESSION['user']['id'],
'is_default' => $is_default
]);
if ($result === true) {
$this->redirect('/');
} else {
$this->view('vehicles/create', ['title' => 'Create vehicle', 'error' => $result, 'validationErrors' => []] );
}
} else {
$this->view('vehicles/create', ['title' => 'Create Vehicle']);
}
}
public function edit() {
// TODO: Edit vehicle (to be implemented later)
}
public function delete() {
if(!$_SERVER['REQUEST_METHOD'] === 'POST') {
echo "Wrong method";
return;
}
// TODO: Validate the request
$vehicle_id = $_POST['vehicle_id'];
$vehicle = new Vehicle();
$result = $vehicle->delete($vehicle_id, $_SESSION['user']['id']);
if($result != true) {
echo "Something went wrong";
return;
}
header("Location: /vehicles");
}
public function setDefault() {
$vehicle = new Vehicle();
// TODO: Validate the request
$result = $vehicle->setDefaultVehicle($_POST['vehicle_id'], $_SESSION['user']['id']);
if($result != true) {
echo "Something went wrong";
return;
}
header("Location: /");
}
public function api_get() {
if(!$_SERVER['REQUEST_METHOD'] === 'GET') {
echo "Wrong method, use GET";
return;
}
$vehicle = new Vehicle();
$result = $vehicle->getVehiclesByUser($_SESSION['user']['id']);
echo json_encode($result);
}
}

View File

@ -1,46 +0,0 @@
<?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;
}
}

113
app/models/Refuel.php Normal file
View File

@ -0,0 +1,113 @@
<?php
class Refuel {
private $db;
public function __construct() {
$this->db = Database::getInstance()->getConnection();
}
public function create($data) {
try{
$stmt = $this->db->prepare("
INSERT INTO refueling_records (user_id, vehicle_id, fuel_type, note, liters, price_per_liter, total_price, mileage, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, NOW())
");
$stmt->bind_param(
"iissdddi",
$data['user_id'],
$data['vehicle_id'],
$data['fuel_type'],
$data['note'],
$data['liters'],
$data['price_per_liter'],
$data['total_price'],
$data['mileage'],
);
if ($stmt->execute()) {
return true;
} else {
return "Error: " . $stmt->error;
}
} catch(mysqli_sql_exception $e) {
return $e->getMessage();
}
}
public function latest_data($vehicle_id, $record_count) {
try {
$sql = "
SELECT `liters`, `price_per_liter`, `total_price`, `mileage`, `created_at`
FROM `refueling_records`
WHERE `vehicle_id` = ?
ORDER BY created_at DESC";
if ($record_count > 0) {
$sql .= " LIMIT ?";
}
$stmt = $this->db->prepare($sql);
if ($record_count > 0) {
$stmt->bind_param("ii", $vehicle_id, $record_count);
} else {
$stmt->bind_param("i", $vehicle_id);
}
if ($stmt->execute()) {
$result = $stmt->get_result();
$data = $result->fetch_all(MYSQLI_ASSOC);
$stmt->close();
return array_reverse($data);
} else {
return "Error: " . $stmt->error;
}
} catch (mysqli_sql_exception $e) {
return $e->getMessage();
}
}
public function latest_one($vehicle_id, $record_count = 1) {
try {
$sql = "
SELECT
`r`.`vehicle_id`,
`v`.`name` AS `vehicle_name`,
`r`.`liters`,
`r`.`price_per_liter`,
`r`.`total_price`,
`r`.`mileage`,
`r`.`note`,
`r`.`created_at`
FROM `refueling_records` AS `r`
JOIN `vehicles` AS `v` ON `r`.`vehicle_id` = `v`.`id`
WHERE `r`.`vehicle_id` = ?
ORDER BY `r`.`created_at` DESC";
if ($record_count > 0) {
$sql .= " LIMIT ?";
}
$stmt = $this->db->prepare($sql);
if ($record_count > 0) {
$stmt->bind_param("ii", $vehicle_id, $record_count);
} else {
$stmt->bind_param("i", $vehicle_id);
}
if ($stmt->execute()) {
$result = $stmt->get_result();
$data = $result->fetch_all(MYSQLI_ASSOC);
$stmt->close();
return array_reverse($data);
} else {
return "Error: " . $stmt->error;
}
} catch (mysqli_sql_exception $e) {
return $e->getMessage();
}
}
}

View File

@ -29,7 +29,7 @@ class User {
$hashedPassword = password_hash($password, PASSWORD_BCRYPT);
$stmt = $this->db->prepare("INSERT INTO users (username, email, password, points, created_at) VALUES (?, ?, ?, 0, NOW())");
$stmt = $this->db->prepare("INSERT INTO users (username, email, password, created_at) VALUES (?, ?, ?, NOW())");
$stmt->bind_param("sss", $username, $email, $hashedPassword);
if ($stmt->execute()) {

121
app/models/Vehicle.php Normal file
View File

@ -0,0 +1,121 @@
<?php
class Vehicle {
private $db;
public function __construct() {
$this->db = Database::getInstance()->getConnection();
}
public function create($data) {
try{
$stmt = $this->db->prepare("
INSERT INTO vehicles (user_id, name, registration_plate, fuel_type, note, is_default, created_at)
VALUES (?, ?, ?, ?, ?, ?, NOW())
");
$stmt->bind_param(
"issssi",
$data['user_id'],
$data['name'],
$data['registration_plate'],
$data['fuel_type'],
$data['note'],
$data['is_default'],
);
if ($stmt->execute()) {
return true;
} else {
return "Error: " . $stmt->error;
}
} catch(mysqli_sql_exception $e) {
return $e->getMessage();
}
}
public function getVehiclesByUser($user_id) {
$stmt = $this->db->prepare("SELECT id, name, registration_plate, fuel_type, note, is_default, created_at FROM vehicles WHERE user_id = ?");
$stmt->bind_param("i", $user_id);
$stmt->execute();
$result = $stmt->get_result();
$vehicles = [];
while ($row = $result->fetch_assoc()) {
$vehicles[] = $row;
}
return $vehicles;
}
public function getDefaultVehicle($user_id) {
$stmt = $this->db->prepare("
SELECT id, name, registration_plate, fuel_type, note, is_default
FROM vehicles
WHERE user_id = ? AND is_default = TRUE
LIMIT 1
");
$stmt->bind_param("i", $user_id);
$stmt->execute();
$result = $stmt->get_result();
return $result->fetch_assoc();
}
public function setDefaultVehicle($vehicle_id, $user_id) {
try {
$this->db->begin_transaction();
$stmt = $this->db->prepare("
UPDATE `vehicles`
SET `is_default` = 0
WHERE `user_id` = ? AND `is_default` = 1
");
$stmt->bind_param("i", $user_id);
$stmt->execute();
$stmt->close();
$stmt = $this->db->prepare("
UPDATE `vehicles`
SET `is_default` = 1
WHERE `id` = ? AND `user_id` = ?
");
$stmt->bind_param("ii", $vehicle_id, $user_id);
if ($stmt->execute()) {
$this->db->commit();
return true;
} else {
$this->db->rollback();
return "Error: " . $stmt->error;
}
} catch (mysqli_sql_exception $e) {
$this->db->rollback();
return $e->getMessage();
}
}
public function delete($vehicle_id, $user_id) {
try {
$stmt = $this->db->prepare("SELECT id FROM vehicles WHERE id = ? AND user_id = ?");
$stmt->bind_param("ii", $vehicle_id, $user_id);
$stmt->execute();
$result = $stmt->get_result();
if ($result->num_rows === 0) {
return "Error: Unauthorized action or vehicle not found.";
}
$stmt = $this->db->prepare("DELETE FROM vehicles WHERE id = ?");
$stmt->bind_param("i", $vehicle_id);
if ($stmt->execute()) {
return true;
} else {
return "Error: " . $stmt->error;
}
} catch (mysqli_sql_exception $e) {
return $e->getMessage();
}
}
}

View File

@ -2,8 +2,8 @@
<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>
<img src="/img/logo.jpg" alt="Fuel Stats Logo">
<h1>Sign in to Fuel Stats</h1>
</div>
<?php if ($this->get('error')): ?>
@ -27,7 +27,7 @@
</form>
<div class="bordered">
<p>New to Habit Tracker?</p>
<p>New to Fuel Stats?</p>
<a href="/auth/signup">Create an account</a>
</div>
</section>

View File

@ -2,8 +2,8 @@
<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>
<img src="/img/logo.jpg" alt="Fuel Stats Logo">
<h1>Sign up to Fuel Stats</h1>
</div>
<?php if ($this->get('error')): ?>

View File

@ -1,47 +1,123 @@
<link rel="stylesheet" href="/css/dashboard.css">
<link rel="stylesheet" href="/css/form.css">
<link rel="stylesheet" href="/css/vehicle_create.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>
<?php if(!isset($data['default_car'])): ?>
<div id="intro">
<a href="/vehicles/create">Create your first vehicle</a>
</div>
<?php elseif (isset($data['latest_record'])): ?>
<div id="actions">
<a href="/refuel/create" class="btn-green">Add new refuel record</a>
<a href="/vehicles" class="btn-primary">List all vehicles</a>
<a class="btn-warning" id="btn-offline-add">Add new offline refuel record</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>
<section class="card latest">
<h2>Latest fuel record</h2>
<hr>
<div>
<b>Car:</b>
<p><?= $data['latest_record']['vehicle_name'] ?></p>
<b>Liters:</b>
<p><?= $data['latest_record']['liters'] ?> liters</p>
<b>Price per liter:</b>
<p><?= $data['latest_record']['price_per_liter'] ?>,-/liter</p>
<b>Total price:</b>
<p><?= $data['latest_record']['total_price'] ?>,-</p>
<b>Mileage:</b>
<p><?= $data['latest_record']['mileage'] ?> km</p>
<?php if (isset($data['latest_record']['note'])): ?>
<b>Note:</b>
<p><?= $data['latest_record']['note'] ?></p>
<?php endif; ?>
</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 class="card">
<h2>Default car</h2>
<hr>
<b>Car</b>
<p><?= $data['default_car']['name'] ?></p>
<b>Registration plate</b>
<p><?= $data['default_car']['registration_plate'] ?></p>
<b>Fuel type</b>
<p><?= $data['default_car']['fuel_type'] ?></p>
<b>Note</b>
<p><?= $data['default_car']['note'] ?></p>
</section>
<section class="card history-graph">
<h2>Graph of History</h2>
<h2>Chart of Gas price</h2>
<hr>
<p><?= $data['default_car']['name'] . " | " . $data['default_car']['registration_plate']?></p>
<canvas id="chart-gas-price"></canvas>
</section>
<section class="card streak">
<h2>Streak</h2>
<p>You're current streak is 123 days</p>
<p>Good job!</p>
<section class="card history-graph">
<h2>Average fuel consumption</h2>
<hr>
<p><?= $data['default_car']['name'] . " | " . $data['default_car']['registration_plate']?></p>
<b id="avg-fl-cnsmp"></b>
</section>
</div>
<?php else: ?>
<div id="actions">
<a href="/refuel/create" class="btn-green">Add new refuel record</a>
<a href="/vehicles" class="btn-primary">List all vehicles</a>
<a class="btn-warning" id="btn-offline-add">Add new offline refuel record</a>
</div>
<div class="alert-warning">
<p>Default vehicle <b><i><?= $data['default_car']['name'] ?></i></b> doesn't have any refuel record yet.</p>
<p>Select another vehicle or create first refuel record.</p>
</div>
<?php endif; ?>
</section>
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
<script>
const ctx = document.getElementById('chart-gas-price');
const data = <?= json_encode($data['date_price_data']); ?>;
new Chart(ctx, {
type: 'line',
data: {
labels: [...data['date']],
datasets: [{
label: 'Gas price history',
data: data['price'],
borderWidth: 1,
}]
},
});
</script>
<script>
const data2 = <?= json_encode($data['date_price_data']); ?>;
let cnt_ltr = 0;
let last_ltr = 0;
let last_km = 0;
let first_km = 0;
for (let i = 0; i < data2['liters'].length; i++) {
if(i === 0) {
first_km = data2['mileage'][i]
continue; // skip bcs were expecting that this was first full tank refuel so this is base value
}
cnt_ltr += data2['liters'][i]
last_ltr = data2['liters'][i]
last_km = data2['mileage'][i]
}
// cnt_ltr -= last_ltr // this would be used if we would track consumption from 0km
last_km -= first_km
document.querySelector("#avg-fl-cnsmp").textContent =
(cnt_ltr/last_km*100).toFixed(1) + " l/100km";
</script>
<script defer src="/js/offline-records.js"></script>

View File

@ -4,7 +4,7 @@
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Habit Tracker | Error 494</title>
<title>Fuel Stats | Error 494</title>
</head>
<body>
<h1>Error 404 - Page not found</h1>

View File

@ -1,70 +0,0 @@
<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

@ -1,24 +0,0 @@
<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,4 +1,4 @@
<div>
<h1>Welcome to Habit Tracker!</h1>
<p>Track your habits and achieve your goals.</p>
<h1>Welcome to Fuel Stats!</h1>
<p>Keep track of your refuels.</p>
</div>

View File

@ -6,7 +6,7 @@
<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>Fuel Stats<?= isset($data['title']) ? " | " . $data['title'] : "" ?></title>
<link rel="stylesheet" href="/css/main.css">
<link rel="stylesheet" href="/css/global.css">
<link rel="stylesheet" href="/css/vars.css">
@ -17,7 +17,7 @@
<header>
<div id="hd-left">
<a href="/"><img src="/img/logo.jpg" alt="home"></a>
<label><?= $data['title'] ?></label>
<label><?= isset($data['title']) ? $data['title'] : "" ?></label>
</div>
<div id="hd-right">
<?php if (!isset($_SESSION['user'])): ?>

View File

@ -6,7 +6,7 @@
<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>Fuel Stats<?= isset($data['title']) ? " | " . $data['title'] : "" ?></title>
<link rel="stylesheet" href="/css/main.css">
<link rel="stylesheet" href="/css/global.css">
<link rel="stylesheet" href="/css/vars.css">

125
app/views/refuel/create.php Normal file
View File

@ -0,0 +1,125 @@
<link rel="stylesheet" href="/css/form.css">
<link rel="stylesheet" href="/css/vehicle_create.css">
<section class="form">
<h1 class="header-form"><?= $this->get('title') ?></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="/refuel/create">
<label for="vehicle">Vehicle</label>
<select name="vehicle" id="vehicle">
<?php foreach ($this->get('vehicles') as $vehicle): ?>
<option value="<?= $vehicle['id'] ?>"><?= $vehicle['name'] . " | " . $vehicle['registration_plate'] ?></option>
<?php endforeach; ?>
</select>
<?php if (isset($this->get('validationErrors')['vehicle'])): ?>
<small class="error"><?= $this->get('validationErrors')['vehicle'] ?></small>
<?php endif; ?>
<label for="fuel_type">Fuel type</label>
<select name="fuel_type" id="fuel_type">
<option value="Diesel">Diesel</option>
<option value="Gasoline 95">Gasoline 95</option>
<option value="Gasoline 98">Gasoline 98</option>
<option value="Premium Diesel">Premium Diesel</option>
<option value="Premium Gasoline 95">Premium Gasoline 95</option>
<option value="Premium Gasoline 98">Premium Gasoline 98</option>
<option value="Other">Other</option>
</select>
<?php if (isset($this->get('validationErrors')['fuel_type'])): ?>
<small class="error"><?= $this->get('validationErrors')['fuel_type'] ?></small>
<?php endif; ?>
<label for="liters">Liters</label>
<input type="number" name="liters" id="liters" min="0" step=".01" value="<?= htmlspecialchars($_POST['liters'] ?? '0.0') ?>">
<?php if (isset($this->get('validationErrors')['liters'])): ?>
<small class="error"><?= $this->get('validationErrors')['liters'] ?></small>
<?php endif; ?>
<label for="price_per_liter">Price per liter</label>
<input type="number" name="price_per_liter" id="price_per_liter" min="0" step=".01" value="<?= htmlspecialchars($_POST['price_per_liter'] ?? '0.0') ?>">
<?php if (isset($this->get('validationErrors')['price_per_liter'])): ?>
<small class="error"><?= $this->get('validationErrors')['price_per_liter'] ?></small>
<?php endif; ?>
<label for="total_price">Total price</label>
<input type="number" name="total_price" id="total_price" min="0" step=".01" value="<?= htmlspecialchars($_POST['total_price'] ?? '0.0') ?>">
<?php if (isset($this->get('validationErrors')['total_price'])): ?>
<small class="error"><?= $this->get('validationErrors')['total_price'] ?></small>
<?php endif; ?>
<label for="mileage">Mileage</label>
<input type="number" name="mileage" id="mileage" min="0" step="1" value="<?= htmlspecialchars($_POST['mileage'] ?? '0') ?>">
<?php if (isset($this->get('validationErrors')['mileage'])): ?>
<small class="error"><?= $this->get('validationErrors')['mileage'] ?></small>
<?php endif; ?>
<label for="note">Note</label>
<input type="text" name="note" id="note" value="<?= htmlspecialchars($_POST['note'] ?? '') ?>">
<?php if (isset($this->get('validationErrors')['note'])): ?>
<small class="error"><?= $this->get('validationErrors')['note'] ?></small>
<?php endif; ?>
<input type="submit" value="Create fuel record">
</form>
</section>
<script>
const inp_lit = document.querySelector("input#liters")
const inp_ppl = document.querySelector("input#price_per_liter")
const inp_tot = document.querySelector("input#total_price")
const rnd = (num) => Math.round((num + Number.EPSILON) * 100) / 100
function calculate(){
let liters = Number(inp_lit.value)
let price_per_liter = Number(inp_ppl.value)
let total_price = Number(inp_tot.value)
if(price_per_liter > 0 && liters > 0) {
total_price = rnd(liters * price_per_liter)
}
if(price_per_liter > 0 && total_price > 0) {
liters = rnd(total_price / price_per_liter)
}
if(liters > 0 && total_price > 0) {
price_per_liter = rnd(total_price / liters)
}
inp_lit.value = liters
inp_ppl.value = price_per_liter
inp_tot.value = total_price
}
[inp_lit, inp_ppl, inp_tot].forEach(inp => {
inp.addEventListener("change", () => {
calculate()
})
})
const vehicles = <?= json_encode($data['vehicles']); ?>;
const fuel_sel = document.querySelector("#fuel_type")
const vehic_sel = document.querySelector("#vehicle")
function selectFuel() {
const veh_id = vehic_sel.value
vehicles.forEach(el => {
if(el.id == veh_id) {
fuel_sel.value = el.fuel_type
return
}
})
}
selectFuel()
vehic_sel.addEventListener("change", () => {
selectFuel()
})
</script>

View File

@ -0,0 +1,35 @@
<link rel="stylesheet" href="/css/form.css">
<link rel="stylesheet" href="/css/vehicle_create.css">
<section class="form">
<h1 class="header-form"><?= $this->get('title', 'Create Vehicle') ?></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="/vehicles/create">
<label for="name">Vehicle name</label>
<input type="text" name="name" id="name" required value="<?= htmlspecialchars($_POST['name'] ?? '') ?>">
<label for="registration_plate">Registration plate</label>
<input type="text" name="registration_plate" id="registration_plate" maxlength="10" onkeypress="return event.charCode != 32" required value="<?= htmlspecialchars($_POST['registration_plate'] ?? '') ?>">
<label for="fuel_type">Fuel type</label>
<select name="fuel_type" id="fuel_type">
<option value="Diesel">Diesel</option>
<option value="Gasoline 95">Gasoline 95</option>
<option value="Gasoline 98">Gasoline 98</option>
<option value="Premium Diesel">Premium Diesel</option>
<option value="Premium Gasoline 95">Premium Gasoline 95</option>
<option value="Premium Gasoline 98">Premium Gasoline 98</option>
<option value="Other">Other</option>
</select>
<label for="note">Note</label>
<input type="text" name="note" id="note" value="<?= htmlspecialchars($_POST['note'] ?? '') ?>">
<input type="submit" value="Create vehicle">
</form>
</section>

View File

@ -0,0 +1,36 @@
<link rel="stylesheet" href="/css/vehicles.css">
<section class="vehicles">
<?php if (empty($this->get('vehicles'))): ?>
<p>No vehicles yet. <a href="/vehicles/create">Add your first vehicle</a>.</p>
<?php else: ?>
<div class="btn-wrapper">
<a href="/vehicles/create" class="btn-green">Add new vehicle</a>
</div>
<div class="vehicle-wrapper">
<?php foreach ($this->get('vehicles') as $vehicle): ?>
<div class="vehicle bordered">
<b><?= htmlspecialchars($vehicle['name']) ?></b>
<p><?= htmlspecialchars($vehicle['registration_plate']) ?></p>
<p><?= htmlspecialchars($vehicle['fuel_type']) ?></p>
<p><?= htmlspecialchars($vehicle['note'] ?? "") ?></p>
<div class="vehicle-actions">
<br>
<form method="POST" action="/vehicles/delete">
<input type="number" name="vehicle_id" value="<?= $vehicle['id'] ?>" style="display: none">
<input type="submit" value="Delete vehicle" class="btn-danger">
</form>
<?php if(!$vehicle['is_default']): ?>
<br>
<form method="POST" action="/vehicles/default">
<input type="number" name="vehicle_id" value="<?= $vehicle['id'] ?>" style="display: none">
<input type="submit" value="Set as default" class="btn-primary">
</form>
<?php endif; ?>
</div>
</div>
<?php endforeach; ?>
</div>
<?php endif; ?>
</section>

View File

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

View File

@ -57,13 +57,12 @@ class Database {
* Check and create required tables if they don't exist
*/
private function checkAndCreateTables() {
// Create users table
// Create tables
$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;";
@ -71,36 +70,57 @@ class Database {
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;";
$vehiclesTableQuery = "
CREATE TABLE IF NOT EXISTS vehicles (
id INT AUTO_INCREMENT PRIMARY KEY,
user_id INT NOT NULL,
name VARCHAR(100) NOT NULL,
registration_plate VARCHAR(50) NOT NULL UNIQUE,
fuel_type ENUM('Diesel', 'Gasoline 95', 'Gasoline 98', 'Premium Diesel', 'Premium Gasoline 95', 'Premium Gasoline 98', 'Other') NOT NULL,
note VARCHAR(150) NULL,
is_default BOOLEAN DEFAULT FALSE NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
) ENGINE=InnoDB;
";
if (!$this->connection->query($progressTableQuery)) {
die("Failed to create progress table: " . $this->connection->error);
if (!$this->connection->query($vehiclesTableQuery)) {
die("Failed to create vehicles table: " . $this->connection->error);
}
// Create habits table
$habitsTableQuery = "CREATE TABLE IF NOT EXISTS habits (
$refuelingTableQuery = "
CREATE TABLE IF NOT EXISTS refueling_records (
id INT AUTO_INCREMENT PRIMARY KEY,
user_id INT NOT NULL,
vehicle_id INT NOT NULL,
fuel_type ENUM('Diesel', 'Gasoline 95', 'Gasoline 98', 'Premium Diesel', 'Premium Gasoline 95', 'Premium Gasoline 98', 'Other') NOT NULL,
note VARCHAR(150) NULL,
liters DOUBLE(10, 2) NOT NULL,
price_per_liter DOUBLE(10, 2) NOT NULL,
total_price DOUBLE(10, 2) NOT NULL,
mileage INT UNSIGNED NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (vehicle_id) REFERENCES vehicles(id) ON DELETE CASCADE,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
) ENGINE=InnoDB;
";
if (!$this->connection->query($refuelingTableQuery)) {
die("Failed to create refueling_records table: " . $this->connection->error);
}
/*
// Create table_name table
$usersTableQuery = "CREATE TABLE IF NOT EXISTS table_name (
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
username VARCHAR(50) NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
) ENGINE=InnoDB;";
if (!$this->connection->query($habitsTableQuery)) {
die("Failed to create habits table: " . $this->connection->error);
if (!$this->connection->query($usersTableQuery)) {
die("Failed to create table_name table: " . $this->connection->error);
}
*/
}
/**

View File

@ -12,6 +12,15 @@ class Validator {
}
}
/**
* Check if a field contains numbers
*/
public function number($field, $value, $message = null) {
if(!is_numeric($value)) {
$this->errors[$field] = $message ?? "$field must be an number";
}
}
/**
* Check if a field meets minimum length
*/
@ -46,6 +55,10 @@ class Validator {
return $this->errors;
}
public function setErrors($errors) {
$this->errors = $errors;
}
/**
* Check if validation passed
*/

View File

@ -7,6 +7,11 @@ class View
// Store the data
$this->data = $data;
// check for status code in data
if(isset($this->data['status'])){
http_response_code($this->data['status']);
}
// Capture the view content
ob_start();
require_once views . $view . '.php';

View File

@ -6,6 +6,10 @@ services:
MARIADB_ROOT_PASSWORD: root
ports:
- 3306:3306
volumes:
- fuelstats_mariadb_data:/var/lib/mysql
networks:
- fuelstats-network
profiles: ["prod", "dev"]
phpmyadmin:
@ -15,9 +19,14 @@ services:
- 8080:80
environment:
- PMA_ARBITRARY=1
- PMA_HOST=mariadb
depends_on:
- mariadb
networks:
- fuelstats-network
profiles: ["dev"]
habittracker:
fuelstats:
build:
context: .
dockerfile: Dockerfile
@ -28,4 +37,13 @@ services:
depends_on:
- mariadb
restart: on-failure:2
networks:
- fuelstats-network
profiles: ["prod"]
networks:
fuelstats-network:
driver: bridge
volumes:
fuelstats_mariadb_data:

View File

@ -9,13 +9,30 @@
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;
width: 18rem;
padding: 1rem;
}
#btn-offline-add {
display: none;
}
.alert-warning {
background-color: var(--clr-warning-muted);
border: var(--borderWidth-thin) solid var(--clr-border-danger);
padding: 1rem;
border-radius: var(--border-radious);
}
.alert-danger {
background-color: var(--clr-danger-muted);
border: var(--borderWidth-thin) solid var(--clr-border-danger);
padding: 1rem;
border-radius: var(--border-radious);
}

View File

@ -20,13 +20,15 @@ h1 {
.btn-secondary,
.btn-tertiary,
.btn-green,
.btn-danger {
.btn-danger,
.btn-warning {
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);
color: white;
}
.btn-secondary {
@ -45,3 +47,13 @@ h1 {
background-color: var(--clr-danger-muted);
border: var(--borderWidth-thin) solid var(--clr-border-danger);
}
.btn-warning {
background-color: var(--clr-warning-muted);
border: var(--borderWidth-thin) solid var(--clr-border-danger);
}
#actions {
padding-top: 1rem;
padding-bottom: 2rem;
}

View File

@ -1,21 +0,0 @@
.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

@ -1,15 +0,0 @@
.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;
}

View File

@ -5,6 +5,7 @@
--clr-tertiary: #151b23;
--clr-green: #238636;
--clr-danger-muted: #f851491a;
--clr-warning-muted: #e08e455e;
--clr-link-blue: #4493f8;
--clr-light-blue: #39a2ae;

View File

@ -0,0 +1,3 @@
#registration_plate {
text-transform: uppercase;
}

36
public/css/vehicles.css Normal file
View File

@ -0,0 +1,36 @@
.vehicle-wrapper {
align-items: center;
display: flex;
flex-wrap: wrap;
gap: 1rem;
justify-content: center;
}
.vehicle {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
background-color: var(--clr-secondary);
border-radius: var(--border-radious);
border: var(--borderWidth-thin) solid var(--clr-border);
width: 17rem;
padding: 1rem;
margin-top: 1rem;
text-align: center;
min-height: 8rem;
}
.btn-wrapper {
display: flex;
justify-content: center;
align-items: center;
margin-bottom: 1rem;
}
.vehicle-actions {
display: flex;
flex-direction: row;
gap: .5rem;
margin-top: 1rem;
}

View File

@ -20,7 +20,8 @@ require_once '../core/Database.php';
require_once '../core/middlewares/RequireAuth.php';
require_once models . 'User.php';
require_once models . 'Habit.php';
require_once models . 'Vehicle.php';
require_once models . 'Refuel.php';
// Initialize router
$router = new Router();
@ -40,12 +41,22 @@ $router->group('/auth', [], function ($router) {
// 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');
// vehicle routes
$router->group('/vehicles', ['RequireAuth'], function ($router) {
$router->add('', 'VehicleController@index');
$router->add('/create', 'VehicleController@create');
$router->add('/edit/{id}', 'VehicleController@edit');
$router->add('/delete', 'VehicleController@delete');
$router->add('/default', 'VehicleController@setDefault');
});
$router->group('/refuel', ['RequireAuth'], function ($router) {
$router->add('/create', 'RefuelController@create');
});
// API
$router->group('/api/v1', ['RequireAuth'], function ($router) {
$router->add('/vehicles/get', 'VehicleController@api_get');
});
$router->dispatch();

View File

@ -0,0 +1,273 @@
async function checkOnline() {
if (!navigator.onLine) {
console.log("Offline (no network connection)");
return false;
}
try {
const response = await fetch(
"https://www.google.com/favicon.ico?" + new Date().getTime(),
{
mode: "no-cors",
},
);
return true;
} catch (error) {
console.log("Connected to network but no internet access");
return false;
}
}
function showDashboard() {
const offline = document.querySelector(".offline");
offline.remove();
document.querySelector(".dashboard").style.display = "flex";
}
const btnOffline = document.querySelector("#btn-offline-add");
const divActions = document.querySelector("#actions");
let visible = true;
setInterval(async () => {
const isOnline = await checkOnline();
//const isOnline = false; // REMOVE!!!
if (!isOnline) {
if (visible) {
console.log("OFFLINE!!!");
Array.from(divActions.children).forEach(
(el) => (el.style.display = "none"),
);
visible = false;
btnOffline.style.display = "block";
document.querySelector(".hd-left").addEventListener("click", () => {
showDashboard();
});
}
}
if (localStorage.getItem("refuelOfflineData")) {
Array.from(divActions.children).forEach(
(el) => (el.style.display = "none"),
);
btnOffline.style.display = "block";
btnOffline.textContent = "Sync data";
btnOffline.setAttribute("disabled", "disabled");
}
if (isOnline && !visible) {
console.log("BACK ONLINE!!!");
visible = true;
btnOffline.removeAttribute("disabled", "disabled");
// TODO: show buttons back, add sync button instead of record creation
// If user is in a process of adding new offline refuel record, let him finish
// Clear the local storage on each login
}
//}, 5000);
}, 1000);
window.onload = async () => {
const rawData = await fetch("/api/v1/vehicles/get", {
method: "GET",
credentials: "include",
});
const data = await rawData.json();
console.log("Fetched:", data);
localStorage.setItem("vehicles", JSON.stringify(data));
console.log(JSON.parse(localStorage.getItem("vehicles")));
};
btnOffline.addEventListener("click", async (e) => {
e.preventDefault();
if (btnOffline.textContent == "Sync data") {
if (!visible) {
alert("You're still offline. Try again later");
return;
}
try {
let data = localStorage.getItem("refuelOfflineData");
if (!data) {
console.error("No offline data found");
alert("No offline data found");
return;
}
data = JSON.parse(data);
const formData = new FormData();
for (const key in data) {
if (data.hasOwnProperty(key)) {
formData.append(key, data[key]);
}
}
const res = await fetch("/refuel/create", {
method: "POST",
body: formData,
credentials: "include",
});
if (!res.ok) {
if (res.status == 400) {
const html = await res.text();
document.open();
document.write(html);
document.close();
localStorage.removeItem("refuelOfflineData");
return;
} else {
throw new Error(`Server error: ${res.statusText}`);
}
}
localStorage.removeItem("refuelOfflineData");
location.reload();
} catch (err) {
console.error(err);
alert("Something went wrong");
location.reload();
}
return;
}
document.querySelector("section.dashboard").style.display = "none";
try {
vehicles = localStorage.getItem("vehicles");
if (vehicles === null) throw new Error("No data was saved locally");
vehicles = JSON.parse(vehicles);
} catch (err) {
console.error(err);
const offline = document.createElement("div");
offline.classList.add("offline");
offline.innerHTML = `
<div class="alert-danger">
<b>You're Offline</b>
<p>No data was saved locally, please try again later</p>
</div>
`;
document.querySelector("main").appendChild(offline);
// TODO: Add button to navigate back to offline dashboard
return;
}
const offline = document.createElement("div");
offline.classList.add("offline");
offline.innerHTML = `
<div class="alert-warning">
<b>You're Offline</b>
<p>You can create an fuel record locally on your device and sync it later</p>
</div>
<section class="form">
<h1 class="header-form">Create offline record</h1>
<form id="offline_refuel_add">
<label for="vehicle">Vehicle</label>
<select name="vehicle" id="vehicle">
${vehicles
.map(
(el) =>
`<option value="${el.id}">${el.name} | ${el.registration_plate}</option>`,
)
.join("")}
</select>
<label for="fuel_type">Fuel type</label>
<select name="fuel_type" id="fuel_type">
<option value="Diesel">Diesel</option>
<option value="Gasoline 95">Gasoline 95</option>
<option value="Gasoline 98">Gasoline 98</option>
<option value="Premium Diesel">Premium Diesel</option>
<option value="Premium Gasoline 95">Premium Gasoline 95</option>
<option value="Premium Gasoline 98">Premium Gasoline 98</option>
<option value="Other">Other</option>
</select>
<label for="liters">Liters</label>
<input type="number" name="liters" id="liters" min="0" step=".01" value="0.0">
<!-- <small class="error"><?= $this->get('validationErrors')['liters'] ?></small> -->
<label for="price_per_liter">Price per liter</label>
<input type="number" name="price_per_liter" id="price_per_liter" min="0" step=".01" value="0.0">
<!-- <small class="error"><?= $this->get('validationErrors')['price_per_liter'] ?></small> -->
<label for="total_price">Total price</label>
<input type="number" name="total_price" id="total_price" min="0" step=".01" value="0.0">
<!-- <small class="error"><?= $this->get('validationErrors')['total_price'] ?></small> -->
<label for="mileage">Mileage</label>
<input type="number" name="mileage" id="mileage" min="0" step="1" value="0">
<!-- <small class="error"><?= $this->get('validationErrors')['mileage'] ?></small> -->
<label for="note">Note</label>
<input type="text" name="note" id="note">
<!-- <small class="error"><?= $this->get('validationErrors')['note'] ?></small> -->
<input type="submit" id="btn-offline-submit" value="Create fuel record">
</form>
</section>
`;
document.querySelector("main").appendChild(offline);
const inp_lit = document.querySelector("input#liters");
const inp_ppl = document.querySelector("input#price_per_liter");
const inp_tot = document.querySelector("input#total_price");
const rnd = (num) => Math.round((num + Number.EPSILON) * 100) / 100;
function calculate() {
let liters = Number(inp_lit.value);
let price_per_liter = Number(inp_ppl.value);
let total_price = Number(inp_tot.value);
if (price_per_liter > 0 && liters > 0) {
total_price = rnd(liters * price_per_liter);
}
if (price_per_liter > 0 && total_price > 0) {
liters = rnd(total_price / price_per_liter);
}
if (liters > 0 && total_price > 0) {
price_per_liter = rnd(total_price / liters);
}
inp_lit.value = liters;
inp_ppl.value = price_per_liter;
inp_tot.value = total_price;
}
[inp_lit, inp_ppl, inp_tot].forEach((inp) => {
inp.addEventListener("change", () => {
calculate();
});
});
const btnSubmit = document.querySelector("#btn-offline-submit");
btnSubmit.addEventListener("click", (e) => {
e.preventDefault();
const formData = {
vehicle: document.querySelector("form#offline_refuel_add #vehicle").value,
fuel_type: document.querySelector("form#offline_refuel_add #fuel_type")
.value,
liters: document.querySelector("form#offline_refuel_add #liters").value,
price_per_liter: document.querySelector(
"form#offline_refuel_add #price_per_liter",
).value,
total_price: document.querySelector(
"form#offline_refuel_add #total_price",
).value,
mileage: document.querySelector("form#offline_refuel_add #mileage").value,
note: document.querySelector("form#offline_refuel_add #note").value,
};
console.log("formData", formData);
localStorage.setItem("refuelOfflineData", JSON.stringify(formData));
alert("Data was locally saved. Sync it later!");
showDashboard();
});
});