libra/docs/stories/story-4.5-client-timeline-v...

25 KiB

Story 4.5: Client Timeline View

Epic Reference

Epic 4: Case Timeline System

User Story

As a client, I want to view my case timelines and updates, So that I can track the progress of my legal matters.

Story Context

Existing System Integration

  • Integrates with: timelines, timeline_updates tables
  • Technology: Livewire Volt (read-only)
  • Follows pattern: Client dashboard pattern
  • Touch points: Client portal navigation
  • Authorization: Client middleware protects routes (defined in routes/web.php)

Relationship to Story 7.3

This story (4.5) implements the core timeline viewing functionality for clients as part of the Case Timeline epic. Story 7.3 (My Cases/Timelines View) in Epic 7 focuses on the client dashboard integration and will reuse the components created here. Implement this story first; Story 7.3 will integrate these components into the dashboard layout.

Prerequisites from Previous Stories

From Story 4.1 (docs/stories/story-4.1-timeline-creation.md):

  • Timeline model exists with fields: user_id, case_name, case_reference, status
  • Database schema:
    Schema::create('timelines', function (Blueprint $table) {
        $table->id();
        $table->foreignId('user_id')->constrained()->cascadeOnDelete();
        $table->string('case_name');
        $table->string('case_reference')->nullable()->unique();
        $table->enum('status', ['active', 'archived'])->default('active');
        $table->timestamps();
    });
    

From Story 4.2 (docs/stories/story-4.2-timeline-updates-management.md):

  • TimelineUpdate model with fields: timeline_id, admin_id, update_text
  • Timeline has updates() HasMany relationship

From Story 4.3 (docs/stories/story-4.3-timeline-archiving.md):

  • Timeline model has scopes: scopeActive(), scopeArchived()
  • Timeline model has methods: isArchived()

User Model Requirement: The User model must have:

public function timelines(): HasMany
{
    return $this->hasMany(Timeline::class);
}

Acceptance Criteria

Timeline List

  • Display all client's timelines
  • Active timelines prominently displayed
  • Archived timelines clearly separated
  • Visual distinction (color/icon) for status
  • Show for each:
    • Case name and reference
    • Status indicator
    • Last update date
    • Update count

Individual Timeline View

  • Case name and reference
  • Status indicator
  • All updates in chronological order
  • Each update shows:
    • Date and time
    • Update content (formatted)

Restrictions

  • Read-only (no edit/comment)
  • No ability to archive/delete
  • Only see own timelines (403 for unauthorized access)

UX Features

  • Recent updates indicator (new since last view, optional)
  • Responsive design for mobile
  • Bilingual labels and dates

Technical Notes

File Structure

Routes (add to routes/web.php within client middleware group):
  GET  /client/timelines           -> client.timelines.index
  GET  /client/timelines/{timeline} -> client.timelines.show

Files to Create:
  resources/views/livewire/pages/client/timelines/index.blade.php (List component)
  resources/views/livewire/pages/client/timelines/show.blade.php (Detail component)

Tests:
  tests/Feature/Client/TimelineViewTest.php

Route Definition

// routes/web.php - within client middleware group
Route::middleware(['auth', 'verified', 'client'])->prefix('client')->name('client.')->group(function () {
    Route::get('/timelines', function () {
        return view('livewire.pages.client.timelines.index');
    })->name('timelines.index');

    Route::get('/timelines/{timeline}', function (Timeline $timeline) {
        return view('livewire.pages.client.timelines.show', ['timeline' => $timeline]);
    })->name('timelines.show');
});

Volt Component for List

<?php
// resources/views/livewire/pages/client/timelines/index.blade.php

use Livewire\Volt\Component;

new class extends Component {
    public function with(): array
    {
        return [
            'activeTimelines' => auth()->user()
                ->timelines()
                ->active()
                ->withCount('updates')
                ->with(['updates' => fn($q) => $q->latest()->limit(1)])
                ->latest('updated_at')
                ->get(),

            'archivedTimelines' => auth()->user()
                ->timelines()
                ->archived()
                ->withCount('updates')
                ->latest('updated_at')
                ->get(),
        ];
    }
}; ?>

