Building a complex countdown table column

I'm trying to build a countdown into a table column. I'm currently using ViewColumn to do this with some alpine code to handle updating the remaining time. The table is displaying records from a ReverseAuction table. The ReverseAuction model has an attribute to calculate what time that auction will end because users in the system can take actions which would change the end time. I call this attribute true_ends_at. I have the table polling every 10 seconds to check for updates made by other users. If I output the true_ends_at timestamp in a table column, I see that this is correctly updating when other users affect the ending time. The problem is that the countdown column will not adjust to the correct time. The countdown just continues from whatever the state of the column was when the page was first loaded.
17 Replies
NolanN
NolanNOP2w ago
Here is the column in the table:
Tables\Columns\ViewColumn::make('true_ends_at')
->label('Ends in')
->sortable()
->view('filament.tables.countdown-column')
->tooltip(fn (ReverseAuction $record) => $record->true_ends_at->format(Table::$defaultDateTimeDisplayFormat)),
Tables\Columns\ViewColumn::make('true_ends_at')
->label('Ends in')
->sortable()
->view('filament.tables.countdown-column')
->tooltip(fn (ReverseAuction $record) => $record->true_ends_at->format(Table::$defaultDateTimeDisplayFormat)),
The view file that is being passed in:
<div class="grid gap-y-1 px-3 py-4 w-full">
<div x-data="{
past: false,
remaining: 0,
showCountdown: false,
endTime: new Date('{{ $getState()->toISOString() }}').getTime(),
formatTime(time) {
const days = Math.floor(time / (1000 * 60 * 60 * 24));
const hours = Math.floor((time % (1000 * 60 * 60 * 24)) / (1000 * 60 * 60));
const minutes = Math.floor((time % (1000 * 60 * 60)) / (1000 * 60));
const seconds = Math.floor((time % (1000 * 60)) / 1000);
return { days, hours, minutes, seconds };
},
updateValues() {
const now = new Date().getTime();
const remaining = this.endTime - now;
this.remaining = remaining > 0 ? remaining : 0;
this.past = remaining <= 0;
this.showCountdown = this.formatTime(remaining).hours <= 0 && !this.past;
},
init() {
const boundUpdate = this.updateValues.bind(this);
$store.countdown.subscribe($el, boundUpdate);
},
}">
<template x-if="!showCountdown && !past">
<div class="text-sm font-medium">{{ $getState()->shortAbsoluteDiffForHumans(parts: 2) }}</div>
</template>
<template x-if="showCountdown">
<div class="font-semibold"
:class="{
'text-red-600 dark:text-red-500': formatTime(remaining).hours <= 0 && formatTime(remaining)
.minutes <=
14
}">
<span x-text="formatTime(remaining).minutes"></span>m
<span x-text="formatTime(remaining).seconds"></span>s
</div>
</template>
<template x-if="past">
<div class="text-sm font-medium">Just ended</div>
</template>
</div>
</div>
<div class="grid gap-y-1 px-3 py-4 w-full">
<div x-data="{
past: false,
remaining: 0,
showCountdown: false,
endTime: new Date('{{ $getState()->toISOString() }}').getTime(),
formatTime(time) {
const days = Math.floor(time / (1000 * 60 * 60 * 24));
const hours = Math.floor((time % (1000 * 60 * 60 * 24)) / (1000 * 60 * 60));
const minutes = Math.floor((time % (1000 * 60 * 60)) / (1000 * 60));
const seconds = Math.floor((time % (1000 * 60)) / 1000);
return { days, hours, minutes, seconds };
},
updateValues() {
const now = new Date().getTime();
const remaining = this.endTime - now;
this.remaining = remaining > 0 ? remaining : 0;
this.past = remaining <= 0;
this.showCountdown = this.formatTime(remaining).hours <= 0 && !this.past;
},
init() {
const boundUpdate = this.updateValues.bind(this);
$store.countdown.subscribe($el, boundUpdate);
},
}">
<template x-if="!showCountdown && !past">
<div class="text-sm font-medium">{{ $getState()->shortAbsoluteDiffForHumans(parts: 2) }}</div>
</template>
<template x-if="showCountdown">
<div class="font-semibold"
:class="{
'text-red-600 dark:text-red-500': formatTime(remaining).hours <= 0 && formatTime(remaining)
.minutes <=
14
}">
<span x-text="formatTime(remaining).minutes"></span>m
<span x-text="formatTime(remaining).seconds"></span>s
</div>
</template>
<template x-if="past">
<div class="text-sm font-medium">Just ended</div>
</template>
</div>
</div>
And the store that is defined on the page:
<x-filament-panels::page>
<script>
document.addEventListener('alpine:init', () => {
Alpine.store('countdown', {
subscribers: new Set(),

subscribe(el, callback) {
this.subscribers.add({
el,
callback
});
},

tick() {
this.subscribers.forEach(({
el,
callback
}) => {
if (document.body.contains(el)) {
callback();
} else {
this.subscribers.delete({
el,
callback
});
}
});
}
})
})

