From ea23c734164ba0aaa568eb9a1f19e12658b5d555 Mon Sep 17 00:00:00 2001 From: Leshe4ka Date: Wed, 9 Jul 2025 16:02:12 +0500 Subject: [PATCH 1/3] user timezones --- frontend/src/components/NavBar/NavBar.tsx | 3 + .../UserTimezone/UserTimezone.styled.ts | 27 ++++ .../NavBar/UserTimezone/UserTimezone.tsx | 73 +++++++++ .../Topics/Topic/Messages/Message.tsx | 6 +- .../MessageContent/MessageContent.tsx | 10 +- .../Topic/Messages/__test__/Message.spec.tsx | 2 +- .../Topic/Statistics/Indicators/Total.tsx | 5 +- .../Topics/Topic/Statistics/Metrics.tsx | 21 ++- .../Topic/Statistics/PartitionInfoRow.tsx | 18 ++- frontend/src/components/Version/Version.tsx | 7 +- .../common/Dropdown/Dropdown.styled.ts | 2 +- .../components/common/Dropdown/Dropdown.tsx | 42 ++++-- .../common/Icons/ChevronDownIcon.tsx | 22 ++- .../common/NewTable/TimestampCell.tsx | 16 +- .../common/NewTable/__test__/Table.spec.tsx | 2 +- frontend/src/components/globalCss.ts | 29 ++++ frontend/src/lib/dateTimeHelpers.ts | 26 +++- .../hooks/__tests__/dateTimeHelpers.spec.ts | 8 +- frontend/src/lib/hooks/useLocalStorage.ts | 104 +++++++++++-- frontend/src/lib/hooks/useTimezones.ts | 138 ++++++++++++++++++ frontend/src/theme/theme.ts | 4 +- 21 files changed, 508 insertions(+), 57 deletions(-) create mode 100644 frontend/src/components/NavBar/UserTimezone/UserTimezone.styled.ts create mode 100644 frontend/src/components/NavBar/UserTimezone/UserTimezone.tsx create mode 100644 frontend/src/lib/hooks/useTimezones.ts diff --git a/frontend/src/components/NavBar/NavBar.tsx b/frontend/src/components/NavBar/NavBar.tsx index d4bd8dfc1..4b0090018 100644 --- a/frontend/src/components/NavBar/NavBar.tsx +++ b/frontend/src/components/NavBar/NavBar.tsx @@ -12,6 +12,7 @@ import ProductHuntIcon from 'components/common/Icons/ProductHuntIcon'; import { Button } from 'components/common/Button/Button'; import MenuIcon from 'components/common/Icons/MenuIcon'; +import { UserTimezone } from './UserTimezone/UserTimezone'; import UserInfo from './UserInfo/UserInfo'; import * as S from './NavBar.styled'; @@ -73,6 +74,8 @@ const NavBar: React.FC = ({ onBurgerClick }) => { + + + + + + {filteredTimezones.map((timezone) => ( + handleTimezoneSelect(timezone)} + > + {timezone.label} + + ))} + + + + ); +}; diff --git a/frontend/src/components/Topics/Topic/Messages/Message.tsx b/frontend/src/components/Topics/Topic/Messages/Message.tsx index e5624624c..611385d83 100644 --- a/frontend/src/components/Topics/Topic/Messages/Message.tsx +++ b/frontend/src/components/Topics/Topic/Messages/Message.tsx @@ -9,6 +9,7 @@ import { JSONPath } from 'jsonpath-plus'; import Ellipsis from 'components/common/Ellipsis/Ellipsis'; import WarningRedIcon from 'components/common/Icons/WarningRedIcon'; import Tooltip from 'components/common/Tooltip/Tooltip'; +import { useTimezone } from 'lib/hooks/useTimezones'; import MessageContent from './MessageContent/MessageContent'; import * as S from './MessageContent/MessageContent.styled'; @@ -41,6 +42,7 @@ const Message: React.FC = ({ keyFilters, contentFilters, }) => { + const { currentTimezone } = useTimezone(); const [isOpen, setIsOpen] = React.useState(false); const savedMessageJson = { Value: value, @@ -107,7 +109,9 @@ const Message: React.FC = ({ {offset} {partition} -
{formatTimestamp(timestamp)}
+
+ {formatTimestamp({ timestamp, timezone: currentTimezone.value })} +
diff --git a/frontend/src/components/Topics/Topic/Messages/MessageContent/MessageContent.tsx b/frontend/src/components/Topics/Topic/Messages/MessageContent/MessageContent.tsx index ad35399a9..96eba6db3 100644 --- a/frontend/src/components/Topics/Topic/Messages/MessageContent/MessageContent.tsx +++ b/frontend/src/components/Topics/Topic/Messages/MessageContent/MessageContent.tsx @@ -3,6 +3,7 @@ import EditorViewer from 'components/common/EditorViewer/EditorViewer'; import BytesFormatted from 'components/common/BytesFormatted/BytesFormatted'; import { SchemaType, TopicMessageTimestampTypeEnum } from 'generated-sources'; import { formatTimestamp } from 'lib/dateTimeHelpers'; +import { useTimezone } from 'lib/hooks/useTimezones'; import * as S from './MessageContent.styled'; @@ -31,6 +32,8 @@ const MessageContent: React.FC = ({ keySerde, valueSerde, }) => { + const { currentTimezone } = useTimezone(); + const [activeTab, setActiveTab] = React.useState('content'); const activeTabContent = () => { switch (activeTab) { @@ -103,7 +106,12 @@ const MessageContent: React.FC = ({ Timestamp - {formatTimestamp(timestamp)} + + {formatTimestamp({ + timestamp, + timezone: currentTimezone.value, + })} + Timestamp type: {timestampType} diff --git a/frontend/src/components/Topics/Topic/Messages/__test__/Message.spec.tsx b/frontend/src/components/Topics/Topic/Messages/__test__/Message.spec.tsx index dd64e8696..a82ccfbc8 100644 --- a/frontend/src/components/Topics/Topic/Messages/__test__/Message.spec.tsx +++ b/frontend/src/components/Topics/Topic/Messages/__test__/Message.spec.tsx @@ -65,7 +65,7 @@ describe('Message component', () => { expect(screen.getByText(mockMessage.value as string)).toBeInTheDocument(); expect(screen.getByText(mockMessage.key as string)).toBeInTheDocument(); expect( - screen.getByText(formatTimestamp(mockMessage.timestamp)) + screen.getByText(formatTimestamp({ timestamp: mockMessage.timestamp })) ).toBeInTheDocument(); expect(screen.getByText(mockMessage.offset.toString())).toBeInTheDocument(); expect( diff --git a/frontend/src/components/Topics/Topic/Statistics/Indicators/Total.tsx b/frontend/src/components/Topics/Topic/Statistics/Indicators/Total.tsx index 65e5892b4..57aaf52ac 100644 --- a/frontend/src/components/Topics/Topic/Statistics/Indicators/Total.tsx +++ b/frontend/src/components/Topics/Topic/Statistics/Indicators/Total.tsx @@ -2,6 +2,7 @@ import React from 'react'; import * as Metrics from 'components/common/Metrics'; import { TopicAnalysisStats } from 'generated-sources'; import { formatTimestamp } from 'lib/dateTimeHelpers'; +import { useTimezone } from 'lib/hooks/useTimezones'; const Total: React.FC = ({ totalMsgs, @@ -14,6 +15,8 @@ const Total: React.FC = ({ approxUniqKeys, approxUniqValues, }) => { + const { currentTimezone } = useTimezone(); + return ( {totalMsgs} @@ -21,7 +24,7 @@ const Total: React.FC = ({ {`${minOffset} - ${maxOffset}`} - {`${formatTimestamp(minTimestamp)} - ${formatTimestamp(maxTimestamp)}`} + {`${formatTimestamp({ timestamp: minTimestamp, timezone: currentTimezone.value })} - ${formatTimestamp({ timestamp: maxTimestamp, timezone: currentTimezone.value })}`} {nullKeys} { const params = useAppParams(); + const { currentTimezone } = useTimezone(); const [isAnalyzing, setIsAnalyzing] = useState(true); const analyzeTopic = useAnalyzeTopic(params); @@ -69,10 +71,14 @@ const Metrics: React.FC = () => { - {formatTimestamp(data.progress.startedAt, { - hour: 'numeric', - minute: 'numeric', - second: 'numeric', + {formatTimestamp({ + timestamp: data.progress.startedAt, + format: { + hour: 'numeric', + minute: 'numeric', + second: 'numeric', + }, + timezone: currentTimezone.value, })} @@ -100,7 +106,12 @@ const Metrics: React.FC = () => { return ( <> - {formatTimestamp(data?.result?.finishedAt)} + + {formatTimestamp({ + timezone: currentTimezone.value, + timestamp: data?.result?.finishedAt, + })} + { await analyzeTopic.mutateAsync(); diff --git a/frontend/src/components/Topics/Topic/Statistics/PartitionInfoRow.tsx b/frontend/src/components/Topics/Topic/Statistics/PartitionInfoRow.tsx index 0646f02e0..9b7defd01 100644 --- a/frontend/src/components/Topics/Topic/Statistics/PartitionInfoRow.tsx +++ b/frontend/src/components/Topics/Topic/Statistics/PartitionInfoRow.tsx @@ -8,6 +8,7 @@ import { } from 'components/common/PropertiesList/PropertiesList.styled'; import { TopicAnalysisStats } from 'generated-sources'; import { formatTimestamp } from 'lib/dateTimeHelpers'; +import { useTimezone } from 'lib/hooks/useTimezones'; import * as S from './Statistics.styles'; @@ -25,6 +26,9 @@ const PartitionInfoRow: React.FC<{ row: Row }> = ({ keySize, valueSize, } = row.original; + + const { currentTimezone } = useTimezone(); + return (
@@ -35,9 +39,19 @@ const PartitionInfoRow: React.FC<{ row: Row }> = ({ - {formatTimestamp(minTimestamp)} + + {formatTimestamp({ + timestamp: minTimestamp, + timezone: currentTimezone.value, + })} + - {formatTimestamp(maxTimestamp)} + + {formatTimestamp({ + timestamp: maxTimestamp, + timezone: currentTimezone.value, + })} + {nullKeys} diff --git a/frontend/src/components/Version/Version.tsx b/frontend/src/components/Version/Version.tsx index 775fbfa5d..2a6483b8c 100644 --- a/frontend/src/components/Version/Version.tsx +++ b/frontend/src/components/Version/Version.tsx @@ -3,10 +3,12 @@ import WarningIcon from 'components/common/Icons/WarningIcon'; import { gitCommitPath } from 'lib/paths'; import { useLatestVersion } from 'lib/hooks/api/latestVersion'; import { formatTimestamp } from 'lib/dateTimeHelpers'; +import { useTimezone } from 'lib/hooks/useTimezones'; import * as S from './Version.styled'; const Version: React.FC = () => { + const { currentTimezone } = useTimezone(); const { data: latestVersionInfo = {} } = useLatestVersion(); const { buildTime, commitId, isLatestRelease, version } = latestVersionInfo.build; @@ -15,7 +17,10 @@ const Version: React.FC = () => { const currentVersion = isLatestRelease && version?.match(versionTag) ? versionTag - : formatTimestamp(buildTime); + : formatTimestamp({ + timestamp: buildTime, + timezone: currentTimezone.value, + }); return ( diff --git a/frontend/src/components/common/Dropdown/Dropdown.styled.ts b/frontend/src/components/common/Dropdown/Dropdown.styled.ts index 99acfca38..14ebf4b7a 100644 --- a/frontend/src/components/common/Dropdown/Dropdown.styled.ts +++ b/frontend/src/components/common/Dropdown/Dropdown.styled.ts @@ -22,7 +22,7 @@ export const Dropdown = styled(ControlledMenu)( ${menuSelector.name} { border: 1px solid ${dropdown.borderColor}; box-shadow: 0 4px 16px ${dropdown.shadow}; - padding: 8px 0; + padding: 8px 4px; border-radius: 4px; font-size: 14px; background-color: ${dropdown.backgroundColor}; diff --git a/frontend/src/components/common/Dropdown/Dropdown.tsx b/frontend/src/components/common/Dropdown/Dropdown.tsx index 280103d6f..409713c12 100644 --- a/frontend/src/components/common/Dropdown/Dropdown.tsx +++ b/frontend/src/components/common/Dropdown/Dropdown.tsx @@ -1,5 +1,5 @@ import { MenuProps } from '@szhsin/react-menu'; -import React, { PropsWithChildren, useRef } from 'react'; +import React, { cloneElement, PropsWithChildren, useRef } from 'react'; import VerticalElipsisIcon from 'components/common/Icons/VerticalElipsisIcon'; import useBoolean from 'lib/hooks/useBoolean'; @@ -8,6 +8,8 @@ import * as S from './Dropdown.styled'; interface DropdownProps extends PropsWithChildren> { label?: React.ReactNode; disabled?: boolean; + openBtnEl?: React.ReactElement; + onClose?: () => void; } const Dropdown: React.FC = ({ @@ -15,6 +17,8 @@ const Dropdown: React.FC = ({ disabled, children, offsetY, + openBtnEl, + onClose, ...props }) => { const ref = useRef(null); @@ -28,21 +32,35 @@ const Dropdown: React.FC = ({ return ( - - {label || } - + {openBtnEl ? ( + + {cloneElement(openBtnEl, { + onClick: handleClick, + disabled, + 'aria-label': props['aria-label'] || 'Dropdown Toggle', + })} + + ) : ( + + {label || } + + )} + { + setFalse(); + onClose?.(); + }} + align={props.align || 'end'} + direction={props.direction || 'bottom'} offsetY={offsetY ?? 10} viewScroll="auto" onClick={(e) => { diff --git a/frontend/src/components/common/Icons/ChevronDownIcon.tsx b/frontend/src/components/common/Icons/ChevronDownIcon.tsx index d9bf10247..e60220ea0 100644 --- a/frontend/src/components/common/Icons/ChevronDownIcon.tsx +++ b/frontend/src/components/common/Icons/ChevronDownIcon.tsx @@ -1,13 +1,25 @@ import React from 'react'; import { useTheme } from 'styled-components'; -const ChevronDownIcon: React.FC = () => { +type ChevronDownIconProps = { + fill?: string; + width?: string; + height?: string; + viewBox?: string; +}; + +const ChevronDownIcon: React.FC = ({ + fill, + width, + viewBox, + height, +}) => { const theme = useTheme(); return ( @@ -15,7 +27,7 @@ const ChevronDownIcon: React.FC = () => { fillRule="evenodd" clipRule="evenodd" d="M0.646447 0.646447C0.841709 0.451184 1.15829 0.451184 1.35355 0.646447L5 4.29289L8.64645 0.646447C8.84171 0.451184 9.15829 0.451184 9.35355 0.646447C9.54882 0.841709 9.54882 1.15829 9.35355 1.35355L5.35355 5.35355C5.15829 5.54882 4.84171 5.54882 4.64645 5.35355L0.646447 1.35355C0.451184 1.15829 0.451184 0.841709 0.646447 0.646447Z" - fill={theme.icons.chevronDownIcon} + fill={fill || theme.icons.chevronDownIcon} /> ); diff --git a/frontend/src/components/common/NewTable/TimestampCell.tsx b/frontend/src/components/common/NewTable/TimestampCell.tsx index f0ff3d5f6..826a85961 100644 --- a/frontend/src/components/common/NewTable/TimestampCell.tsx +++ b/frontend/src/components/common/NewTable/TimestampCell.tsx @@ -1,12 +1,22 @@ import { CellContext } from '@tanstack/react-table'; import { formatTimestamp } from 'lib/dateTimeHelpers'; import React from 'react'; +import { useTimezone } from 'lib/hooks/useTimezones'; import * as S from './Table.styled'; // eslint-disable-next-line @typescript-eslint/no-explicit-any -const TimestampCell: React.FC> = ({ getValue }) => ( - {formatTimestamp(getValue())} -); +const TimestampCell: React.FC> = ({ getValue }) => { + const { currentTimezone } = useTimezone(); + + return ( + + {formatTimestamp({ + timestamp: getValue(), + timezone: currentTimezone.value, + })} + + ); +}; export default TimestampCell; diff --git a/frontend/src/components/common/NewTable/__test__/Table.spec.tsx b/frontend/src/components/common/NewTable/__test__/Table.spec.tsx index 46f50e1af..73d044c00 100644 --- a/frontend/src/components/common/NewTable/__test__/Table.spec.tsx +++ b/frontend/src/components/common/NewTable/__test__/Table.spec.tsx @@ -145,7 +145,7 @@ describe('Table', () => { it('renders TimestampCell', () => { renderComponent(); expect( - screen.getByText(formatTimestamp(data[0].timestamp)) + screen.getByText(formatTimestamp({ timestamp: data[0].timestamp })) ).toBeInTheDocument(); }); diff --git a/frontend/src/components/globalCss.ts b/frontend/src/components/globalCss.ts index ebe1e2158..8cc794ec3 100644 --- a/frontend/src/components/globalCss.ts +++ b/frontend/src/components/globalCss.ts @@ -98,6 +98,35 @@ export default createGlobalStyle( margin: 0; } + ::-webkit-scrollbar { + width: 4px; + height: 4px; + } + + ::-webkit-scrollbar-track { + background: ${theme.scrollbar.trackColor.normal}; + border-radius: 4px; + } + + ::-webkit-scrollbar-thumb { + background: ${theme.scrollbar.thumbColor.normal}; + border-radius: 4px; + transition: background-color 0.2s ease; + } + + ::-webkit-scrollbar-thumb:hover { + cursor: pointer; + background: ${theme.scrollbar.thumbColor.active}; + } + + ::-webkit-scrollbar-thumb:active { + background: ${theme.scrollbar.thumbColor.active}; + } + + ::-webkit-scrollbar-corner { + background: transparent; + } + fieldset { border: none; } diff --git a/frontend/src/lib/dateTimeHelpers.ts b/frontend/src/lib/dateTimeHelpers.ts index ee082fdd1..1b2387324 100644 --- a/frontend/src/lib/dateTimeHelpers.ts +++ b/frontend/src/lib/dateTimeHelpers.ts @@ -1,12 +1,18 @@ -export const formatTimestamp = ( - timestamp?: number | string | Date, - format: Intl.DateTimeFormatOptions = { hourCycle: 'h23' } -): string => { +import { getSystemTimezone } from 'lib/hooks/useTimezones'; + +export const formatTimestamp = ({ + timestamp, + format = { hourCycle: 'h23' }, + timezone, +}: { + timestamp: number | string | Date | undefined; + format?: Intl.DateTimeFormatOptions; + timezone?: string; +}): string => { if (!timestamp) { return ''; } - // empty array gets the default one from the browser const date = new Date(timestamp); // invalid date if (Number.isNaN(date.getTime())) { @@ -15,7 +21,15 @@ export const formatTimestamp = ( // browser support const language = navigator.language || navigator.languages[0]; - return date.toLocaleString(language || [], format); + + const finalTimezone = timezone || getSystemTimezone().value; + + const formatOptions: Intl.DateTimeFormatOptions = { + ...format, + timeZone: finalTimezone, + }; + + return date.toLocaleString(language || [], formatOptions); }; export const formatMilliseconds = (input = 0) => { diff --git a/frontend/src/lib/hooks/__tests__/dateTimeHelpers.spec.ts b/frontend/src/lib/hooks/__tests__/dateTimeHelpers.spec.ts index ad5346283..61e188f29 100644 --- a/frontend/src/lib/hooks/__tests__/dateTimeHelpers.spec.ts +++ b/frontend/src/lib/hooks/__tests__/dateTimeHelpers.spec.ts @@ -3,19 +3,19 @@ import { formatTimestamp } from 'lib/dateTimeHelpers'; describe('dateTimeHelpers', () => { describe('formatTimestamp', () => { it('should check the empty case', () => { - expect(formatTimestamp('')).toBe(''); + expect(formatTimestamp({ timestamp: '' })).toBe(''); }); it('should check the invalid case', () => { - expect(formatTimestamp('invalid')).toBe(''); + expect(formatTimestamp({ timestamp: 'invalid' })).toBe(''); }); it('should output the correct date', () => { const date = new Date(); - expect(formatTimestamp(date)).toBe( + expect(formatTimestamp({ timestamp: date })).toBe( date.toLocaleString([], { hourCycle: 'h23' }) ); - expect(formatTimestamp(date.getTime())).toBe( + expect(formatTimestamp({ timestamp: date.getTime() })).toBe( date.toLocaleString([], { hourCycle: 'h23' }) ); }); diff --git a/frontend/src/lib/hooks/useLocalStorage.ts b/frontend/src/lib/hooks/useLocalStorage.ts index 65215fd2c..317b1ac4c 100644 --- a/frontend/src/lib/hooks/useLocalStorage.ts +++ b/frontend/src/lib/hooks/useLocalStorage.ts @@ -1,23 +1,105 @@ import { LOCAL_STORAGE_KEY_PREFIX } from 'lib/constants'; -import { useState, useEffect, Dispatch, SetStateAction } from 'react'; +import { + useState, + useEffect, + Dispatch, + SetStateAction, + useCallback, +} from 'react'; + +const subscribers = new Map void>>(); + +const notifySubscribers = (key: string) => { + const keySubscribers = subscribers.get(key); + if (keySubscribers) { + keySubscribers.forEach((callback) => callback()); + } +}; + +const subscribe = (key: string, callback: () => void) => { + if (!subscribers.has(key)) { + subscribers.set(key, new Set()); + } + subscribers.get(key)!.add(callback); + + return () => { + const keySubscribers = subscribers.get(key); + if (keySubscribers) { + keySubscribers.delete(callback); + if (keySubscribers.size === 0) { + subscribers.delete(key); + } + } + }; +}; + +const getStorageValue = (key: string, defaultValue: T): T => { + try { + const saved = localStorage.getItem(key); + if (saved !== null) { + return JSON.parse(saved); + } + } catch (error) { + console.warn(`Failed to read localStorage key "${key}":`, error); + } + return defaultValue; +}; + +const setStorageValue = (key: string, value: T): void => { + try { + localStorage.setItem(key, JSON.stringify(value)); + notifySubscribers(key); + } catch (error) { + console.warn(`Failed to write localStorage key "${key}":`, error); + } +}; export const useLocalStorage = ( featureKey: string, defaultValue: T ): [T, Dispatch>] => { const key = `${LOCAL_STORAGE_KEY_PREFIX}-${featureKey}`; - const [value, setValue] = useState(() => { - const saved = localStorage.getItem(key); - if (saved !== null) { - return JSON.parse(saved); - } - return defaultValue; - }); + const [value, setValue] = useState(() => + getStorageValue(key, defaultValue) + ); + + const setStoredValue = useCallback>>( + (newValue) => { + setValue((prevValue) => { + const valueToStore = + typeof newValue === 'function' + ? (newValue as (prevState: T) => T)(prevValue) + : newValue; + + setStorageValue(key, valueToStore); + return valueToStore; + }); + }, + [key] + ); useEffect(() => { - localStorage.setItem(key, JSON.stringify(value)); - }, [key, value]); + const handleStorageChange = () => { + const newValue = getStorageValue(key, defaultValue); + setValue(newValue); + }; + + const unsubscribe = subscribe(key, handleStorageChange); + + const handleStorageEvent = (e: StorageEvent) => { + if (e.key === key) { + handleStorageChange(); + } + }; + + window.addEventListener('storage', handleStorageEvent); + + return () => { + unsubscribe(); + window.removeEventListener('storage', handleStorageEvent); + }; + }, [key, defaultValue]); - return [value, setValue]; + return [value, setStoredValue]; }; diff --git a/frontend/src/lib/hooks/useTimezones.ts b/frontend/src/lib/hooks/useTimezones.ts new file mode 100644 index 000000000..109108c85 --- /dev/null +++ b/frontend/src/lib/hooks/useTimezones.ts @@ -0,0 +1,138 @@ +import { useLocalStorage } from 'lib/hooks/useLocalStorage'; + +interface Timezone { + value: string; + label: string; + offset: string; +} + +const generateTimezones = (): Timezone[] => { + try { + return Intl.supportedValuesOf('timeZone').map((timeZone) => { + try { + const offsetPart = + new Intl.DateTimeFormat('en', { + timeZone, + timeZoneName: 'shortOffset', + }) + .formatToParts() + .find((part) => part.type === 'timeZoneName')?.value || '+00:00'; + + let offset: string; + + if (!offsetPart || offsetPart.trim() === '') { + offset = 'GMT+00:00'; + } else if (offsetPart.startsWith('GMT')) { + const gmtPart = offsetPart.replace('GMT', '').trim(); + if (!gmtPart) { + offset = 'GMT+00:00'; + } else if (gmtPart.includes(':')) { + offset = offsetPart; + } else { + const sign = + gmtPart.startsWith('+') || gmtPart.startsWith('-') ? '' : '+'; + offset = `GMT${sign}${gmtPart}:00`; + } + } else if (offsetPart.startsWith('+') || offsetPart.startsWith('-')) { + if (offsetPart.includes(':')) { + offset = `GMT${offsetPart}`; + } else { + offset = `GMT${offsetPart}:00`; + } + } else { + offset = 'GMT+00:00'; + } + + return { + value: timeZone, + label: `${offset.replace('GMT', 'UTC')} ${timeZone.replace(/_/g, ' ')}`, + offset, + }; + } catch (error) { + return { + value: timeZone, + label: timeZone.replace(/_/g, ' '), + offset: 'GMT+00:00', + }; + } + }); + } catch (error) { + console.warn( + 'Intl.supportedValuesOf not supported, using fallback timezones' + ); + return [ + { value: 'UTC', label: 'UTC', offset: 'GMT+00:00' }, + { + value: 'America/New York', + label: 'America/New York', + offset: 'GMT-05:00', + }, + { value: 'Europe/London', label: 'Europe/London', offset: 'GMT+00:00' }, + { value: 'Asia/Tokyo', label: 'Asia/Tokyo', offset: 'GMT+09:00' }, + ]; + } +}; + +const TIMEZONES: Timezone[] = generateTimezones().sort((a, b) => { + const parseOffset = (offset: string): number => { + const offsetPart = offset.replace('GMT', ''); + + const match = offsetPart.match(/([+-])(\d{1,2}):?(\d{0,2})/); + if (!match) return 0; + + const sign = match[1] === '+' ? 1 : -1; + const hours = parseInt(match[2], 10); + const minutes = parseInt(match[3] || '0', 10); + + return sign * (hours * 60 + minutes); + }; + + const offsetA = parseOffset(a.offset); + const offsetB = parseOffset(b.offset); + + if (offsetA !== offsetB) { + return offsetA - offsetB; + } + + return a.value.localeCompare(b.value); +}); + +export const getSystemTimezone = (): Timezone => { + const systemTimezone = Intl.DateTimeFormat().resolvedOptions().timeZone; + + const matchedTimezone = TIMEZONES.find((tz) => tz.value === systemTimezone); + + if (matchedTimezone) { + return matchedTimezone; + } + + const now = new Date(); + const offset = -now.getTimezoneOffset() / 60; + const offsetStr = `GMT${offset >= 0 ? '+' : ''}${offset.toString().padStart(2, '0')}:00`; + + return { + value: systemTimezone, + label: systemTimezone, + offset: offsetStr, + }; +}; + +const TIMEZONE_STORAGE_KEY = `timezone`; + +export const useTimezone = () => { + const [currentTimezone, setCurrentTimezone] = + useLocalStorage(TIMEZONE_STORAGE_KEY, null); + + const setTimezone = (timezone: Timezone | null) => { + setCurrentTimezone(timezone); + }; + + return { + currentTimezone: currentTimezone ?? getSystemTimezone(), + availableTimezones: TIMEZONES, + setTimezone, + }; +}; + +export type { Timezone }; +export { TIMEZONES }; diff --git a/frontend/src/theme/theme.ts b/frontend/src/theme/theme.ts index 389e93212..c2ccdb449 100644 --- a/frontend/src/theme/theme.ts +++ b/frontend/src/theme/theme.ts @@ -808,11 +808,11 @@ export const theme = { }, scrollbar: { trackColor: { - normal: Colors.neutral[0], + normal: Colors.neutral[5], active: Colors.neutral[5], }, thumbColor: { - normal: Colors.neutral[0], + normal: Colors.neutral[15], active: Colors.neutral[50], }, }, From c21e7e35cd8bfbcd4834bc4a45ca909a16885fe3 Mon Sep 17 00:00:00 2001 From: Leshe4ka Date: Wed, 9 Jul 2025 17:22:33 +0500 Subject: [PATCH 2/3] fix eslint --- frontend/src/lib/hooks/useLocalStorage.ts | 1 + frontend/src/lib/hooks/useTimezones.ts | 1 + 2 files changed, 2 insertions(+) diff --git a/frontend/src/lib/hooks/useLocalStorage.ts b/frontend/src/lib/hooks/useLocalStorage.ts index 317b1ac4c..b8231a9e2 100644 --- a/frontend/src/lib/hooks/useLocalStorage.ts +++ b/frontend/src/lib/hooks/useLocalStorage.ts @@ -1,3 +1,4 @@ +/* eslint-disable no-console */ import { LOCAL_STORAGE_KEY_PREFIX } from 'lib/constants'; import { useState, diff --git a/frontend/src/lib/hooks/useTimezones.ts b/frontend/src/lib/hooks/useTimezones.ts index 109108c85..2954ee9a8 100644 --- a/frontend/src/lib/hooks/useTimezones.ts +++ b/frontend/src/lib/hooks/useTimezones.ts @@ -57,6 +57,7 @@ const generateTimezones = (): Timezone[] => { } }); } catch (error) { + // eslint-disable-next-line no-console console.warn( 'Intl.supportedValuesOf not supported, using fallback timezones' ); From 3467a9db2254ea68279ad3b70b6516cc171f85ec Mon Sep 17 00:00:00 2001 From: Leshe4ka Date: Wed, 9 Jul 2025 18:45:08 +0500 Subject: [PATCH 3/3] get rid of GMT in header support for reset timestamp with timezone --- .../Details/ResetOffsets/Form.tsx | 10 ++++- .../NavBar/UserTimezone/UserTimezone.tsx | 2 +- frontend/src/lib/hooks/useTimezones.ts | 37 +++++++++++++++++-- 3 files changed, 43 insertions(+), 6 deletions(-) diff --git a/frontend/src/components/ConsumerGroups/Details/ResetOffsets/Form.tsx b/frontend/src/components/ConsumerGroups/Details/ResetOffsets/Form.tsx index eef9aa282..94fd1606c 100644 --- a/frontend/src/components/ConsumerGroups/Details/ResetOffsets/Form.tsx +++ b/frontend/src/components/ConsumerGroups/Details/ResetOffsets/Form.tsx @@ -23,6 +23,7 @@ import useAppParams from 'lib/hooks/useAppParams'; import { useResetConsumerGroupOffsetsMutation } from 'lib/hooks/api/consumers'; import { FlexFieldset, StyledForm } from 'components/common/Form/Form.styled'; import ControlledSelect from 'components/common/Select/ControlledSelect'; +import { useTimezone } from 'lib/hooks/useTimezones'; import * as S from './ResetOffsets.styled'; @@ -38,6 +39,7 @@ const resetTypeOptions = Object.values(ConsumerGroupOffsetsResetType).map( const Form: React.FC = ({ defaultValues, partitions, topics }) => { const navigate = useNavigate(); + const { getDateInCurrentTimezone } = useTimezone(); const routerParams = useAppParams(); const reset = useResetConsumerGroupOffsetsMutation(routerParams); const topicOptions = React.useMemo( @@ -142,8 +144,12 @@ const Form: React.FC = ({ defaultValues, partitions, topics }) => { render={({ field: { onChange, onBlur, value, ref } }) => ( onChange(e?.getTime())} + selected={getDateInCurrentTimezone( + new Date(value as number) + )} + onChange={(selectedDate: Date | null) => { + onChange(selectedDate?.getTime()); + }} onBlur={onBlur} /> )} diff --git a/frontend/src/components/NavBar/UserTimezone/UserTimezone.tsx b/frontend/src/components/NavBar/UserTimezone/UserTimezone.tsx index 982f7b29f..170b503b1 100644 --- a/frontend/src/components/NavBar/UserTimezone/UserTimezone.tsx +++ b/frontend/src/components/NavBar/UserTimezone/UserTimezone.tsx @@ -39,7 +39,7 @@ export const UserTimezone = () => { openBtnEl={ diff --git a/frontend/src/lib/hooks/useTimezones.ts b/frontend/src/lib/hooks/useTimezones.ts index 2954ee9a8..7fa2afe69 100644 --- a/frontend/src/lib/hooks/useTimezones.ts +++ b/frontend/src/lib/hooks/useTimezones.ts @@ -4,6 +4,7 @@ interface Timezone { value: string; label: string; offset: string; + UTCOffset: string; } const generateTimezones = (): Timezone[] => { @@ -47,12 +48,14 @@ const generateTimezones = (): Timezone[] => { value: timeZone, label: `${offset.replace('GMT', 'UTC')} ${timeZone.replace(/_/g, ' ')}`, offset, + UTCOffset: offset.replace('GMT', 'UTC'), }; } catch (error) { return { value: timeZone, label: timeZone.replace(/_/g, ' '), offset: 'GMT+00:00', + UTCOffset: 'UTC+00:00', }; } }); @@ -62,14 +65,30 @@ const generateTimezones = (): Timezone[] => { 'Intl.supportedValuesOf not supported, using fallback timezones' ); return [ - { value: 'UTC', label: 'UTC', offset: 'GMT+00:00' }, + { + value: 'UTC', + label: 'UTC', + offset: 'GMT+00:00', + UTCOffset: 'GMT+00:00', + }, { value: 'America/New York', label: 'America/New York', offset: 'GMT-05:00', + UTCOffset: 'GMT-05:00', + }, + { + value: 'Europe/London', + label: 'Europe/London', + offset: 'GMT+00:00', + UTCOffset: 'GMT+00:00', + }, + { + value: 'Asia/Tokyo', + label: 'Asia/Tokyo', + offset: 'GMT+09:00', + UTCOffset: 'GMT+09:00', }, - { value: 'Europe/London', label: 'Europe/London', offset: 'GMT+00:00' }, - { value: 'Asia/Tokyo', label: 'Asia/Tokyo', offset: 'GMT+09:00' }, ]; } }; @@ -115,6 +134,7 @@ export const getSystemTimezone = (): Timezone => { value: systemTimezone, label: systemTimezone, offset: offsetStr, + UTCOffset: offsetStr.replace('GMT', 'UTC'), }; }; @@ -128,10 +148,21 @@ export const useTimezone = () => { setCurrentTimezone(timezone); }; + const getDateInCurrentTimezone = (date: Date = new Date()): Date => { + const timezone = (currentTimezone ?? getSystemTimezone()).value; + + const timeInTimezone = date.toLocaleString('sv-SE', { + timeZone: timezone, + }); + + return new Date(timeInTimezone); + }; + return { currentTimezone: currentTimezone ?? getSystemTimezone(), availableTimezones: TIMEZONES, setTimezone, + getDateInCurrentTimezone, }; };