# Story 6.2: Analytics Charts

> **Note:** The color values in this story were implemented with the original Navy+Gold palette.
> These colors were updated in Epic 10 (Brand Color Refresh) to the new Charcoal+Warm Gray palette.
> See `docs/brand.md` for current color specifications.

## Epic Reference
**Epic 6:** Admin Dashboard

## User Story
As an **admin**,
I want **visual charts showing trends and historical data**,
So that **I can identify patterns in client acquisition and consultation outcomes to make informed business decisions**.

## Prerequisites / Dependencies

This story requires the following to be completed first:

| Dependency | Required From | What's Needed |
|------------|---------------|---------------|
| Dashboard Layout | Story 6.1 | Admin dashboard page structure and route |
| User Model | Epic 2 | `users` table with `created_at` for tracking new clients |
| Consultation Model | Epic 3 | `consultations` table with `consultation_type`, `status`, `scheduled_date` |
| Admin Layout | Epic 1 | Admin authenticated layout with navigation |

**References:**
- Epic 6 details: `docs/epics/epic-6-admin-dashboard.md`
- PRD Analytics Section: `docs/prd.md` Section 11.1 (Analytics & Reporting)
- PRD Dashboard Section: `docs/prd.md` Section 5.7 (Admin Dashboard)
- Story 6.1 for dashboard structure: `docs/stories/story-6.1-dashboard-overview-statistics.md`

## Acceptance Criteria

### Monthly Trends Chart (Line Chart)
- [ ] Display new clients per month (from `users.created_at`)
- [ ] Display consultations per month (from `consultations.scheduled_date`)
- [ ] Two data series on same chart with legend
- [ ] X-axis: Month labels (e.g., "Jan 2025", "Feb 2025")
- [ ] Y-axis: Count values with appropriate scale

### Consultation Breakdown Chart (Pie/Donut)
- [ ] Show free vs paid consultation ratio
- [ ] Display percentage labels on segments
- [ ] Legend showing "Free" and "Paid" with counts

### No-show Rate Chart (Line Chart)
- [ ] Monthly no-show percentage trend
- [ ] X-axis: Month labels
- [ ] Y-axis: Percentage (0-100%)
- [ ] Visual threshold line at concerning rate (e.g., 20%)

### Date Range Selector
- [ ] Preset options: Last 6 months, Last 12 months
- [ ] Custom date range picker (start/end month)
- [ ] Charts update when range changes
- [ ] Default selection: Last 6 months

### Chart Features
- [ ] Tooltips showing exact values on hover
- [ ] Responsive sizing (charts resize with viewport)
- [ ] Bilingual labels (Arabic/English based on locale)
- [ ] Loading state while fetching data
- [ ] Empty state when no data available

### Design
- [ ] Charts section below statistics cards from Story 6.1
- [ ] Card-based layout for each chart
- [ ] Navy blue and gold color scheme per PRD Section 7.1
- [ ] Responsive grid (1 column mobile, 2 columns tablet+)

## Technical Implementation

### Files to Create/Modify

| File | Purpose |
|------|---------|
| `resources/views/livewire/admin/dashboard.blade.php` | Add charts section to existing dashboard |
| `app/Services/AnalyticsService.php` | Data aggregation service for chart data |

### Integration with Story 6.1 Dashboard

The charts will be added as a new section in the existing dashboard component from Story 6.1. Add below the statistics cards section.

### Component Updates (Volt Class-Based)

Add to the existing dashboard component:

```php
<?php

use App\Models\User;
use App\Models\Consultation;
use App\Services\AnalyticsService;
use Livewire\Volt\Component;

new class extends Component {
    public string $chartPeriod = '6m';
    public ?string $customStart = null;
    public ?string $customEnd = null;

    public function with(): array
    {
        return [
            // ... existing metrics from Story 6.1 ...
            'chartData' => $this->getChartData(),
        ];
    }

    public function updatedChartPeriod(): void
    {
        // Livewire will re-render with new data
    }

    public function setCustomRange(string $start, string $end): void
    {
        $this->customStart = $start;
        $this->customEnd = $end;
        $this->chartPeriod = 'custom';
    }

    private function getChartData(): array
    {
        $service = app(AnalyticsService::class);

        $months = match($this->chartPeriod) {
            '6m' => 6,
            '12m' => 12,
            'custom' => $this->getCustomMonthCount(),
            default => 6,
        };

        $startDate = $this->chartPeriod === 'custom' && $this->customStart
            ? Carbon::parse($this->customStart)->startOfMonth()
            : now()->subMonths($months - 1)->startOfMonth();

        return [
            'labels' => $service->getMonthLabels($startDate, $months),
            'newClients' => $service->getMonthlyNewClients($startDate, $months),
            'consultations' => $service->getMonthlyConsultations($startDate, $months),
            'consultationBreakdown' => $service->getConsultationTypeBreakdown($startDate, $months),
            'noShowRates' => $service->getMonthlyNoShowRates($startDate, $months),
        ];
    }

    private function getCustomMonthCount(): int
    {
        if (!$this->customStart || !$this->customEnd) {
            return 6;
        }
        return Carbon::parse($this->customStart)
            ->diffInMonths(Carbon::parse($this->customEnd)) + 1;
    }
}; ?>
```

