Custom Pulse Card as a Volt Component

While working on AI the Docs, I wanted to create a custom Pulse card so I could see chat counts by user to get a sense of activity and usage. Most of the examples I was seeing online for custom cards were using the recording system from Pulse, but I already had data in the database so wanted to read it from there.

I got the card working as a regular Livewire component, but then decided that since almost everything Livewire-related in my app is a Volt component, I really wanted to convert this card over to be a Volt component instead.

Building a Bridge

The first issue I ran into is that the standard Laravel\Pulse\Livewire\Card inherits from Livewire\Compontent, but to be a Volt component, I needed to inherit from Livewire\Volt\Component. It would be nice in the future if Pulse provided a Volt-based Card we could inherit from but for now I simply made my own basic bridge class.

<?php

namespace App\Livewire\Pulse\Volt;

use Livewire\Volt\Component;

abstract class Card extends Component
{
    /**
     * The number of columns to span.
     *
     * @var 1|2|3|4|5|6|7|8|9|10|11|12|'full'
     */
    public int|string|null $cols = null;

    /**
     * The number of rows to span.
     *
     * @var 1|2|3|4|5|6|'full'
     */
    public int|string|null $rows = null;

    /**
     * Whether to expand the card body instead of scrolling.
     */
    public bool $expand = false;

    /**
     * Custom CSS classes.
     */
    public string $class = '';
}

I didn’t fully implement the bridge class, I only built out the bits that I needed to get my simple card to display. Also, I just put it in my App namespace for simplicity.

The Custom Card

Here’s the code for the custom card. After, we’ll look at some key lines to understand what’s going on.

One note, I’d have liked to use the functional API but I wasn’t sure how make it inherit from the Pulse Card so I “kept it simple” and just went with the class API.

<?php

use App\Livewire\Pulse\Volt\Card;
use App\Models\Chat;
use App\Models\User;
use Illuminate\Support\Collection;
use Laravel\Pulse\Livewire\Concerns\HasPeriod;

new class extends Card {
    use HasPeriod;

    protected function buildUserData() : Collection
    {
        // Get interval set in interface
        $interval = $this->periodAsInterval();

        // Convert interval to time in expected timezone
        $time = now()->setTimezone('America/Los_Angeles')->subHours($interval->hours);

        // Get all chats in the interval
        $user_ids = Chat::where('created_at', '>=', $time)->pluck('user_id');

        // Build a mapping of user_ids to counts
        $user_mapping = $user_ids->reduce(function(Collection $carry, int $user_id) {
            if ( !isset($carry[$user_id]) ) {
                $carry[$user_id] = 0;
            }

            $carry[$user_id] = $carry[$user_id] + 1;
            return $carry;
        }, collect());

        $users = Pulse::resolveUsers( $user_mapping->keys() );

        return $user_mapping->map(
            fn($count, $key) => (object) [
                'key' => $key,
                'user' => $users->find($key),
                'count' => $count,
            ],
        );
    }

    public function with() : array
    {
        return [
            'users' => $this->buildUserData(),
        ];
    }
}; ?>

<x-pulse::card :$cols :$rows :$class wire:poll.5s="">
    <x-pulse::card-header name="Recent Chats">
        <x-slot:icon>
            <x-icon.chat-bubble-bottom-center-text />
        </x-slot:icon>
    </x-pulse::card-header>
    <x-pulse::scroll :$expand>
        @if ( $users->isEmpty() )
            <x-pulse::no-results />
        @else
            <div class="grid grid-cols-1 @lg:grid-cols-2 @3xl:grid-cols-3 @6xl:grid-cols-4 gap-2">
                @foreach ($users as $user_data)
                    <x-pulse::user-card
                        wire:key="{{ $user_data->key }}"
                        :user="$user_data->user"
                    >
                        <x-slot:stats>
                            {{ $user_data->count }}
                        </x-slot:stats>
                    </x-pulse::user-card>
                @endforeach
            </div>
        @endif
    </x-pulse::scroll>
</x-pulse>

Supporting Periods

The Pulse interface has a wonderful built-in period picket at the top which lets you look at the last 1 hour, 6 hours, 24 hours, or 7 days of data. I wanted to make my card honor that.

Line 10 adds the Trait which provides the functionality and we grab the interval on line 15.

On Line 18 we convert that interval to a time which can be used in a query (line 21). One issue I have that I coded around is that my app runs on UTC but my database dates are all Pacific Time so I had to convert my time on line 18 too.