<div>
    <flux:heading class="mb-6">{{ __('client.my_cases') }}</flux:heading>

    {{-- Active Timelines --}}
    @if($activeTimelines->isNotEmpty())
        <div class="mb-8">
            <h2 class="text-lg font-semibold text-charcoal mb-4">{{ __('client.active_cases') }}</h2>
            <div class="space-y-4">
                @foreach($activeTimelines as $timeline)
                    <div wire:key="timeline-{{ $timeline->id }}" class="bg-white p-4 rounded-lg shadow-sm border-l-4 border-gold">
                        <div class="flex justify-between items-start">
                            <div>
                                <h3 class="font-medium text-charcoal">{{ $timeline->case_name }}</h3>
                                @if($timeline->case_reference)
                                    <p class="text-sm text-charcoal/70">{{ __('client.reference') }}: {{ $timeline->case_reference }}</p>
                                @endif
                                <p class="text-sm text-charcoal/60 mt-1">
                                    {{ __('client.updates') }}: {{ $timeline->updates_count }}
                                    @if($timeline->updates->first())
                                        · {{ __('client.last_update') }}: {{ $timeline->updates->first()->created_at->diffForHumans() }}
                                    @endif
                                </p>
                            </div>
                            <div class="flex items-center gap-2">
                                <flux:badge variant="success">{{ __('client.active') }}</flux:badge>
                                <flux:button size="sm" href="{{ route('client.timelines.show', $timeline) }}">
                                    {{ __('client.view') }}
                                </flux:button>
                            </div>
                        </div>
                    </div>
                @endforeach
            </div>
        </div>
    @endif

    {{-- Archived Timelines --}}
    @if($archivedTimelines->isNotEmpty())
        <div>
            <h2 class="text-lg font-semibold text-charcoal/70 mb-4">{{ __('client.archived_cases') }}</h2>
            <div class="space-y-4 opacity-75">
                @foreach($archivedTimelines as $timeline)
                    <div wire:key="timeline-{{ $timeline->id }}" class="bg-gray-50 p-4 rounded-lg shadow-sm">
                        <div class="flex justify-between items-start">
                            <div>
                                <h3 class="font-medium text-charcoal/80">{{ $timeline->case_name }}</h3>
                                @if($timeline->case_reference)
                                    <p class="text-sm text-charcoal/60">{{ __('client.reference') }}: {{ $timeline->case_reference }}</p>
                                @endif
                                <p class="text-sm text-charcoal/50 mt-1">
                                    {{ __('client.updates') }}: {{ $timeline->updates_count }}
                                </p>
                            </div>
                            <div class="flex items-center gap-2">
                                <flux:badge variant="secondary">{{ __('client.archived') }}</flux:badge>
                                <flux:button size="sm" variant="ghost" href="{{ route('client.timelines.show', $timeline) }}">
                                    {{ __('client.view') }}
                                </flux:button>
                            </div>
                        </div>
                    </div>
                @endforeach
            </div>
        </div>
    @endif

    {{-- Empty State --}}
    @if($activeTimelines->isEmpty() && $archivedTimelines->isEmpty())
        <div class="text-center py-12">
            <flux:icon name="folder-open" class="w-12 h-12 text-charcoal/30 mx-auto mb-4" />
            <p class="text-charcoal/70">{{ __('client.no_cases_yet') }}</p>
        </div>
    @endif
</div>

Timeline Detail View

<?php
// resources/views/livewire/pages/client/timelines/show.blade.php

use App\Models\Timeline;
use Livewire\Volt\Component;

new class extends Component {
    public Timeline $timeline;

    public function mount(Timeline $timeline): void
    {
        // Authorization: Ensure client owns this timeline
        abort_unless($timeline->user_id === auth()->id(), 403);

        $this->timeline = $timeline->load(['updates' => fn($q) => $q->oldest()]);
    }
}; ?>

<div class="max-w-3xl mx-auto">
    <!-- Header -->
    <div class="flex justify-between items-start mb-6">
        <div>
            <flux:heading>{{ $timeline->case_name }}</flux:heading>
            @if($timeline->case_reference)
                <p class="text-charcoal/70">{{ __('client.reference') }}: {{ $timeline->case_reference }}</p>
            @endif
        </div>
        <flux:badge :variant="$timeline->status === 'active' ? 'success' : 'secondary'">
            {{ __('client.' . $timeline->status) }}
        </flux:badge>
    </div>

    <!-- Timeline Updates -->
    <div class="relative">
        <!-- Vertical line -->
        <div class="absolute {{ app()->getLocale() === 'ar' ? 'right-4' : 'left-4' }} top-0 bottom-0 w-0.5 bg-gold/30"></div>

        <div class="space-y-6">
            @forelse($timeline->updates as $update)
                <div wire:key="update-{{ $update->id }}" class="relative {{ app()->getLocale() === 'ar' ? 'pr-12' : 'pl-12' }}">
                    <!-- Dot -->
                    <div class="absolute {{ app()->getLocale() === 'ar' ? 'right-2' : 'left-2' }} top-2 w-4 h-4 rounded-full bg-gold border-4 border-cream"></div>

                    <div class="bg-white p-4 rounded-lg shadow-sm">
                        <div class="text-sm text-charcoal/70 mb-2">
                            {{ $update->created_at->translatedFormat('l, d M Y - g:i A') }}
                        </div>
                        <div class="prose prose-sm">
                            {!! $update->update_text !!}
                        </div>
                    </div>
                </div>
            @empty
                <p class="text-center text-charcoal/70 py-8">
                    {{ __('client.no_updates_yet') }}
                </p>
            @endforelse
        </div>
    </div>

    <div class="mt-6">
        <flux:button href="{{ route('client.timelines.index') }}">
            {{ __('client.back_to_cases') }}
        </flux:button>
    </div>