### Analytics Service

Create `app/Services/AnalyticsService.php`:

```php
<?php

namespace App\Services;

use App\Models\User;
use App\Models\Consultation;
use Carbon\Carbon;
use Illuminate\Support\Collection;

class AnalyticsService
{
    public function getMonthLabels(Carbon $startDate, int $months): array
    {
        return collect(range(0, $months - 1))
            ->map(fn($i) => $startDate->copy()->addMonths($i)->translatedFormat('M Y'))
            ->toArray();
    }

    public function getMonthlyNewClients(Carbon $startDate, int $months): array
    {
        $endDate = $startDate->copy()->addMonths($months);

        $data = User::query()
            ->whereIn('user_type', ['individual', 'company'])
            ->whereBetween('created_at', [$startDate, $endDate])
            ->selectRaw("DATE_FORMAT(created_at, '%Y-%m') as month, COUNT(*) as count")
            ->groupBy('month')
            ->pluck('count', 'month');

        return $this->fillMonthlyData($startDate, $months, $data);
    }

    public function getMonthlyConsultations(Carbon $startDate, int $months): array
    {
        $endDate = $startDate->copy()->addMonths($months);

        $data = Consultation::query()
            ->whereBetween('scheduled_date', [$startDate, $endDate])
            ->selectRaw("DATE_FORMAT(scheduled_date, '%Y-%m') as month, COUNT(*) as count")
            ->groupBy('month')
            ->pluck('count', 'month');

        return $this->fillMonthlyData($startDate, $months, $data);
    }

    public function getConsultationTypeBreakdown(Carbon $startDate, int $months): array
    {
        $endDate = $startDate->copy()->addMonths($months);

        return [
            'free' => Consultation::whereBetween('scheduled_date', [$startDate, $endDate])
                ->where('consultation_type', 'free')->count(),
            'paid' => Consultation::whereBetween('scheduled_date', [$startDate, $endDate])
                ->where('consultation_type', 'paid')->count(),
        ];
    }

    public function getMonthlyNoShowRates(Carbon $startDate, int $months): array
    {
        $endDate = $startDate->copy()->addMonths($months);

        $results = [];
        for ($i = 0; $i < $months; $i++) {
            $monthStart = $startDate->copy()->addMonths($i);
            $monthEnd = $monthStart->copy()->endOfMonth();

            $total = Consultation::whereBetween('scheduled_date', [$monthStart, $monthEnd])
                ->whereIn('status', ['completed', 'no-show'])
                ->count();

            $noShows = Consultation::whereBetween('scheduled_date', [$monthStart, $monthEnd])
                ->where('status', 'no-show')
                ->count();

            $results[] = $total > 0 ? round(($noShows / $total) * 100, 1) : 0;
        }

        return $results;
    }

    private function fillMonthlyData(Carbon $startDate, int $months, Collection $data): array
    {
        return collect(range(0, $months - 1))
            ->map(fn($i) => $data->get($startDate->copy()->addMonths($i)->format('Y-m'), 0))
            ->toArray();
    }
}
```

### Chart.js Integration with Livewire

Use `wire:ignore` to prevent Livewire from re-rendering chart canvas, and Alpine.js to manage Chart.js instances:

