diff --git a/e2e/tools/clock.spec.ts b/e2e/tools/clock.spec.ts new file mode 100644 index 00000000..252aaf55 --- /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/e2e/tools/countdown.spec.ts b/e2e/tools/countdown.spec.ts new file mode 100644 index 00000000..56be7dae --- /dev/null +++ b/e2e/tools/countdown.spec.ts @@ -0,0 +1,47 @@ +import { test, expect } from '@playwright/test'; + +// The Countdown island lazy-loads via ToolHost, so wait for hydration before +// interacting: retry opening the calendar until the day grid actually appears. +test('picks a date via the calendar picker and shows the countdown', async ({ page }) => { + await page.goto('/tools/countdown'); + await page.waitForLoadState('networkidle').catch(() => {}); + + const field = page.getByRole('button', { name: /pick .*date|pilih .*tanggal/i }); + await field.waitFor({ state: 'visible' }); + + // Open the popover; the "In 1 week" preset only exists once it is hydrated + open. + const preset = page.getByRole('button', { name: 'In 1 week' }); + await expect(async () => { + await field.click(); + await expect(preset).toBeVisible({ timeout: 2000 }); + }).toPass({ timeout: 30_000 }); + + await preset.click(); + + // A valid target renders the live breakdown + the calendar-days summary. + await expect(page.getByText('Calendar days:')).toBeVisible(); + await expect(page.getByText('Business days (Mon–Fri):')).toBeVisible(); + + // The live countdown is mirrored in the tab title for other-tab visibility. + await expect(page).toHaveTitle(/⏳/); +}); + +test('clicking a day in the grid selects it', async ({ page }) => { + await page.goto('/tools/countdown'); + await page.waitForLoadState('networkidle').catch(() => {}); + + const field = page.getByRole('button', { name: /pick .*date|pilih .*tanggal/i }); + await field.waitFor({ state: 'visible' }); + + const nextMonth = page.getByRole('button', { name: 'Next month' }); + await expect(async () => { + await field.click(); + await expect(nextMonth).toBeVisible({ timeout: 2000 }); + }).toPass({ timeout: 30_000 }); + + // Jump to next month so a real (non-spillover) day is guaranteed clickable. + await nextMonth.click(); + await page.getByRole('button', { name: '15' }).first().click(); + + await expect(page.getByText('Calendar days:')).toBeVisible(); +}); diff --git a/e2e/tools/pomodoro-timer.spec.ts b/e2e/tools/pomodoro-timer.spec.ts new file mode 100644 index 00000000..38472c3a --- /dev/null +++ b/e2e/tools/pomodoro-timer.spec.ts @@ -0,0 +1,15 @@ +import { test, expect } from '@playwright/test'; + +// The Pomodoro island lazy-loads via ToolHost; retry Start until it hydrates. +test('pomodoro shows the phase + remaining time in the tab title', async ({ page }) => { + await page.goto('/tools/pomodoro-timer'); + await page.waitForLoadState('networkidle').catch(() => {}); + + const start = page.getByRole('button', { name: 'Start' }); + await start.waitFor({ state: 'visible' }); + + await expect(async () => { + await start.click(); + await expect(page).toHaveTitle(/πŸ…/, { timeout: 2000 }); + }).toPass({ timeout: 30_000 }); +}); diff --git a/e2e/tools/timer-stopwatch.spec.ts b/e2e/tools/timer-stopwatch.spec.ts new file mode 100644 index 00000000..10229d37 --- /dev/null +++ b/e2e/tools/timer-stopwatch.spec.ts @@ -0,0 +1,16 @@ +import { test, expect } from '@playwright/test'; + +// The TimerHub island lazy-loads via ToolHost, so retry the first interaction +// until it takes effect (the reactive title only changes once hydrated). +test('stopwatch reflects the running time in the tab title', async ({ page }) => { + await page.goto('/tools/timer-stopwatch'); + await page.waitForLoadState('networkidle').catch(() => {}); + + const start = page.getByRole('button', { name: 'Start' }); + await start.waitFor({ state: 'visible' }); + + await expect(async () => { + await start.click(); + await expect(page).toHaveTitle(/⏱️/, { timeout: 2000 }); + }).toPass({ timeout: 30_000 }); +}); diff --git a/src/components/ui/DateTimePicker.tsx b/src/components/ui/DateTimePicker.tsx new file mode 100644 index 00000000..64168f64 --- /dev/null +++ b/src/components/ui/DateTimePicker.tsx @@ -0,0 +1,273 @@ +import { useEffect, useMemo, useRef, useState } from 'react'; +import { Calendar, ChevronLeft, ChevronRight } from 'lucide-react'; +import { + parseLocalValue, + toLocalValue, + sameDay, + buildMonthGrid, + to12h, + from12h, + presetDate, + type PresetKind, +} from '@/tools/calculators/datetime-picker.lib'; +import type { Lang } from '@/i18n/config'; + +interface Props { + /** `YYYY-MM-DDTHH:mm` local value, or '' when unset. */ + value: string; + onChange: (value: string) => void; + lang?: Lang; + /** Placeholder shown on the field when no value is set. */ + placeholder?: string; + id?: string; +} + +const LOCALE: Record = { en: 'en-US', id: 'id-ID' }; + +const PRESETS: PresetKind[] = ['tomorrow', 'nextWeek', 'newYear']; +const PRESET_LABELS: Record> = { + en: { tomorrow: 'Tomorrow', nextWeek: 'In 1 week', newYear: "New Year's Day" }, + id: { tomorrow: 'Besok', nextWeek: 'Dalam 1 minggu', newYear: 'Tahun Baru' }, +}; +const TR: Record = { + en: { clear: 'Clear', done: 'Done', time: 'Time', placeholder: 'Pick a date & time' }, + id: { clear: 'Hapus', done: 'Selesai', time: 'Waktu', placeholder: 'Pilih tanggal & waktu' }, +}; + +export function DateTimePicker({ value, onChange, lang = 'en', placeholder, id }: Props) { + const locale = LOCALE[lang] ?? LOCALE.en; + const t = TR[lang] ?? TR.en; + const presetLabels = PRESET_LABELS[lang] ?? PRESET_LABELS.en; + + const [open, setOpen] = useState(false); + const wrapRef = useRef(null); + + const selected = useMemo(() => parseLocalValue(value), [value]); + const [view, setView] = useState(() => { + const d = parseLocalValue(value) ?? new Date(); + return { year: d.getFullYear(), month: d.getMonth() }; + }); + + // Keep the visible month in sync when the value is set from the outside (e.g. a preset). + useEffect(() => { + if (selected) setView({ year: selected.getFullYear(), month: selected.getMonth() }); + }, [selected]); + + // Close on outside click / Escape while open. + useEffect(() => { + if (!open) return; + const onDown = (e: MouseEvent) => { + if (wrapRef.current && !wrapRef.current.contains(e.target as Node)) setOpen(false); + }; + const onKey = (e: KeyboardEvent) => { + if (e.key === 'Escape') setOpen(false); + }; + document.addEventListener('mousedown', onDown); + document.addEventListener('keydown', onKey); + return () => { + document.removeEventListener('mousedown', onDown); + document.removeEventListener('keydown', onKey); + }; + }, [open]); + + const weekdays = useMemo(() => { + const fmt = new Intl.DateTimeFormat(locale, { weekday: 'short' }); + // 2023-01-01 was a Sunday. + return Array.from({ length: 7 }, (_, i) => fmt.format(new Date(2023, 0, 1 + i))); + }, [locale]); + + const cells = useMemo(() => buildMonthGrid(view.year, view.month), [view]); + const monthLabel = useMemo( + () => new Intl.DateTimeFormat(locale, { month: 'long', year: 'numeric' }).format(new Date(view.year, view.month, 1)), + [locale, view], + ); + + const displayLabel = selected + ? new Intl.DateTimeFormat(locale, { dateStyle: 'medium', timeStyle: 'short' }).format(selected) + : (placeholder ?? t.placeholder); + + const hour24 = selected?.getHours() ?? 0; + const minute = selected?.getMinutes() ?? 0; + const { hour12, ampm } = to12h(hour24); + + // Base date used when only the time changes (or falls back to today). + const baseDate = selected ?? new Date(new Date().setHours(0, 0, 0, 0)); + + function emit(d: Date) { + onChange(toLocalValue(d)); + } + + function pickDay(day: Date) { + const d = new Date(day.getFullYear(), day.getMonth(), day.getDate(), hour24, minute); + emit(d); + } + + function setHour12(h12: number) { + emit(new Date(baseDate.getFullYear(), baseDate.getMonth(), baseDate.getDate(), from12h(h12, ampm), minute)); + } + function setMinute(mi: number) { + emit(new Date(baseDate.getFullYear(), baseDate.getMonth(), baseDate.getDate(), hour24, mi)); + } + function setAmpm(next: 'AM' | 'PM') { + emit(new Date(baseDate.getFullYear(), baseDate.getMonth(), baseDate.getDate(), from12h(hour12, next), minute)); + } + function applyPreset(kind: PresetKind) { + emit(presetDate(kind, new Date())); + } + function moveMonth(delta: number) { + setView(v => { + const m = v.month + delta; + return { year: v.year + Math.floor(m / 12), month: ((m % 12) + 12) % 12 }; + }); + } + + const today = new Date(); + + return ( +
+ + + {open && ( +
+
+ {PRESETS.map(k => ( + + ))} +
+ +
+ + {monthLabel} + +
+ +
+ {weekdays.map((w, i) => ( + {w} + ))} +
+
+ {cells.map((c, i) => { + const isSel = selected != null && sameDay(c.date, selected); + const isToday = sameDay(c.date, today); + return ( + + ); + })} +
+ +
+ {t.time} + + : + +
+ {(['AM', 'PM'] as const).map(p => ( + + ))} +
+
+ +
+ + +
+
+ )} +
+ ); +} + +export default DateTimePicker; diff --git a/src/hooks/useTabTitle.ts b/src/hooks/useTabTitle.ts new file mode 100644 index 00000000..b2684a30 --- /dev/null +++ b/src/hooks/useTabTitle.ts @@ -0,0 +1,25 @@ +import { useEffect, useRef } from 'react'; + +/** + * Reflect a live status (a running timer, countdown or stopwatch) in the + * browser tab title so the user can keep an eye on it from another tab. + * + * Pass a string to override the title; pass null/'' to restore the page's + * original title. The original is captured once, on first run, and always + * restored when the component unmounts. SSR-safe β€” only touches `document` + * inside the effect. + */ +export function useTabTitle(title: string | null | undefined) { + const originalRef = useRef(null); + + useEffect(() => { + if (typeof document === 'undefined') return; + if (originalRef.current === null) originalRef.current = document.title; + + document.title = title ? title : originalRef.current; + + return () => { + if (originalRef.current !== null) document.title = originalRef.current; + }; + }, [title]); +} diff --git a/src/islands/calculators/Clock.tsx b/src/islands/calculators/Clock.tsx new file mode 100644 index 00000000..a38331e8 --- /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/islands/calculators/Countdown.tsx b/src/islands/calculators/Countdown.tsx index 210cd4a2..dac38205 100644 --- a/src/islands/calculators/Countdown.tsx +++ b/src/islands/calculators/Countdown.tsx @@ -1,7 +1,11 @@ import { useEffect, useState } from 'react'; import { breakdown, daysUntil, businessDaysUntil } from '@/tools/calculators/countdown.lib'; +import { DateTimePicker } from '@/components/ui/DateTimePicker'; +import { useTabTitle } from '@/hooks/useTabTitle'; import type { Lang } from '@/i18n/config'; +const pad2 = (n: number) => String(n).padStart(2, '0'); + const TR: Record

