Skip to content
LarawellUi

<x-widget.clock>

Clock

Live clocks: digital and analog, world clocks across timezones, countdowns to a date, and a stopwatch or timer. First paint from the server, then they tick in the browser. Works inside Livewire components: they keep ticking, and a running stopwatch keeps running, through renders.

php artisan larawell:add clock
Also adds
Button, Icon

Usage

Livewire

Inside a Livewire component the clocks keep ticking through every render, and world clocks keep their offsets from the visitor. A countdown follows its :to, so moving the deadline in PHP moves it. A stopwatch or timer is the browser's alone and keeps running through renders; its props are read once, when it first appears, so give it a wire:key that changes to start a new one. Listen for clock:finished to call the component when a countdown or timer reaches zero.

Blade
<div x-on:clock:finished="$wire.timeUp()" class="space-y-6">
    <x-widget.clock :timezone="$user->timezone" seconds date />

    <x-widget.clock.countdown :to="$auction->ends_at" label="Bidding closes in" />

    <x-widget.clock.stopwatch mode="timer" :duration="$round->seconds" wire:key="round-{{ $round->id }}" />
</div>

Examples

Digital

timezone is required, so the time the server paints is the time the browser ticks on. For a user's own time, store their timezone and pass it, e.g. :timezone="$user->timezone". hour12, seconds and date add the rest.

Mumbai
New York Saturday, 3 October
London office
Show code
Blade
<div class="flex flex-wrap items-end gap-10">
    <x-widget.clock timezone="Asia/Kolkata" label="Mumbai" />
    <x-widget.clock timezone="America/New_York" label="New York" hour12 seconds date />
    <x-widget.clock timezone="Europe/London" label="London office" size="sm" />
</div>

Analog

A face with hands. numbers adds 12, 3, 6 and 9; seconds can be turned off for a calmer face. Screen readers get the time in words.

London
Tokyo
New York
Show code
Blade
<div class="flex flex-wrap items-end gap-10">
    <x-widget.clock.analog timezone="Europe/London" label="London" />
    <x-widget.clock.analog timezone="Asia/Tokyo" label="Tokyo" numbers />
    <x-widget.clock.analog timezone="America/New_York" label="New York" :seconds="false" size="sm" />
</div>

World

Several cities at once, each with how far ahead or behind the visitor it is, "Tomorrow" when the date has rolled over, and day or night.

  • London +1h
  • New York −4h
  • Mumbai +5h 30m
  • Tokyo +9h
Show code
Blade
<x-widget.clock.world class="w-full" :zones="[
    'London' => 'Europe/London',
    'New York' => 'America/New_York',
    'Mumbai' => 'Asia/Kolkata',
    'Tokyo' => 'Asia/Tokyo',
]" />

Countdown

Counts down to a date: launches, sale ends. The server's clock is the reference, so a wrong device clock doesn't matter. At zero it shows the done message, announces it, and fires clock:finished.

Launch in
2 days, 4 hours left

Show code
Blade
<x-widget.clock.countdown label="Launch in" :to="now()->addDays(2)->addHours(4)" done="We're live!" />

Countdown inline

variant="inline" sits in running text. Short ones suit one-time codes and sessions; this one ends in 15 seconds.

Sale ends in 5 hours, 12 minutes left

Your code expires in 15 seconds left

Show code
Blade
<div class="flex flex-col gap-3 text-sm">
    <p>Sale ends in <x-widget.clock.countdown variant="inline" :to="now()->addHours(5)->addMinutes(12)" label="Sale ends in" /></p>
    <p>Your code expires in <x-widget.clock.countdown variant="inline" :to="now()->addSeconds(15)" label="Code expires in" done="Code expired. Request a new one." /></p>
</div>

Stopwatch

mode="stopwatch" counts up; mode="timer" counts down from duration (in seconds), announces the end and fires clock:finished. Pause keeps the time; Reset starts over.

Lap time
0:00.0

Plank
1:30

Show code
Blade
<div class="flex flex-wrap items-start gap-12">
    <x-widget.clock.stopwatch label="Lap time" />
    <x-widget.clock.stopwatch mode="timer" :duration="90" label="Plank" done="Done. Take a break." />
</div>

Props

Other attributes, such as autocomplete or data-*, are passed through to the element. class styles the component's outer wrapper.

<x-widget.clock>

Prop Default Description
timezone Required Required: an IANA timezone like "Europe/London".
seconds false
date false A line under the time: "Friday, 27 September".
label null
size 'md'