if (!window.sharedInterval) {
window.sharedInterval = setInterval(() => {
Alpine.store('countdown').tick();
}, 1000);
}
</script>
<div>
{{ $this->table }}
</div>
</x-filament-panels::page>
<x-filament-panels::page>
<script>
document.addEventListener('alpine:init', () => {
Alpine.store('countdown', {
subscribers: new Set(),

subscribe(el, callback) {
this.subscribers.add({
el,
callback
});
},

tick() {
this.subscribers.forEach(({
el,
callback
}) => {
if (document.body.contains(el)) {
callback();
} else {
this.subscribers.delete({
el,
callback
});
}
});
}
})
})

if (!window.sharedInterval) {
window.sharedInterval = setInterval(() => {
Alpine.store('countdown').tick();
}, 1000);
}
</script>
<div>
{{ $this->table }}
</div>
</x-filament-panels::page>
Abmael Souza
Abmael Souza2w ago
i believe the solution could be this https://alpinejs.dev/magics/watch
Abmael Souza
Abmael Souza2w ago
using x-init you could pass
x-init="$watch('{{ $getState()->toISOString() }}', value => updateValues(new Date(value).getTime()))"
x-init="$watch('{{ $getState()->toISOString() }}', value => updateValues(new Date(value).getTime()))"
on the same div as x-data it would watch changes in the getState and recalculate everything
NolanN
NolanNOP2w ago
Ok, I think I see where you are going but I want to clarify a few things. I'm not using x-init so I'll put it in init() instead. Shouldn't this be $watch('endTime', value => ....) instead of $watch('{{ $getState()->toISOString() }}', value => ....)? Yeah, it seems that the key would have to match a property in x-data. This watch is not being triggered however, even though the state is changing. I think this is because when the table polls, Alpine is reinitialized. If I add console.log('init') to the beginning of the method, I do see that when the table polls and finds new data
init() {
console.log('init');
$watch('endTime', value => console.log('endTime changed', value))

this.updateValues();
const boundUpdate = this.updateValues.bind(this);
$store.countdown.subscribe($el, boundUpdate);
},
init() {
console.log('init');
$watch('endTime', value => console.log('endTime changed', value))

this.updateValues();
const boundUpdate = this.updateValues.bind(this);
$store.countdown.subscribe($el, boundUpdate);
},
Trying to simplify this to narrow down the issue... After changing to the following code, I see that init() is still running when the table polls and finds new data for the true_ends_at column. I can also verify that remaining is changing but the display does not update until the following poll before it updates. So at the moment the sequence is like this: - true_ends_at changes - table polls and new data is printed to console but display does not update - table polls again and there is NOT new data but the display updates with the new data from the previous poll
<div class="grid gap-y-1 px-3 py-4 w-full">
<div x-data="{
remaining: 0,
endTime: new Date('{{ $getState()->toISOString() }}').getTime(),
formatTime(time) {
const days = Math.floor(time / (1000 * 60 * 60 * 24));
const hours = Math.floor((time % (1000 * 60 * 60 * 24)) / (1000 * 60 * 60));
const minutes = Math.floor((time % (1000 * 60 * 60)) / (1000 * 60));
const seconds = Math.floor((time % (1000 * 60)) / 1000);
return { days, hours, minutes, seconds };
},
updateValues() {
const now = new Date().getTime();
const remaining = this.endTime - now;
this.remaining = remaining > 0 ? remaining : 0;
},
init() {
this.updateValues();
console.log('init: ' + this.remaining + ' ' + this.formatTime(this.remaining).seconds);
},
}">
<div class="font-semibold"
:class="{
'text-red-600 dark:text-red-500': formatTime(remaining).hours <= 0 && formatTime(remaining)
.minutes <=
14
}">
<span x-text="formatTime(remaining).minutes"></span>m
<span x-text="formatTime(remaining).seconds"></span>s
</div>
</div>
</div>
<div class="grid gap-y-1 px-3 py-4 w-full">
<div x-data="{
remaining: 0,
endTime: new Date('{{ $getState()->toISOString() }}').getTime(),
formatTime(time) {
const days = Math.floor(time / (1000 * 60 * 60 * 24));
const hours = Math.floor((time % (1000 * 60 * 60 * 24)) / (1000 * 60 * 60));
const minutes = Math.floor((time % (1000 * 60 * 60)) / (1000 * 60));
const seconds = Math.floor((time % (1000 * 60)) / 1000);
return { days, hours, minutes, seconds };
},
updateValues() {
const now = new Date().getTime();
const remaining = this.endTime - now;
this.remaining = remaining > 0 ? remaining : 0;
},
init() {
this.updateValues();
console.log('init: ' + this.remaining + ' ' + this.formatTime(this.remaining).seconds);
},
}">
<div class="font-semibold"
:class="{
'text-red-600 dark:text-red-500': formatTime(remaining).hours <= 0 && formatTime(remaining)
.minutes <=
14
}">
<span x-text="formatTime(remaining).minutes"></span>m
<span x-text="formatTime(remaining).seconds"></span>s
</div>
</div>
</div>
awcodes
awcodes2w ago
Just a random thought, maybe try entangling the end time $getState()
NolanN
NolanNOP7d ago
How exactly would I do that? endTime: $wire.$entangle('{{ $getState() }}') throws errors because it isn't found on the component. How would I specify a field from the record that is associated with that table row?
awcodes
awcodes7d ago
getStatePath()
NolanN
NolanNOP7d ago
I know that I can use $getRecord()->true_ends_at or $getState() but I need to entangle the property name Ok, let me try that I get an undefined variable error
awcodes
awcodes7d ago
Undefined for what exactly?
NolanN
NolanNOP7d ago
No description
NolanN
NolanNOP7d ago
Is $getStatePath() available on a ViewColumn ?
awcodes
awcodes7d ago
Maybe that’s only a form thing Sorry, I’m probably leading you down a wrong path. Do you have a repo you could share?
NolanN
NolanNOP7d ago
No, sorry. It is a private repo. I could upload the view I'm using for the column and the custom page. Would that work for you?
NolanN
NolanNOP7d ago
The countdown-column.blade.php that I just uploaded is stripped down from what it need to be in its final state. It should ultimately be using the countdown store to trigger an update of the countdown every second but I pulled some of that out to try to figure out what is going on
awcodes
awcodes2d ago
Sorry, not ignoring you, just had a medical thing happen. Hoping to get back to you soon.
NolanN
NolanNOP22h ago
No worries. Praying for a speedy recovery!

Did you find this page helpful?