Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 24 additions & 0 deletions e2e/tools/clock.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import { test, expect } from '@playwright/test';

// The Clock island lazy-loads via ToolHost and only starts ticking (and setting
// the tab title) once hydrated — retry the first assertion until that happens.
test('ticks live and reflects the time zone + format controls', async ({ page }) => {
await page.goto('/tools/clock');
await page.waitForLoadState('networkidle').catch(() => {});

// The live clock mirrors HH:MM:SS into the tab title once it is running.
await expect(async () => {
await expect(page).toHaveTitle(/🕐 \d{2}:\d{2}:\d{2}/, { timeout: 2000 });
}).toPass({ timeout: 30_000 });

// Big digital readout is visible.
await expect(page.getByText(/\d{2}:\d{2}:\d{2}/).first()).toBeVisible();

// Switch to UTC → the offset line reads 'UTC · UTC'.
await page.getByLabel('Time zone').selectOption('UTC');
await expect(page.getByText('UTC · UTC')).toBeVisible();

// 12-hour format surfaces an AM/PM marker.
await page.getByRole('button', { name: '12h' }).click();
await expect(page.getByText(/\b(AM|PM)\b/).first()).toBeVisible();
});
125 changes: 125 additions & 0 deletions src/islands/calculators/Clock.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
import { useEffect, useRef, useState } from 'react';
import { Locate } from 'lucide-react';
import { CopyButton } from '@/components/ui/CopyButton';
import { useTabTitle } from '@/hooks/useTabTitle';
import { clockReadout, detectTimeZone, listTimeZones, type ClockReadout } from '@/tools/calculators/clock.lib';
import type { Lang } from '@/i18n/config';

const LOCALE: Record<Lang, string> = { en: 'en-US', id: 'id-ID' };

const TR: Record<Lang, {
intro: string; timezone: string; useMine: string; format: string;
date: string; unix: string; seconds: string; millis: string; copyMs: string; copyIso: string;
precision: string;
}> = {
en: {
intro: 'The current time, ticking live in real time — with milliseconds and microseconds — for any time zone. Runs entirely in your browser using your device clock.',
timezone: 'Time zone', useMine: 'My zone', format: 'Format',
date: 'Date', unix: 'Unix time', seconds: 's', millis: 'ms', copyMs: 'Copy ms', copyIso: 'Copy ISO',
precision: 'Milliseconds and microseconds are read from the browser high-resolution timer. Browsers clamp this timer for security, so the smallest digits are best-effort, not a true hardware microsecond clock.',
},
id: {
intro: 'Waktu saat ini, berdetak langsung secara real-time — dengan milidetik dan mikrodetik — untuk zona waktu apa pun. Berjalan sepenuhnya di browser Anda memakai jam perangkat.',
timezone: 'Zona waktu', useMine: 'Zona saya', format: 'Format',
date: 'Tanggal', unix: 'Waktu Unix', seconds: 'd', millis: 'md', copyMs: 'Salin ms', copyIso: 'Salin ISO',
precision: 'Milidetik dan mikrodetik dibaca dari high-resolution timer browser. Browser membatasi timer ini demi keamanan, jadi digit terkecil bersifat best-effort, bukan jam mikrodetik perangkat keras sebenarnya.',
},
};