</div>

Required Translation Keys

// resources/lang/en/client.php
'my_cases' => 'My Cases',
'active_cases' => 'Active Cases',
'archived_cases' => 'Archived Cases',
'reference' => 'Reference',
'updates' => 'Updates',
'last_update' => 'Last update',
'active' => 'Active',
'archived' => 'Archived',
'view' => 'View',
'back_to_cases' => 'Back to Cases',
'no_cases_yet' => 'You don\'t have any cases yet.',
'no_updates_yet' => 'No updates yet.',

// resources/lang/ar/client.php
'my_cases' => 'قضاياي',
'active_cases' => 'القضايا النشطة',
'archived_cases' => 'القضايا المؤرشفة',
'reference' => 'المرجع',
'updates' => 'التحديثات',
'last_update' => 'آخر تحديث',
'active' => 'نشط',
'archived' => 'مؤرشف',
'view' => 'عرض',
'back_to_cases' => 'العودة للقضايا',
'no_cases_yet' => 'لا توجد لديك قضايا بعد.',
'no_updates_yet' => 'لا توجد تحديثات بعد.',

Test Scenarios

All tests should use Pest and be placed in tests/Feature/Client/TimelineViewTest.php.

<?php

use App\Models\{User, Timeline, TimelineUpdate};
use Livewire\Volt\Volt;

// Authorization Tests
test('client can view own timelines list', function () {
    $client = User::factory()->create(['user_type' => 'individual']);
    Timeline::factory()->count(3)->create(['user_id' => $client->id]);

    $this->actingAs($client)
        ->get(route('client.timelines.index'))
        ->assertOk()
        ->assertSeeLivewire('pages.client.timelines.index');
});

test('client cannot view other clients timelines in list', function () {
    $client = User::factory()->create(['user_type' => 'individual']);
    $otherClient = User::factory()->create(['user_type' => 'individual']);
    $otherTimeline = Timeline::factory()->create([
        'user_id' => $otherClient->id,
        'case_name' => 'Other Client Case',
    ]);

    Volt::test('pages.client.timelines.index')
        ->actingAs($client)
        ->assertDontSee('Other Client Case');
});

test('client can view own timeline detail', function () {
    $client = User::factory()->create(['user_type' => 'individual']);
    $timeline = Timeline::factory()->create([
        'user_id' => $client->id,
        'case_name' => 'My Contract Case',
    ]);

    $this->actingAs($client)
        ->get(route('client.timelines.show', $timeline))
        ->assertOk()
        ->assertSee('My Contract Case');
});

test('client cannot view other clients timeline detail', function () {
    $client = User::factory()->create(['user_type' => 'individual']);
    $otherClient = User::factory()->create(['user_type' => 'individual']);
    $otherTimeline = Timeline::factory()->create(['user_id' => $otherClient->id]);

    $this->actingAs($client)
        ->get(route('client.timelines.show', $otherTimeline))
        ->assertForbidden();
});

test('guest cannot access timelines', function () {
    $this->get(route('client.timelines.index'))
        ->assertRedirect(route('login'));
});

