diff --git a/src/components/AppController.tsx b/src/components/AppController.tsx index ccdfc155c..a2d9632be 100644 --- a/src/components/AppController.tsx +++ b/src/components/AppController.tsx @@ -24,10 +24,11 @@ import { * NOTE: this may modify the URL parameters. */ const updateViewHashParams = () => { - const {isPrettified, logEventNum} = getWindowUrlHashParams(); - const {updateIsPrettified, updateLogEventNum} = useViewStore.getState(); + const {isPrettified, logEventNum, timezone} = getWindowUrlHashParams(); + const {updateIsPrettified, updateTimezoneName, updateLogEventNum} = useViewStore.getState(); updateIsPrettified(isPrettified); + updateTimezoneName(timezone); updateLogEventNum(logEventNum); }; diff --git a/src/components/StatusBar/LogLevelSelect/index.css b/src/components/StatusBar/LogLevelSelect/index.css index 00a4cec40..23cf3bd66 100644 --- a/src/components/StatusBar/LogLevelSelect/index.css +++ b/src/components/StatusBar/LogLevelSelect/index.css @@ -14,5 +14,5 @@ } .log-level-select-listbox { - max-height: calc(100vh - var(--ylv-menu-bar-height) - var(--ylv-status-bar-height)) !important; + max-height: var(--ylv-list-box-max-height) !important; } diff --git a/src/components/StatusBar/TimezoneSelect/TimezoneCategoryChip.css b/src/components/StatusBar/TimezoneSelect/TimezoneCategoryChip.css new file mode 100644 index 000000000..8d6f40dfe --- /dev/null +++ b/src/components/StatusBar/TimezoneSelect/TimezoneCategoryChip.css @@ -0,0 +1,8 @@ +.timezone-category-chip { + cursor: pointer; +} + +.timezone-category-chip-default-timezone { + /* Disable `Chip`'s background style. */ + background-color: initial !important; +} diff --git a/src/components/StatusBar/TimezoneSelect/TimezoneCategoryChip.tsx b/src/components/StatusBar/TimezoneSelect/TimezoneCategoryChip.tsx new file mode 100644 index 000000000..1a83b9f85 --- /dev/null +++ b/src/components/StatusBar/TimezoneSelect/TimezoneCategoryChip.tsx @@ -0,0 +1,69 @@ +import {Chip} from "@mui/joy"; +import {DefaultColorPalette} from "@mui/joy/styles/types/colorSystem"; + +import {TIMEZONE_CATEGORY} from "../../../typings/date"; + +import "./TimezoneCategoryChip.css"; + + +interface TimezoneTypeMetadata { + label: string; + color: DefaultColorPalette; +} + +const TIMEZONE_CATEGORY_METADATA: Record = { + [TIMEZONE_CATEGORY.DEFAULT]: { + label: "Default", + color: "neutral", + }, + [TIMEZONE_CATEGORY.BROWSER]: { + label: "Browser", + color: "warning", + }, + [TIMEZONE_CATEGORY.LOGGER]: { + label: "Logger", + color: "primary", + }, + [TIMEZONE_CATEGORY.MANUAL]: { + label: "Manual", + color: "success", + }, +}; + +interface TimezoneCategoryChipProps { + category: TIMEZONE_CATEGORY; + disabled: boolean; +} + +/** + * Render a chip that represents the category of the timezone. + * + * @param props + * @param props.category + * @param props.disabled + * @return + */ +const TimezoneCategoryChip = ({ + category, + disabled, +}: TimezoneCategoryChipProps) => { + const isDefault = category === TIMEZONE_CATEGORY.DEFAULT; + + return ( + + {"Timezone"} + {false === isDefault && " | "} + {false === isDefault && TIMEZONE_CATEGORY_METADATA[category].label} + + ); +}; + + +export default TimezoneCategoryChip; diff --git a/src/components/StatusBar/TimezoneSelect/index.css b/src/components/StatusBar/TimezoneSelect/index.css new file mode 100644 index 000000000..eb3c70906 --- /dev/null +++ b/src/components/StatusBar/TimezoneSelect/index.css @@ -0,0 +1,14 @@ +.timezone-select { + /* stylelint-disable-next-line custom-property-pattern */ + --Input-focusedThickness: 0 !important; +} + +.timezone-select-listbox { + min-width: fit-content; + max-height: var(--ylv-list-box-max-height) !important; +} + +.timezone-select-pop-up-indicator { + /* stylelint-disable-next-line custom-property-pattern */ + --Icon-fontSize: 1.1rem !important; +} diff --git a/src/components/StatusBar/TimezoneSelect/index.tsx b/src/components/StatusBar/TimezoneSelect/index.tsx new file mode 100644 index 000000000..7dbbf1d2b --- /dev/null +++ b/src/components/StatusBar/TimezoneSelect/index.tsx @@ -0,0 +1,115 @@ +import { + useCallback, + useEffect, + useMemo, + useState, +} from "react"; + +import {SelectValue} from "@mui/base/useSelect"; +import {Autocomplete} from "@mui/joy"; + +import KeyboardArrowUpIcon from "@mui/icons-material/KeyboardArrowUp"; + +import useUiStore from "../../../stores/uiStore"; +import useViewStore from "../../../stores/viewStore"; +import { + BROWSER_TIMEZONE_NAME, + DEFAULT_TIMEZONE_NAME, + getTimezoneCategory, + INTL_SUPPORTED_TIMEZONE_NAMES, + LOGGER_TIMEZONE_NAME, + UTC_TIMEZONE_OFFSET_NAMES, +} from "../../../typings/date"; +import {UI_ELEMENT} from "../../../typings/states"; +import {HASH_PARAM_NAMES} from "../../../typings/url"; +import {isDisabled} from "../../../utils/states"; +import {updateWindowUrlHashParams} from "../../../utils/url"; +import TimezoneCategoryChip from "./TimezoneCategoryChip.tsx"; + +import "./index.css"; + + +/** + * The timezone select dropdown menu, the selectable options can be classified as three types: + * - Default (use the origin timezone of the log events) + * - Browser Timezone (use the timezone that the browser is currently using) + * - Frequently-used Timezone + * + * @return A timezone select dropdown menu + */ +const TimezoneSelect = () => { + const uiState = useUiStore((state) => state.uiState); + const timezoneName = useViewStore((state) => state.timezoneName); + + const [inputWidth, setInputWidth] = useState(`${timezoneName.length}ch`); + const timezoneNameOptions = useMemo(() => [ + DEFAULT_TIMEZONE_NAME, + BROWSER_TIMEZONE_NAME, + LOGGER_TIMEZONE_NAME, + ...UTC_TIMEZONE_OFFSET_NAMES, + ...INTL_SUPPORTED_TIMEZONE_NAMES.filter( + (tzName) => tzName !== BROWSER_TIMEZONE_NAME + ), + ], []); + + const handleTimezoneSelectChange = + useCallback((_: unknown, value: SelectValue) => { + if (null === value) { + throw new Error("Unexpected null value in non-clearable timezone select."); + } + + const {updateTimezoneName} = useViewStore.getState(); + updateTimezoneName(value); + updateWindowUrlHashParams({ + [HASH_PARAM_NAMES.TIMEZONE]: value, + }); + }, []); + + useEffect(() => { + // Update the input width based on the selected timezone name. + setInputWidth(`${timezoneName.length}ch`); + }, [timezoneName]); + + + const disabled = isDisabled(uiState, UI_ELEMENT.TIMEZONE_SETTER); + + return ( + } + size={"sm"} + value={timezoneName} + variant={"soft"} + slotProps={{ + popupIndicator: { + className: "timezone-select-pop-up-indicator", + }, + input: { + sx: { + width: inputWidth, + }, + }, + listbox: { + className: "timezone-select-listbox", + placement: "top-end", + modifiers: [ + // Remove gap between the listbox and the `Select` button. + {name: "offset", enabled: false}, + ], + }, + }} + startDecorator={} + onChange={handleTimezoneSelectChange}/> + ); +}; + + +export default TimezoneSelect; diff --git a/src/components/StatusBar/index.tsx b/src/components/StatusBar/index.tsx index 00bd92659..486be16a4 100644 --- a/src/components/StatusBar/index.tsx +++ b/src/components/StatusBar/index.tsx @@ -24,6 +24,7 @@ import { } from "../../utils/url"; import LogLevelSelect from "./LogLevelSelect"; import StatusBarToggleButton from "./StatusBarToggleButton"; +import TimezoneSelect from "./TimezoneSelect"; import "./index.css"; @@ -89,6 +90,7 @@ const StatusBar = () => { + diff --git a/src/main.css b/src/main.css index 1a82ac60b..870cdc5a2 100644 --- a/src/main.css +++ b/src/main.css @@ -23,6 +23,9 @@ html { --ylv-status-bar-height: 32px; --ylv-menu-bar-height: 32px; --ylv-panel-resize-handle-width: 4px; + --ylv-list-box-max-height: calc( + 100vh - var(--ylv-menu-bar-height) - var(--ylv-status-bar-height) + ); /* z-index globals * diff --git a/src/stores/viewStore/createViewFormattingSlice.ts b/src/stores/viewStore/createViewFormattingSlice.ts index 7f70fc78a..6fbac312d 100644 --- a/src/stores/viewStore/createViewFormattingSlice.ts +++ b/src/stores/viewStore/createViewFormattingSlice.ts @@ -1,11 +1,13 @@ import {StateCreator} from "zustand"; +import {DEFAULT_TIMEZONE_NAME} from "../../typings/date"; import {UI_STATE} from "../../typings/states"; import { CURSOR_CODE, CursorType, } from "../../typings/worker"; import useLogFileManagerStore from "../logFileManagerProxyStore"; +import useLogFileStore from "../logFileStore"; import {handleErrorWithNotification} from "../notificationStore"; import useUiStore from "../uiStore"; import {VIEW_EVENT_DEFAULT} from "./createViewEventSlice"; @@ -18,6 +20,7 @@ import { const VIEW_FORMATTING_DEFAULT: ViewFormattingValues = { isPrettified: false, + timezoneName: DEFAULT_TIMEZONE_NAME, }; /** @@ -59,6 +62,48 @@ const createViewFormattingSlice: StateCreator< updatePageData(pageData); })().catch(handleErrorWithNotification); }, + updateTimezoneName: (newTimezoneName: string) => { + if ("" === newTimezoneName) { + newTimezoneName = DEFAULT_TIMEZONE_NAME; + } + + const {numEvents} = useLogFileStore.getState(); + if (0 === numEvents) { + return; + } + + const {timezoneName} = get(); + if (newTimezoneName === timezoneName) { + return; + } + + const {setUiState} = useUiStore.getState(); + setUiState(UI_STATE.FAST_LOADING); + + set({timezoneName: newTimezoneName}); + + const {logEventNum} = get(); + let cursor: CursorType = {code: CURSOR_CODE.LAST_EVENT, args: null}; + if (VIEW_EVENT_DEFAULT.logEventNum !== logEventNum) { + cursor = { + code: CURSOR_CODE.EVENT_NUM, + args: {eventNum: logEventNum}, + }; + } + + (async () => { + const {logFileManagerProxy} = useLogFileManagerStore.getState(); + const {isPrettified, updatePageData} = get(); + + // await logFileManagerProxy.setTimezone(newTimezoneName); + const pageData = await logFileManagerProxy.loadPage( + cursor, + isPrettified, + ); + + updatePageData(pageData); + })().catch(handleErrorWithNotification); + }, }); export default createViewFormattingSlice; diff --git a/src/stores/viewStore/types.ts b/src/stores/viewStore/types.ts index 428a50d9b..357516686 100644 --- a/src/stores/viewStore/types.ts +++ b/src/stores/viewStore/types.ts @@ -33,10 +33,12 @@ type ViewEventSlice = ViewEventValues & ViewEventActions; interface ViewFormattingValues { isPrettified: boolean; + timezoneName: string; } interface ViewFormattingActions { updateIsPrettified: (newIsPrettified: boolean) => void; + updateTimezoneName: (newTimezoneName: string) => void; } type ViewFormattingSlice = ViewFormattingValues & ViewFormattingActions; diff --git a/src/typings/date.ts b/src/typings/date.ts new file mode 100644 index 000000000..676ae4219 --- /dev/null +++ b/src/typings/date.ts @@ -0,0 +1,108 @@ +/** + * Represents a time zone, which can either be a numeric offset from UTC or a string identifier. + * + * - A numeric value indicates the offset from UTC in hours (e.g., `-5` represents UTC-5, which is 5 + * hours behind UTC). + * - A string value represents a time zone identifier as either a time zone offset notation (e.g., + * `'UTC+02:00'`, `'UTC-05:00'`) or a time zone name listed by the + * `Intl.supportedValuesOf("timeZone")` method (e.g., `'America/Toronto'`, `'Asia/Hong_Kong'`). + */ +type Timezone = number | string; + +const UTC_TIMEZONE_NAME = "UTC"; + +const DEFAULT_TIMEZONE_NAME = UTC_TIMEZONE_NAME; +const {timeZone: BROWSER_TIMEZONE_NAME} = new Intl.DateTimeFormat().resolvedOptions(); + +const LOGGER_TIMEZONE_NAME = "Original"; + +const UTC_TIMEZONE_OFFSET_NAMES: string[] = [ + "UTC-12:00", + "UTC-11:00", + "UTC-10:00", + "UTC-09:30", + "UTC-09:00", + "UTC-08:00", + "UTC-07:00", + "UTC-06:00", + "UTC-05:00", + "UTC-04:00", + "UTC-03:30", + "UTC-03:00", + "UTC-02:30", + "UTC-02:00", + "UTC-01:00", + "UTC+01:00", + "UTC+02:00", + "UTC+03:00", + "UTC+03:30", + "UTC+04:00", + "UTC+04:30", + "UTC+05:00", + "UTC+05:30", + "UTC+05:45", + "UTC+06:00", + "UTC+06:30", + "UTC+07:00", + "UTC+08:00", + "UTC+09:00", + "UTC+09:30", + "UTC+10:00", + "UTC+10:30", + "UTC+11:00", + "UTC+12:00", + "UTC+12:45", + "UTC+13:00", + "UTC+13:45", + "UTC+14:00", +]; + +const INTL_SUPPORTED_TIMEZONE_NAMES: string[] = Intl.supportedValuesOf("timeZone"); + + +/** + * Checks if the provided timezone name is a valid UTC offset. + * + * @param timezoneName + * @return Returns true if the timezone name is in the format 'UTC±HH:MM', false otherwise. + */ +const isTimezoneUtcOffsetName = (timezoneName: string) +: boolean => UTC_TIMEZONE_OFFSET_NAMES.includes(timezoneName); + +enum TIMEZONE_CATEGORY { + DEFAULT = "default", + BROWSER = "browser", + LOGGER = "logger", + MANUAL = "manual", +} + +/** + * Classify the timezone name into TIMEZONE_CATEGORY. + * + * @param timezoneName + * @return + */ +const getTimezoneCategory = (timezoneName: string): TIMEZONE_CATEGORY => { + switch (timezoneName) { + case DEFAULT_TIMEZONE_NAME: + return TIMEZONE_CATEGORY.DEFAULT; + case BROWSER_TIMEZONE_NAME: + return TIMEZONE_CATEGORY.BROWSER; + case LOGGER_TIMEZONE_NAME: + return TIMEZONE_CATEGORY.LOGGER; + default: + return TIMEZONE_CATEGORY.MANUAL; + } +}; + +export type {Timezone}; +export { + BROWSER_TIMEZONE_NAME, + DEFAULT_TIMEZONE_NAME, + getTimezoneCategory, + INTL_SUPPORTED_TIMEZONE_NAMES, + isTimezoneUtcOffsetName, + LOGGER_TIMEZONE_NAME, + TIMEZONE_CATEGORY, + UTC_TIMEZONE_OFFSET_NAMES, +}; diff --git a/src/typings/states.ts b/src/typings/states.ts index e39a6d3ec..a85ba7b64 100644 --- a/src/typings/states.ts +++ b/src/typings/states.ts @@ -39,6 +39,7 @@ enum UI_ELEMENT { PRETTIFY_BUTTON, PROGRESS_BAR, QUERY_INPUT_BOX, + TIMEZONE_SETTER, } type UiElementRow = { @@ -65,6 +66,7 @@ const UI_STATE_GRID: UiStateGrid = Object.freeze({ [UI_ELEMENT.PRETTIFY_BUTTON]: false, [UI_ELEMENT.PROGRESS_BAR]: false, [UI_ELEMENT.QUERY_INPUT_BOX]: false, + [UI_ELEMENT.TIMEZONE_SETTER]: false, }, [UI_STATE.FILE_LOADING]: { [UI_ELEMENT.DRAG_AND_DROP]: false, @@ -76,6 +78,7 @@ const UI_STATE_GRID: UiStateGrid = Object.freeze({ [UI_ELEMENT.PRETTIFY_BUTTON]: false, [UI_ELEMENT.PROGRESS_BAR]: true, [UI_ELEMENT.QUERY_INPUT_BOX]: false, + [UI_ELEMENT.TIMEZONE_SETTER]: false, }, [UI_STATE.FAST_LOADING]: { [UI_ELEMENT.DRAG_AND_DROP]: true, @@ -87,6 +90,7 @@ const UI_STATE_GRID: UiStateGrid = Object.freeze({ [UI_ELEMENT.PRETTIFY_BUTTON]: false, [UI_ELEMENT.PROGRESS_BAR]: true, [UI_ELEMENT.QUERY_INPUT_BOX]: false, + [UI_ELEMENT.TIMEZONE_SETTER]: false, }, [UI_STATE.READY]: { [UI_ELEMENT.DRAG_AND_DROP]: true, @@ -98,6 +102,7 @@ const UI_STATE_GRID: UiStateGrid = Object.freeze({ [UI_ELEMENT.PRETTIFY_BUTTON]: true, [UI_ELEMENT.PROGRESS_BAR]: false, [UI_ELEMENT.QUERY_INPUT_BOX]: true, + [UI_ELEMENT.TIMEZONE_SETTER]: true, }, }); diff --git a/src/typings/url.ts b/src/typings/url.ts index d8f5e4903..fe2ed2349 100644 --- a/src/typings/url.ts +++ b/src/typings/url.ts @@ -6,11 +6,12 @@ enum SEARCH_PARAM_NAMES { } enum HASH_PARAM_NAMES { - LOG_EVENT_NUM = "logEventNum", IS_PRETTIFIED = "isPrettified", + LOG_EVENT_NUM = "logEventNum", QUERY_IS_CASE_SENSITIVE = "queryIsCaseSensitive", QUERY_IS_REGEX = "queryIsRegex", QUERY_STRING = "queryString", + TIMEZONE = "timezone", } interface UrlSearchParams { @@ -23,6 +24,7 @@ interface UrlHashParams { [HASH_PARAM_NAMES.QUERY_IS_CASE_SENSITIVE]: boolean; [HASH_PARAM_NAMES.QUERY_IS_REGEX]: boolean; [HASH_PARAM_NAMES.QUERY_STRING]: string; + [HASH_PARAM_NAMES.TIMEZONE]: string; } type UrlSearchParamUpdatesType = { diff --git a/src/utils/date.ts b/src/utils/date.ts new file mode 100644 index 000000000..e06186fb9 --- /dev/null +++ b/src/utils/date.ts @@ -0,0 +1,21 @@ +const MINUTES_IN_HOUR = 60; + +/** + * Gets the UTC offset in minutes for a given timezone name. + * + * @param timezoneName + * @return The UTC offset in minutes. For example, "UTC+02:00" returns 120. + */ +const getUtcOffsetFrom = (timezoneName: string): number => { + const [hours, minutes] = timezoneName + .replace("UTC", "") + .split(":") + .map((part) => parseInt(part, 10)); + + return ( + (hours || 0) * MINUTES_IN_HOUR + ) + (minutes || 0); +}; + + +export {getUtcOffsetFrom}; diff --git a/src/utils/url.ts b/src/utils/url.ts index 5ae3ea68d..3ed195133 100644 --- a/src/utils/url.ts +++ b/src/utils/url.ts @@ -25,6 +25,7 @@ const URL_HASH_PARAMS_DEFAULT = Object.freeze({ [HASH_PARAM_NAMES.QUERY_IS_CASE_SENSITIVE]: false, [HASH_PARAM_NAMES.QUERY_IS_REGEX]: false, [HASH_PARAM_NAMES.QUERY_STRING]: "", + [HASH_PARAM_NAMES.TIMEZONE]: "", }); /** @@ -180,6 +181,8 @@ const parseWindowUrlHashParams = () : Partial => { parsedHashParams[HASH_PARAM_NAMES.LOG_EVENT_NUM] = Number.isNaN(parsed) ? 0 : parsed; + } else if (HASH_PARAM_NAMES.TIMEZONE === key) { + parsedHashParams[HASH_PARAM_NAMES.TIMEZONE] = value; } else if (HASH_PARAM_NAMES.QUERY_STRING === key) { parsedHashParams[HASH_PARAM_NAMES.QUERY_STRING] = value; } else if (HASH_PARAM_NAMES.QUERY_IS_CASE_SENSITIVE === key) {