<x-widget.clock.analog>

Prop Default Description
timezone Required Required: an IANA timezone like "Europe/London".
seconds true
numbers false 12, 3, 6 and 9 on the face.
label null
size 'md'

<x-widget.clock.countdown>

Prop Default Description
to Required When it ends: a date string ("2026-12-31 23:59") in the app's timezone, or a Carbon/DateTime instance.
label 'Time left'
done "Time's up" Shown in place of the numbers, and announced, once it reaches zero.
variant 'boxes' boxes: a card per unit. inline: "2d 04h 12m 09s" in running text.
size 'md'

<x-widget.clock.stopwatch>

Prop Default Description
mode 'stopwatch' stopwatch counts up from zero; timer counts down from duration.
duration 60 Timer length in seconds, e.g. 1500 for 25 minutes.
label null
done "Time's up" Announced, and shown, when a timer reaches zero.
size 'md'

<x-widget.clock.world>

Prop Default Description
zones [] ['London' => 'Europe/London', 'Tokyo' => 'Asia/Tokyo'], or a list of ['label' => …, 'timezone' => …].
label 'World clocks'

Source

What larawell:add clock writes to your app with the default namespaces. Prefer to copy by hand? Take these files, plus the ones from the components it also adds , and the theme and base CSS.

resources/views/components/widget/clock/analog.blade.php Show
analog.blade.php
@props([
    // Required: an IANA timezone like "Europe/London".
    'timezone',
    'seconds' => true,
    // 12, 3, 6 and 9 on the face.
    'numbers' => false,
    'label' => null,
    'size' => 'md',
])

@php
    // Required, so the server paints the same time the browser will; pass the user's own for "their" time.
    if (! isset($timezone) || ! is_string($timezone) || $timezone === '') {
        throw new \InvalidArgumentException('<x-widget.clock.analog> needs a timezone, e.g. timezone="Europe/London" or :timezone="$user->timezone".');
    }
    $now = now($timezone);
    $sizes = ['sm' => 'size-24', 'md' => 'size-36', 'lg' => 'size-52'];
    // Hand angles, clockwise from 12. resources/js/widget/clock sets the same rotation every second.
    $angles = [
        'hour' => ($now->hour % 12) * 30 + $now->minute * 0.5,
        'minute' => $now->minute * 6 + $now->second * 0.1,
        'second' => $now->second * 6,
    ];
    // The SVG transform attribute, not style="": a Content Security Policy doesn't restrict it, and rotate(a 50 50)
    // turns the hand about the centre by itself.
    $hand = fn (string $name): string => "rotate({$angles[$name]} 50 50)";
@endphp

<div
    data-clock="analog"
    data-timezone="{{ $timezone }}"
    {{ $attributes->class(['inline-flex flex-col items-center gap-2']) }}
>
    <svg viewBox="0 0 100 100" aria-hidden="true" class="{{ $sizes[$size] ?? $sizes['md'] }}">
        <circle cx="50" cy="50" r="48" class="fill-surface stroke-line" stroke-width="2" />
        @for ($i = 0; $i < 12; $i++)
            {{-- Longer, darker marks at the quarters. --}}
            <line
                x1="50" y1="{{ $i % 3 === 0 ? 6 : 7 }}" x2="50" y2="{{ $i % 3 === 0 ? 13 : 10 }}"
                stroke-linecap="round" stroke-width="{{ $i % 3 === 0 ? 2.5 : 1.5 }}"
                class="{{ $i % 3 === 0 ? 'stroke-foreground' : 'stroke-muted' }}"
                transform="rotate({{ $i * 30 }} 50 50)"
            />
        @endfor
        @if ($numbers)
            @foreach ([12 => [50, 23], 3 => [79, 53.5], 6 => [50, 84], 9 => [21, 53.5]] as $n => [$x, $y])
                <text x="{{ $x }}" y="{{ $y }}" text-anchor="middle" class="fill-foreground text-[9px] font-semibold">{{ $n }}</text>
            @endforeach
        @endif

        <line data-clock-hand="hour" x1="50" y1="50" x2="50" y2="28" stroke-linecap="round" stroke-width="4" class="stroke-foreground" transform="{{ $hand('hour') }}" />
        <line data-clock-hand="minute" x1="50" y1="50" x2="50" y2="15" stroke-linecap="round" stroke-width="3" class="stroke-foreground" transform="{{ $hand('minute') }}" />
        @if ($seconds)
            <line data-clock-hand="second" x1="50" y1="58" x2="50" y2="12" stroke-linecap="round" stroke-width="1.5" class="stroke-error" transform="{{ $hand('second') }}" />
        @endif
        <circle cx="50" cy="50" r="3" class="{{ $seconds ? 'fill-error' : 'fill-foreground' }}" />
    </svg>

    @if ($label)
        <span class="text-foreground text-sm font-medium">{{ $label }}</span>
    @endif
    {{-- The face is a picture; this is the time in words for screen readers, updated each minute. --}}
    <time data-clock-spoken datetime="{{ $now->toIso8601String() }}" class="sr-only">{{ $now->format('g:i A') }}</time>
