diff --git a/e2e/tools/clock.spec.ts b/e2e/tools/clock.spec.ts new file mode 100644 index 0000000..252aaf5 --- /dev/null +++ b/e2e/tools/clock.spec.ts @@ -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(); +}); diff --git a/src/islands/calculators/Clock.tsx b/src/islands/calculators/Clock.tsx new file mode 100644 index 0000000..a38331e --- /dev/null +++ b/src/islands/calculators/Clock.tsx @@ -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 = { en: 'en-US', id: 'id-ID' }; + +const TR: Record = { + 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(Date.now(), detectTimeZone(), false, locale)); + const zones = useRef(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 ( +
+

{t.intro}

+ +
+
+ {ro.hh}:{ro.mm}:{ro.ss} + .{ro.millis} + {ro.micros} + {ro.dayPeriod && {ro.dayPeriod}} +
+
{ro.dateLabel}
+
{tz.replace('_', ' ')} · {ro.offsetLabel}
+
+ +
+ + +
+ {t.format} +
+ {([['24h', false], ['12h', true]] as const).map(([lbl, is12]) => ( + + ))} +
+
+
+ +
+ + {t.unix}: {ro.epochSec}{t.seconds} · {ro.epochMs}{t.millis} + + + +
+ +

{t.precision}

+
+ ); +} diff --git a/src/registry/tool-seo.ts b/src/registry/tool-seo.ts index 2ec7055..6b7b96e 100644 --- a/src/registry/tool-seo.ts +++ b/src/registry/tool-seo.ts @@ -910,6 +910,24 @@ const en: Record = { { 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.', @@ -4302,6 +4320,24 @@ const id: Record = { { 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.', diff --git a/src/registry/tools.ts b/src/registry/tools.ts index 3b6c047..ce39687 100644 --- a/src/registry/tools.ts +++ b/src/registry/tools.ts @@ -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', diff --git a/src/tools/calculators/clock.lib.test.ts b/src/tools/calculators/clock.lib.test.ts new file mode 100644 index 0000000..1c0a000 --- /dev/null +++ b/src/tools/calculators/clock.lib.test.ts @@ -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); + }); +}); diff --git a/src/tools/calculators/clock.lib.ts b/src/tools/calculators/clock.lib.ts new file mode 100644 index 0000000..b26cb08 --- /dev/null +++ b/src/tools/calculators/clock.lib.ts @@ -0,0 +1,131 @@ +/** + * Pure helpers for the live Digital Clock. Framework- and timer-free so they + * are deterministic to unit-test — the island supplies the ticking `epochMs` + * (a high-resolution float) via requestAnimationFrame. + * + * Note on precision: browsers only expose millisecond-resolution wall-clock + * time (`Date.now()`); `performance.now()` is deliberately clamped (~5–100µs, + * sometimes 1ms) as a Spectre mitigation. The microsecond field is therefore a + * best-effort reading of the high-resolution timer, not a true hardware clock. + */ + +export interface ClockReadout { + hh: string; + mm: string; + ss: string; + /** '000'–'999' — whole milliseconds within the current second. */ + millis: string; + /** '000'–'999' — microseconds within the current millisecond (best-effort). */ + micros: string; + /** Localized AM/PM when in 12-hour mode, otherwise ''. */ + dayPeriod: string; + /** Localized date line, e.g. 'Tue, 9 Sep 2026'. */ + dateLabel: string; + /** e.g. 'UTC+7', 'UTC+05:30', 'UTC'. */ + offsetLabel: string; + epochSec: number; + epochMs: number; +} + +const pad = (n: number, len = 2) => String(n).padStart(len, '0'); + +/** The IANA time zone the browser is running in (falls back to 'UTC'). */ +export function detectTimeZone(): string { + try { + return new Intl.DateTimeFormat().resolvedOptions().timeZone || 'UTC'; + } catch { + return 'UTC'; + } +} + +/** Every IANA time zone the runtime knows, or a small fallback list. */ +export function listTimeZones(): string[] { + const anyIntl = Intl as unknown as { supportedValuesOf?: (key: string) => string[] }; + if (typeof anyIntl.supportedValuesOf === 'function') { + try { + const zones = anyIntl.supportedValuesOf('timeZone'); + // Some engines omit bare 'UTC' from the IANA list — always offer it. + return zones.includes('UTC') ? zones : ['UTC', ...zones]; + } catch { + /* fall through */ + } + } + return ['UTC', 'America/New_York', 'America/Los_Angeles', 'Europe/London', 'Asia/Jakarta', 'Asia/Tokyo', 'Australia/Sydney']; +} + +/** 'UTC+7' / 'UTC+05:30' / 'UTC' for the given instant + zone. */ +export function zoneOffsetLabel(date: Date, timeZone: string): string { + try { + const parts = new Intl.DateTimeFormat('en-US', { timeZone, timeZoneName: 'shortOffset' }).formatToParts(date); + const tz = (parts.find(p => p.type === 'timeZoneName')?.value ?? 'UTC').replace('GMT', 'UTC'); + // Canonicalise the engine-dependent offset ('UTC+5:30' vs 'UTC+05:30') to + // 'UTC±H' for whole hours and 'UTC±HH:MM' when there are minutes. + const m = /^UTC([+-])(\d{1,2})(?::?(\d{2}))?$/.exec(tz); + if (!m) return tz; // already 'UTC' + const [, sign, h, min] = m; + if ((h === '0' || h === '00') && (!min || min === '00')) return 'UTC'; + return min && min !== '00' + ? `UTC${sign}${h.padStart(2, '0')}:${min}` + : `UTC${sign}${String(Number(h))}`; + } catch { + return 'UTC'; + } +} + +/** + * Build a full readout for a high-resolution epoch time (ms, may be + * fractional) in the given zone. `locale` drives the date/AM-PM wording. + */ +export function clockReadout(epochMs: number, timeZone: string, hour12: boolean, locale = 'en-US'): ClockReadout { + const whole = Math.floor(epochMs); + const date = new Date(whole); + + let hh = '00', mm = '00', ss = '00', dayPeriod = ''; + try { + const parts = new Intl.DateTimeFormat(locale, { + timeZone, + hour12, + hour: '2-digit', + minute: '2-digit', + second: '2-digit', + }).formatToParts(date); + for (const p of parts) { + if (p.type === 'hour') hh = p.value.padStart(2, '0'); + else if (p.type === 'minute') mm = p.value.padStart(2, '0'); + else if (p.type === 'second') ss = p.value.padStart(2, '0'); + else if (p.type === 'dayPeriod') dayPeriod = p.value; + } + } catch { + /* leave zeros on a bad zone */ + } + + let dateLabel = ''; + try { + dateLabel = new Intl.DateTimeFormat(locale, { + timeZone, + weekday: 'short', + year: 'numeric', + month: 'short', + day: 'numeric', + }).format(date); + } catch { + /* ignore */ + } + + const subSecond = ((epochMs % 1000) + 1000) % 1000; // 0–1000 float + const millis = Math.floor(subSecond); + const micros = Math.floor((subSecond - millis) * 1000); + + return { + hh, + mm, + ss, + millis: pad(millis, 3), + micros: pad(micros, 3), + dayPeriod: hour12 ? dayPeriod : '', + dateLabel, + offsetLabel: zoneOffsetLabel(date, timeZone), + epochSec: Math.floor(whole / 1000), + epochMs: whole, + }; +}