export default function Clock({ lang = 'en' }: { lang?: Lang }) {
const t = TR[lang] ?? TR.en;
const locale = LOCALE[lang] ?? LOCALE.en;

const [tz, setTz] = useState(() => detectTimeZone());
const [hour12, setHour12] = useState(false);
const [ro, setRo] = useState<ClockReadout>(() => clockReadout(Date.now(), detectTimeZone(), false, locale));
const zones = useRef<string[]>(listTimeZones());

useEffect(() => {
// A single high-resolution clock: anchor the wall-clock epoch to the
// monotonic timer once, then advance it every animation frame.
const base = Date.now() - performance.now();
let raf = 0;
const loop = () => {
setRo(clockReadout(base + performance.now(), tz, hour12, locale));
raf = requestAnimationFrame(loop);
};
raf = requestAnimationFrame(loop);
return () => cancelAnimationFrame(raf);
}, [tz, hour12, locale]);

// Mirror HH:MM:SS in the tab title (updates once per second — the string only
// changes when the second does) so the time is visible from another tab.
useTabTitle(`🕐 ${ro.hh}:${ro.mm}:${ro.ss}`);

const iso = new Date(ro.epochMs).toISOString();

return (
<div className="space-y-5">
<p className="text-sm text-muted-foreground">{t.intro}</p>

<div className="border-2 border-border p-6 text-center shadow-brutal">
<div className="font-mono font-black tabular-nums leading-none">
<span className="text-6xl sm:text-7xl">{ro.hh}:{ro.mm}:{ro.ss}</span>
<span className="text-3xl text-accent sm:text-4xl">.{ro.millis}</span>
<span className="text-2xl text-muted-foreground sm:text-3xl"> {ro.micros}</span>
{ro.dayPeriod && <span className="ml-2 text-2xl sm:text-3xl">{ro.dayPeriod}</span>}
</div>
<div className="mt-3 text-sm font-semibold">{ro.dateLabel}</div>
<div className="text-xs text-muted-foreground">{tz.replace('_', ' ')} · {ro.offsetLabel}</div>
</div>

<div className="flex flex-wrap items-end gap-4">
<label className="flex flex-col gap-1 text-sm">
<span className="font-bold uppercase tracking-wide text-muted-foreground">{t.timezone}</span>
<div className="flex gap-2">
<select
aria-label={t.timezone}
value={tz}
onChange={e => setTz(e.target.value)}
className="h-10 max-w-[16rem] border-2 border-border bg-muted px-2 text-sm outline-none focus:shadow-brutal-sm"
>
{zones.current.map(z => (
<option key={z} value={z}>{z.replace('_', ' ')}</option>
))}
</select>
<button
type="button"
onClick={() => setTz(detectTimeZone())}
className="inline-flex h-10 items-center gap-1 border-2 border-border px-3 text-xs font-bold uppercase tracking-wide hover:bg-muted"
>
<Locate className="h-4 w-4" aria-hidden />{t.useMine}
</button>
</div>
</label>

<div className="flex flex-col gap-1 text-sm">
<span className="font-bold uppercase tracking-wide text-muted-foreground">{t.format}</span>
<div className="flex overflow-hidden border-2 border-border">
{([['24h', false], ['12h', true]] as const).map(([lbl, is12]) => (
<button
key={lbl}
type="button"
aria-pressed={hour12 === is12}
onClick={() => setHour12(is12)}
className={`h-10 px-4 text-sm font-bold ${hour12 === is12 ? 'bg-accent text-accent-foreground' : 'hover:bg-muted'}`}
>
{lbl}
</button>
))}
</div>
</div>
</div>

<div className="flex flex-wrap items-center gap-3 border-t border-border pt-4 text-sm">
<span className="font-mono tabular-nums">
{t.unix}: <strong>{ro.epochSec}</strong>{t.seconds} · <strong>{ro.epochMs}</strong>{t.millis}
</span>
<CopyButton value={String(ro.epochMs)} label={t.copyMs} />
<CopyButton value={iso} label={t.copyIso} />
</div>

<p className="text-xs text-muted-foreground">{t.precision}</p>
</div>
);
}
36 changes: 36 additions & 0 deletions src/registry/tool-seo.ts
Original file line number Diff line number Diff line change
Expand Up @@ -910,6 +910,24 @@ const en: Record<string, ToolSeoContent> = {
{ q: 'Does it work offline?', a: 'Yes. As a PWA it keeps working with no connection once loaded.' },
],
},
'clock': {
title: 'Online Digital Clock — Live Current Time with Seconds & Milliseconds',
description: 'A live digital clock showing the current time down to milliseconds and microseconds in any time zone. Free, accurate and runs in your browser — nothing uploaded.',
intro: 'This free online digital clock shows the current time ticking live in real time — with seconds, milliseconds and microseconds — for any time zone you choose. It uses your device clock and runs entirely in your browser, so nothing is uploaded.',
howTo: [
'Read the live time — hours, minutes, seconds, then the animated milliseconds and microseconds.',
'Pick a time zone from the list, or tap “My zone” to use your own.',
'Switch between 24-hour and 12-hour format.',
'Copy the current Unix timestamp (ms) or ISO string with one tap.',
],
faqs: [
{ q: 'Is the time accurate?', a: 'It reads your device clock, so it is as accurate as your computer or phone is (which is usually synced to internet time automatically).' },
{ q: 'Can it really show microseconds?', a: 'It reads the browser high-resolution timer for the sub-millisecond digits. Browsers clamp that timer for security, so the smallest digits are best-effort rather than a true hardware microsecond clock.' },
{ q: 'Is anything uploaded?', a: 'No. The clock runs entirely in your browser using your device time and time-zone data; nothing is sent anywhere.' },
{ q: 'Can I see the time in another country?', a: 'Yes. Choose any IANA time zone from the picker and the clock shows that region’s current time and UTC offset.' },
{ q: 'Does it work offline?', a: 'Yes. As a PWA it keeps ticking with no connection once loaded.' },
],
},
'timezone-converter': {
title: 'Time Zone Converter & Meeting Planner — Times Across Regions',
description: 'Convert a time across time zones and plan meetings across regions — pick a time in one place and see it everywhere at once, with daylight-saving handled. In your browser.',
Expand Down Expand Up @@ -4302,6 +4320,24 @@ const id: Record<string, ToolSeoContent> = {
{ q: 'Apakah bekerja offline?', a: 'Ya. Sebagai PWA tetap berjalan tanpa koneksi setelah dimuat.' },
],
},
'clock': {
title: 'Jam Digital Online — Waktu Saat Ini Langsung dengan Detik & Milidetik',
description: 'Jam digital langsung yang menampilkan waktu saat ini hingga milidetik dan mikrodetik di zona waktu apa pun. Gratis, akurat, dan berjalan di browser Anda — tanpa unggah.',
intro: 'Jam digital online gratis ini menampilkan waktu saat ini berdetak langsung secara real-time — dengan detik, milidetik, dan mikrodetik — untuk zona waktu apa pun yang Anda pilih. Memakai jam perangkat Anda dan berjalan sepenuhnya di browser, jadi tidak ada yang diunggah.',
howTo: [
'Baca waktu langsung — jam, menit, detik, lalu milidetik dan mikrodetik yang beranimasi.',
'Pilih zona waktu dari daftar, atau tap “Zona saya” untuk memakai zona Anda sendiri.',
'Ganti antara format 24 jam dan 12 jam.',
'Salin timestamp Unix saat ini (ms) atau string ISO dengan satu tap.',
],
faqs: [
{ q: 'Apakah waktunya akurat?', a: 'Jam ini membaca jam perangkat Anda, jadi seakurat komputer atau ponsel Anda (yang biasanya otomatis sinkron dengan waktu internet).' },
{ q: 'Apakah benar bisa menampilkan mikrodetik?', a: 'Jam membaca high-resolution timer browser untuk digit di bawah milidetik. Browser membatasi timer itu demi keamanan, jadi digit terkecil bersifat best-effort, bukan jam mikrodetik perangkat keras sebenarnya.' },
{ q: 'Apakah ada yang diunggah?', a: 'Tidak. Jam berjalan sepenuhnya di browser Anda memakai waktu perangkat dan data zona waktu; tidak ada yang dikirim.' },
{ q: 'Bisakah melihat waktu di negara lain?', a: 'Bisa. Pilih zona waktu IANA mana pun dari picker dan jam menampilkan waktu saat ini serta offset UTC wilayah tersebut.' },
{ q: 'Apakah bekerja offline?', a: 'Ya. Sebagai PWA tetap berdetak tanpa koneksi setelah dimuat.' },
],
},
'timezone-converter': {
title: 'Konverter Zona Waktu & Perencana Rapat — Waktu Lintas Wilayah',
description: 'Konversi waktu antar zona waktu dan rencanakan rapat lintas wilayah — pilih waktu di satu tempat dan lihat di semua tempat sekaligus, dengan daylight-saving otomatis. Di browser Anda.',
Expand Down
11 changes: 11 additions & 0 deletions src/registry/tools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -795,6 +795,17 @@ export const tools: ToolDef[] = [
load: () => import('@/islands/calculators/TimezoneConverter'),
status: 'beta'
},
{
id: 'clock',
name: 'Digital Clock',
category: 'Calculators',
route: '/tools/clock',
keywords: ['clock', 'digital clock', 'online clock', 'live clock', 'current time', 'what time is it', 'real time clock', 'time with seconds', 'milliseconds', 'microseconds', 'time now'],
icon: Clock,
summary: 'Live current time with seconds, milliseconds and a timezone picker',
load: () => import('@/islands/calculators/Clock'),
status: 'beta'
},
{
id: 'favicon-generator',
name: 'Favicon Generator',
Expand Down
64 changes: 64 additions & 0 deletions src/tools/calculators/clock.lib.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
import { describe, it, expect } from 'vitest';
import { clockReadout, zoneOffsetLabel, listTimeZones, detectTimeZone } from './clock.lib';

// A fixed instant: 2026-09-09T07:07:32.418726Z
// (07:07:32.418 UTC → 14:07:32.418 in Asia/Jakarta, UTC+7).
const EPOCH = Date.UTC(2026, 8, 9, 7, 7, 32) + 418.726;

describe('clockReadout', () => {
it('renders 24-hour time in UTC with ms + µs', () => {
const r = clockReadout(EPOCH, 'UTC', false);
expect(`${r.hh}:${r.mm}:${r.ss}`).toBe('07:07:32');
expect(r.millis).toBe('418');
expect(r.micros).toBe('726');
expect(r.dayPeriod).toBe('');
expect(r.offsetLabel).toBe('UTC');
});

it('applies the target time zone offset', () => {
const r = clockReadout(EPOCH, 'Asia/Jakarta', false);
expect(`${r.hh}:${r.mm}:${r.ss}`).toBe('14:07:32');
expect(r.offsetLabel).toBe('UTC+7');
});

it('renders 12-hour time with a day period', () => {
const r = clockReadout(EPOCH, 'Asia/Jakarta', true, 'en-US');
expect(r.hh).toBe('02'); // 14:07 → 2 PM
expect(r.dayPeriod).toMatch(/PM/i);
});

it('exposes epoch seconds and whole milliseconds', () => {
const r = clockReadout(EPOCH, 'UTC', false);
expect(r.epochMs).toBe(Math.floor(EPOCH));
expect(r.epochSec).toBe(Math.floor(EPOCH / 1000));
});

it('zero-pads sub-second fields and includes a date label', () => {
const r = clockReadout(Date.UTC(2026, 0, 1, 0, 0, 0) + 5.009, 'UTC', false);
expect(r.millis).toBe('005');
expect(r.micros).toBe('009');
expect(r.dateLabel).toContain('2026');
});
});

describe('zoneOffsetLabel', () => {
it('normalises GMT to UTC and keeps fractional offsets', () => {
const d = new Date(EPOCH);
expect(zoneOffsetLabel(d, 'UTC')).toBe('UTC');
expect(zoneOffsetLabel(d, 'Asia/Kolkata')).toBe('UTC+05:30');
});
});

describe('listTimeZones / detectTimeZone', () => {
it('returns a non-empty zone list including a known zone', () => {
const zones = listTimeZones();
expect(zones.length).toBeGreaterThan(0);
expect(zones).toContain('Asia/Jakarta');
expect(zones).toContain('UTC');
});

it('detects a valid IANA-looking zone string', () => {
expect(typeof detectTimeZone()).toBe('string');
expect(detectTimeZone().length).toBeGreaterThan(0);
});
});
Loading
Loading