</div><?php /* No newline after this: PHP drops it after a closing tag, so no space trails the component in running text. */ ?>
resources/views/components/widget/clock/countdown.blade.php Show
countdown.blade.php
@props([
    // When it ends: a date string ("2026-12-31 23:59") in the app's timezone, or a Carbon/DateTime instance.
    'to',
    'label' => 'Time left',
    // Shown in place of the numbers, and announced, once it reaches zero.
    'done' => "Time's up",
    // boxes: a card per unit. inline: "2d 04h 12m 09s" in running text.
    'variant' => 'boxes',
    'size' => 'md',
])

@php
    $end = \Illuminate\Support\Carbon::parse($to, config('app.timezone'));
    $now = now();
    // Rounded up, as the script does, so the first tick in the browser doesn't jump a second up.
    $left = max(0, (int) ceil($now->diffInSeconds($end, false)));
    // Days only when there are any to begin with, so the layout doesn't shift as it runs.
    $withDays = $left >= 86400;
    $units = [
        'days' => intdiv($left, 86400),
        'hours' => intdiv($left % 86400, 3600),
        'minutes' => intdiv($left % 3600, 60),
        'seconds' => $left % 60,
    ];
    if (! $withDays) {
        unset($units['days']);
    }
    $names = ['days' => ['Days', 'd'], 'hours' => ['Hours', 'h'], 'minutes' => ['Minutes', 'm'], 'seconds' => ['Seconds', 's']];
    $pad = fn (string $unit, int $n): string => $unit === 'days' ? (string) $n : str_pad((string) $n, 2, '0', STR_PAD_LEFT);
    // Spoken without the zero units ("13 seconds left", not "0 hours, 0 minutes, 13 seconds left").
    $spoken = collect($units)->filter()->whenEmpty(fn ($c) => $c->put('seconds', 0))->map(fn (int $n, string $unit): string => $n.' '.($n === 1 ? rtrim($unit, 's') : $unit))->implode(', ');
    $inline = $variant === 'inline';
    // Phones share the width between the boxes, so md and lg step down a size there.
    $numbers = ['sm' => 'text-xl', 'md' => 'text-2xl sm:text-3xl', 'lg' => 'text-4xl sm:text-5xl'][$size] ?? 'text-2xl sm:text-3xl';
@endphp

