Adding new tables to paginate results with many rows

This commit is contained in:
Alfonso Saavedra "Son Link" 2024-12-30 19:45:28 +01:00
parent 01de175fe9
commit a14398f906
No known key found for this signature in database
GPG key ID: D3594BCF897F74D8
11 changed files with 495 additions and 78 deletions

View file

@ -29,4 +29,13 @@ $routes->group('register', static function ($routes) {
$routes->post('newuser', 'Register::newuser');
$routes->get('new_captcha', 'Register::newCaptcha');
$routes->get('ok', 'Register::ok');
});
$routes->group('api', static function ($routes) {
$routes->get('bests_laps', 'Api::getBestsLaps');
$routes->get('most_active_users', 'Api::getMostActiveUsers');
});
$routes->group('test', static function ($routes) {
$routes->get('tables', 'Test::tables');
});

63
app/Controllers/Api.php Normal file
View file

@ -0,0 +1,63 @@
<?php
namespace App\Controllers;
use App\Controllers\BaseController;
use App\Models\BestLapsModel;
use App\Models\CarCatsModel;
use CodeIgniter\API\ResponseTrait;
class Api extends BaseController
{
use ResponseTrait;
public function getBestsLaps()
{
$bestLapsModel = new BestLapsModel;
$period = $this->request->getGet('period');
$carCatId = $this->request->getGet('car_cat');
if (!$period || !$carCatId) return $this->fail('The period and/or category were not indicated');
$page = $this->request->getGet('page');
$limit = $this->request->getGet('limit');
if (!$page | !is_numeric($page)) $page = 0;
if (!$limit | !is_numeric($limit)) $limit = 0;
[$list, $total] = $bestLapsModel->getBests($period, $carCatId, $page, $limit);
return $this->respond(['data' => $list, 'total' => $total]);
}
public function getMostActiveUsers()
{
$period = $this->request->getGet('period');
$carCatId = $this->request->getGet('car_cat');
if (!$period || !$carCatId) return $this->fail('The period and/or category were not indicated');
$backto = getDateDiff($period);
$carCatsModel = new CarCatsModel;
$carsCatIds = $carCatsModel->getCarsInCat($carCatId);
$builder = $this->db->table('races r');
$builder->select('COUNT(*) AS count, u.username');
$builder->join('users u', 'u.id = r.user_id');
$builder->where('UNIX_TIMESTAMP(r.timestamp) >', $backto);
$builder->whereIn('r.car_id', $carsCatIds);
$builder->groupBy('u.username');
$builder->orderBy('count DESC');
$query = $builder->get(20);
$list = [];
$total = 0;
if ($query && $query->getNumRows() > 0) {
$list = $query->getResult();
$total = $query->getNumRows();
}
return $this->respond(['data' => $list, 'total' => $total]);
}
}

View file

@ -84,13 +84,14 @@ class Home extends BaseController
$categoriesList = $carCatModel->select('id, name, count(carId) as totalCars')->groupBy('id')->findAll();
$currCat = $carCatModel->find($carCatId);
$carsCatList = $carCatModel->select('carId')->where('id', $carCatId)->findAll();
//$carsCatList = $carCatModel->select('carId')->where('id', $carCatId)->findAll();
$carsCatIds = $carCatModel->getCarsInCat($carCatId);
$tplData['currCat'] = $currCat;
$tplData['carCategoriesList'] = $categoriesList;
$carsCatIds = [];
foreach ($carsCatList as $car) $carsCatIds[] = $car->carId;
//$carsCatIds = [];
//foreach ($carsCatList as $car) $carsCatIds[] = $car->carId;
//UGLY: there is some category that have no car assigned so create a fake $carsql for them
//to prevent errors in the generated queries
@ -108,6 +109,7 @@ class Home extends BaseController
################################
*/
/*
$builder = $this->db->table('races r');
$builder->select('r.user_id, COUNT(*) AS count, u.username');
$builder->join('users u', 'u.id = r.user_id');
@ -119,6 +121,7 @@ class Home extends BaseController
$tplData['users'] = [];
$query = $builder->get();
if ($query && $query->getNumRows() > 0) $tplData['users'] = $query->getResult();
*/
/*
################################
@ -126,8 +129,7 @@ class Home extends BaseController
## WITH A CAR OFT HIS CATEGORY
################################
*/
$tplData['mylaps'] = $bestLapsModel->getBests($period, $carCatId, 0, 0);
//list($tplData['mylaps'], $_) = $bestLapsModel->getBests($period, $carCatId, 0, 0);
$tplData['tracks'] = [];
$builder = $this->db->table('races');
@ -173,9 +175,9 @@ class Home extends BaseController
$query = $builder->get();
if ($query && $query->getNumRows() > 0) $tplData['cars'] = $query->getResult();
echo get_header('Home');
echo get_header('Home', ['minidt.css']);
echo view('main', $tplData);
echo get_footer();
echo get_footer(['minidt.js', 'home_tables.js']);
}
public function error404()