```blade
{{-- Charts Section --}}
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6 mt-8">
    {{-- Date Range Selector --}}
    <div class="lg:col-span-2">
        <div class="flex flex-wrap gap-2">
            <flux:button
                wire:click="$set('chartPeriod', '6m')"
                :variant="$chartPeriod === '6m' ? 'primary' : 'ghost'"
            >
                {{ __('Last 6 Months') }}
            </flux:button>
            <flux:button
                wire:click="$set('chartPeriod', '12m')"
                :variant="$chartPeriod === '12m' ? 'primary' : 'ghost'"
            >
                {{ __('Last 12 Months') }}
            </flux:button>
            {{-- Custom range picker would go here --}}
        </div>
    </div>

    {{-- Monthly Trends Chart --}}
    <div class="bg-white dark:bg-gray-800 rounded-lg p-6 shadow">
        <flux:heading size="sm">{{ __('Monthly Trends') }}</flux:heading>
        <div
            wire:ignore
            x-data="trendsChart(@js($chartData))"
            x-init="init()"
            class="h-64 mt-4"
        >
            <canvas x-ref="canvas"></canvas>
        </div>
    </div>

    {{-- Consultation Breakdown Chart --}}
    <div class="bg-white dark:bg-gray-800 rounded-lg p-6 shadow">
        <flux:heading size="sm">{{ __('Consultation Breakdown') }}</flux:heading>
        <div
            wire:ignore
            x-data="breakdownChart(@js($chartData['consultationBreakdown']))"
            x-init="init()"
            class="h-64 mt-4"
        >
            <canvas x-ref="canvas"></canvas>
        </div>
    </div>

    {{-- No-show Rate Chart --}}
    <div class="lg:col-span-2 bg-white dark:bg-gray-800 rounded-lg p-6 shadow">
        <flux:heading size="sm">{{ __('No-show Rate Trend') }}</flux:heading>
        <div
            wire:ignore
            x-data="noShowChart(@js($chartData))"
            x-init="init()"
            class="h-64 mt-4"
        >
            <canvas x-ref="canvas"></canvas>
        </div>
    </div>
</div>

@push('scripts')
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
<script>
document.addEventListener('alpine:init', () => {
    Alpine.data('trendsChart', (data) => ({
        chart: null,
        init() {
            this.chart = new Chart(this.$refs.canvas, {
                type: 'line',
                data: {
                    labels: data.labels,
                    datasets: [
                        {
                            label: '{{ __("New Clients") }}',
                            data: data.newClients,
                            borderColor: '#D4AF37',
                            tension: 0.3,
                        },
                        {
                            label: '{{ __("Consultations") }}',
                            data: data.consultations,
                            borderColor: '#0A1F44',
                            tension: 0.3,
                        }
                    ]
                },
                options: {
                    responsive: true,
                    maintainAspectRatio: false,
                    plugins: {
                        tooltip: { enabled: true }
                    }
                }
            });
        }
    }));

    Alpine.data('breakdownChart', (data) => ({
        chart: null,
        init() {
            this.chart = new Chart(this.$refs.canvas, {
                type: 'doughnut',
                data: {
                    labels: ['{{ __("Free") }}', '{{ __("Paid") }}'],
                    datasets: [{
                        data: [data.free, data.paid],
                        backgroundColor: ['#D4AF37', '#0A1F44'],
                    }]
                },
                options: {
                    responsive: true,
                    maintainAspectRatio: false,
                }
            });
        }
    }));

    Alpine.data('noShowChart', (data) => ({
        chart: null,
        init() {
            this.chart = new Chart(this.$refs.canvas, {
                type: 'line',
                data: {
                    labels: data.labels,
                    datasets: [{
                        label: '{{ __("No-show Rate %") }}',
                        data: data.noShowRates,
                        borderColor: '#E74C3C',
                        backgroundColor: 'rgba(231, 76, 60, 0.1)',
                        fill: true,
                        tension: 0.3,
                    }]
                },
                options: {
                    responsive: true,
                    maintainAspectRatio: false,
                    scales: {
                        y: { min: 0, max: 100 }
                    }
                }
            });
        }
    }));
});
</script>
@endpush
```

### NPM Dependencies

Chart.js can be loaded via CDN (as shown) or installed via npm:

```bash
npm install chart.js
```

Then import in `resources/js/app.js`:
```js
import Chart from 'chart.js/auto';
window.Chart = Chart;
```

## Edge Cases & Error Handling

| Scenario | Expected Behavior |
|----------|-------------------|
| No data for selected period | Show "No data available" message in chart area |
| Only one month of data | Chart renders single point with label |
| Zero consultations (division by zero for no-show rate) | Return 0% no-show rate, not error |
| Very large numbers | Y-axis scales appropriately with Chart.js auto-scaling |
| Custom range spans years | Labels show "Jan 2024", "Jan 2025" to distinguish |
| RTL language (Arabic) | Chart labels render correctly, legend on appropriate side |
| Chart.js fails to load | Show fallback message "Charts unavailable" |