{t.intro}

- + {!valid ? (

{t.pick}

diff --git a/src/islands/calculators/TimerHub.tsx b/src/islands/calculators/TimerHub.tsx index c3631126..5a171db2 100644 --- a/src/islands/calculators/TimerHub.tsx +++ b/src/islands/calculators/TimerHub.tsx @@ -3,8 +3,17 @@ import { usePrefill } from '@/hooks/usePrefill'; import { Play, Pause, RotateCcw, Flag, Plus, BellOff, X } from 'lucide-react'; import { Button } from '@/components/ui/Button'; import { formatStopwatch, formatCountdown, msUntilNext, msOfDay } from '@/tools/calculators/stopwatch.lib'; +import { useTabTitle } from '@/hooks/useTabTitle'; import type { Lang } from '@/i18n/config'; +/** Elapsed ms β†’ `M:SS` / `H:MM:SS`, floored to the second (for the tab title). */ +function clockTitle(ms: number): string { + const total = Math.floor(Math.max(0, ms) / 1000); + const p = (n: number) => String(n).padStart(2, '0'); + const s = total % 60, m = Math.floor(total / 60) % 60, h = Math.floor(total / 3600); + return h > 0 ? `${h}:${p(m)}:${p(s)}` : `${m}:${p(s)}`; +} + type Tab = 'stopwatch' | 'timer' | 'alarm'; interface Alarm { id: number; label: string; hh: number; mm: number; fireAt: number } @@ -161,6 +170,17 @@ export default function TimerHub({ lang = 'en' }: { lang?: Lang }) { const fmtHM = (a: Alarm) => `${String(a.hh).padStart(2, '0')}:${String(a.mm).padStart(2, '0')}`; + // Show the running timer/stopwatch (or the ringing alert) in the tab title. + useTabTitle( + ringing + ? `πŸ”” ${t.done}` + : tRunning + ? `⏳ ${formatCountdown(tRemaining)}` + : swRunning + ? `⏱️ ${clockTitle(swElapsed)}` + : null, + ); + return (

{t.intro}

diff --git a/src/islands/calculators/TimerPomodoro.tsx b/src/islands/calculators/TimerPomodoro.tsx index 1de6ecc9..1d8fa705 100644 --- a/src/islands/calculators/TimerPomodoro.tsx +++ b/src/islands/calculators/TimerPomodoro.tsx @@ -1,8 +1,11 @@ import { useCallback, useEffect, useRef, useState } from 'react'; import { Button } from '@/components/ui/Button'; import { formatClock, phaseDuration, nextPhase, type Phase, type PomodoroConfig } from '@/tools/calculators/pomodoro.lib'; +import { useTabTitle } from '@/hooks/useTabTitle'; import type { Lang } from '@/i18n/config'; +const PHASE_ICON: Record = { work: 'πŸ…', short: 'β˜•', long: 'β˜•' }; + const TR: Record { if (!running) setRemaining(phaseDuration(phase, cfg)); diff --git a/src/registry/tool-seo.ts b/src/registry/tool-seo.ts index 20f59482..6b7b96e9 100644 --- a/src/registry/tool-seo.ts +++ b/src/registry/tool-seo.ts @@ -898,7 +898,7 @@ const en: Record = { description: 'Count down to any date and time and see the days, hours, minutes and seconds left β€” deadlines, birthdays, launches and holidays. Free and in your browser.', intro: 'This free countdown timer counts down to any date and time, showing the exact days, hours, minutes and seconds remaining, plus the number of calendar and business days until it. Great for deadlines, birthdays, launches and holidays. Runs in your browser.', howTo: [ - 'Pick a target date and time.', + 'Click the field to open the calendar, click a day, then set the hour and minute β€” or tap a quick preset like Tomorrow or New Year.', 'Watch the live countdown of days, hours, minutes and seconds.', 'See how many calendar days and business days (Mon–Fri) remain.', 'Leave the tab open β€” it keeps ticking.', @@ -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.', @@ -4290,7 +4308,7 @@ const id: Record = { description: 'Hitung mundur ke tanggal dan waktu apa pun serta lihat hari, jam, menit, dan detik tersisa β€” tenggat, ulang tahun, peluncuran, dan liburan. Gratis dan di browser Anda.', intro: 'Tool timer hitung mundur gratis ini menghitung mundur ke tanggal dan waktu apa pun, menampilkan hari, jam, menit, dan detik yang tersisa persis, plus jumlah hari kalender dan hari kerja sampai saat itu. Bagus untuk tenggat, ulang tahun, peluncuran, dan liburan. Berjalan di browser Anda.', howTo: [ - 'Pilih tanggal dan waktu target.', + 'Klik kolomnya untuk membuka kalender, klik tanggal, lalu atur jam dan menit β€” atau tap preset cepat seperti Besok atau Tahun Baru.', 'Amati hitung mundur langsung hari, jam, menit, dan detik.', 'Lihat berapa hari kalender dan hari kerja (Sen–Jum) tersisa.', 'Biarkan tab terbuka β€” hitungan terus berjalan.', @@ -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 3b6c0478..ce396878 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 00000000..1c0a0000 --- /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 00000000..b26cb08f --- /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, + }; +} diff --git a/src/tools/calculators/datetime-picker.lib.test.ts b/src/tools/calculators/datetime-picker.lib.test.ts new file mode 100644 index 00000000..693871e5 --- /dev/null +++ b/src/tools/calculators/datetime-picker.lib.test.ts @@ -0,0 +1,122 @@ +import { describe, it, expect } from 'vitest'; +import { + parseLocalValue, + toLocalValue, + sameDay, + addDays, + buildMonthGrid, + to12h, + from12h, + presetDate, +} from './datetime-picker.lib'; + +describe('parseLocalValue', () => { + it('parses a valid local value', () => { + const d = parseLocalValue('2026-09-09T15:30'); + expect(d).not.toBeNull(); + expect(d!.getFullYear()).toBe(2026); + expect(d!.getMonth()).toBe(8); // September + expect(d!.getDate()).toBe(9); + expect(d!.getHours()).toBe(15); + expect(d!.getMinutes()).toBe(30); + }); + + it.each(['', 'not-a-date', '2026-13-01T00:00', '2026-02-31T00:00', '2026-09-09T25:00', '2026-09-09T10:70'])( + 'rejects invalid value %j', + v => expect(parseLocalValue(v)).toBeNull(), + ); + + it('round-trips with toLocalValue', () => { + const v = '2026-01-05T08:07'; + expect(toLocalValue(parseLocalValue(v)!)).toBe(v); + }); +}); + +describe('toLocalValue', () => { + it('zero-pads month, day, hour and minute', () => { + expect(toLocalValue(new Date(2026, 0, 3, 4, 5))).toBe('2026-01-03T04:05'); + }); +}); + +describe('sameDay', () => { + it('ignores the time of day', () => { + expect(sameDay(new Date(2026, 8, 9, 0, 0), new Date(2026, 8, 9, 23, 59))).toBe(true); + expect(sameDay(new Date(2026, 8, 9), new Date(2026, 8, 10))).toBe(false); + }); +}); + +describe('addDays', () => { + it('crosses a month boundary and keeps the time', () => { + const d = addDays(new Date(2026, 8, 30, 14, 15), 2); + expect(d.getMonth()).toBe(9); // October + expect(d.getDate()).toBe(2); + expect(d.getHours()).toBe(14); + expect(d.getMinutes()).toBe(15); + }); +}); + +describe('buildMonthGrid', () => { + it('always returns 42 cells', () => { + expect(buildMonthGrid(2026, 8)).toHaveLength(42); + }); + + it('starts on a Sunday and marks in-month days', () => { + const cells = buildMonthGrid(2026, 8); // Sep 2026 (1st is a Tuesday) + expect(cells[0].date.getDay()).toBe(0); // Sunday + const firstInMonth = cells.find(c => c.inMonth)!; + expect(firstInMonth.date.getDate()).toBe(1); + expect(cells.filter(c => c.inMonth)).toHaveLength(30); // September has 30 days + }); + + it('handles a January grid without leaking December as in-month', () => { + const cells = buildMonthGrid(2026, 0); + expect(cells.filter(c => c.inMonth)).toHaveLength(31); + expect(cells.filter(c => c.inMonth).every(c => c.date.getFullYear() === 2026)).toBe(true); + }); +}); + +describe('to12h / from12h', () => { + it.each([ + [0, 12, 'AM'], + [1, 1, 'AM'], + [11, 11, 'AM'], + [12, 12, 'PM'], + [13, 1, 'PM'], + [23, 11, 'PM'], + ] as const)('to12h(%i) β†’ %i %s', (h24, h12, ampm) => { + expect(to12h(h24)).toEqual({ hour12: h12, ampm }); + }); + + it('round-trips every hour', () => { + for (let h = 0; h < 24; h++) { + const { hour12, ampm } = to12h(h); + expect(from12h(hour12, ampm)).toBe(h); + } + }); +}); + +describe('presetDate', () => { + const now = new Date(2026, 8, 9, 15, 30); // Sep 9 2026, 15:30 + + it('tomorrow β†’ next day at 09:00', () => { + const d = presetDate('tomorrow', now); + expect(d.getDate()).toBe(10); + expect(d.getMonth()).toBe(8); + expect(d.getHours()).toBe(9); + expect(d.getMinutes()).toBe(0); + }); + + it('nextWeek β†’ +7 days at 09:00', () => { + const d = presetDate('nextWeek', now); + expect(d.getDate()).toBe(16); + expect(d.getHours()).toBe(9); + }); + + it('newYear β†’ Jan 1 of next year at midnight', () => { + const d = presetDate('newYear', now); + expect(d.getFullYear()).toBe(2027); + expect(d.getMonth()).toBe(0); + expect(d.getDate()).toBe(1); + expect(d.getHours()).toBe(0); + }); +}); diff --git a/src/tools/calculators/datetime-picker.lib.ts b/src/tools/calculators/datetime-picker.lib.ts new file mode 100644 index 00000000..80cf827a --- /dev/null +++ b/src/tools/calculators/datetime-picker.lib.ts @@ -0,0 +1,92 @@ +/** + * Pure helpers for the calendar / date-time picker used by the Countdown tool. + * Framework-free and deterministic β€” the "current time" is always passed in so + * the presets are testable. The picker's value uses the same + * `YYYY-MM-DDTHH:mm` local string shape as a native `datetime-local` input, so + * it stays a drop-in replacement. + */ + +export interface DayCell { + /** Local date at midnight for this grid cell. */ + date: Date; + /** True when the cell belongs to the month being displayed (not a spill-over). */ + inMonth: boolean; +} + +export type PresetKind = 'tomorrow' | 'nextWeek' | 'newYear'; + +const pad2 = (n: number) => String(n).padStart(2, '0'); + +/** Parse a `YYYY-MM-DDTHH:mm` local value into a Date. Returns null if empty/invalid. */ +export function parseLocalValue(value: string): Date | null { + const m = /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2})$/.exec(value); + if (!m) return null; + const [, y, mo, d, h, mi] = m.map(Number); + if (mo < 1 || mo > 12 || d < 1 || d > 31 || h > 23 || mi > 59) return null; + const date = new Date(y, mo - 1, d, h, mi, 0, 0); + // Reject roll-overs (e.g. Feb 31 β†’ Mar 3). + if (date.getFullYear() !== y || date.getMonth() !== mo - 1 || date.getDate() !== d) return null; + return date; +} + +/** Serialise a Date to the `YYYY-MM-DDTHH:mm` local value shape. */ +export function toLocalValue(d: Date): string { + return `${d.getFullYear()}-${pad2(d.getMonth() + 1)}-${pad2(d.getDate())}T${pad2(d.getHours())}:${pad2(d.getMinutes())}`; +} + +/** True when two Dates fall on the same local calendar day. */ +export function sameDay(a: Date, b: Date): boolean { + return a.getFullYear() === b.getFullYear() && a.getMonth() === b.getMonth() && a.getDate() === b.getDate(); +} + +/** Add `n` days, preserving the time of day. */ +export function addDays(d: Date, n: number): Date { + return new Date(d.getFullYear(), d.getMonth(), d.getDate() + n, d.getHours(), d.getMinutes()); +} + +/** + * A 6Γ—7 (42-cell) grid for the given month, Sunday-first, including the + * spill-over days from the neighbouring months so every week row is full. + * `month` is 0-based (0 = January). + */ +export function buildMonthGrid(year: number, month: number): DayCell[] { + const first = new Date(year, month, 1); + const startOffset = first.getDay(); // 0 = Sunday + const cells: DayCell[] = []; + for (let i = 0; i < 42; i++) { + const date = new Date(year, month, 1 - startOffset + i); + cells.push({ date, inMonth: date.getMonth() === month && date.getFullYear() === year }); + } + return cells; +} + +/** Convert a 24-hour hour into a 12-hour clock value + AM/PM. */ +export function to12h(hour24: number): { hour12: number; ampm: 'AM' | 'PM' } { + const ampm = hour24 < 12 ? 'AM' : 'PM'; + const hour12 = hour24 % 12 === 0 ? 12 : hour24 % 12; + return { hour12, ampm }; +} + +/** Convert a 12-hour clock value + AM/PM back into a 24-hour hour. */ +export function from12h(hour12: number, ampm: 'AM' | 'PM'): number { + const h = hour12 % 12; // 12 β†’ 0 + return ampm === 'PM' ? h + 12 : h; +} + +/** The Date a quick-preset button resolves to, relative to `from` (now). */ +export function presetDate(kind: PresetKind, from: Date): Date { + switch (kind) { + case 'tomorrow': { + const d = addDays(from, 1); + d.setHours(9, 0, 0, 0); + return d; + } + case 'nextWeek': { + const d = addDays(from, 7); + d.setHours(9, 0, 0, 0); + return d; + } + case 'newYear': + return new Date(from.getFullYear() + 1, 0, 1, 0, 0, 0, 0); + } +}