View file

@ -9,7 +9,7 @@ class BestLapsModel extends BaseModel
public function getBests(string $period, string $carCat, int $page=0, int $limit=20)
{
$from = $page * $limit;
$offset = $page * $limit;
$list = [];
switch ($period)
@ -36,7 +36,19 @@ class BestLapsModel extends BaseModel
break;
}
$total = 0;
$builder = $this->builder();
$builder->select('COUNT(*) AS total');
$builder->join('laps l', 'l.id = bl.lap_id');
$builder->join('races r', 'r.id = bl.race_id');
$builder->where('UNIX_TIMESTAMP(r.timestamp) >', $backto);
$builder->where('bl.car_cat', $carCat);
$builder->groupBy(['r.track_id', 'l.wettness']);
$query = $builder->get();
if ($query) $total = $query->getNumRows();
$builder->resetQuery();
$builder->join('laps l', 'l.id = bl.lap_id');
$builder->select('l.race_id, r.track_id, r.car_id, r.user_id, r.timestamp, l.wettness, bl.laptime AS bestlap, c.name AS car_name, t.name AS track_name, u.username');
$builder->join('races r', 'r.id = bl.race_id');
@ -46,11 +58,11 @@ class BestLapsModel extends BaseModel
$builder->where('UNIX_TIMESTAMP(r.timestamp) >', $backto);
$builder->where('bl.car_cat', $carCat);
$builder->groupBy(['r.track_id', 'l.wettness']);
if ($limit > 0) $builder->limit($from, $limit);
if ($limit > 0) $builder->limit($limit, $offset);
$query = $builder->get();
if ($query && $query->getNumRows() > 0) $list = $query->getResult();
return $list;
return [$list, $total];
}
}

View file

@ -6,4 +6,19 @@ class CarCatsModel extends BaseModel
{
protected $table = 'cars_cats';
protected $allowedFields = ['id', 'name', 'carId'];
/**
* Return all cars in the indicated category
* @param mixed $carCatId The ID od the category
* @return array A array with all cars ID in teh category
*/
public function getCarsInCat($carCatId)
{
$carsCatList = $this->select('carId')->where('id', $carCatId)->findAll();
$carsCatIds = [];
foreach ($carsCatList as $car) $carsCatIds[] = $car->carId;
return $carsCatIds;
}
}

View file