## Testing Requirements

### Test File
`tests/Feature/Admin/AnalyticsChartsTest.php`

### Test Scenarios

```php
<?php

use App\Models\User;
use App\Models\Consultation;
use App\Services\AnalyticsService;
use Livewire\Volt\Volt;
use Carbon\Carbon;

test('admin can view analytics charts section', function () {
    $admin = User::factory()->admin()->create();

    $this->actingAs($admin)
        ->get(route('admin.dashboard'))
        ->assertSuccessful()
        ->assertSee(__('Monthly Trends'));
});

test('chart data returns correct monthly client counts', function () {
    // Create clients across different months
    User::factory()->create([
        'user_type' => 'individual',
        'created_at' => now()->subMonths(2),
    ]);
    User::factory()->count(3)->create([
        'user_type' => 'individual',
        'created_at' => now()->subMonth(),
    ]);
    User::factory()->count(2)->create([
        'user_type' => 'company',
        'created_at' => now(),
    ]);

    $service = new AnalyticsService();
    $data = $service->getMonthlyNewClients(now()->subMonths(2)->startOfMonth(), 3);

    expect($data)->toBe([1, 3, 2]);
});

test('consultation breakdown calculates free vs paid correctly', function () {
    Consultation::factory()->count(5)->create(['consultation_type' => 'free']);
    Consultation::factory()->count(3)->create(['consultation_type' => 'paid']);

    $service = new AnalyticsService();
    $breakdown = $service->getConsultationTypeBreakdown(now()->subYear(), 12);

    expect($breakdown['free'])->toBe(5);
    expect($breakdown['paid'])->toBe(3);
});

test('no-show rate calculates correctly', function () {
    // Create 8 completed, 2 no-shows = 20% rate
    Consultation::factory()->count(8)->create([
        'status' => 'completed',
        'scheduled_date' => now(),
    ]);
    Consultation::factory()->count(2)->create([
        'status' => 'no-show',
        'scheduled_date' => now(),
    ]);

    $service = new AnalyticsService();
    $rates = $service->getMonthlyNoShowRates(now()->startOfMonth(), 1);

    expect($rates[0])->toBe(20.0);
});

test('no-show rate returns zero when no consultations exist', function () {
    $service = new AnalyticsService();
    $rates = $service->getMonthlyNoShowRates(now()->startOfMonth(), 1);

    expect($rates[0])->toBe(0);
});

test('date range selector changes chart period', function () {
    $admin = User::factory()->admin()->create();

    Volt::test('admin.dashboard')
        ->actingAs($admin)
        ->assertSet('chartPeriod', '6m')
        ->set('chartPeriod', '12m')
        ->assertSet('chartPeriod', '12m');
});

test('chart handles empty data gracefully', function () {
    $admin = User::factory()->admin()->create();

    // No clients or consultations created
    $this->actingAs($admin)
        ->get(route('admin.dashboard'))
        ->assertSuccessful();
    // Should not throw errors
});

test('non-admin cannot access analytics charts', function () {
    $client = User::factory()->create(['user_type' => 'individual']);

    $this->actingAs($client)
        ->get(route('admin.dashboard'))
        ->assertForbidden();
});
```

