From 89c38af88cbf0116a9c834c958a9d34ab9f8634c Mon Sep 17 00:00:00 2001 From: ajithakdev Date: Sat, 11 Jul 2026 23:12:25 +0530 Subject: [PATCH 1/3] feat(timestamp): rebuild as multi-mode dev utility MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Transforms the single-converter timestamp tool into a full four-mode developer utility with a shared session history drawer. Modes: - Convert: smart single-input parser (seconds/ms/ISO/RFC) with grouped output sections — Raw Unix, Standard UTC, and Localized (timezone dropdown with pinned defaults + extensible list). - Batch: multiline textarea; regex-detects Unix timestamps and ISO 8601 strings in raw log paste; renders results as a sortable table with original, ISO, and relative columns. - Diff/Duration: two datetime inputs → live human-readable duration; reverse mode (base datetime + offset expression → resulting datetime). - Cron: expression input with live description, next-5-runs preview in selected timezone, and copy-to-clipboard per run. Other additions: - Session history sidebar (persisted to localStorage, max 10 entries) that replays any past convert/diff/cron into the relevant tab. - Code snippet flyout on each value row (JS/Python/Go/Rust snippets). - URL state sharing via useUrlState for all four modes. - ✦ symbol on Now button and Unix Timestamp label for visual identity. - useToolStats wired up: usage counter increments on every copy action. --- src/tools/timestamp/TimestampTool.tsx | 1165 ++++++++++++++++++++++++- 1 file changed, 1124 insertions(+), 41 deletions(-) diff --git a/src/tools/timestamp/TimestampTool.tsx b/src/tools/timestamp/TimestampTool.tsx index f6e42ba..5e1dcb6 100644 --- a/src/tools/timestamp/TimestampTool.tsx +++ b/src/tools/timestamp/TimestampTool.tsx @@ -1,63 +1,1146 @@ -import { useEffect, useState } from 'react'; +import { useEffect, useState, useRef } from 'react'; +import { useParams } from 'react-router-dom'; +import { motion, AnimatePresence } from 'framer-motion'; import { Button } from '../../components/ui/Button'; -import { Field, TextInput } from '../../components/ui/Field'; -import { Output } from '../../components/ui/Output'; +import { Field, TextInput, TextArea, Dropdown } from '../../components/ui/Field'; import { ToolLayout } from '../../components/ToolLayout'; +import { useToast } from '../../components/ui/Toast'; +import { useUrlState } from '../../hooks/useUrlState'; +import { useClipboard } from '../../hooks/useClipboard'; +import { useToolStats } from '../../hooks/useToolStats'; -function toHuman(ts: number): string { - if (!Number.isFinite(ts)) return ''; - const ms = ts > 1e12 ? ts : ts * 1000; - const d = new Date(ms); - if (Number.isNaN(d.getTime())) return ''; - return `${d.toISOString()} · ${d.toLocaleString()}`; +// ── Timezone Lists ── +const DEFAULT_TZS = ['UTC', 'America/New_York', 'Asia/Kolkata', 'Europe/London']; +const POPULAR_TZS = [ + 'UTC', 'Europe/London', 'America/New_York', 'Asia/Kolkata', + 'Europe/Paris', 'Asia/Tokyo', 'America/Los_Angeles', 'America/Chicago', + 'Australia/Sydney', 'Asia/Singapore', 'Asia/Dubai', 'America/Sao_Paulo', + 'Africa/Johannesburg' +]; + +interface HistoryItem { + id: string; + mode: 'convert' | 'batch' | 'diff' | 'cron'; + timestamp: number; + label: string; + details: string; + data: Record; +} + +// ── Formatting/Calculation Helpers ── +function getRelativeTime(d: Date): string { + const now = new Date(); + const diffMs = d.getTime() - now.getTime(); + const diffSecs = Math.round(diffMs / 1000); + const diffMins = Math.round(diffSecs / 60); + const diffHours = Math.round(diffMins / 60); + const diffDays = Math.round(diffHours / 24); + + const absSecs = Math.abs(diffSecs); + const absMins = Math.abs(diffMins); + const absHours = Math.abs(diffHours); + const absDays = Math.abs(diffDays); + + if (absSecs < 5) return 'just now'; + if (absSecs < 60) return diffSecs > 0 ? `in ${absSecs}s` : `${absSecs}s ago`; + if (absMins < 60) return diffMins > 0 ? `in ${absMins}m` : `${absMins}m ago`; + if (absHours < 24) return diffHours > 0 ? `in ${absHours}h` : `${absHours}h ago`; + if (absDays < 30) return diffDays > 0 ? `in ${absDays}d` : `${absDays}d ago`; + + const diffMonths = Math.round(absDays / 30.4); + if (diffMonths < 12) return diffDays > 0 ? `in ${diffMonths}mo` : `${diffMonths}mo ago`; + + const diffYears = Math.round(diffMonths / 12); + return diffDays > 0 ? `in ${diffYears}y` : `${diffYears}y ago`; +} + +function parseSmartDate(input: string): Date | null { + const clean = input.trim(); + if (!clean) return null; + if (/^-?\d+$/.test(clean)) { + const num = Number(clean); + return Math.abs(num) < 1e11 ? new Date(num * 1000) : new Date(num); + } + const d = new Date(clean); + return Number.isNaN(d.getTime()) ? null : d; +} + +function formatLocal(date: Date, timeZone: string, type: 'all' | 'date' | 'time' | 'offset'): string { + try { + if (type === 'all') { + return new Intl.DateTimeFormat('en-US', { + timeZone, + dateStyle: 'medium', + timeStyle: 'medium', + }).format(date); + } + if (type === 'date') { + return new Intl.DateTimeFormat('en-US', { + timeZone, + dateStyle: 'medium', + }).format(date); + } + if (type === 'time') { + return new Intl.DateTimeFormat('en-US', { + timeZone, + timeStyle: 'medium', + }).format(date); + } + if (type === 'offset') { + const parts = new Intl.DateTimeFormat('en-US', { + timeZone, + timeZoneName: 'longOffset', + }).formatToParts(date); + const tzPart = parts.find((p) => p.type === 'timeZoneName'); + return tzPart ? tzPart.value : ''; + } + } catch { + return 'Invalid TZ'; + } + return ''; +} + +// ── Cron Next Runs ── +function getNextCronRuns(cron: string, count = 5): Date[] { + const parts = cron.trim().split(/\s+/); + if (parts.length < 5) return []; + const [minPat, hourPat, domPat, monthPat, dowPat] = parts; + + function parseField(pattern: string, minVal: number, maxVal: number): number[] { + const list: number[] = []; + const subpatterns = pattern.split(','); + for (const sub of subpatterns) { + if (sub === '*') { + for (let i = minVal; i <= maxVal; i++) list.push(i); + } else if (sub.includes('/')) { + const [range, stepStr] = sub.split('/'); + const step = Number(stepStr) || 1; + let start = minVal; + let end = maxVal; + if (range !== '*') { + if (range.includes('-')) { + const [s, e] = range.split('-'); + start = Number(s); + end = Number(e); + } else { + start = Number(range); + } + } + for (let i = start; i <= end; i += step) { + list.push(i); + } + } else if (sub.includes('-')) { + const [s, e] = sub.split('-'); + const start = Number(s); + const end = Number(e); + for (let i = start; i <= end; i++) list.push(i); + } else { + list.push(Number(sub)); + } + } + return Array.from(new Set(list)).sort((a, b) => a - b); + } + + try { + const minutes = parseField(minPat!, 0, 59); + const hours = parseField(hourPat!, 0, 23); + const doms = parseField(domPat!, 1, 31); + const months = parseField(monthPat!, 1, 12); + const dows = parseField(dowPat!, 0, 6); + + const runs: Date[] = []; + const current = new Date(); + current.setSeconds(0); + current.setMilliseconds(0); + current.setMinutes(current.getMinutes() + 1); + + let iterations = 0; + while (runs.length < count && iterations < 30000) { + iterations++; + const min = current.getMinutes(); + const hour = current.getHours(); + const dom = current.getDate(); + const month = current.getMonth() + 1; + const dow = current.getDay(); + + if ( + minutes.includes(min) && + hours.includes(hour) && + doms.includes(dom) && + months.includes(month) && + dows.includes(dow) + ) { + runs.push(new Date(current)); + } + current.setMinutes(current.getMinutes() + 1); + } + return runs; + } catch { + return []; + } +} + +function describeCron(cron: string): string { + const parts = cron.trim().split(/\s+/); + if (parts.length < 5) return 'Invalid cron expression'; + const [minPat, hourPat, domPat, monthPat, dowPat] = parts; + const describeField = (pat: string | undefined, name: string) => { + if (!pat || pat === '*') return `every ${name}`; + if (pat.startsWith('*/')) return `every ${pat.slice(2)} ${name}s`; + return `at ${name} ${pat}`; + }; + return `Runs ${describeField(minPat, 'minute')}, ${describeField(hourPat, 'hour')}, on day ${describeField(domPat, 'of month')}, in ${describeField(monthPat, 'month')}, on ${describeField(dowPat, 'day of week')}.`; +} + +// ── Diff Duration helper ── +function getDurationDescription(ms: number): string { + const isPast = ms < 0; + const absMs = Math.abs(ms); + const secs = Math.floor(absMs / 1000); + const mins = Math.floor(secs / 60); + const hours = Math.floor(mins / 60); + const days = Math.floor(hours / 24); + + const remHours = hours % 24; + const remMins = mins % 60; + const remSecs = secs % 60; + + const parts = []; + if (days > 0) parts.push(`${days}d`); + if (remHours > 0) parts.push(`${remHours}h`); + if (remMins > 0) parts.push(`${remMins}m`); + if (remSecs > 0) parts.push(`${remSecs}s`); + + if (parts.length === 0) return '0s'; + return parts.join(' ') + (isPast ? ' ago' : ''); +} + +// ── Offset Parser ── +const OFFSET_REGEX = /^([+-])?\s*(?:(\d+)\s*y)?\s*(?:(\d+)\s*mo)?\s*(?:(\d+)\s*d)?\s*(?:(\d+)\s*h)?\s*(?:(\d+)\s*m)?\s*(?:(\d+)\s*s)?$/i; + +function applyOffset(base: Date, offsetStr: string): Date { + const clean = offsetStr.trim(); + const match = clean.match(OFFSET_REGEX); + if (!match) return base; + + const sign = match[1] === '-' ? -1 : 1; + const y = (Number(match[2]) || 0) * sign; + const mo = (Number(match[3]) || 0) * sign; + const d = (Number(match[4]) || 0) * sign; + const h = (Number(match[5]) || 0) * sign; + const m = (Number(match[6]) || 0) * sign; + const s = (Number(match[7]) || 0) * sign; + + const res = new Date(base); + if (y) res.setFullYear(res.getFullYear() + y); + if (mo) res.setMonth(res.getMonth() + mo); + if (d) res.setDate(res.getDate() + d); + if (h) res.setHours(res.getHours() + h); + if (m) res.setMinutes(res.getMinutes() + m); + if (s) res.setSeconds(res.getSeconds() + s); + return res; } export default function TimestampTool() { - const [ts, setTs] = useState(String(Math.floor(Date.now() / 1000))); - const [iso, setIso] = useState(''); - const [out, setOut] = useState(''); + const toast = useToast(); + const { copy } = useClipboard(); + const { slug } = useParams<{ slug: string }>(); + const { increment } = useToolStats(slug); + // ── URL & Local states ── + const [activeMode, setActiveMode] = useUrlState('mode', 'convert', (r) => r, (v) => v); + const [convertInput, setConvertInput] = useUrlState('q', String(Math.floor(Date.now() / 1000)), (r) => r, (v) => v); + const [batchInput, setBatchInput] = useUrlState('batch', '', (r) => r, (v) => v); + const [cronInput, setCronInput] = useUrlState('cron', '*/5 * * * *', (r) => r, (v) => v); + + // Diff states + const [diffMode, setDiffMode] = useUrlState('diffMode', 'range', (r) => r, (v) => v); + const [diffDate1, setDiffDate1] = useUrlState('d1', 'now', (r) => r, (v) => v); + const [diffDate2, setDiffDate2] = useUrlState('d2', '2027-01-01', (r) => r, (v) => v); + const [diffBase, setDiffBase] = useUrlState('db', 'now', (r) => r, (v) => v); + const [diffOffset, setDiffOffset] = useUrlState('do', '+1y 2mo 15d', (r) => r, (v) => v); + + // Timezones State + const [selectedTz, setSelectedTz] = useState('UTC'); + const [customTzs, setCustomTzs] = useState(DEFAULT_TZS); + + // History Drawer State + const [historyOpen, setHistoryOpen] = useState(false); + const [historyList, setHistoryList] = useState(() => { + try { + return JSON.parse(localStorage.getItem('tg_ts_history') || '[]'); + } catch { + return []; + } + }); + + // Code Flyout States + const [flyoutValue, setFlyoutValue] = useState(null); + const [flyoutDate, setFlyoutDate] = useState(null); + const [copiedSnippetIndex, setCopiedSnippetIndex] = useState(null); + const flyoutRef = useRef(null); + + // Auto relative tick + const [, setTick] = useState(0); useEffect(() => { - const n = Number(ts); - setOut(toHuman(n)); - }, [ts]); + const timer = setInterval(() => setTick((t) => t + 1), 1000); + return () => clearInterval(timer); + }, []); + + // Close flyout on click outside + useEffect(() => { + const clickOutside = (e: MouseEvent) => { + if (flyoutRef.current && !flyoutRef.current.contains(e.target as Node)) { + setFlyoutValue(null); + } + }; + document.addEventListener('mousedown', clickOutside); + return () => document.removeEventListener('mousedown', clickOutside); + }, []); + + // Add history helper + const addHistory = (label: string, mode: 'convert' | 'batch' | 'diff' | 'cron', details: string, data: Record) => { + const nextItem: HistoryItem = { + id: Math.random().toString(36).substring(2, 9), + mode, + timestamp: Date.now(), + label, + details, + data, + }; + setHistoryList((prev) => { + const filtered = prev.filter((item) => item.label !== label || item.mode !== mode); + const updated = [nextItem, ...filtered].slice(0, 10); + localStorage.setItem('tg_ts_history', JSON.stringify(updated)); + return updated; + }); + }; + + const handleCopy = async (val: string) => { + if (!val) return; + const ok = await copy(val); + if (ok) { + increment(); + toast.push(`Copied: ${val.substring(0, 25)}${val.length > 25 ? '...' : ''}`, 'success'); + } + }; + + const handleCopySnippet = async (code: string, index: number) => { + const ok = await copy(code); + if (ok) { + increment(); + setCopiedSnippetIndex(index); + setTimeout(() => setCopiedSnippetIndex(null), 1500); + toast.push('Copied snippet to clipboard', 'success'); + } + }; - const fromIso = () => { - const d = new Date(iso); - if (!Number.isNaN(d.getTime())) setTs(String(Math.floor(d.getTime() / 1000))); + const openCodeFlyout = (e: React.MouseEvent, val: string, dateObj: Date) => { + e.stopPropagation(); + setFlyoutValue(val); + setFlyoutDate(dateObj); }; + // ── Mode Outputs ── + + // 1. Convert — always needed for the convert tab + const parsedDate = activeMode === 'convert' ? parseSmartDate(convertInput) : null; + const parseStatus = parsedDate ? 'Valid Date' : 'Waiting for valid input...'; + + // 2. Batch Mode — only parse when on batch tab + const batchMatches: Array<{ original: string; date: Date; iso: string; relative: string }> = []; + if (activeMode === 'batch' && batchInput.trim()) { + const regex = /(?:-?\d{9,13})|(?:\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:?\d{2}))/g; + const tokens = batchInput.match(regex); + if (tokens) { + for (const token of tokens) { + const date = parseSmartDate(token); + if (date) { + batchMatches.push({ + original: token, + date, + iso: date.toISOString(), + relative: getRelativeTime(date), + }); + } + } + } + } + + // 3. Diff & Duration — only parse when on diff tab + const d1 = activeMode === 'diff' ? parseSmartDate(diffDate1 === 'now' ? String(Math.floor(Date.now() / 1000)) : diffDate1) : null; + const d2 = activeMode === 'diff' ? parseSmartDate(diffDate2 === 'now' ? String(Math.floor(Date.now() / 1000)) : diffDate2) : null; + const baseD = activeMode === 'diff' ? parseSmartDate(diffBase === 'now' ? String(Math.floor(Date.now() / 1000)) : diffBase) : null; + + // Offset validation + const isValidOffset = diffOffset.trim() === '' || OFFSET_REGEX.test(diffOffset.trim()); + const offsetResult = baseD && isValidOffset ? applyOffset(baseD, diffOffset) : null; + + // 4. Cron — only parse when on cron tab + const cronRuns = activeMode === 'cron' ? getNextCronRuns(cronInput, 5) : []; + const cronDesc = activeMode === 'cron' ? describeCron(cronInput) : ''; + + // Trigger history save on successful parses. + // IMPORTANT: only use primitive string deps here — Date objects are new references + // every render tick and would cause the effect (and history) to fire every second. + const lastLoggedRef = useRef(''); + useEffect(() => { + if (activeMode === 'convert') { + const val = convertInput.trim(); + const parsed = parseSmartDate(val); + if (val && parsed && val !== lastLoggedRef.current) { + lastLoggedRef.current = val; + addHistory(val, 'convert', parsed.toISOString(), { q: val }); + } + } else if (activeMode === 'diff' && diffMode === 'range') { + const parsed1 = parseSmartDate(diffDate1 === 'now' ? String(Math.floor(Date.now() / 1000)) : diffDate1); + const parsed2 = parseSmartDate(diffDate2 === 'now' ? String(Math.floor(Date.now() / 1000)) : diffDate2); + const label = `${diffDate1} ↔ ${diffDate2}`; + if (parsed1 && parsed2 && label !== lastLoggedRef.current) { + lastLoggedRef.current = label; + addHistory(label, 'diff', 'Duration Diff', { d1: diffDate1, d2: diffDate2, diffMode: 'range' }); + } + } else if (activeMode === 'cron') { + const label = cronInput.trim(); + const runs = getNextCronRuns(label, 1); + if (label && runs.length > 0 && label !== lastLoggedRef.current) { + lastLoggedRef.current = label; + addHistory(label, 'cron', describeCron(label), { cron: label }); + } + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [convertInput, activeMode, diffDate1, diffDate2, diffMode, cronInput]); + return ( -
- - setTs(e.target.value)} /> - -
+
+ {/* Genuine Glass Backdrop Blurring Blobs */} +
+ + {/* Top Navigation: Mode Selector + Toggle Sidebar Trigger */} +
+ {/* Segmented Modes Tabs with heightened contrast on inactive ones */} +
+ {['convert', 'batch', 'diff', 'cron'].map((m) => ( + + ))} +
+ + {/* Connected Trigger Button that controls sidebar */}
- - -
- - - setIso(e.target.value)} - /> - -
- + + {/* Main layout containing the workspace and collapsible history sidebar */} +
+ {/* Scroll Constrained Workspace Panel */} +
+ {/* CONVERT MODE */} + {activeMode === 'convert' && ( +
+ +
+ setConvertInput(e.target.value)} + /> + +
+
+ + {parsedDate ? ( +
+ {/* Raw Group */} +
+
Raw Unix Timestamps
+
+ + +
+
+ + {/* Standard Group */} +
+
Standard UTC Formats
+
+ + +
+
+ + {/* Localized Group */} +
+
+
Localized Dates
+ {/* Custom-styled Dropzone Selection for timezone */} +
+ ({ label: tz, value: tz }))} + onChange={(tz) => { + setSelectedTz(tz); + if (!customTzs.includes(tz)) { + setCustomTzs([...customTzs, tz]); + } + }} + /> +
+
+ + {/* TZ Pinned Pills */} +
+ {customTzs.map((tz) => ( + + ))} +
+ +
+ + + + + +
+
+
+ ) : ( +
+ ⚠️ {parseStatus} +
+ )} +
+ )} + + {/* BATCH MODE */} + {activeMode === 'batch' && ( +
+ +
+