@ -57,77 +57,17 @@
Most active users<br />
<small><?= $periodString; ?></small>
</h3>
<table class="fullPage responsive cat-table">
<thead>
<tr>
<th>Pilot</th>
<th>Races</th>
</tr>
</thead>
<tbody>
<?php
foreach ($users as $user):
?>
<tr>
<td data-title="Pilot">
<?= clickableName($user->username, 'user', $user->username) ?>
</td>
<td data-title="Races">
<?= $user->count ?>
</td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
<div class="table-container">
<table id="most_active_users" class="responsive cat-table"></table>
</div>
<h3>
Bests lap for each track<br />
<small><?=$periodString; ?></small>
</h3>
<table class="fullPage responsive cat-table">
<thead>
<tr>
<th>Track</th>
<th>Pilot</th>
<th>Car</th>
<th>Laptime</th>
<th>Weather</th>
<th>Date</th>
<th>Session</th>
</tr>
</thead>
<tbody>
<?php
foreach ($mylaps as $mylap):
$track = $mylap->track_id;
$car = $mylap->car_id;
?>
<tr>
<td data-title="Track">
<?= clickableName($mylap->track_id, 'track', $mylap->track_name) ?>
</td>
<td data-title="Pilot">
<?= clickableName($mylap->username, 'user', $mylap->username) ?>
</td>
<td data-title="Car">
<?= clickableName($mylap->car_id, 'car', $mylap->car_name) ?>
</td>
<td data-title="Laptime">
<?= formatLaptime($mylap->bestlap); ?>
</td>
<td data-title="Weather">
<?= weatherTag($mylap->wettness); ?>
</td>
<td data-title="Date">
<?= $mylap->timestamp; ?>
</td>
<td data-title="Session">
<a href="<?= base_url() ?>race/<?= $mylap->race_id ?>">#<?=$mylap->race_id?></a>
</td>
</tr>
<?php endforeach ?>
</tbody>
</table>
</h3>
<div class="table-container">
<table id="best_laps" class="responsive"></table>
</div>
<h3>
Most used Tracks<br />

View file

@ -8,6 +8,11 @@
<title><?=$title?></title>
<link rel="stylesheet" type="text/css" href="<?= base_url() ?>/css/style.css" />
<link rel="stylesheet" type="text/css" href="<?= base_url() ?>/font/weather-icons.css" />
<?php if(!empty($custom_css)): ?>
<?php foreach ($custom_css as $css): ?>
<link rel="stylesheet" type="text/css" href="<?=base_url()."/css/$css"?>"></script>
<?php endforeach ?>
<?php endif ?>
</head>
<body>
<nav class="mainMenu">

89
public/css/minidt.css Normal file
View file

@ -0,0 +1,89 @@
.table-container {
margin: 1rem auto;
max-height: 70vh;
position: relative;
overflow-y: auto;
}
.mini-dt thead th,
.mini-dt tfoot th {
position: sticky;
background-color: #fff;
}
.mini-dt thead th {
top: 0;
}
.mini-dt th,
.mini-dt td {
padding: .5rem;
}
.mini-dt tfoot th {
bottom: 0;
}
.mini-dt thead,
.mini-dt tbody,
.mini-dt tfoot {
box-sizing: border-box;
}
.mini-dt tfoot.hide {
display: none;
}
.mini-dt th.text-center,
.mini-dt td.text-center {
text-align: center;
}
.mini-dt th.text-right,
.mini-dt td.text-right {
text-align: right;
}
.pagination {
display: flex;
align-items: center;
justify-content: end;
width: 100%;
gap: .5rem;
color: #666;
padding: 1rem;
box-sizing: border-box;
}
.pagination button {
background: transparent;
border: 1px solid #666;
border-radius: 3px;
width: 24px;
height: 24px;
}
.pagination button::after {
display: block;
width: 100%;
height: 100%;
content: "";
mask-repeat: no-repeat;
mask-position: center;
background-color: #666;
mask-size: 100%;
}
.pagination .prev-btn::after {
mask-image: url("data:image/svg+xml;base64,PHN2ZyB3aWR0aD0nMjQnIGhlaWdodD0nMjQnIHZpZXdCb3g9JzAgMCAyNCAyNCcgZmlsbD0nbm9uZScgeG1sbnM9J2h0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnJwo+PHBhdGggZD0nTTE2LjI0MjYgNi4zNDMxN0wxNC44Mjg0IDQuOTI4OTZMNy43NTczOSAxMkwxNC44Mjg1IDE5LjA3MTFMMTYuMjQyNyAxNy42NTY5TDEwLjU4NTggMTJMMTYuMjQyNiA2LjM0MzE3WicgZmlsbD0nY3VycmVudENvbG9yJyAvPjwvc3ZnPg==");
}
.pagination .next-btn::after {
mask-image: url("data:image/svg+xml;base64,PHN2ZyB3aWR0aD0nMjQnIGhlaWdodD0nMjQnIHZpZXdCb3g9JzAgMCAyNCAyNCcgZmlsbD0nbm9uZScgeG1sbnM9J2h0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnJwo+PHBhdGggZD0nTTEwLjU4NTggNi4zNDMxN0wxMiA0LjkyODk2TDE5LjA3MTEgMTJMMTIgMTkuMDcxMUwxMC41ODU4IDE3LjY1NjlMMTYuMjQyNyAxMkwxMC41ODU4IDYuMzQzMTdaJyBmaWxsPSdjdXJyZW50Q29sb3InIC8+PC9zdmc+");
}
.page-selector {
border: 0;
color: #666;
margin: 0;
}