### Manual Testing Checklist
- [ ] Verify charts render on desktop (1200px+)
- [ ] Verify charts resize correctly on tablet (768px)
- [ ] Verify charts stack vertically on mobile (375px)
- [ ] Verify tooltips show exact values on hover
- [ ] Verify 6-month button is selected by default
- [ ] Verify 12-month button updates all charts
- [ ] Verify chart colors match brand (navy #0A1F44, gold #D4AF37)
- [ ] Verify charts work in Arabic (RTL) mode
- [ ] Verify loading state appears while data fetches
- [ ] Verify empty state message when no data

## Definition of Done
- [ ] All three charts render correctly (trends, breakdown, no-show)
- [ ] Date range selector switches between 6/12 months
- [ ] Tooltips show exact values on all charts
- [ ] Charts are responsive on mobile, tablet, desktop
- [ ] Bilingual labels work (Arabic/English)
- [ ] Empty state handled gracefully
- [ ] No-show rate handles zero consultations (no division error)
- [ ] AnalyticsService unit tests pass
- [ ] Feature tests for chart data pass
- [ ] Code formatted with Pint
- [ ] Admin-only access enforced

## Estimation
**Complexity:** Medium-High | **Effort:** 4-5 hours

## Out of Scope
- Custom date range picker with calendar UI (can use simple month selects)
- Exporting charts as images
- Real-time chart updates (polling) - charts update on page load/range change only
- Animated chart transitions

---

## Dev Agent Record

### Status
**Ready for Review**

### Agent Model Used
Claude Opus 4.5

### File List

| File | Action | Description |
|------|--------|-------------|
| `app/Services/AnalyticsService.php` | Created | Analytics data aggregation service with methods for monthly clients, consultations, breakdown, and no-show rates |
| `resources/views/livewire/admin/dashboard.blade.php` | Modified | Added chart period state, getChartData method, and charts section with 3 charts |
| `resources/views/partials/head.blade.php` | Modified | Added Chart.js CDN script |
| `lang/en/admin_metrics.php` | Modified | Added chart-related translations (12 new keys) |
| `lang/ar/admin_metrics.php` | Modified | Added Arabic chart translations (12 new keys) |
| `tests/Feature/Admin/AnalyticsChartsTest.php` | Created | 19 test scenarios covering service, component, and UI |

### Change Log

| Date | Change |
|------|--------|
| 2025-12-27 | Initial implementation of Story 6.2 - Analytics Charts |

### Completion Notes

- All three charts implemented: Monthly Trends (line), Consultation Breakdown (doughnut), No-show Rate Trend (line)
- Date range selector with 6 months, 12 months presets, and custom month range
- Chart.js loaded via CDN (v4.4.1) for simplicity
- Empty state handling shows "No data available" message when no data exists
- RTL support included (legend position adapts to document direction)
- No-show rate chart includes 20% threshold line annotation
- AnalyticsService uses PHP-based grouping (not raw SQL DATE_FORMAT) for SQLite compatibility
- All 19 tests passing, no regressions in existing 21 dashboard tests

## QA Results

### Review Date: 2025-12-27

### Reviewed By: Quinn (Test Architect)

### Code Quality Assessment

Implementation quality is excellent. The AnalyticsService follows proper service pattern with clean separation of concerns. The developer correctly identified that the actual database column is `booking_date` (not `scheduled_date` as mentioned in the story spec) and implemented accordingly. Code uses proper enums, type hints, and PHPDoc blocks throughout.

Key strengths:
- PHP-based data grouping ensures SQLite compatibility for testing
- Clean chart data aggregation with proper date range handling
- Appropriate use of `wire:ignore` for Chart.js canvas elements
- Proper RTL support with legend positioning based on `document.dir`
- Empty state handling prevents errors when no data exists

### Refactoring Performed

None required - code quality meets standards.

### Compliance Check

- Coding Standards: ✓ Pint passes with no changes needed
- Project Structure: ✓ Service in correct location, follows existing patterns
- Testing Strategy: ✓ 19 tests covering unit and feature scenarios
- All ACs Met: ✓ All acceptance criteria have corresponding implementations and tests

### Improvements Checklist

All items handled by developer implementation:

- [x] Monthly Trends line chart with two data series
- [x] Consultation Breakdown doughnut chart with percentages
- [x] No-show Rate line chart with 20% threshold annotation
- [x] Date range selector with 6m/12m presets and custom range
- [x] Responsive chart sizing with maintainAspectRatio: false
- [x] Bilingual labels in both language files
- [x] Empty state message when no data available
- [x] Division by zero protection in no-show calculation
- [x] Admin-only access enforced via middleware

Minor consideration (non-blocking):
- [ ] Chart.js annotation plugin may need explicit import if threshold line doesn't render (Chart.js annotation plugin is a separate package)

### Security Review

No security concerns. Admin middleware properly enforces access control. Non-admin users receive 403 Forbidden response (verified by test).

### Performance Considerations

- Chart data queries are reasonably efficient using Eloquent groupBy
- No caching applied to chart data (acceptable - data freshness is important for analytics)
- No N+1 query issues detected

### Files Modified During Review

None - no modifications were necessary.

### Gate Status

Gate: **PASS** → docs/qa/gates/6.2-analytics-charts.yml

### Recommended Status

✓ **Ready for Done** - All acceptance criteria met, tests passing, code quality excellent
