feature(active users graph)

- users active is recorded when users logges in
- add active broker and active customer multi axis line chart
- add filter option of 30 days and 7 days
This commit is contained in:
kusowl 2026-02-02 16:25:12 +05:30
parent aa8ad6b84b
commit 1edfd7b9d4
23 changed files with 2792 additions and 4 deletions

2371
.phpstorm.meta.php Normal file

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,14 @@
<?php
namespace App\Actions;
use App\Models\PageVisit;
use App\Models\User;
final readonly class RecordUserPageVisitAction
{
public function execute(?User $user, string $page): void
{
PageVisit::create(['user_id' => $user?->id, 'page' => $page, 'user_type' => $user?->role ?? null, 'created_at' => now()]);
}
}

View File

@ -2,6 +2,7 @@
namespace App\Http\Controllers\Auth; namespace App\Http\Controllers\Auth;
use App\Actions\RecordUserPageVisitAction;
use App\Enums\UserStatus; use App\Enums\UserStatus;
use App\Enums\UserTypes; use App\Enums\UserTypes;
use App\Http\Controllers\Controller; use App\Http\Controllers\Controller;
@ -16,7 +17,7 @@ public function create()
return view('auth.login'); return view('auth.login');
} }
public function store(AuthenticateUserRequest $request) public function store(AuthenticateUserRequest $request, RecordUserPageVisitAction $action)
{ {
$data = $request->validated(); $data = $request->validated();
if (Auth::attempt($data, $data['remember_me'] ?? false)) { if (Auth::attempt($data, $data['remember_me'] ?? false)) {
@ -35,6 +36,12 @@ public function store(AuthenticateUserRequest $request)
UserTypes::Broker->value, UserTypes::User->value => 'explore', UserTypes::Broker->value, UserTypes::User->value => 'explore',
}; };
try {
$action->execute($user, $route);
} catch (\Throwable $e) {
\Log::error('Error recording user page visit', [$e->getMessage()]);
}
return to_route($route); return to_route($route);
} else { } else {
return back() return back()

View File

@ -0,0 +1,25 @@
<?php
namespace App\Http\Controllers;
use App\Enums\UserTypes;
use App\Http\Resources\ActiveUsersStatsCollection;
use App\Queries\PageVisitStatsQuery;
use Illuminate\Http\Request;
class StatsController extends Controller
{
public function getActiveUsers(Request $request, PageVisitStatsQuery $baseQuery)
{
$startDay = $request->from ?? now()->subDays(30);
$endDay = $request->to ?? now();
$activeCustomers = $baseQuery->builder(UserTypes::User, $startDay, $endDay)->get();
$activeBrokers = $baseQuery->builder(UserTypes::Broker, $startDay, $endDay)->get();
return response()->json([
'activeCustomers' => new ActiveUsersStatsCollection($activeCustomers),
'activeBrokers' => new ActiveUsersStatsCollection($activeBrokers),
]);
}
}

View File

@ -0,0 +1,16 @@
<?php
namespace App\Http\Resources;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\ResourceCollection;
class ActiveUsersStatsCollection extends ResourceCollection
{
public function toArray(Request $request): array
{
return [
'data' => $this->collection,
];
}
}

View File

@ -0,0 +1,20 @@
<?php
namespace App\Http\Resources;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
class ActiveUsersStatsResource extends JsonResource
{
/**
* @return array{data: string, userCount: int}
*/
public function toArray(Request $request): array
{
return [
'date' => $this->date,
'userCount' => (int) $this->user_count,
];
}
}

32
app/Models/PageVisit.php Normal file
View File

@ -0,0 +1,32 @@
<?php
namespace App\Models;
use App\Enums\UserTypes;
use Illuminate\Database\Eloquent\Model;
/**
* @property UserTypes $user_type
*
* @method static \Illuminate\Database\Eloquent\Builder<static>|PageVisit newModelQuery()
* @method static \Illuminate\Database\Eloquent\Builder<static>|PageVisit newQuery()
* @method static \Illuminate\Database\Eloquent\Builder<static>|PageVisit query()
*
* @mixin \Eloquent
*/
class PageVisit extends Model
{
protected $fillable = [
'user_id', 'page', 'user_type',
'created_at',
];
public $timestamps = false;
protected function casts(): array
{
return [
'user_type' => UserTypes::class,
];
}
}

View File

@ -0,0 +1,22 @@
<?php
namespace App\Queries;
use App\Enums\UserTypes;
use App\Models\PageVisit;
use Illuminate\Database\Eloquent\Builder;
final readonly class PageVisitStatsQuery
{
/**
* @return Builder<PageVisit>
*/
public function builder(UserTypes $userType, string $startDay, string $endDay): Builder
{
return PageVisit::query()
->selectRaw('count(distinct user_id) as user_count, created_at as date')
->where('user_type', $userType)
->whereBetween('created_at', [$startDay.' 00:00:00', $endDay.' 23:59:59'])
->groupBy('date');
}
}

View File

@ -13,7 +13,8 @@
"blade-ui-kit/blade-heroicons": "^2.6", "blade-ui-kit/blade-heroicons": "^2.6",
"laravel/framework": "^12.0", "laravel/framework": "^12.0",
"laravel/tinker": "^2.10.1", "laravel/tinker": "^2.10.1",
"twilio/sdk": "^8.10" "twilio/sdk": "^8.10",
"ext-cassandra": "*"
}, },
"require-dev": { "require-dev": {
"barryvdh/laravel-ide-helper": "^3.6", "barryvdh/laravel-ide-helper": "^3.6",

View File

@ -0,0 +1,32 @@
<?php
use App\Enums\UserTypes;
use App\Models\User;
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('page_visits', function (Blueprint $table) {
$table->id();
$table->foreignIdFor(User::class)->nullable();
$table->string('page');
$table->enum('user_type', UserTypes::values())->nullable();
$table->date('created_at')->useCurrent();
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('page_visits');
}
};

21
package-lock.json generated
View File

@ -4,6 +4,9 @@
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"dependencies": {
"chart.js": "^4.5.1"
},
"devDependencies": { "devDependencies": {
"@tailwindcss/vite": "^4.0.0", "@tailwindcss/vite": "^4.0.0",
"axios": "^1.11.0", "axios": "^1.11.0",
@ -505,6 +508,12 @@
"@jridgewell/sourcemap-codec": "^1.4.14" "@jridgewell/sourcemap-codec": "^1.4.14"
} }
}, },
"node_modules/@kurkle/color": {
"version": "0.3.4",
"resolved": "https://registry.npmjs.org/@kurkle/color/-/color-0.3.4.tgz",
"integrity": "sha512-M5UknZPHRu3DEDWoipU6sE8PdkZ6Z/S+v4dD+Ke8IaNlpdSQah50lz1KtcFBa2vsdOnwbbnxJwVM4wty6udA5w==",
"license": "MIT"
},
"node_modules/@rollup/rollup-android-arm-eabi": { "node_modules/@rollup/rollup-android-arm-eabi": {
"version": "4.55.1", "version": "4.55.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.55.1.tgz", "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.55.1.tgz",
@ -1223,6 +1232,18 @@
"node": ">=8" "node": ">=8"
} }
}, },
"node_modules/chart.js": {
"version": "4.5.1",
"resolved": "https://registry.npmjs.org/chart.js/-/chart.js-4.5.1.tgz",
"integrity": "sha512-GIjfiT9dbmHRiYi6Nl2yFCq7kkwdkp1W/lp2J99rX0yo9tgJGn3lKQATztIjb5tVtevcBtIdICNWqlq5+E8/Pw==",
"license": "MIT",
"dependencies": {
"@kurkle/color": "^0.3.0"
},
"engines": {
"pnpm": ">=8"
}
},
"node_modules/cliui": { "node_modules/cliui": {
"version": "8.0.1", "version": "8.0.1",
"resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz",

View File

@ -13,5 +13,8 @@
"laravel-vite-plugin": "^2.0.0", "laravel-vite-plugin": "^2.0.0",
"tailwindcss": "^4.0.0", "tailwindcss": "^4.0.0",
"vite": "^7.0.7" "vite": "^7.0.7"
},
"dependencies": {
"chart.js": "^4.5.1"
} }
} }