View file

@ -2,4 +2,37 @@ $('#menu-select').on('change', function() {
location.href = this.value;
});
if (typeof period !== 'undefined') $(`a#${period}`).addClass('selected')
if (typeof period !== 'undefined') $(`a#${period}`).addClass('selected')
function formatLaptime(time) {
const date = new Date(time * 1000);
const minutes = String(date.getMinutes()).padStart(2, '0')
const seconds = String(date.getSeconds()).padStart(2, '0')
const miliseconds = date.getMilliseconds()
return `${minutes}:${seconds}:${miliseconds}`
}
function weatherTag(weather)
{
switch(weather)
{
case 0:
return '<i class="wi wi-day-sunny"></i>';
case 1:
return '<i class="wi wi-rain"></i>';
case 2:
return '<i class="wi wi-rain"></i>';
case 3:
return '<i class="wi wi-rain"></i>';
default:
return ''
}
}
function linkTag(id, type , content) {
const url = `${base_url}/${type}/${id}`
if (type == 'race') content = `#${content}`
return `<a href="${url}">${content}</a>`;
}

83
public/js/home_tables.js Normal file
View file

@ -0,0 +1,83 @@
const urlParams = new URL(window.location).searchParams
const car_cat = (!!urlParams.get('cat')) ? urlParams.get('cat') : 'TRB1'
const period = (!!urlParams.get('period')) ? urlParams.get('period') : 'today'
const dt_active_users = new MiniDT({
target: 'most_active_users',
url: `${base_url}/api/most_active_users`,
cols: [
{
title: 'Pilot',
col: 'username',
render: (row) => {
return linkTag(row.username, 'user', row.username)
}
},
{
title: 'Races',
col: 'count',
},
],
params: {
period: period,
car_cat: car_cat
}
})
const dt_bests_laps = new MiniDT({
target: 'best_laps',
url: `${base_url}/api/bests_laps`,
cols: [
{
title: 'Track',
col: 'track_name',
render: (row) => {
return linkTag(row.track_id, 'track', row.track_name)
}
},
{
title: 'Pilot',
col: 'username',
render: (row) => {
return linkTag(row.username, 'user', row.username)
}
},
{
title: 'Car',
col: 'car_name',
render: (row) => {
return linkTag(row.car_id, 'car', row.car_name)
}
},
{
title: 'Laptime',
col: 'bestlap',
render: (row) => {
return formatLaptime(row.bestlap)
}
},
{
title: 'Weather',
col: 'wettness',
align: 'center',
render: (row) => {
return weatherTag(parseInt(row.wettness))
}
},
{
title: 'Date',
col: 'timestamp'
},
{
title: 'Session',
col: 'race_id',
render: (row) => {
return linkTag(row.race_id, 'race', row.race_id)
}
},
],
params: {
period: period,
car_cat: car_cat
}
})

166
public/js/minidt.js Normal file
View file

