Files
usher-manage-stack/.planning/phases/03-note-history-display/03-RESEARCH.md

23 KiB

Phase 03: Note History & Display - Research

Researched: 2026-02-13 Domain: Alpine.js expandable table rows, Laravel query patterns, accessibility Confidence: HIGH

Summary

Phase 3 implements an expandable inline panel in the member list table that displays full note history when clicking the note count badge. This requires Alpine.js state management for expand/collapse behavior, Laravel query optimization for fetching notes with author relationships, client-side search filtering within the displayed notes, and proper accessibility attributes for screen readers.

The existing architecture already provides the foundation: Phase 1 built the Notes API endpoint (GET /admin/members/{member}/notes) that returns notes with author information, and Phase 2 established the per-row Alpine.js pattern with independent x-data scopes that work correctly with Laravel pagination. The key technical challenges are: (1) adding expand/collapse state to the existing Alpine component, (2) fetching notes via AJAX when expanding, (3) implementing client-side search filtering, and (4) formatting dates in Traditional Chinese locale.

Primary recommendation: Use Alpine.js x-show with x-collapse plugin for smooth height animation, fetch notes once on first expand and cache in component state, implement client-side filtering with computed property pattern, ensure ARIA accessibility with aria-expanded and aria-controls attributes.

Standard Stack

Core

Library Version Purpose Why Standard
Alpine.js 3.4.2 Reactive UI state management Already used in Phase 2 for inline form; lightweight, works with server-rendered HTML
@alpinejs/collapse 3.x Smooth expand/collapse animation Official Alpine plugin for height transitions, cleaner than manual CSS
Laravel Eloquent 10.x Query notes with relationships Built-in ORM with eager loading prevents N+1 queries
Axios Latest AJAX requests for notes Already included in Laravel bootstrap.js
Tailwind CSS 3.1 Styling and dark mode Project standard for all UI components

Supporting

Library Version Purpose When to Use
Laravel Carbon 2.x (Laravel default) Datetime formatting Format created_at for display, supports locale

Alternatives Considered

Instead of Could Use Tradeoff
x-collapse plugin Manual x-transition with height classes More boilerplate, harder to maintain smooth animations
Client-side search Server-side filtering with AJAX More complex, requires additional API endpoint, overkill for small datasets
Fetch on expand Pre-load all notes in page load Wasteful for members with many notes, degrades pagination performance

Installation:

npm install @alpinejs/collapse

Then register in resources/js/app.js:

import Alpine from 'alpinejs';
import collapse from '@alpinejs/collapse';

Alpine.plugin(collapse);
window.Alpine = Alpine;
Alpine.start();

Architecture Patterns

resources/views/admin/members/
├── index.blade.php           # Main table with expandable rows
└── partials/
    └── note-history.blade.php  # (Optional) Extracted panel markup for clarity

Pattern 1: Expandable Row with Lazy-Loaded Content

What: Clicking badge expands panel below the row, fetches notes on first expand only, caches in Alpine state

When to use: When content is not needed immediately, reduces initial page load, prevents N+1 in index query

Example:

// Source: https://alpinejs.dev/plugins/collapse
{
    noteFormOpen: false,      // From Phase 2
    noteContent: '',          // From Phase 2
    noteCount: {{ $member->notes_count }},  // From Phase 1

    // NEW in Phase 3
    historyOpen: false,       // Controls panel visibility
    notes: [],                // Cached note data
    notesLoaded: false,       // Tracks if we've fetched
    isLoadingNotes: false,    // Loading state
    searchQuery: '',          // Filter text

    async toggleHistory() {
        this.historyOpen = !this.historyOpen;
        if (this.historyOpen && !this.notesLoaded) {
            await this.loadNotes();
        }
    },

    async loadNotes() {
        this.isLoadingNotes = true;
        try {
            const response = await axios.get('{{ route("admin.members.notes.index", $member) }}');
            this.notes = response.data.notes;
            this.notesLoaded = true;
        } catch (error) {
            console.error('Failed to load notes:', error);
        } finally {
            this.isLoadingNotes = false;
        }
    },

    get filteredNotes() {
        if (!this.searchQuery.trim()) return this.notes;
        const query = this.searchQuery.toLowerCase();
        return this.notes.filter(note =>
            note.content.toLowerCase().includes(query) ||
            note.author.name.toLowerCase().includes(query)
        );
    }
}