test('admin cannot access client timeline routes', function () {
    $admin = User::factory()->admin()->create();

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

// List View Tests
test('active timelines displayed separately from archived', function () {
    $client = User::factory()->create(['user_type' => 'individual']);
    Timeline::factory()->create([
        'user_id' => $client->id,
        'case_name' => 'Active Case',
        'status' => 'active',
    ]);
    Timeline::factory()->create([
        'user_id' => $client->id,
        'case_name' => 'Archived Case',
        'status' => 'archived',
    ]);

    Volt::test('pages.client.timelines.index')
        ->actingAs($client)
        ->assertSee('Active Case')
        ->assertSee('Archived Case')
        ->assertSeeInOrder([__('client.active_cases'), 'Active Case', __('client.archived_cases'), 'Archived Case']);
});

test('timeline list shows update count', function () {
    $client = User::factory()->create(['user_type' => 'individual']);
    $timeline = Timeline::factory()->create(['user_id' => $client->id]);
    TimelineUpdate::factory()->count(5)->create(['timeline_id' => $timeline->id]);

    Volt::test('pages.client.timelines.index')
        ->actingAs($client)
        ->assertSee('5');
});

test('empty state shown when no timelines', function () {
    $client = User::factory()->create(['user_type' => 'individual']);

    Volt::test('pages.client.timelines.index')
        ->actingAs($client)
        ->assertSee(__('client.no_cases_yet'));
});

// Detail View Tests
test('timeline detail shows all updates chronologically', function () {
    $client = User::factory()->create(['user_type' => 'individual']);
    $timeline = Timeline::factory()->create(['user_id' => $client->id]);

    $oldUpdate = TimelineUpdate::factory()->create([
        'timeline_id' => $timeline->id,
        'update_text' => 'First Update',
        'created_at' => now()->subDays(2),
    ]);
    $newUpdate = TimelineUpdate::factory()->create([
        'timeline_id' => $timeline->id,
        'update_text' => 'Second Update',
        'created_at' => now()->subDay(),
    ]);

    Volt::test('pages.client.timelines.show', ['timeline' => $timeline])
        ->actingAs($client)
        ->assertSeeInOrder(['First Update', 'Second Update']);
});

test('timeline detail shows case name and reference', function () {
    $client = User::factory()->create(['user_type' => 'individual']);
    $timeline = Timeline::factory()->create([
        'user_id' => $client->id,
        'case_name' => 'Property Dispute',
        'case_reference' => 'REF-2024-001',
    ]);

    Volt::test('pages.client.timelines.show', ['timeline' => $timeline])
        ->actingAs($client)
        ->assertSee('Property Dispute')
        ->assertSee('REF-2024-001');
});

test('timeline detail shows status badge', function () {
    $client = User::factory()->create(['user_type' => 'individual']);
    $activeTimeline = Timeline::factory()->create([
        'user_id' => $client->id,
        'status' => 'active',
    ]);

    Volt::test('pages.client.timelines.show', ['timeline' => $activeTimeline])
        ->actingAs($client)
        ->assertSee(__('client.active'));
});

test('empty updates shows no updates message', function () {
    $client = User::factory()->create(['user_type' => 'individual']);
    $timeline = Timeline::factory()->create(['user_id' => $client->id]);

    Volt::test('pages.client.timelines.show', ['timeline' => $timeline])
        ->actingAs($client)
        ->assertSee(__('client.no_updates_yet'));
});

// Read-Only Enforcement Tests
test('client cannot edit timeline or updates', function () {
    $client = User::factory()->create(['user_type' => 'individual']);
    $timeline = Timeline::factory()->create(['user_id' => $client->id]);

    // Verify no edit methods exist on the component
    Volt::test('pages.client.timelines.show', ['timeline' => $timeline])
        ->actingAs($client)
        ->assertMethodDoesNotExist('edit')
        ->assertMethodDoesNotExist('update')
        ->assertMethodDoesNotExist('delete')
        ->assertMethodDoesNotExist('archive');
});

// N+1 Query Prevention Test
test('timeline list uses eager loading', function () {
    $client = User::factory()->create(['user_type' => 'individual']);
    Timeline::factory()->count(10)->create(['user_id' => $client->id]);

    // Component should load with reasonable query count
    $this->actingAs($client);
    Volt::test('pages.client.timelines.index');
    // With eager loading: ~3-4 queries (timelines, updates count, latest update)
});

Definition of Done

  • Volt components created at specified file locations
  • Routes registered for client timeline views
  • Client can view list of their timelines
  • Active/archived clearly separated with visual distinction
  • Can view individual timeline details
  • All updates displayed chronologically (oldest first)
  • Read-only enforced (no edit/delete methods)
  • Cannot view other clients' timelines (403 response)
  • Empty state displayed when no timelines
  • Mobile responsive
  • RTL support with proper positioning
  • All translation keys added (AR/EN)
  • All tests pass
  • Code formatted with Pint

Dependencies

  • Story 4.1: Timeline creation (docs/stories/story-4.1-timeline-creation.md) - Timeline model and database
  • Story 4.2: Timeline updates (docs/stories/story-4.2-timeline-updates-management.md) - TimelineUpdate model
  • Story 4.3: Timeline archiving (docs/stories/story-4.3-timeline-archiving.md) - Active/archived scopes
  • Story 7.3: Will integrate these components into client dashboard (docs/stories/story-7.3-my-cases-timelines-view.md)

Estimation

Complexity: Medium Estimated Effort: 3-4 hours

QA Results

Review Date: 2025-12-27

Reviewed By: Quinn (Test Architect)

Code Quality Assessment

Overall: Excellent - The implementation follows established project patterns consistently. Both Volt components use class-based architecture as required by coding standards. The code is clean, well-organized, and matches sibling component patterns (e.g., client/consultations/index.blade.php).

Strengths:

  • Routes correctly registered with client middleware in routes/web.php:113-117
  • Authorization properly enforced via abort_unless() in show component at show.blade.php:13
  • Eager loading used appropriately to prevent N+1 queries (withCount, with)
  • Read-only enforcement - no edit/delete/archive methods exist in components
  • RTL support with locale-aware positioning (app()->getLocale() === 'ar')
  • Dark mode support with proper Tailwind classes
  • Bilingual translations complete in both lang/en/client.php and lang/ar/client.php
  • Consistent use of Flux UI components (badges, buttons, headings, icons)

File Structure:

  • resources/views/livewire/client/timelines/index.blade.php - List component
  • resources/views/livewire/client/timelines/show.blade.php - Detail component
  • tests/Feature/Client/TimelineViewTest.php - 15 comprehensive tests
  • lang/en/client.php - English translations
  • lang/ar/client.php - Arabic translations
  • app/Http/Middleware/EnsureUserIsClient.php - Client middleware
  • bootstrap/app.php - Middleware alias registration

Refactoring Performed

None required - implementation follows project conventions correctly.

Compliance Check

  • Coding Standards: ✓ Class-based Volt components, Flux UI, proper testing patterns
  • Project Structure: ✓ Files at correct locations, routes properly defined
  • Testing Strategy: ✓ 15 Pest tests covering authorization, display, and read-only enforcement
  • All ACs Met: ✓ See detailed trace below

Acceptance Criteria Trace

AC Description Test Coverage Status
1 Display all client's timelines client can view own timelines list
2 Active timelines prominently displayed active timelines displayed separately from archived
3 Archived timelines clearly separated active timelines displayed separately from archived
4 Visual distinction for status timeline detail shows status badge, badges used in views
5 Show case name and reference timeline detail shows case name and reference
6 Show status indicator timeline detail shows status badge
7 Show last update date index.blade.php:47 shows diffForHumans()
8 Show update count timeline list shows update count
9 Individual view: case name/reference timeline detail shows case name and reference
10 Individual view: status indicator timeline detail shows status badge
11 Updates in chronological order timeline detail shows all updates chronologically
12 Update shows date/time show.blade.php:46 with translatedFormat()
13 Update shows formatted content show.blade.php:48-49 with prose styling
14 Read-only (no edit/comment) client timeline view is read-only with no edit actions
15 No archive/delete ability No such methods in components
16 Only own timelines (403) client cannot view other clients timeline detail
17 Responsive design Tailwind responsive classes throughout
18 Bilingual labels/dates Translation keys + translatedFormat()

Improvements Checklist

  • All acceptance criteria implemented
  • All 15 tests passing
  • Pint formatting verified
  • Authorization via middleware and component-level checks
  • N+1 query prevention with eager loading
  • RTL/LTR support implemented
  • Dark mode support implemented
  • Consider pagination for clients with many timelines (future enhancement)

Security Review

Authorization:

  • Route-level: client middleware enforces client-only access (EnsureUserIsClient)
  • Component-level: abort_unless($timeline->user_id === auth()->id(), 403) in show component
  • Tests verify: guest redirect, admin forbidden, other client forbidden

XSS Protection:

  • {!! $update->update_text !!} uses unescaped output, BUT:
  • Input is sanitized with clean() helper (HTMLPurifier) on admin input side (admin/timelines/show.blade.php:46,82)
  • This matches the established pattern in the codebase (same as email templates)

Performance Considerations

Eager Loading:

  • Index: withCount('updates'), with(['updates' => fn($q) => $q->latest()->limit(1)])
  • Show: $timeline->load(['updates' => fn($q) => $q->oldest()])

Potential Future Optimization:

  • If clients accumulate many timelines, consider adding pagination to index view

Files Modified During Review

None - implementation is complete and follows standards.

Gate Status

Gate: PASSdocs/qa/gates/4.5-client-timeline-view.yml

Ready for Done - All acceptance criteria met, all tests passing, code follows project patterns.