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):
Timelinemodel 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):
TimelineUpdatemodel 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
clientmiddleware inroutes/web.php:113-117 - Authorization properly enforced via
abort_unless()in show component atshow.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.phpandlang/ar/client.php - Consistent use of Flux UI components (badges, buttons, headings, icons)
File Structure:
resources/views/livewire/client/timelines/index.blade.php- List componentresources/views/livewire/client/timelines/show.blade.php- Detail componenttests/Feature/Client/TimelineViewTest.php- 15 comprehensive testslang/en/client.php- English translationslang/ar/client.php- Arabic translationsapp/Http/Middleware/EnsureUserIsClient.php- Client middlewarebootstrap/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:
clientmiddleware 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.