@ -0,0 +1,166 @@
class MiniDT {
constructor(config) {
if (!config.url || !config.target || !config.cols) return false;
this.rows = []
this.page = 0
this.total = 0
this.total_pages = 0
this.limit = (!!config.limit) ? config.limit : 20
this.params = (!!config.params) ? config.params : {}
this.url = config.url
this.cols = {}
this.target = document.getElementById(config.target)
if (!this.target.classList.contains('mini-dt')) this.target.classList.add('mini-dt')
// Create table header, body and footer
this.thead = this.target.createTHead()
this.tbody = this.target.createTBody()
this.tfoot = this.target.createTFoot()
this.tfoot.classList.add('hide')
// Draw header columns
const head_row = this.thead.insertRow()
config.cols.forEach( col => {
const th = document.createElement('th')
th.innerText = col.title
if (!!col.align && ['center', 'right'].includes(col.align)) th.classList.add(`text-${col.align}`)
// Las columnas las almacenamos de este modo para ahorrar tener que recorrer el array cada vez
this.cols[col.col] = {
title: col.title,
render: (!!col.render && typeof col.render === 'function') ? col.render : null,
align: (!!col.align && ['center', 'right'].includes(col.align)) ? col.align: null
}
// Añadimos la columna a la cabecera
head_row.appendChild(th)
});
// Y añadimos la fila a la cabecera
this.thead.appendChild(head_row)
// Draw footer
const row_footer = this.tfoot.insertRow()
const th = document.createElement('th')
th.setAttribute('colspan', Object.keys(this.cols).length)
// Pagination container
this.pagination_container = document.createElement('div')
this.pagination_container.classList.add('pagination')
// Pagination text
this.pageText = document.createTextNode(`Page 1 of 1`)
this.pagination_container.appendChild(this.pageText)
// Previous page button
this.prev_btn = document.createElement('button')
//this.prev_btn.innerText = '<'
this.prev_btn.classList.add('prev-btn')
this.prev_btn.addEventListener('click', this.previous)
this.pagination_container.appendChild(this.prev_btn)
// Page selector
this.pageSelector = document.createElement('select')
this.pageSelector.classList.add('page-selector')
this.pageSelector.addEventListener('change', this.changePage)
this.pagination_container.appendChild(this.pageSelector)
// Next page button
this.next_btn = document.createElement('button')
//this.next_btn.innerText = '>'
this.next_btn.classList.add('next-btn')
this.next_btn.addEventListener('click', this.next)
this.pagination_container.appendChild(this.next_btn)
th.appendChild(this.pagination_container)
row_footer.appendChild(th);
this.getData()
}
getData = async () => {
this.rows = []
this.params.limit = this.limit
this.params.page = this.page
try {
const resp = await fetch(this.url + '?' + new URLSearchParams(this.params).toString())
const data = await resp.json()
if (!!data.data) this.rows = data.data
this.total = (!!data.total) ? data.total : 0
if (this.total > 0 && this.total > this.limit) this.total_pages = Math.ceil(this.total / this.limit)
else this.total_pages = 1
this.pageText.textContent = `Page ${this.page + 1} of ${this.total_pages}`
} catch (err) {
console.error(err);
}
this.render()
}
/**
* Rendering the component
*/
render = () => {
this.tbody.innerHTML = ''
if (this.rows.length == 0) {
const row = this.tbody.insertRow()
const td = document.createElement('td')
td.setAttribute('colspan', Object.keys(this.cols).length)
td.innerHTML = '<strong>No data</strong>'
row.appendChild(td)
this.tfoot.classList.add('hide')
} else {
this.rows.forEach( row => {
const tr = this.tbody.insertRow()
Object.keys(this.cols).forEach( key => {
if (key in row) {
const cell = tr.insertCell()
cell.setAttribute('data-title', this.cols[key].title)
if (!!this.cols[key].align) cell.classList.add(`text-${this.cols[key].align}`)
cell.innerHTML = (!!this.cols[key].render)
? this.cols[key].render(row)
: cell.innerText = row[key]
}
})
})
this.tfoot.classList.remove('hide')
}
this.pageSelector.removeEventListener('change', this.changePage)
this.pageSelector.innerHTML = ''
for (let i=0; i < this.total_pages; i++) {
this.pageSelector.append(new Option(i + 1, i, i == this.page))
}
this.pageSelector.value = this.page
this.pageSelector.addEventListener('change', this.changePage)
}
changePage = (value) => {
this.page = (value instanceof Event) ? parseInt(value.target.value) : parseInt(value)
this.pageText.textContent = `Page ${this.page + 1} of ${this.total_pages}`
this.getData()
}
previous = () => {
if (this.page > 0) this.page--
this.pageText.textContent = `Page ${this.page + 1} of ${this.total_pages}`
this.getData()
}
next = () => {
if (this.page < this.total_pages - 1) this.page++
this.pageText.textContent = `Page ${this.page + 1} of ${this.total_pages}`
this.getData()
}
}