# 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: ```php 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: ```php 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 ```php // 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 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(), ]; } }; ?>
{{ __('client.my_cases') }} {{-- Active Timelines --}} @if($activeTimelines->isNotEmpty())

{{ __('client.active_cases') }}

@foreach($activeTimelines as $timeline)

{{ $timeline->case_name }}

@if($timeline->case_reference)

{{ __('client.reference') }}: {{ $timeline->case_reference }}

@endif

{{ __('client.updates') }}: {{ $timeline->updates_count }} @if($timeline->updates->first()) · {{ __('client.last_update') }}: {{ $timeline->updates->first()->created_at->diffForHumans() }} @endif

{{ __('client.active') }} {{ __('client.view') }}
@endforeach
@endif {{-- Archived Timelines --}} @if($archivedTimelines->isNotEmpty())

{{ __('client.archived_cases') }}

@foreach($archivedTimelines as $timeline)

{{ $timeline->case_name }}

@if($timeline->case_reference)

{{ __('client.reference') }}: {{ $timeline->case_reference }}

@endif

{{ __('client.updates') }}: {{ $timeline->updates_count }}

{{ __('client.archived') }} {{ __('client.view') }}
@endforeach
@endif {{-- Empty State --}} @if($activeTimelines->isEmpty() && $archivedTimelines->isEmpty())

{{ __('client.no_cases_yet') }}

@endif
``` ### Timeline Detail View ```php user_id === auth()->id(), 403); $this->timeline = $timeline->load(['updates' => fn($q) => $q->oldest()]); } }; ?>
{{ $timeline->case_name }} @if($timeline->case_reference)

{{ __('client.reference') }}: {{ $timeline->case_reference }}

@endif
{{ __('client.' . $timeline->status) }}
@forelse($timeline->updates as $update)
{{ $update->created_at->translatedFormat('l, d M Y - g:i A') }}
{!! $update->update_text !!}
@empty

{{ __('client.no_updates_yet') }}

@endforelse
{{ __('client.back_to_cases') }}
``` ### Required Translation Keys ```php // 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 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 - [x] All acceptance criteria implemented - [x] All 15 tests passing - [x] Pint formatting verified - [x] Authorization via middleware and component-level checks - [x] N+1 query prevention with eager loading - [x] RTL/LTR support implemented - [x] 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: **PASS** → `docs/qa/gates/4.5-client-timeline-view.yml` ### Recommended Status ✓ **Ready for Done** - All acceptance criteria met, all tests passing, code follows project patterns.