Template:

<!-- Badge with click handler -->
<button @click="toggleHistory()"
        type="button"
        :aria-expanded="historyOpen"
        aria-controls="notes-panel-{{ $member->id }}"
        class="inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium bg-blue-100 dark:bg-blue-900 text-blue-800 dark:text-blue-200 hover:bg-blue-200 dark:hover:bg-blue-800 cursor-pointer">
    <span x-text="noteCount"></span>
</button>

<!-- Expandable panel (separate <tr> for proper table structure) -->
<tr x-show="historyOpen"
    x-collapse
    :id="'notes-panel-' + {{ $member->id }}"
    class="bg-gray-50 dark:bg-gray-900">
    <td colspan="8" class="px-4 py-3">
        <!-- Loading state -->
        <div x-show="isLoadingNotes" class="text-center py-4">
            <span class="text-sm text-gray-500 dark:text-gray-400">載入中...</span>
        </div>

        <!-- Content when loaded -->
        <div x-show="!isLoadingNotes" x-cloak>
            <!-- Search input -->
            <input type="text"
                   x-model="searchQuery"
                   placeholder="搜尋備忘錄內容..."
                   class="mb-3 block w-full rounded-md border-gray-300 dark:border-gray-600 shadow-sm...">

            <!-- Notes list -->
            <template x-if="filteredNotes.length > 0">
                <div class="space-y-2">
                    <template x-for="note in filteredNotes" :key="note.id">
                        <div class="border-l-4 border-blue-500 pl-3 py-2">
                            <p class="text-sm text-gray-900 dark:text-gray-100" x-text="note.content"></p>
                            <p class="text-xs text-gray-500 dark:text-gray-400 mt-1">
                                <span x-text="note.author.name"></span> ·
                                <span x-text="formatDateTime(note.created_at)"></span>
                            </p>
                        </div>
                    </template>
                </div>
            </template>

            <!-- Empty state -->
            <template x-if="notes.length === 0">
                <p class="text-sm text-gray-500 dark:text-gray-400">尚無備註</p>
            </template>

            <!-- No results state -->
            <template x-if="notes.length > 0 && filteredNotes.length === 0">
                <p class="text-sm text-gray-500 dark:text-gray-400">找不到符合的備忘錄</p>
            </template>
        </div>
    </td>
</tr>

Pattern 2: Client-Side Search with Computed Property

What: Reactive filtering using Alpine.js getter that automatically updates when searchQuery changes

When to use: Small to medium datasets (< 100 items), instant feedback, no server round-trip needed

Example:

// Source: https://github.com/alpinejs/alpine/discussions/484
{
    searchQuery: '',
    notes: [...],

    get filteredNotes() {
        if (!this.searchQuery.trim()) return this.notes;

        const query = this.searchQuery.toLowerCase();
        return this.notes.filter(note => {
            // Search in content and author name
            const searchableText = (note.content + ' ' + note.author.name).toLowerCase();
            return searchableText.includes(query);
        });
    }
}

Template:

<input type="text" x-model="searchQuery" placeholder="搜尋備忘錄...">
<template x-for="note in filteredNotes" :key="note.id">
    <!-- Note display -->
</template>

Pattern 3: Laravel Query Optimization for Notes Index

What: Fetch notes with author relationship, order by newest first

When to use: Always for the notes index endpoint to prevent N+1 and ensure consistent ordering

Example:

// Source: https://laravel.com/docs/10.x/pagination
public function index(Member $member)
{
    $notes = $member->notes()
        ->with('author:id,name')  // Eager load only needed author fields
        ->latest('created_at')     // Newest first (equivalent to orderBy('created_at', 'desc'))
        ->get();

    return response()->json(['notes' => $notes]);
}

Why not paginate?

  • Members typically have < 20 notes (based on system context)
  • Client-side search/filter requires all notes present
  • Pagination adds complexity without meaningful UX benefit for this use case