new class extends Card {
    use HasPeriod;

    protected function buildUserData() : Collection
    {
        // Get interval set in interface
        $interval = $this->periodAsInterval();

        // Convert interval to time in expected timezone
        $time = now()->setTimezone('America/Los_Angeles')->subHours($interval->hours);

        // ...
    }
}

Chats to Users

We have the info necessary to query the Chats for the time period, but we don’t want the Chats, we really want the Users for the chats; and really, we don’t need all the user info, we only need the ID values so we can load just the Users who have Chats in the time period.

This is a standard Eloquent query on line 21.

        // Get all chats in the interval
        $user_ids = Chat::where('created_at', '>=', $time)->pluck('user_id');

Chat Counts by User

Now we need to turn a list of User ID vales (likely with duplicates) into a map of User ID keys and their Chat count as value. There may be a way to do this with a clever Eloquent query but we shouldn’t have that many values and it’s easy enough to do in PHP.

It could be done with a loop, but I found that using reduce was more elegant and more interesting.

        // Build a mapping of user_ids to counts
        $user_mapping = $user_ids->reduce(function(Collection $carry, int $user_id) {
            if ( !isset($carry[$user_id]) ) {
                $carry[$user_id] = 0;
            }

            $carry[$user_id] = $carry[$user_id] + 1;
            return $carry;
        }, collect());

Resolving Users

We’re getting close, but now that we have a set of just the Users that we’re interested in, we need to load them. Look at the default Pulse cards, the Usage card has an output very similar to what I’m looking for and that card expects some properties to be present that aren’t present by default.

We can make use of the Pulse::resolveUsers method like the Usage card does. In this case, it expects a collection of User ID values (which we have as keys on $user_mapping) and returns a “resolver” that can be used to load users and “decorate” them with the properties that the x-pulse::user-card component expects.

        $users = Pulse::resolveUsers( $user_mapping->keys() );

Building Data

Now we can actually build the data structure that the template will use. Again, this is very similar to how the Usage card is setup, we just have a different starting point for data to loop over.

For each entry in $user_mapping, we want to generate an object with key (User ID), user (the decorated User object), and count (our count of Chats for the user which we figured out above with reduce).

        return $user_mapping->map(
            fn($count, $key) => (object) [
                'key' => $key,
                'user' => $users->find($key),
                'count' => $count,
            ],
        );

The Rest

Now the with function (line 44) is pretty standard Volt stuff.

    public function with() : array
    {
        return [
            'users' => $this->buildUserData(),
        ];
    }

The template is almost an exact copy of the template for the out-of-the-box Usage card from Pulse.

<x-pulse::card :$cols :$rows :$class wire:poll.5s="">
    <x-pulse::card-header
        name="Recent Chats"
    >
        <x-slot:icon>
            <x-icon.chat-bubble-bottom-center-text />
        </x-slot:icon>
    </x-pulse::card-header>
    <x-pulse::scroll :$expand>
        @if ( $users->isEmpty() )
            <x-pulse::no-results />
        @else
            <div class="grid grid-cols-1 @lg:grid-cols-2 @3xl:grid-cols-3 @6xl:grid-cols-4 gap-2">
                @foreach ($users as $user_data)
                    <x-pulse::user-card
                        wire:key="{{ $user_data->key }}"
                        :user="$user_data->user"
                    >
                        <x-slot:stats>
                            {{ $user_data->count }}
                        </x-slot:stats>
                    </x-pulse::user-card>
                @endforeach
            </div>
        @endif
    </x-pulse::scroll>
</x-pulse>

On the Dashboard

Now we can add the custom card (chats-card-v) to the dashboard like any other card.

<x-pulse>
    <livewire:pulse.servers cols="full" />

    <livewire:pulse.usage cols="4" rows="2" />

    <livewire:pulse.queues cols="4" />

    <livewire:pulse.chats-card-v cols="4" />

    <livewire:pulse.slow-queries cols="8" />

    <livewire:pulse.exceptions cols="6" />

    <livewire:pulse.slow-requests cols="6" />

    <livewire:pulse.slow-jobs cols="6" />

    <livewire:pulse.slow-outgoing-requests cols="6" />
</x-pulse>

Wrapping Up

And there we have it. I’m not sure how really valuable creating the card as a Volt component is, but it was a really interesting exploration and pushed me to dig deeper into the internals of both Volt and Livewire.

The biggest takeaways for me were definitely:

  • Creating the Volt\Card Bridge class
  • Figuring out how to use the HasPeriod trait
  • Figuring out how to use the Pulse::resolveUsers function