Files
zemailnator/app/Filament/Widgets/TrialPerformance.php
idevakk e60973c391 fix(widgets): add multi-database compatibility to all dashboard widgets
Replace SQLite-specific functions with database-agnostic expressions to support
  MySQL, SQLite, PostgreSQL, and SQL Server across all Filament dashboard widgets.

  - Fix strftime() date formatting in SubscriptionMetrics, RevenueMetrics, and TrialPerformance
  - Fix CAST AS REAL syntax in ChurnAnalysis widget
  - Add getDateFormatExpression() method for date function compatibility
  - Add getCastExpression() method for CAST syntax compatibility
  - Support MySQL/MariaDB, SQLite, PostgreSQL, and SQL Server drivers
  - Maintain identical functionality across all database types

  Fixes multiple SQLSTATE[42000] syntax errors when using MySQL/MariaDB databases.
2025-12-02 07:09:30 -08:00

192 lines
6.4 KiB
PHP

<?php
namespace App\Filament\Widgets;
use App\Models\Subscription;
use App\Models\TrialExtension;
use Filament\Widgets\ChartWidget;
use Illuminate\Support\Facades\DB;
class TrialPerformance extends ChartWidget
{
protected static ?int $sort = 4;
protected int|string|array $columnSpan = 'full';
public function getHeading(): string
{
return 'Trial Performance';
}
protected function getData(): array
{
$trialConversion = $this->getTrialConversionRates();
$trialExtensions = $this->getTrialExtensionsTrend();
return [
'datasets' => [
[
'label' => 'Trial Conversion Rate (%)',
'data' => array_values($trialConversion),
'borderColor' => 'rgba(168, 85, 247, 1)',
'backgroundColor' => 'rgba(168, 85, 247, 0.1)',
'fill' => true,
'tension' => 0.4,
],
[
'label' => 'Trial Extensions',
'data' => array_values($trialExtensions),
'borderColor' => 'rgba(251, 146, 60, 1)',
'backgroundColor' => 'rgba(251, 146, 60, 0.1)',
'fill' => true,
'tension' => 0.4,
],
],
'labels' => array_keys($trialConversion),
];
}
protected function getType(): string
{
return 'line';
}
protected function getOptions(): array
{
return [
'responsive' => true,
'interaction' => [
'intersect' => false,
'mode' => 'index',
],
'plugins' => [
'legend' => [
'position' => 'top',
],
'tooltip' => [
'callbacks' => [
'label' => 'function(context) {
let label = context.dataset.label || "";
if (label) {
label += ": ";
}
if (context.parsed.y !== null) {
if (label.includes("Rate")) {
label += context.parsed.y.toFixed(1) + "%";
} else {
label += context.parsed.y;
}
}
return label;
}',
],
],
],
'scales' => [
'y' => [
'beginAtZero' => true,
'ticks' => [
'callback' => 'function(value, index, values) {
if (index === 0 || index === values.length - 1) {
return value + (this.chart.data.datasets[0].label.includes("Rate") ? "%" : "");
}
return "";
}',
],
],
],
];
}
private function getTrialConversionRates(): array
{
$conversionData = [];
for ($i = 5; $i >= 0; $i--) {
$month = now()->subMonths($i);
$monthStart = $month->copy()->startOfMonth();
$monthEnd = $month->copy()->endOfMonth();
// Trials that ended during this month
$endedTrials = Subscription::query()
->where('status', 'trialing')
->whereBetween('trial_ends_at', [$monthStart, $monthEnd])
->get();
$totalTrials = $endedTrials->count();
$convertedTrials = $endedTrials->filter(function ($trial) use ($monthEnd) {
// Check if trial converted to paid subscription
return Subscription::query()
->where('user_id', $trial->user_id)
->where('status', 'active')
->where('created_at', '>', $trial->trial_ends_at)
->where('created_at', '<=', $monthEnd)
->exists();
})->count();
$conversionRate = $totalTrials > 0 ? ($convertedTrials / $totalTrials) * 100 : 0;
$conversionData[$month->format('M Y')] = round($conversionRate, 2);
}
return $conversionData;
}
private function getTrialExtensionsTrend(): array
{
$dateFormat = $this->getDateFormatExpression();
return TrialExtension::query()
->select(
DB::raw("{$dateFormat} as month"),
DB::raw('COUNT(*) as extensions'),
DB::raw('SUM(extension_days) as total_days')
)
->where('trial_extensions.granted_at', '>=', now()->subMonths(6))
->groupBy('month')
->orderBy('month')
->get()
->mapWithKeys(function ($item) {
$date = \Carbon\Carbon::createFromFormat('Y-m', $item->month);
return [$date->format('M Y') => $item->extensions];
})
->toArray();
}
private function getDateFormatExpression(): string
{
$connection = DB::connection()->getDriverName();
return match ($connection) {
'sqlite' => "strftime('%Y-%m', trial_extensions.granted_at)",
'mysql', 'mariadb' => "DATE_FORMAT(trial_extensions.granted_at, '%Y-%m')",
'pgsql' => "TO_CHAR(trial_extensions.granted_at, 'YYYY-MM')",
'sqlsrv' => "FORMAT(trial_extensions.granted_at, 'yyyy-MM')",
default => "DATE_FORMAT(trial_extensions.granted_at, '%Y-%m')", // fallback to MySQL format
};
}
private function getAverageTrialLength(): float
{
return Subscription::query()
->where('status', 'trialing')
->orWhere('status', 'active')
->whereNotNull('trial_ends_at')
->selectRaw('AVG(DATEDIFF(trial_ends_at, created_at)) as avg_trial_days')
->value('avg_trial_days') ?? 0;
}
private function getMostCommonExtensionReasons(): array
{
return TrialExtension::query()
->select('reason', DB::raw('COUNT(*) as count'))
->whereNotNull('reason')
->groupBy('reason')
->orderBy('count', 'desc')
->limit(5)
->pluck('count', 'reason')
->toArray();
}
}