Pattern 4: Datetime Formatting for Traditional Chinese

What: Format created_at in Blade for server-side rendering, or use JavaScript helper for client-side

When to use: When displaying dates in JSON responses consumed by Alpine.js

Example (Blade - server-side):

{{ $note->created_at->format('Y年m月d日 H:i') }}

Example (Alpine.js helper - client-side):

{
    formatDateTime(dateString) {
        const date = new Date(dateString);
        const year = date.getFullYear();
        const month = String(date.getMonth() + 1).padStart(2, '0');
        const day = String(date.getDate()).padStart(2, '0');
        const hours = String(date.getHours()).padStart(2, '0');
        const minutes = String(date.getMinutes()).padStart(2, '0');
        return `${year}${month}${day}${hours}:${minutes}`;
    }
}

Why client-side? Notes are fetched via AJAX, so Blade can't format them. Carbon's toIso8601String() provides consistent JSON serialization, then format in JavaScript.

Anti-Patterns to Avoid

  • Nested x-data scopes within table rows: Creates complexity with event bubbling and state isolation issues. Keep all row state in a single x-data on the <tr>.

  • Fetching notes on every expand: Wasteful if user toggles multiple times. Cache in notes array and use notesLoaded flag.

  • Server-side search for small datasets: Adds latency and requires new API endpoint. Client-side filtering with computed property is instant and simpler.

  • Including panel markup in main row <tr>: Breaks table semantics. Use separate <tr> with colspan for the expansion panel.

  • Forgetting x-cloak on conditional content: Causes flash of unstyled content during Alpine initialization. Add [x-cloak] { display: none !important; } in styles.

Don't Hand-Roll

Problem Don't Build Use Instead Why
Smooth height animation Manual transition classes with max-height hacks @alpinejs/collapse plugin Official plugin handles edge cases (dynamic content height, nested animations), cleaner API
AJAX wrapper Custom fetch/promise handling Axios (already in Laravel bootstrap) Error handling, request/response interceptors, CSRF token auto-injection for Laravel
Datetime localization String concatenation with date parts Carbon ->format() on server, or built-in Intl.DateTimeFormat in JS Edge cases with timezones, leap years, DST; Carbon handles locale-aware formatting
Search algorithm Custom string matching logic Native String.includes() with normalization Built-in, fast, handles Unicode edge cases; for advanced needs use fuse.js or lunr.js

Key insight: Alpine.js excels at enhancing server-rendered HTML with reactivity. Don't fight this by building complex client-side state synchronization—let Laravel render initial state, use Alpine for interactions only.

Common Pitfalls

Pitfall 1: Expansion Panel Breaks Table Layout

What goes wrong: Putting panel content inside the main <td> distorts column widths, makes styling inconsistent

Why it happens: HTML table layout algorithm treats content as part of the cell's intrinsic size calculation

How to avoid: Use a separate <tr> immediately after the main row, with a single <td colspan="8"> spanning all columns

Warning signs: Uneven column widths when panel is open, horizontal scrollbar appears, adjacent rows shift

Example:

<!-- Main row -->
<tr x-data="{ ... }">
    <td>...</td>
    <td>
        <button @click="toggleHistory()">{{ count }}</button>
    </td>
    <td>...</td>
</tr>

<!-- Expansion panel - SEPARATE row -->
<tr x-show="historyOpen" x-collapse>
    <td colspan="8" class="bg-gray-50 dark:bg-gray-900 px-4 py-3">
        <!-- Panel content here -->
    </td>
</tr>

Pitfall 2: Notes Not Ordered Newest First

What goes wrong: Notes display in random or oldest-first order, confusing for users who expect recent notes at top

Why it happens: Eloquent returns results in database order (usually insertion order = oldest first) unless explicitly ordered

How to avoid: Always use ->latest('created_at') in controller, add test to verify ordering (Phase 1 already includes this test)

Warning signs: Test test_notes_returned_newest_first() fails, manual testing shows oldest notes at top

Example:

// WRONG - no ordering
$notes = $member->notes()->with('author')->get();

// RIGHT - explicit newest first
$notes = $member->notes()->with('author')->latest('created_at')->get();