{{-- data-now is the server's clock, so a visitor whose device clock is off still sees the right time left. --}}
{{-- Inline, everything is a span: it sits inside running text, and a div would end the paragraph. --}}
<{{ $inline ? 'span' : 'div' }}
    data-clock="countdown"
    data-to="{{ $end->getTimestampMs() }}"
    data-now="{{ $now->getTimestampMs() }}"
    data-done="{{ $done }}"
    @if ($left === 0) data-finished @endif
    {{ $attributes->class(['group/countdown', 'inline-flex flex-col gap-2 max-sm:flex max-sm:w-full' => $variant !== 'inline', 'inline' => $variant === 'inline']) }}
>
    @if ($variant !== 'inline' && $label)
        <span class="text-foreground/60 text-xs">{{ $label }}</span>
    @endif

    {{-- role="timer" without aria-live: not announced every second. The words below read when reached. --}}
    <{{ $inline ? 'span' : 'div' }} role="timer" aria-label="{{ $label }}" class="group-data-finished/countdown:hidden">
        <span data-clock-spoken class="sr-only">{{ $spoken }} left</span>
        @if ($variant === 'inline')
            <span aria-hidden="true" class="text-foreground font-semibold tabular-nums">
                @foreach ($units as $unit => $n)
                    <span data-clock-unit="{{ $unit }}">{{ $pad($unit, $n) }}</span>{{ $names[$unit][1] }}
                @endforeach
            </span>
        @else
            <div aria-hidden="true" class="flex gap-2 max-sm:gap-1.5">
                @foreach ($units as $unit => $n)
                    <div class="bg-field flex min-w-16 flex-col items-center rounded-xl px-3 py-2 max-sm:min-w-0 max-sm:flex-1 max-sm:px-1">
                        <span data-clock-unit="{{ $unit }}" class="{{ $numbers }} text-foreground leading-tight font-semibold tabular-nums">{{ $pad($unit, $n) }}</span>
                        <span class="text-foreground/60 text-xs">{{ $names[$unit][0] }}</span>
                    </div>
                @endforeach
            </div>
        @endif
    </{{ $inline ? 'span' : 'div' }}>

    {{-- Always present and empty until the end: a status region only announces text added while it is on the page. --}}
    <{{ $inline ? 'span' : 'p' }} role="status" data-clock-done class="text-foreground font-medium">{{ $left === 0 ? $done : '' }}</{{ $inline ? 'span' : 'p' }}>
</{{ $inline ? 'span' : 'div' }}><?php /* No newline after this: PHP drops it after a closing tag, so no space trails the component in running text. */ ?>
resources/views/components/widget/clock/index.blade.php Show
index.blade.php
@props([
    // Required: an IANA timezone like "Europe/London".
    'timezone',
    'hour12' => false,
    'seconds' => false,
    // A line under the time: "Friday, 27 September".
    'date' => false,
    'label' => null,
    'size' => 'md',
])

@php
    // The first paint comes from the server so there's no empty flash; resources/js/widget/clock takes over.
    // Required, so the server paints the same time the browser will; pass the user's own for "their" time.
    if (! isset($timezone) || ! is_string($timezone) || $timezone === '') {
        throw new \InvalidArgumentException('<x-widget.clock> needs a timezone, e.g. timezone="Europe/London" or :timezone="$user->timezone".');
    }
    $now = now($timezone);
    $sizes = ['sm' => 'text-lg', 'md' => 'text-3xl', 'lg' => 'text-5xl'];
@endphp

<div
    data-clock="digital"
    data-timezone="{{ $timezone }}"
    @if ($hour12) data-hour12 @endif
    @if ($seconds) data-seconds @endif
    {{ $attributes->class(['inline-flex flex-col gap-1']) }}
>
    @if ($label)
        <span class="text-foreground/60 text-xs">{{ $label }}</span>
    @endif

    {{-- Not a live region: announcing every second would drown out everything else. It reads when reached. --}}
    <time datetime="{{ $now->toIso8601String() }}" class="{{ $sizes[$size] ?? $sizes['md'] }} text-foreground leading-none font-semibold tracking-tight tabular-nums">
        <span data-clock-time>{{ $now->format($hour12 ? 'g:i' : 'H:i') }}{{ $seconds ? $now->format(':s') : '' }}</span>
        @if ($hour12)
            <span data-clock-period class="text-[0.45em] font-medium tracking-normal">{{ $now->format('A') }}</span>
        @endif
    </time>

    @if ($date)
        <span data-clock-date class="text-foreground/60 text-sm">{{ $now->format('l, j F') }}</span>
    @endif
</div><?php /* No newline after this: PHP drops it after a closing tag, so no space trails the component in running text. */ ?>
resources/views/components/widget/clock/stopwatch.blade.php Show
stopwatch.blade.php
@props([
    // stopwatch counts up from zero; timer counts down from duration.
    'mode' => 'stopwatch',
    // Timer length in seconds, e.g. 1500 for 25 minutes.
    'duration' => 60,
    'label' => null,
    // Announced, and shown, when a timer reaches zero.
    'done' => "Time's up",
    'size' => 'md',
])

@php
    $timer = $mode === 'timer';
    $duration = max(1, (int) $duration);
    // Same format as resources/js/widget/clock: m:ss (h:mm:ss past an hour), plus tenths on a stopwatch.
    $start = $timer
        ? ($duration >= 3600 ? sprintf('%d:%02d:%02d', intdiv($duration, 3600), intdiv($duration % 3600, 60), $duration % 60) : sprintf('%d:%02d', intdiv($duration, 60), $duration % 60))
        : '0:00.0';
    $numbers = ['sm' => 'text-2xl', 'md' => 'text-4xl', 'lg' => 'text-6xl'][$size] ?? 'text-4xl';
@endphp

{{-- wire:ignore: all of it is the browser's (running or not, the time, the button's label), and a Livewire render would put
     back the server's 0:00.0 and Start, stopping it. So its props are read once, when it first appears. --}}
<div
    wire:ignore
    data-clock="{{ $timer ? 'timer' : 'stopwatch' }}"
    @if ($timer) data-duration="{{ $duration }}" data-done="{{ $done }}" @endif
    {{ $attributes->class(['group/clock inline-flex flex-col items-center gap-3']) }}
>
    @if ($label)
        <span class="text-foreground/60 text-xs">{{ $label }}</span>
    @endif

    {{-- Not a live region; it reads when reached. The status line below announces the end of a timer. --}}
    <div role="timer" @if ($label) aria-label="{{ $label }}" @endif data-clock-display class="{{ $numbers }} text-foreground leading-none font-semibold tracking-tight tabular-nums group-data-finished/clock:text-error">{{ $start }}</div>

    <div class="flex gap-2">
        <x-widget.button size="sm" data-clock-toggle class="min-w-24">
            <x-widget.icon name="play" class="size-4 group-data-running/clock:hidden" />
            <x-widget.icon name="pause" class="hidden size-4 group-data-running/clock:block" />
            <span data-clock-toggle-label>Start</span>
        </x-widget.button>
        <x-widget.button size="sm" variant="neutral" data-clock-reset>
            <x-widget.icon name="rotate-ccw" class="size-4" />
            Reset
        </x-widget.button>
    </div>

    <p role="status" data-clock-done class="text-error text-sm font-medium"></p>
</div><?php /* No newline after this: PHP drops it after a closing tag, so no space trails the component in running text. */ ?>
resources/views/components/widget/clock/world.blade.php Show
world.blade.php
@props([
    // ['London' => 'Europe/London', 'Tokyo' => 'Asia/Tokyo'], or a list of ['label' => …, 'timezone' => …].
    'zones' => [],
    'hour12' => false,
    'label' => 'World clocks',
])

@php
    $zones = collect($zones)->map(fn (mixed $zone, int|string $key): array => is_array($zone)
        ? ['label' => (string) $zone['label'], 'timezone' => (string) $zone['timezone']]
        : ['label' => (string) $key, 'timezone' => (string) $zone])->values();

    // Offsets and "tomorrow" are relative to the visitor, whom only the browser knows; the server's first
    // paint uses the app's timezone and resources/js/widget/clock corrects it straight away.
    $here = now(config('app.timezone'));
    $relative = function (\Carbon\CarbonInterface $there) use ($here): string {
        $minutes = $there->utcOffset() - $here->utcOffset();
        if ($minutes === 0) {
            return 'Same time';
        }
        $sign = $minutes > 0 ? '+' : '−';
        $minutes = abs($minutes);

        return $sign.intdiv($minutes, 60).'h'.($minutes % 60 ? ' '.($minutes % 60).'m' : '');
    };
    $day = fn (\Carbon\CarbonInterface $there): ?string => match ($there->toDateString() <=> $here->toDateString()) {
        1 => 'Tomorrow',
        -1 => 'Yesterday',
        default => null,
    };
@endphp

<ul aria-label="{{ $label }}" {{ $attributes->class(['grid gap-3 sm:grid-cols-[repeat(auto-fit,minmax(10rem,1fr))]']) }}>
    @foreach ($zones as $zone)
        @php
            $there = now($zone['timezone']);
            $night = $there->hour < 6 || $there->hour >= 18;
            $dayHint = $day($there);
        @endphp
        <li
            data-clock="world"
            data-timezone="{{ $zone['timezone'] }}"
            @if ($hour12) data-hour12 @endif
            class="border-line bg-surface flex items-start justify-between gap-3 rounded-2xl border p-4"
        >
            <div class="flex min-w-0 flex-col gap-1">
                <span class="text-foreground truncate text-sm font-medium">{{ $zone['label'] }}</span>
                <time datetime="{{ $there->toIso8601String() }}" class="text-foreground text-2xl leading-none font-semibold tabular-nums">
                    <span data-clock-time>{{ $there->format($hour12 ? 'g:i' : 'H:i') }}</span>
                    @if ($hour12)
                        <span data-clock-period class="text-xs font-medium">{{ $there->format('A') }}</span>
                    @endif
                </time>
                <span class="text-foreground/60 text-xs">
                    <span data-clock-offset>{{ $relative($there) }}</span><span data-clock-day>{{ $dayHint ? ', '.$dayHint : '' }}</span>
                </span>
            </div>
            {{-- Day or night there (6:00 to 18:00 counts as day); the time already says it for screen readers. --}}
            <span data-clock-daylight aria-hidden="true" class="text-foreground/50 shrink-0">
                <x-widget.icon name="sun" :class="$night ? 'size-5 hidden' : 'size-5'" data-clock-sun />
                <x-widget.icon name="moon" :class="$night ? 'size-5' : 'size-5 hidden'" data-clock-moon />
            </span>
        </li>
    @endforeach
</ul><?php /* No newline after this: PHP drops it after a closing tag, so no space trails the component in running text. */ ?>
resources/js/widget/clock/index.js Show
index.js
// Drives <x-widget.clock> and its variants. The server paints the first frame; this keeps them ticking.
// Every clock reads the device time and formats it for its own timezone (data-timezone, set by the Blade views).
// Events: clock:finished on a countdown or timer when it reaches zero (bubbles).

const LOCALE = 'en-US';
const formatters = new Map();

// Intl formatters are costly to build, so one per timezone/setting.
function formatter(timeZone, options) {
    const key = `${timeZone ?? ''}|${JSON.stringify(options)}`;
    if (!formatters.has(key)) {
        formatters.set(key, new Intl.DateTimeFormat(LOCALE, { ...options, ...(timeZone ? { timeZone } : {}) }));
    }

    return formatters.get(key);
}

// The wall-clock parts of `date` in a timezone: { year, month, day, hour, minute, second, weekday, monthName }.
function partsIn(date, timeZone) {
    const parts = {};
    for (const { type, value } of formatter(timeZone, {
        year: 'numeric', month: 'numeric', day: 'numeric', hour: 'numeric', minute: 'numeric', second: 'numeric',
        weekday: 'long', hourCycle: 'h23',
    }).formatToParts(date)) {
        parts[type] = value;
    }
    parts.monthName = formatter(timeZone, { month: 'long' }).format(date);

    return {
        year: Number(parts.year), month: Number(parts.month), day: Number(parts.day),
        hour: Number(parts.hour) % 24, minute: Number(parts.minute), second: Number(parts.second),
        weekday: parts.weekday, monthName: parts.monthName,
    };
}

const pad = (n) => String(n).padStart(2, '0');

// "09:41" / "9:41" + "AM", matching what the Blade views print.
function timeText({ hour, minute, second }, { hour12 = false, seconds = false } = {}) {
    const h = hour12 ? String(hour % 12 || 12) : pad(hour);

    return { time: `${h}:${pad(minute)}${seconds ? `:${pad(second)}` : ''}`, period: hour < 12 ? 'AM' : 'PM' };
}

// Minutes the timezone is ahead of UTC at `date`.
function offsetMinutes(date, timeZone) {
    const p = partsIn(date, timeZone);

    return Math.round((Date.UTC(p.year, p.month - 1, p.day, p.hour, p.minute, p.second) - date.getTime()) / 60000 / 15) * 15;
}

const setText = (el, text) => {
    if (el && el.textContent !== text) {
        el.textContent = text;
    }
};

// --- Clocks: digital, analog, world -------------------------------------------------------------------

function tickDigital(el, now) {
    const p = partsIn(now, el.dataset.timezone);
    const { time, period } = timeText(p, { hour12: 'hour12' in el.dataset, seconds: 'seconds' in el.dataset });
    setText(el.querySelector('[data-clock-time]'), time);
    setText(el.querySelector('[data-clock-period]'), period);
    setText(el.querySelector('[data-clock-date]'), `${p.weekday}, ${p.day} ${p.monthName}`);
    el.querySelector('time')?.setAttribute('datetime', now.toISOString());
}

function tickAnalog(el, now) {
    const p = partsIn(now, el.dataset.timezone);
    const angles = { hour: (p.hour % 12) * 30 + p.minute * 0.5, minute: p.minute * 6 + p.second * 0.1, second: p.second * 6 };
    for (const [name, angle] of Object.entries(angles)) {
        const hand = el.querySelector(`[data-clock-hand="${name}"]`);
        if (hand) {
            // The server's first frame uses the SVG attribute rotate(a 50 50); CSS rotate() has no centre argument,
            // so give it the clock's centre as its origin. Set from here, which a Content Security Policy allows.
            hand.style.transformOrigin = '50px 50px';
            hand.style.transform = `rotate(${angle}deg)`;
        }
    }
    const { time, period } = timeText(p, { hour12: true });
    setText(el.querySelector('[data-clock-spoken]'), `${time} ${period}`);
}

function tickWorld(el, now) {
    const p = partsIn(now, el.dataset.timezone);
    const { time, period } = timeText(p, { hour12: 'hour12' in el.dataset });
    setText(el.querySelector('[data-clock-time]'), time);
    setText(el.querySelector('[data-clock-period]'), period);

    // Relative to the visitor: "+8h", "−5h 30m", "Same time"; and whether it's already tomorrow there.
    const here = partsIn(now);
    let diff = offsetMinutes(now, el.dataset.timezone) - offsetMinutes(now);
    const sign = diff > 0 ? '+' : '−';
    diff = Math.abs(diff);
    setText(el.querySelector('[data-clock-offset]'), diff === 0 ? 'Same time' : `${sign}${Math.floor(diff / 60)}h${diff % 60 ? ` ${diff % 60}m` : ''}`);
    const dayDiff = Math.sign(Date.UTC(p.year, p.month - 1, p.day) - Date.UTC(here.year, here.month - 1, here.day));
    setText(el.querySelector('[data-clock-day]'), dayDiff > 0 ? ', Tomorrow' : dayDiff < 0 ? ', Yesterday' : '');

    const night = p.hour < 6 || p.hour >= 18;
    el.querySelector('[data-clock-sun]')?.classList.toggle('hidden', night);
    el.querySelector('[data-clock-moon]')?.classList.toggle('hidden', !night);
}

// --- Countdown ------------------------------------------------------------------------------------------

// Server time minus device time, per countdown, measured when the page loaded. Only a big difference counts: the
// render-to-script delay (a slow network, a page served from cache) also shows up here, and treating that as
// skew would make the countdown run late by it. A device clock that's really off is usually off by minutes.
const skew = new WeakMap();
const SKEW_THRESHOLD_MS = 60_000;

function tickCountdown(el) {
    if (!skew.has(el)) {
        const measured = Number(el.dataset.now) - Date.now();
        skew.set(el, Math.abs(measured) > SKEW_THRESHOLD_MS ? measured : 0);
    }
    // Rounded up, as the server does, so the first tick doesn't jump a second up and 0 means the time is up.
    const left = Math.max(0, Math.ceil((Number(el.dataset.to) - (Date.now() + skew.get(el))) / 1000));
    const units = { days: Math.floor(left / 86400), hours: Math.floor((left % 86400) / 3600), minutes: Math.floor((left % 3600) / 60), seconds: left % 60 };
    const shown = [];
    for (const [unit, n] of Object.entries(units)) {
        const cell = el.querySelector(`[data-clock-unit="${unit}"]`);
        if (cell) {
            setText(cell, unit === 'days' ? String(n) : pad(n));
            if (n > 0) {
                shown.push(`${n} ${n === 1 ? unit.slice(0, -1) : unit}`);
            }
        }
    }
    // Hours absorb the days when the countdown started with less than a day to go (no days cell).
    if (!el.querySelector('[data-clock-unit="days"]')) {
        setText(el.querySelector('[data-clock-unit="hours"]'), pad(units.hours + units.days * 24));
    }
    // Spoken without the zero units, like the server.
    setText(el.querySelector('[data-clock-spoken]'), `${shown.join(', ') || '0 seconds'} left`);

    if (left === 0 && !('finished' in el.dataset)) {
        finish(el);
    }
}

function finish(el) {
    el.dataset.finished = '';
    setText(el.querySelector('[data-clock-done]'), el.dataset.done ?? '');
    el.dispatchEvent(new CustomEvent('clock:finished', { bubbles: true }));
}

// --- Stopwatch and timer --------------------------------------------------------------------------------

// Per element: { elapsed (ms before the current run), startedAt (performance.now() of the current run, or null) }.
const runs = new WeakMap();
// Per running timer: the timeout that ends it even in a background tab (see start()).
const finishTimers = new WeakMap();

function elapsedOf(el) {
    const run = runs.get(el) ?? { elapsed: 0, startedAt: null };

    return run.elapsed + (run.startedAt === null ? 0 : performance.now() - run.startedAt);
}

function durationText(ms, tenths) {
    const total = tenths ? Math.floor(ms / 100) / 10 : Math.ceil(ms / 1000);
    const whole = Math.floor(total);
    const h = Math.floor(whole / 3600);
    const m = Math.floor((whole % 3600) / 60);
    const s = whole % 60;
    const clock = h > 0 ? `${h}:${pad(m)}:${pad(s)}` : `${m}:${pad(s)}`;

    return tenths ? `${clock}.${Math.floor((total * 10) % 10)}` : clock;
}

function renderStopwatch(el) {
    const timer = el.dataset.clock === 'timer';
    const elapsed = elapsedOf(el);
    const display = el.querySelector('[data-clock-display]');
    if (!timer) {
        setText(display, durationText(elapsed, true));

        return;
    }
    const left = Math.max(0, Number(el.dataset.duration) * 1000 - elapsed);
    setText(display, durationText(left, false));
    if (left === 0 && !('finished' in el.dataset)) {
        pause(el);
        finish(el);
        setText(el.querySelector('[data-clock-toggle-label]'), 'Start');
    }
}

function start(el) {
    if ('finished' in el.dataset) {
        reset(el);
    }
    const run = runs.get(el) ?? { elapsed: 0, startedAt: null };
    run.startedAt = performance.now();
    runs.set(el, run);
    el.dataset.running = '';
    setText(el.querySelector('[data-clock-toggle-label]'), 'Pause');
    loop();
    // Browsers pause animation frames in a background tab, so a timer's end is also scheduled with a timeout:
    // clock:finished and "Time's up" then happen on time, not when the tab is looked at again.
    if (el.dataset.clock === 'timer') {
        clearTimeout(finishTimers.get(el));
        const left = Math.max(0, Number(el.dataset.duration) * 1000 - elapsedOf(el));
        finishTimers.set(el, setTimeout(() => renderStopwatch(el), left + 20));
    }
}

function pause(el) {
    clearTimeout(finishTimers.get(el));
    const run = runs.get(el);
    if (run?.startedAt != null) {
        run.elapsed += performance.now() - run.startedAt;
        run.startedAt = null;
    }
    delete el.dataset.running;
    setText(el.querySelector('[data-clock-toggle-label]'), elapsedOf(el) > 0 ? 'Resume' : 'Start');
}

function reset(el) {
    clearTimeout(finishTimers.get(el));
    runs.set(el, { elapsed: 0, startedAt: null });
    delete el.dataset.running;
    delete el.dataset.finished;
    setText(el.querySelector('[data-clock-done]'), '');
    setText(el.querySelector('[data-clock-toggle-label]'), 'Start');
    renderStopwatch(el);
}

// Runs every frame only while something is running; the display changes every tenth of a second.
let looping = false;
function loop() {
    if (looping) {
        return;
    }
    looping = true;
    const frame = () => {
        const running = document.querySelectorAll('[data-clock="stopwatch"][data-running], [data-clock="timer"][data-running]');
        running.forEach(renderStopwatch);
        if (running.length) {
            requestAnimationFrame(frame);
        } else {
            looping = false;
        }
    };
    requestAnimationFrame(frame);
}

document.addEventListener('click', (event) => {
    const toggle = event.target.closest?.('[data-clock-toggle]');
    const resetButton = event.target.closest?.('[data-clock-reset]');
    const el = (toggle ?? resetButton)?.closest('[data-clock="stopwatch"], [data-clock="timer"]');
    if (!el) {
        return;
    }
    if (resetButton) {
        reset(el);
    } else if ('running' in el.dataset) {
        pause(el);
    } else {
        start(el);
    }
});

// --- One tick for all the clocks, on the second ---------------------------------------------------------

function tick() {
    const now = new Date();
    document.querySelectorAll('[data-clock="digital"]').forEach((el) => tickDigital(el, now));
    document.querySelectorAll('[data-clock="analog"]').forEach((el) => tickAnalog(el, now));
    document.querySelectorAll('[data-clock="world"]').forEach((el) => tickWorld(el, now));
    document.querySelectorAll('[data-clock="countdown"]:not([data-finished])').forEach(tickCountdown);
}

tick();
// Line the ticks up with the device's second, so seconds change together and on time.
setTimeout(() => {
    tick();
    setInterval(tick, 1000);
}, 1000 - (Date.now() % 1000));

export const clock = { start, pause, reset };

// A Livewire render puts back the server's first frame: its time, and world clocks' offsets from the app's timezone
// rather than the visitor's. Tick straight away, so that frame never shows. (Stopwatches and timers are wire:ignore'd.)
const hook = (Livewire) => Livewire.hook('morphed', () => tick());
if (window.Livewire) {
    hook(window.Livewire);
} else {
    document.addEventListener('livewire:init', () => hook(window.Livewire));
}