View File

@ -13,12 +13,14 @@ import {initTabs} from "./tab.js";
import {loadModalFromQuery} from "./explore-page.js"; import {loadModalFromQuery} from "./explore-page.js";
import {deleteRecentSearch} from "./deleteRecentSearch.js"; import {deleteRecentSearch} from "./deleteRecentSearch.js";
import {initNavMenu} from "./nav-menu.js"; import {initNavMenu} from "./nav-menu.js";
import {toggleShimmer} from "./shimmer.js";
document.deleteSearch = deleteRecentSearch; document.deleteSearch = deleteRecentSearch;
document.like = like; document.like = like;
document.favorite = favorite; document.favorite = favorite;
document.redirect = redirect; document.redirect = redirect;
document.showReportModal = showReportModal; document.showReportModal = showReportModal;
window.toggleShimmer = toggleShimmer;
window.addEventListener('load', async () => { window.addEventListener('load', async () => {
const preloader = document.getElementById('preloader'); const preloader = document.getElementById('preloader');

View File

@ -1,6 +1,10 @@
import axios from 'axios'; import axios from 'axios';
import {Chart, registerables} from "chart.js";
Chart.register(...registerables)
window.axios = axios; window.axios = axios;
window.Chart = Chart;
window.axios.defaults.headers.common['X-Requested-With'] = 'XMLHttpRequest'; window.axios.defaults.headers.common['X-Requested-With'] = 'XMLHttpRequest';
window.axios.defaults.headers.common['Accept'] = 'application/json'; window.axios.defaults.headers.common['Accept'] = 'application/json';

View File

@ -2,6 +2,6 @@ export function toggleShimmer(isLoading, parentElement) {
const dataElements = parentElement.querySelectorAll('[data-is-loading]'); const dataElements = parentElement.querySelectorAll('[data-is-loading]');
dataElements.forEach(el => { dataElements.forEach(el => {
el.dataset.isLoading = isLoading ? 'true' : 'false'; el.dataset.isLoading = isLoading ? 'false' : 'true';
}); });
} }

View File

@ -0,0 +1,150 @@
<div id="active-users-chart-parent" class="col-span-2">
<x-dashboard.card data-is-loading="false" class="h-75">
<x-ui.toggle-button-group class="mb-4">
<x-ui.toggle-button :active="request('sortBy') == null">
<button onclick="switchGraph(this, 30)" class="graphBtn flex items-center px-2 space-x-2">
<p class="font-bold text-xs sm:text-sm md:text-md">30 Days</p>
</button>
</x-ui.toggle-button>
<x-ui.toggle-button>
<button onclick="switchGraph(this, 7)" class="graphBtn flex items-center px-2 space-x-2">
<p class="font-bold text-xs sm:text-sm md:text-md">7 Days</p>
</button>
</x-ui.toggle-button>
<x-ui.toggle-button>
<button class="flex items-center pt-0.5 px-4 space-x-2">
<x-heroicon-o-calendar-date-range class="w-4"/>
</button>
</x-ui.toggle-button>
</x-ui.toggle-button-group>
<div class="h-50">
<canvas id="active-users-chart"></canvas>
</div>
</x-dashboard.card>
</div>
@push('scripts')
<script>
window.addEventListener('DOMContentLoaded', () => {
// BY default load 30 days data
const end = new Date();
const start = new Date();
start.setDate(end.getDate() - 30);
const startDate = start.toISOString().split('T')[0];
const endDate = end.toISOString().split('T')[0];
generateActiveUsersChart(startDate, endDate);
})
const generateActiveUsersChart = async (startDay, endDay) => {
const activeChartParent = document.getElementById('active-users-chart-parent');
const activeUsersChart = document.getElementById('active-users-chart')
if (!activeUsersChart || !activeChartParent) {
console.error('canvas not defined');
return;
}
toggleShimmer(false, activeChartParent);
// Generate last 30-day labels from today
let labels = getDaysArray(startDay, endDay);
try {
const {data: apiData} = await axios.get('/api/stats/active-users/', {
params: {from: startDay, to: endDay}
});
const customerDataFromAPI = apiData?.activeCustomers?.data || [];
const brokerDataFromAPI = apiData?.activeBrokers?.data || [];
// Fill the data from api response
const customerData = labels.map((date) => {
let found = customerDataFromAPI.find(item => item.date === date);
return found ? found.userCount : 0;
})
const brokerData = labels.map((date) => {
let found = brokerDataFromAPI.find(item => item.date === date);
return found ? found.userCount : 0;
})
const data = {
labels: labels,
datasets: [
{
label: `Active Customers`,
backgroundColor: 'rgba(255, 99, 132, 0.3)',
borderColor: 'rgb(255, 99, 132)',
borderRadius: 4,
borderWidth: 1,
data: customerData,
},
{
label: `Active Brokers`,
backgroundColor: 'rgba(91,162,238,0.3)',
borderColor: 'rgb(99,102,255)',
borderRadius: 4,
borderWidth: 1,
data: brokerData,
}
]
}
const config = {
type: 'line',
data: data,
options: {
responsive: true,
maintainAspectRatio: false,
scales: {
y: {beginAtZero: true, ticks: {precision: 0}}
},
interaction: {intersect: false, mode: 'index'},
}
};
const existingChart = Chart.getChart(activeUsersChart);
if (existingChart) {
existingChart.destroy()
}
const chart = new Chart(activeUsersChart, config);
toggleShimmer(true, activeChartParent);
} catch (e) {
console.log(e)
}
};
const switchGraph = (el, daysCount) => {
activeBtn(el);
const end = new Date();
const start = new Date();
start.setDate(end.getDate() - daysCount);
generateActiveUsersChart(
start.toISOString().split('T')[0],
end.toISOString().split('T')[0]
);
}
const activeBtn = (el) => {
const graphBtns = document.getElementById('active-users-chart-parent').querySelectorAll('.toggleBtn');
graphBtns.forEach(btn => btn.classList.remove('bg-white'));
el.closest('.toggleBtn').classList.add('bg-white');
}
const getDaysArray = (start, end) => {
let arr = [];
for (let dt = new Date(start); dt <= new Date(end); dt.setDate(dt.getDate() + 1)) {
arr.push(new Date(dt).toISOString().split('T')[0]);
}
return arr;
};
</script>
@endpush

View File

@ -0,0 +1,54 @@
<x-dashboard.card class="col-span-1 h-75">
<canvas id="category-wise-deals-pie-chart"></canvas>
</x-dashboard.card>
@push('scripts')
<script async>
window.addEventListener('DOMContentLoaded', async () => {
const dealsPieChart = document.getElementById('category-wise-deals-pie-chart');
try {
// const response = await axios.get('/api/stats/customer/active/30');
// const apiData = response.data.data;
// Fill the data from api response
// const chartData = labels.map((date) => {
// let found = apiData.find(item => item.date === date);
// return found ? found.userCount : 0;
// })
const data = {
labels: [
'Real Estate',
'Food',
'Sell & Deal',
'Palaces'
],
datasets: [{
label: 'My First Dataset',
data: [30, 20, 15, 5],
backgroundColor: [
'rgb(255, 99, 132)',
'rgb(54, 162, 235)',
'rgb(255, 205, 86)',
'rgb(99,102,255)',
],
hoverOffset: 4
}]
};
const config = {
type: 'pie',
data: data,
options: {
maintainAspectRatio: false,
responsive: true,
}
};
const chart = new Chart(dealsPieChart, config);
// toggleShimmer(false, activeChartParent);
} catch (e) {
console.log(e)
}
})
</script>
@endpush

View File

@ -0,0 +1,4 @@
<section class="grid md:grid-cols-3 gap-4 md:gap-8 wrapper ">
<x-dashboard.admin.active-users-chart/>
<x-dashboard.admin.category-wise-deals-pie/>
</section>

View File

@ -32,4 +32,5 @@
{{$slot}} {{$slot}}
</div> </div>
</body> </body>
@stack('scripts')
</html> </html>

View File

@ -1,7 +1,7 @@
@props(['active' => false]) @props(['active' => false])
@aware(['activeColor' => 'bg-white']) @aware(['activeColor' => 'bg-white'])
<div <div
{{$attributes->class(["rounded-full hover:border-gray-300 border border-transparent transition-colors duration-300 ease-in-out", $activeColor => $active])}} {{$attributes->class(["toggleBtn rounded-full hover:border-gray-300 border border-transparent transition-colors duration-300 ease-in-out", $activeColor => $active])}}
> >
{{ $slot }} {{ $slot }}
</div> </div>

View File

@ -3,4 +3,5 @@
<x-dashboard.admin.navbar/> <x-dashboard.admin.navbar/>
</x-slot:heading> </x-slot:heading>
<x-dashboard.admin.stats :stats="$stats" /> <x-dashboard.admin.stats :stats="$stats" />
<x-dashboard.admin.charts />
</x-dashboard.admin.layout> </x-dashboard.admin.layout>

View File

@ -8,6 +8,7 @@
->group(function () { ->group(function () {
include __DIR__.'/interactions.php'; include __DIR__.'/interactions.php';
include __DIR__.'/deals.php'; include __DIR__.'/deals.php';
include __DIR__.'/stats/stats.php';
Route::delete('/recent-search/{recentSearch}', RecentSearchController::class)->name('recent-search.destroy'); Route::delete('/recent-search/{recentSearch}', RecentSearchController::class)->name('recent-search.destroy');
}); });

View File

@ -0,0 +1,7 @@
<?php
use App\Http\Controllers\StatsController;
Route::prefix('/stats')->group(function () {
Route::get('/active-users', [StatsController::class, 'getActiveUsers']);
});