Pitfall 3: N+1 Query When Loading Authors

What goes wrong: Loading 10 notes triggers 1 query for notes + 10 queries for authors (11 total), slow page load

Why it happens: Lazy loading relationships fetches author individually for each note when accessed

How to avoid: Use ->with('author') to eager load, Laravel debugbar shows query count in development

Warning signs: Many duplicate SELECT queries for users table, slow response time for notes endpoint

Example:

// WRONG - N+1 queries
$notes = $member->notes()->latest()->get();
// When iterating: $notes->each(fn($n) => $n->author->name) triggers N queries

// RIGHT - 2 queries total
$notes = $member->notes()->with('author')->latest()->get();

Pitfall 4: Stale Note Count After Adding Note

What goes wrong: User adds note via Phase 2 inline form, count badge updates, but history panel shows old data if already loaded

Why it happens: Phase 2 increments noteCount++ but doesn't update cached notes array in Phase 3

How to avoid: After successful note creation in submitNote(), check if notesLoaded === true, if so, unshift new note into notes array

Warning signs: Count says "3" but panel only shows 2 notes, refreshing page fixes it

Example:

async submitNote() {
    this.isSubmitting = true;
    try {
        const response = await axios.post('...', { content: this.noteContent });
        this.noteCount++;
        this.noteContent = '';
        this.noteFormOpen = false;

        // IMPORTANT: Update cached notes if panel has been opened
        if (this.notesLoaded) {
            this.notes.unshift(response.data.note);  // Add to beginning (newest first)
        }
    } catch (error) {
        // ...
    } finally {
        this.isSubmitting = false;
    }
}

Pitfall 5: Accessibility - Missing ARIA Attributes

What goes wrong: Screen reader users don't know button expands content, can't navigate back to collapsed state

Why it happens: Expandable patterns require explicit ARIA attributes that aren't automatically added by Alpine.js

How to avoid: Add aria-expanded, aria-controls, and id attributes to button and panel

Warning signs: Automated accessibility testing flags missing attributes, manual testing with screen reader shows poor UX

Example:

<!-- Toggle button -->
<button @click="toggleHistory()"
        :aria-expanded="historyOpen"
        aria-controls="notes-panel-{{ $member->id }}"
        type="button">
    <span x-text="noteCount"></span>
</button>

<!-- Expansion panel -->
<tr x-show="historyOpen"
    x-collapse
    :id="'notes-panel-' + {{ $member->id }}">
    <td colspan="8">...</td>
</tr>

Sources:

Pitfall 6: Search Doesn't Clear When Closing Panel

What goes wrong: User searches in panel, finds note, closes panel, reopens later—old search term still active, confusing results

Why it happens: searchQuery state persists across open/close cycles

How to avoid: Reset searchQuery = '' in toggleHistory() when closing (when historyOpen becomes false)

Warning signs: Reopening panel shows filtered results without visible search query, or search input has old value

Example:

toggleHistory() {
    this.historyOpen = !this.historyOpen;

    // Clear search when closing
    if (!this.historyOpen) {
        this.searchQuery = '';
    }

    // Load notes when opening for first time
    if (this.historyOpen && !this.notesLoaded) {
        this.loadNotes();
    }
}

Code Examples

Verified patterns from official sources and existing codebase:

Alpine.js Collapse Plugin Usage

// Source: https://alpinejs.dev/plugins/collapse
<div x-data="{ expanded: false }">
    <button @click="expanded = !expanded">Toggle</button>
    <div x-show="expanded" x-collapse>
        Content here
    </div>
</div>

Laravel Latest (Newest First) with Eager Loading

// Source: https://laravel.com/docs/10.x/pagination
$notes = Note::with('author:id,name')
    ->latest('created_at')  // Same as orderBy('created_at', 'desc')
    ->get();

Alpine.js Client-Side Search Pattern

// Source: https://github.com/alpinejs/alpine/discussions/484
{
    search: '',
    items: [...],

    get filteredItems() {
        if (!this.search) return this.items;
        return this.items.filter(item =>
            item.name.toLowerCase().includes(this.search.toLowerCase())
        );
    }
}

ARIA Expanded Pattern

<!-- Source: https://adrianroselli.com/2019/09/table-with-expando-rows.html -->
<button aria-expanded="false"
        aria-controls="panel-id"
        @click="expanded = !expanded"
        :aria-expanded="expanded.toString()">
    Toggle
</button>
<div id="panel-id" x-show="expanded">
    Panel content
</div>

State of the Art

Old Approach Current Approach When Changed Impact
jQuery slideToggle() Alpine.js x-collapse plugin Alpine 3.x (2021) Lighter bundle, reactive state, better DX
Server-side pagination for notes Client-side filtering Modern SPA patterns (2020+) Instant feedback, reduced server load
Manual height transitions x-collapse with automatic height detection @alpinejs/collapse v3 Handles dynamic content, smoother animations
aria-hidden for hiding x-show (uses display: none) Alpine 3.x Better screen reader support, x-show doesn't need aria-hidden

Deprecated/outdated:

  • x-show.transition modifier: Replaced by x-collapse plugin for height animations (x-transition is for opacity/scale)
  • Storing notes in global Alpine.store(): Per-row state is cleaner for table rows, avoids complexity
  • Using v-if / v-show from Vue.js syntax: Alpine uses x-if / x-show

Open Questions

  1. Should notes be paginated on the backend?

    • What we know: Current API returns all notes with ->get(), client-side filtering requires all data
    • What's unclear: If a member could have 100+ notes, would pagination be needed?
    • Recommendation: Start without pagination (simpler UX, matches requirement DISP-04 "search within member's history"). Add pagination later if performance issue emerges in real usage. Consider limit of 50 notes per member as reasonable threshold.
  2. Should the expansion panel be a separate Blade component?

    • What we know: Current member list has all markup inline in index.blade.php, panel adds ~50 lines of markup
    • What's unclear: Project preference for inline vs. extracted partials
    • Recommendation: Start inline for simplicity (keeps all row logic in one file), extract to partials/note-history-panel.blade.php if it grows beyond 100 lines or if reused elsewhere.
  3. Should datetime formatting use JavaScript Intl.DateTimeFormat or manual formatting?

    • What we know: Project uses ->format('Y年m月d日 H:i') pattern in Blade (seen in documents views)
    • What's unclear: Whether to replicate this exact format in JavaScript or use Intl API
    • Recommendation: Use manual formatting helper to match existing project style (2026年02月13日 14:30), ensures consistency with server-rendered dates. Intl API would be more flexible for future i18n but adds complexity.

Sources

Primary (HIGH confidence)

  • Alpine.js Collapse Plugin Official Docs - x-collapse usage, modifiers
  • Laravel 10.x Pagination Docs - orderBy, latest, pagination patterns
  • Alpine.js Transition Directive - x-show animations
  • Existing codebase:
    • /Users/gbanyan/Project/usher-manage-stack/app/Http/Controllers/Admin/MemberNoteController.php - Current API structure
    • /Users/gbanyan/Project/usher-manage-stack/resources/views/admin/members/index.blade.php - Phase 2 Alpine.js patterns
    • /Users/gbanyan/Project/usher-manage-stack/tests/Feature/Admin/MemberNoteTest.php - Test coverage, ordering expectations

Secondary (MEDIUM confidence)

Tertiary (LOW confidence)

  • Alpine Toolbox examples - Community-contributed patterns (useful for inspiration, verify before using)
  • GitHub search results for Alpine.js table patterns - Various implementations, quality varies

Metadata

Confidence breakdown:

  • Standard stack: HIGH - Alpine.js 3.4.2 already in use, @alpinejs/collapse is official plugin, Laravel patterns are documented
  • Architecture: HIGH - Patterns verified in existing codebase (Phase 1 & 2), official docs confirm syntax
  • Pitfalls: MEDIUM to HIGH - Expansion panel layout and N+1 queries are well-known issues (HIGH), ARIA patterns verified with official specs (HIGH), stale cache sync is inferred from Alpine reactivity model (MEDIUM)

Research date: 2026-02-13 Valid until: ~30 days (Alpine.js and Laravel 10 are stable, no breaking changes expected)