diff --git a/src/@types/translations/en.json b/src/@types/translations/en.json index 45d4f96e..722bbf58 100644 --- a/src/@types/translations/en.json +++ b/src/@types/translations/en.json @@ -2817,7 +2817,9 @@ }, "visitOurWebsite": "Visit our official website", "addMessagesToPage": "Add messages to page", - "addMessagesToPageDisabled": "No messages available" + "addMessagesToPageDisabled": "No messages available", + "accountSettings": "Account settings", + "startWeekOn": "Start week on" }, "globalComment": { "comments": "Comments", diff --git a/src/application/__tests__/user-metadata.test.ts b/src/application/__tests__/user-metadata.test.ts index 7eb3e8d6..8b03ab43 100644 --- a/src/application/__tests__/user-metadata.test.ts +++ b/src/application/__tests__/user-metadata.test.ts @@ -1,7 +1,7 @@ import { expect } from '@jest/globals'; import { toZonedTime } from 'date-fns-tz'; +import { DateFormat } from '../types'; import { - DateFormatType, MetadataDefaults, MetadataKey, MetadataUtils, @@ -18,28 +18,11 @@ describe('User Metadata', () => { jest.clearAllMocks(); }); - describe('MetadataKey enum', () => { - it('should have correct values', () => { - expect(MetadataKey.Timezone).toBe('timezone'); - expect(MetadataKey.Language).toBe('language'); - expect(MetadataKey.DateFormat).toBe('date_format'); - expect(MetadataKey.IconUrl).toBe('icon_url'); - }); - }); - - describe('DateFormatType enum', () => { - it('should have correct date format patterns', () => { - expect(DateFormatType.US).toBe('MM/DD/YYYY'); - expect(DateFormatType.EU).toBe('DD/MM/YYYY'); - expect(DateFormatType.ISO).toBe('YYYY-MM-DD'); - }); - }); - describe('MetadataDefaults', () => { it('should have default values for all metadata keys', () => { expect(MetadataDefaults[MetadataKey.Timezone]).toBe('UTC'); expect(MetadataDefaults[MetadataKey.Language]).toBe('en'); - expect(MetadataDefaults[MetadataKey.DateFormat]).toBe(DateFormatType.US); + expect(MetadataDefaults[MetadataKey.DateFormat]).toBe(DateFormat.US); expect(MetadataDefaults[MetadataKey.IconUrl]).toBe(''); }); }); @@ -62,8 +45,8 @@ describe('User Metadata', () => { }); it('should set date format', () => { - const result = builder.setDateFormat(DateFormatType.EU).build(); - expect(result[MetadataKey.DateFormat]).toBe('DD/MM/YYYY'); + const result = builder.setDateFormat(DateFormat.DayMonthYear).build(); + expect(result[MetadataKey.DateFormat]).toBe(4); }); it('should set icon URL', () => { @@ -71,24 +54,17 @@ describe('User Metadata', () => { expect(result[MetadataKey.IconUrl]).toBe('https://example.com/icon.png'); }); - it('should set custom metadata', () => { - const result = builder.setCustom('theme', 'dark').build(); - expect(result.theme).toBe('dark'); - }); - it('should chain multiple setters', () => { const result = builder .setTimezone('Europe/London') .setLanguage('en-GB') - .setDateFormat(DateFormatType.EU) - .setCustom('theme', 'light') + .setDateFormat(DateFormat.DayMonthYear) .build(); expect(result).toEqual({ [MetadataKey.Timezone]: 'Europe/London', [MetadataKey.Language]: 'en-GB', - [MetadataKey.DateFormat]: DateFormatType.EU, - theme: 'light', + [MetadataKey.DateFormat]: DateFormat.DayMonthYear, }); }); @@ -105,21 +81,21 @@ describe('User Metadata', () => { describe('MetadataUtils', () => { describe('detectDateFormat', () => { it('should detect US format for US locale', () => { - expect(MetadataUtils.detectDateFormat('en-US')).toBe(DateFormatType.US); - expect(MetadataUtils.detectDateFormat('en-CA')).toBe(DateFormatType.US); - expect(MetadataUtils.detectDateFormat('en-PH')).toBe(DateFormatType.US); + expect(MetadataUtils.detectDateFormat('en-US')).toBe(DateFormat.US); + expect(MetadataUtils.detectDateFormat('en-CA')).toBe(DateFormat.US); + expect(MetadataUtils.detectDateFormat('en-PH')).toBe(DateFormat.US); }); it('should detect EU format for European locales', () => { - expect(MetadataUtils.detectDateFormat('en-GB')).toBe(DateFormatType.EU); - expect(MetadataUtils.detectDateFormat('fr-FR')).toBe(DateFormatType.EU); - expect(MetadataUtils.detectDateFormat('de-DE')).toBe(DateFormatType.EU); + expect(MetadataUtils.detectDateFormat('en-GB')).toBe(DateFormat.DayMonthYear); + expect(MetadataUtils.detectDateFormat('fr-FR')).toBe(DateFormat.DayMonthYear); + expect(MetadataUtils.detectDateFormat('de-DE')).toBe(DateFormat.DayMonthYear); }); it('should detect ISO format for specific regions', () => { - expect(MetadataUtils.detectDateFormat('sv-SE')).toBe(DateFormatType.ISO); - expect(MetadataUtils.detectDateFormat('fi-FI')).toBe(DateFormatType.ISO); - expect(MetadataUtils.detectDateFormat('ko-KR')).toBe(DateFormatType.ISO); + expect(MetadataUtils.detectDateFormat('sv-SE')).toBe(DateFormat.ISO); + expect(MetadataUtils.detectDateFormat('fi-FI')).toBe(DateFormat.ISO); + expect(MetadataUtils.detectDateFormat('ko-KR')).toBe(DateFormat.ISO); }); it('should use browser locale as default', () => { @@ -129,7 +105,7 @@ describe('User Metadata', () => { configurable: true, }); - expect(MetadataUtils.detectDateFormat()).toBe(DateFormatType.US); + expect(MetadataUtils.detectDateFormat()).toBe(DateFormat.US); Object.defineProperty(navigator, 'language', { value: originalLanguage, @@ -138,8 +114,8 @@ describe('User Metadata', () => { }); it('should handle locale without region', () => { - expect(MetadataUtils.detectDateFormat('en')).toBe(DateFormatType.EU); - expect(MetadataUtils.detectDateFormat('fr')).toBe(DateFormatType.EU); + expect(MetadataUtils.detectDateFormat('en')).toBe(DateFormat.DayMonthYear); + expect(MetadataUtils.detectDateFormat('fr')).toBe(DateFormat.DayMonthYear); }); }); @@ -222,14 +198,12 @@ describe('User Metadata', () => { it('should merge multiple metadata objects', () => { const obj1 = { [MetadataKey.Timezone]: 'UTC' }; const obj2 = { [MetadataKey.Language]: 'en' }; - const obj3 = { custom: 'value' }; - const result = MetadataUtils.merge(obj1, obj2, obj3); + const result = MetadataUtils.merge(obj1, obj2); expect(result).toEqual({ [MetadataKey.Timezone]: 'UTC', [MetadataKey.Language]: 'en', - custom: 'value', }); }); @@ -336,4 +310,4 @@ describe('User Metadata', () => { }); }); }); -}); \ No newline at end of file +}); diff --git a/src/application/database-yjs/cell.parse.ts b/src/application/database-yjs/cell.parse.ts index b27762a0..a85bd7b9 100644 --- a/src/application/database-yjs/cell.parse.ts +++ b/src/application/database-yjs/cell.parse.ts @@ -2,7 +2,7 @@ import * as Y from 'yjs'; import { FieldType } from '@/application/database-yjs/database.type'; import { getDateCellStr, parseChecklistData, parseSelectOptionTypeOptions } from '@/application/database-yjs/fields'; -import { YDatabaseCell, YDatabaseField, YjsDatabaseKey } from '@/application/types'; +import { User, YDatabaseCell, YDatabaseField, YjsDatabaseKey } from '@/application/types'; import { Cell, DateTimeCell, FileMediaCell, FileMediaCellData } from './cell.type'; @@ -94,7 +94,7 @@ export function parseYDatabaseRelationCellToCell(cell: YDatabaseCell): Cell { }; } -export function getCellDataText(cell: YDatabaseCell, field: YDatabaseField): string { +export function getCellDataText(cell: YDatabaseCell, field: YDatabaseField, currentUser?: User): string { const type = parseInt(field.get(YjsDatabaseKey.type)); switch (type) { @@ -143,7 +143,7 @@ export function getCellDataText(cell: YDatabaseCell, field: YDatabaseField): str case FieldType.DateTime: { const dateCell = parseYDatabaseDateTimeCellToCell(cell); - return getDateCellStr({ cell: dateCell, field }); + return getDateCellStr({ cell: dateCell, field, currentUser }); } case FieldType.CreatedTime: diff --git a/src/application/database-yjs/cell.type.ts b/src/application/database-yjs/cell.type.ts index a98de4e7..a0ff414e 100644 --- a/src/application/database-yjs/cell.type.ts +++ b/src/application/database-yjs/cell.type.ts @@ -2,8 +2,7 @@ import React from 'react'; import * as Y from 'yjs'; import { FieldType } from '@/application/database-yjs/database.type'; -import { DateFormat, TimeFormat } from '@/application/database-yjs/index'; -import { FieldId, RowId } from '@/application/types'; +import { DateFormat, FieldId, RowId, TimeFormat } from '@/application/types'; export interface Cell { createdAt: number; diff --git a/src/application/database-yjs/dispatch.ts b/src/application/database-yjs/dispatch.ts index 8db17cda..2867ff91 100644 --- a/src/application/database-yjs/dispatch.ts +++ b/src/application/database-yjs/dispatch.ts @@ -28,7 +28,6 @@ import { SortCondition, } from '@/application/database-yjs/database.type'; import { - DateFormat, getDateCellStr, getFieldName, isDate, @@ -38,7 +37,6 @@ import { safeParseTimestamp, SelectOption, SelectTypeOption, - TimeFormat, } from '@/application/database-yjs/fields'; import { createCheckboxCell, getChecked } from '@/application/database-yjs/fields/checkbox/utils'; import { EnhancedBigStats } from '@/application/database-yjs/fields/number/EnhancedBigStats'; @@ -51,8 +49,10 @@ import { useBoardLayoutSettings, useFieldSelector, useFieldType } from '@/applic import { executeOperations } from '@/application/slate-yjs/utils/yjs'; import { DatabaseViewLayout, + DateFormat, FieldId, RowId, + TimeFormat, UpdatePagePayload, ViewLayout, YDatabase, @@ -81,6 +81,7 @@ import { YMapFieldTypeOption, YSharedRoot, } from '@/application/types'; +import { useCurrentUser } from '@/components/main/app.hooks'; export function useResizeColumnWidthDispatch() { const database = useDatabase(); @@ -2232,6 +2233,7 @@ export function useSwitchPropertyType() { const database = useDatabase(); const sharedRoot = useSharedRoot(); const rowDocMap = useRowDocMap(); + const currentUser = useCurrentUser(); return useCallback( (fieldId: string, fieldType: FieldType) => { @@ -2284,8 +2286,6 @@ export function useSwitchPropertyType() { // Set default values for the type option if ([FieldType.CreatedTime, FieldType.LastEditedTime, FieldType.DateTime].includes(fieldType)) { // to DateTime - newTypeOption.set(YjsDatabaseKey.time_format, TimeFormat.TwentyFourHour); - newTypeOption.set(YjsDatabaseKey.date_format, DateFormat.Friendly); if (oldFieldType !== FieldType.DateTime) { newTypeOption.set(YjsDatabaseKey.include_time, true); } @@ -2455,6 +2455,7 @@ export function useSwitchPropertyType() { newData = getDateCellStr({ cell: dateCell, field, + currentUser, }); break; @@ -2545,7 +2546,7 @@ export function useSwitchPropertyType() { 'switchPropertyType' ); }, - [database, sharedRoot, rowDocMap] + [database, sharedRoot, rowDocMap, currentUser] ); } diff --git a/src/application/database-yjs/fields/date/date.type.ts b/src/application/database-yjs/fields/date/date.type.ts index 463fed11..9bb15c79 100644 --- a/src/application/database-yjs/fields/date/date.type.ts +++ b/src/application/database-yjs/fields/date/date.type.ts @@ -1,18 +1,5 @@ import { Filter } from '@/application/database-yjs'; -export enum TimeFormat { - TwelveHour = 0, - TwentyFourHour = 1, -} - -export enum DateFormat { - Local = 0, - US = 1, - ISO = 2, - Friendly = 3, - DayMonthYear = 4, -} - export enum DateFilterCondition { DateStartsOn = 0, DateStartsBefore = 1, diff --git a/src/application/database-yjs/fields/date/utils.test.ts b/src/application/database-yjs/fields/date/utils.test.ts index 9d3821ba..c08d20ad 100644 --- a/src/application/database-yjs/fields/date/utils.test.ts +++ b/src/application/database-yjs/fields/date/utils.test.ts @@ -1,6 +1,6 @@ -import { getTimeFormat, getDateFormat } from './utils'; +import { DateFormat, TimeFormat } from '@/application/types'; +import { getDateFormat, getTimeFormat } from '@/utils/time'; import { expect } from '@jest/globals'; -import { DateFormat, TimeFormat } from '@/application/database-yjs'; describe('DateFormat', () => { it('should return time format', () => { diff --git a/src/application/database-yjs/fields/date/utils.ts b/src/application/database-yjs/fields/date/utils.ts index d6c9fd42..68116e37 100644 --- a/src/application/database-yjs/fields/date/utils.ts +++ b/src/application/database-yjs/fields/date/utils.ts @@ -1,37 +1,10 @@ import dayjs from 'dayjs'; -import { DateFormat, getTypeOptions, TimeFormat } from '@/application/database-yjs'; +import { getTypeOptions } from '@/application/database-yjs'; import { DateTimeCell } from '@/application/database-yjs/cell.type'; -import { YDatabaseField, YjsDatabaseKey } from '@/application/types'; -import { renderDate } from '@/utils/time'; - -export function getTimeFormat(timeFormat?: TimeFormat) { - switch (timeFormat) { - case TimeFormat.TwelveHour: - return 'h:mm A'; - case TimeFormat.TwentyFourHour: - return 'HH:mm'; - default: - return 'HH:mm'; - } -} - -export function getDateFormat(dateFormat?: DateFormat) { - switch (dateFormat) { - case DateFormat.Friendly: - return 'MMM DD, YYYY'; - case DateFormat.ISO: - return 'YYYY-MM-DD'; - case DateFormat.US: - return 'YYYY/MM/DD'; - case DateFormat.Local: - return 'MM/DD/YYYY'; - case DateFormat.DayMonthYear: - return 'DD/MM/YYYY'; - default: - return 'YYYY-MM-DD'; - } -} +import { DateFormat, TimeFormat, User, YDatabaseField, YjsDatabaseKey, YMapFieldTypeOption } from '@/application/types'; +import { MetadataKey } from '@/application/user-metadata'; +import { getDateFormat, getTimeFormat, renderDate } from '@/utils/time'; function getDateTimeStr({ timeStamp, @@ -59,39 +32,45 @@ function getDateTimeStr({ export const RIGHTWARDS_ARROW = '→'; -export function getRowTimeString(field: YDatabaseField, timeStamp: string) { +export function getRowTimeString(field: YDatabaseField, timeStamp: string, currentUser?: User) { const typeOption = getTypeOptions(field); + const typeOptionValue = getFieldDateTimeFormats(typeOption, currentUser); - const timeFormat = parseInt(typeOption.get(YjsDatabaseKey.time_format)) as TimeFormat; - const dateFormat = parseInt(typeOption.get(YjsDatabaseKey.date_format)) as DateFormat; const includeTime = typeOption.get(YjsDatabaseKey.include_time); - return getDateTimeStr({ timeStamp, includeTime, - typeOptionValue: { - timeFormat, - dateFormat, - }, + typeOptionValue, }); } -export function getDateCellStr({ cell, field }: { cell: DateTimeCell; field: YDatabaseField }) { +export function getFieldDateTimeFormats(typeOption: YMapFieldTypeOption, currentUser?: User) { + const typeOptionTimeFormat = typeOption.get(YjsDatabaseKey.time_format); + const typeOptionDateFormat = typeOption.get(YjsDatabaseKey.date_format); + + const dateFormat = typeOptionDateFormat + ? parseInt(typeOptionDateFormat) as DateFormat + : currentUser?.metadata?.[MetadataKey.DateFormat] as DateFormat ?? DateFormat.Local; + const timeFormat = typeOptionTimeFormat + ? parseInt(typeOptionTimeFormat) as TimeFormat + : currentUser?.metadata?.[MetadataKey.TimeFormat] as TimeFormat ?? TimeFormat.TwelveHour; + + return { + dateFormat, + timeFormat, + } +} + +export function getDateCellStr({ cell, field, currentUser }: { cell: DateTimeCell; field: YDatabaseField, currentUser?: User }) { const typeOptionMap = field.get(YjsDatabaseKey.type_option); const typeOption = typeOptionMap.get(String(cell.fieldType)); - const timeFormat = parseInt(typeOption.get(YjsDatabaseKey.time_format)) as TimeFormat; - const dateFormat = parseInt(typeOption.get(YjsDatabaseKey.date_format)) as DateFormat; + const typeOptionValue = getFieldDateTimeFormats(typeOption, currentUser); const startData = cell.data || ''; const includeTime = cell.includeTime; - const typeOptionValue = { - timeFormat, - dateFormat, - }; - const startDateTime = getDateTimeStr({ timeStamp: startData, includeTime, @@ -105,10 +84,10 @@ export function getDateCellStr({ cell, field }: { cell: DateTimeCell; field: YDa const endDateTime = endTimestamp && isRange ? getDateTimeStr({ - timeStamp: endTimestamp, - includeTime, - typeOptionValue, - }) + timeStamp: endTimestamp, + includeTime, + typeOptionValue, + }) : null; return [startDateTime, endDateTime].filter(Boolean).join(` ${RIGHTWARDS_ARROW} `); diff --git a/src/application/database-yjs/selector.ts b/src/application/database-yjs/selector.ts index 4f356426..4b159c80 100644 --- a/src/application/database-yjs/selector.ts +++ b/src/application/database-yjs/selector.ts @@ -13,15 +13,12 @@ import { useRowDocMap, } from '@/application/database-yjs/context'; import { - DateFormat, getDateCellStr, - getDateFormat, - getTimeFormat, + getFieldDateTimeFormats, getTypeOptions, parseRelationTypeOption, parseSelectOptionTypeOptions, SelectOption, - TimeFormat, } from '@/application/database-yjs/fields'; import { filterBy, parseFilter } from '@/application/database-yjs/filter'; import { groupByField } from '@/application/database-yjs/group'; @@ -38,7 +35,8 @@ import { YjsDatabaseKey, YjsEditorKey, } from '@/application/types'; -import { renderDate } from '@/utils/time'; +import { useCurrentUser } from '@/components/main/app.hooks'; +import { getDateFormat, getTimeFormat, renderDate } from '@/utils/time'; import { CalendarLayoutSetting, FieldType, FieldVisibility, Filter, RowMeta, SortCondition } from './database.type'; @@ -1145,28 +1143,32 @@ export const usePropertiesSelector = (isFilterHidden?: boolean) => { }; export const useDateTimeCellString = (cell: DateTimeCell | undefined, fieldId: string) => { + const currentUser = useCurrentUser(); const { field, clock } = useFieldSelector(fieldId); return useMemo(() => { if (!cell) return null; - return getDateCellStr({ cell, field }); + return getDateCellStr({ cell, field, currentUser }); // eslint-disable-next-line react-hooks/exhaustive-deps - }, [cell, field, clock]); + }, [cell, field, clock, currentUser]); }; export const useRowTimeString = (rowId: string, fieldId: string, attrName: string) => { + const currentUser = useCurrentUser(); const { field, clock } = useFieldSelector(fieldId); const typeOptionValue = useMemo(() => { const typeOption = getTypeOptions(field); + const { dateFormat, timeFormat } = getFieldDateTimeFormats(typeOption, currentUser); + return { - timeFormat: parseInt(typeOption.get(YjsDatabaseKey.time_format)) as TimeFormat, - dateFormat: parseInt(typeOption.get(YjsDatabaseKey.date_format)) as DateFormat, + dateFormat, + timeFormat, includeTime: typeOption.get(YjsDatabaseKey.include_time), }; // eslint-disable-next-line react-hooks/exhaustive-deps - }, [field, clock]); + }, [field, clock, currentUser?.metadata]); const getDateTimeStr = useCallback( (timeStamp: string, includeTime?: boolean) => { diff --git a/src/application/types.ts b/src/application/types.ts index 6fc67c1b..a3193710 100644 --- a/src/application/types.ts +++ b/src/application/types.ts @@ -344,8 +344,8 @@ export enum YjsDatabaseKey { include_time = 'include_time', is_range = 'is_range', reminder_id = 'reminder_id', - time_format = 'time_format', - date_format = 'date_format', + time_format = 'time_format_v2', + date_format = 'date_format_v2', calculations = 'calculations', field_id = 'field_id', calculation_value = 'calculation_value', @@ -686,11 +686,11 @@ export interface YMapFieldTypeOption extends Y.Map { // CreatedTime, LastEditedTime, DateTime // eslint-disable-next-line @typescript-eslint/unified-signatures - get(key: YjsDatabaseKey.time_format): string; + get(key: YjsDatabaseKey.time_format): string | undefined; // CreatedTime, LastEditedTime, DateTime // eslint-disable-next-line @typescript-eslint/unified-signatures - get(key: YjsDatabaseKey.date_format): string; + get(key: YjsDatabaseKey.date_format): string | undefined; // Relation get(key: YjsDatabaseKey.database_id): DatabaseId; @@ -699,7 +699,7 @@ export interface YMapFieldTypeOption extends Y.Map { // eslint-disable-next-line @typescript-eslint/unified-signatures get(key: YjsDatabaseKey.format): string; - // LastModified and CreatedTime + // LastEditedTime and CreatedTime get(key: YjsDatabaseKey.include_time): boolean; // AI Translate @@ -1086,6 +1086,7 @@ export interface ViewComponentProps { }>; updatePageIcon?: (viewId: string, icon: { ty: ViewIconType; value: string }) => Promise; updatePageName?: (viewId: string, name: string) => Promise; + currentUser?: User; } export interface CreatePagePayload { @@ -1206,3 +1207,16 @@ export interface MentionablePerson { invited: boolean; last_mentioned_at: string | null; } + +export enum DateFormat { + Local = 0, + US = 1, + ISO = 2, + Friendly = 3, + DayMonthYear = 4, +} + +export enum TimeFormat { + TwelveHour = 0, + TwentyFourHour = 1, +} diff --git a/src/application/user-metadata.ts b/src/application/user-metadata.ts index 85e863b1..7cf4f71f 100644 --- a/src/application/user-metadata.ts +++ b/src/application/user-metadata.ts @@ -1,4 +1,6 @@ import { toZonedTime } from 'date-fns-tz'; + +import { DateFormat, TimeFormat } from './types'; import { UserTimezone } from './user-timezone.types'; /** @@ -9,6 +11,8 @@ export enum MetadataKey { Timezone = 'timezone', Language = 'language', DateFormat = 'date_format', + TimeFormat = 'time_format', + StartWeekOn = 'start_week_on', IconUrl = 'icon_url', } @@ -18,18 +22,10 @@ export enum MetadataKey { export interface MetadataValues { [MetadataKey.Timezone]: string | UserTimezone; [MetadataKey.Language]: string; - [MetadataKey.DateFormat]: DateFormatType; + [MetadataKey.DateFormat]: DateFormat; + [MetadataKey.TimeFormat]: TimeFormat; + [MetadataKey.StartWeekOn]: number; [MetadataKey.IconUrl]: string; - [key: string]: string | DateFormatType | UserTimezone | Record | number | boolean | null | undefined; // Allow custom keys -} - -/** - * Supported date format types - */ -export enum DateFormatType { - US = 'MM/DD/YYYY', - EU = 'DD/MM/YYYY', - ISO = 'YYYY-MM-DD', } /** @@ -38,7 +34,9 @@ export enum DateFormatType { export const MetadataDefaults: Partial = { [MetadataKey.Timezone]: 'UTC', [MetadataKey.Language]: 'en', - [MetadataKey.DateFormat]: DateFormatType.US, + [MetadataKey.DateFormat]: DateFormat.US, + [MetadataKey.TimeFormat]: TimeFormat.TwelveHour, + [MetadataKey.StartWeekOn]: 0, [MetadataKey.IconUrl]: '', }; @@ -67,7 +65,7 @@ export class UserMetadataBuilder { /** * Set date format metadata */ - setDateFormat(format: DateFormatType): this { + setDateFormat(format: DateFormat): this { this.metadata[MetadataKey.DateFormat] = format; return this; } @@ -80,14 +78,6 @@ export class UserMetadataBuilder { return this; } - /** - * Set custom metadata - */ - setCustom(key: string, value: string | DateFormatType | UserTimezone | Record | number | boolean | null | undefined): this { - this.metadata[key] = value; - return this; - } - /** * Build the final metadata object */ @@ -103,21 +93,21 @@ export const MetadataUtils = { /** * Detect user's preferred date format based on locale */ - detectDateFormat(locale: string = navigator.language): DateFormatType { + detectDateFormat(locale: string = navigator.language): DateFormat { const region = locale.split('-')[1]?.toUpperCase() || locale.toUpperCase(); // US format countries if (['US', 'CA', 'PH'].includes(region)) { - return DateFormatType.US; + return DateFormat.US; } // ISO format preference if (['SE', 'FI', 'JP', 'KR', 'CN', 'TW', 'HK'].includes(region)) { - return DateFormatType.ISO; + return DateFormat.ISO; } // Default to EU format for most other countries - return DateFormatType.EU; + return DateFormat.DayMonthYear; }, /** @@ -153,10 +143,10 @@ export const MetadataUtils = { // Validate timezone if present - must be valid IANA timezone for chrono-tz compatibility if (metadata[MetadataKey.Timezone]) { const timezoneValue = metadata[MetadataKey.Timezone]; - + // Extract the timezone string whether it's a string or UserTimezone object - const timezone = typeof timezoneValue === 'string' - ? timezoneValue + const timezone = typeof timezoneValue === 'string' + ? timezoneValue : timezoneValue.timezone || timezoneValue.default_timezone; if (timezone) { @@ -196,4 +186,4 @@ export const MetadataUtils = { errors, }; }, -}; \ No newline at end of file +}; diff --git a/src/components/app/app.hooks.tsx b/src/components/app/app.hooks.tsx index b613dcb6..d3836d1b 100644 --- a/src/components/app/app.hooks.tsx +++ b/src/components/app/app.hooks.tsx @@ -313,4 +313,4 @@ export function useAppTrash() { loadTrash: context.loadTrash, trashList: context.trashList, }; -} \ No newline at end of file +} diff --git a/src/components/app/hooks/useWorkspaceData.ts b/src/components/app/hooks/useWorkspaceData.ts index 56a61a84..fb8062d6 100644 --- a/src/components/app/hooks/useWorkspaceData.ts +++ b/src/components/app/hooks/useWorkspaceData.ts @@ -1,9 +1,9 @@ -import { useCallback, useEffect, useRef, useState } from 'react'; +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { useNavigate } from 'react-router-dom'; import { sortBy, uniqBy } from 'lodash-es'; import { validate as uuidValidate } from 'uuid'; -import { View, DatabaseRelations, UIVariant , ViewLayout, MentionablePerson } from '@/application/types'; +import { View, DatabaseRelations, UIVariant, ViewLayout, MentionablePerson } from '@/application/types'; import { findView, findViewByLayout } from '@/components/_shared/outline/utils'; import { createDeduplicatedNoArgsRequest } from '@/utils/deduplicateRequest'; import { useAuthInternal } from '../contexts/AuthInternalContext'; @@ -14,7 +14,7 @@ const USER_NO_ACCESS_CODE = [1024, 1012]; export function useWorkspaceData() { const { service, currentWorkspaceId, userWorkspaceInfo } = useAuthInternal(); const navigate = useNavigate(); - + const [outline, setOutline] = useState(); const stableOutlineRef = useRef([]); const [favoriteViews, setFavoriteViews] = useState(); @@ -22,7 +22,7 @@ export function useWorkspaceData() { const [trashList, setTrashList] = useState(); const [workspaceDatabases, setWorkspaceDatabases] = useState(undefined); const [requestAccessOpened, setRequestAccessOpened] = useState(false); - + const mentionableUsersRef = useRef([]); // Load application outline @@ -214,7 +214,9 @@ export function useWorkspaceData() { } }, [currentWorkspaceId, service]); - const loadMentionableUsers = createDeduplicatedNoArgsRequest(_loadMentionableUsers); + const loadMentionableUsers = useMemo(() => { + return createDeduplicatedNoArgsRequest(_loadMentionableUsers); + }, [_loadMentionableUsers]); // Get mention user const getMentionUser = useCallback( @@ -273,4 +275,4 @@ export function useWorkspaceData() { loadMentionableUsers, stableOutlineRef, }; -} \ No newline at end of file +} diff --git a/src/components/app/layers/AppSyncLayer.tsx b/src/components/app/layers/AppSyncLayer.tsx index df97348f..d7c5b55d 100644 --- a/src/components/app/layers/AppSyncLayer.tsx +++ b/src/components/app/layers/AppSyncLayer.tsx @@ -31,7 +31,7 @@ export const AppSyncLayer: React.FC = ({ children }) => { // Initialize broadcast channel for multi-tab communication const broadcastChannel = useBroadcastChannel(`workspace:${currentWorkspaceId!}`); - + // Initialize sync context for collaborative editing const { registerSyncContext, lastUpdatedCollab } = useSync(webSocket, broadcastChannel, eventEmitterRef.current); @@ -60,23 +60,23 @@ export const AppSyncLayer: React.FC = ({ children }) => { // Handle user profile change notifications // This provides automatic UI updates when user profile changes occur via WebSocket. - // + // // Notification Flow: // 1. Server sends WorkspaceNotification with profileChange // 2. useSync processes notification from WebSocket OR BroadcastChannel - // 3. useSync emits USER_PROFILE_CHANGED event via eventEmitter + // 3. useSync emits USER_PROFILE_CHANGED event via eventEmitter // 4. This handler receives the event and updates local database // 5. useLiveQuery in AppConfig detects database change // 6. All components using currentUser automatically re-render with new data // // Multi-tab Support: // - Active tab: WebSocket → useSync → this handler → database update - // - Other tabs: BroadcastChannel → useSync → this handler → database update + // - Other tabs: BroadcastChannel → useSync → this handler → database update // - Result: All tabs show updated profile simultaneously // // UI Components that auto-update: // - Workspace dropdown (shows email) - // - Collaboration user lists (shows names/avatars) + // - Collaboration user lists (shows names/avatars) // - Any component using useCurrentUser() hook useEffect(() => { if (!isAuthenticated || !currentWorkspaceId) return; @@ -86,11 +86,11 @@ export const AppSyncLayer: React.FC = ({ children }) => { const handleUserProfileChange = async (profileChange: notification.IUserProfileChange) => { try { console.log('Received user profile change notification:', profileChange); - + // Extract user ID from authentication token const token = getTokenParsed(); const userId = token?.user?.id; - + if (!userId) { console.warn('No user ID found for profile update'); return; @@ -98,26 +98,14 @@ export const AppSyncLayer: React.FC = ({ children }) => { // Retrieve current user data from local database cache const existingUser = await db.users.get(userId); - + if (!existingUser) { console.warn('No existing user found in database for profile update'); return; } - // Merge notification changes with existing user data - // Only update fields that are present in the notification (selective update) - const updatedUser = { - ...existingUser, - // Update name if provided in notification - ...(profileChange.name !== undefined && { name: profileChange.name }), - // Update email if provided in notification - ...(profileChange.email !== undefined && { email: profileChange.email }), - }; + const updatedUser = service?.getCurrentUser(); - // Update database cache - this triggers useLiveQuery to re-render all UI components - // displaying user profile information. No manual component updates needed. - await db.users.put(updatedUser, userId); - console.log('User profile updated in database:', updatedUser); } catch (error) { console.error('Failed to handle user profile change notification:', error); @@ -131,21 +119,20 @@ export const AppSyncLayer: React.FC = ({ children }) => { return () => { currentEventEmitter.off(APP_EVENTS.USER_PROFILE_CHANGED, handleUserProfileChange); }; - }, [isAuthenticated, currentWorkspaceId]); + }, [isAuthenticated, currentWorkspaceId, service]); // Context value for synchronization layer - const syncContextValue: SyncInternalContextType = useMemo(() => ({ - webSocket, - broadcastChannel, - registerSyncContext, - eventEmitter: eventEmitterRef.current, - awarenessMap, - lastUpdatedCollab, - }), [webSocket, broadcastChannel, registerSyncContext, awarenessMap, lastUpdatedCollab]); - - return ( - - {children} - + const syncContextValue: SyncInternalContextType = useMemo( + () => ({ + webSocket, + broadcastChannel, + registerSyncContext, + eventEmitter: eventEmitterRef.current, + awarenessMap, + lastUpdatedCollab, + }), + [webSocket, broadcastChannel, registerSyncContext, awarenessMap, lastUpdatedCollab] ); -}; \ No newline at end of file + + return {children}; +}; diff --git a/src/components/app/workspaces/AccountSettings.tsx b/src/components/app/workspaces/AccountSettings.tsx new file mode 100644 index 00000000..d0909b9f --- /dev/null +++ b/src/components/app/workspaces/AccountSettings.tsx @@ -0,0 +1,270 @@ +import dayjs from 'dayjs'; +import { useCallback, useMemo, useState } from 'react'; +import { useTranslation } from 'react-i18next'; + +import { DateFormat, TimeFormat } from '@/application/types'; +import { MetadataKey } from '@/application/user-metadata'; +import { ReactComponent as ChevronDownIcon } from '@/assets/icons/alt_arrow_down.svg'; +import { useCurrentUser, useService } from '@/components/main/app.hooks'; +import { Dialog, DialogContent, DialogTitle, DialogTrigger } from '@/components/ui/dialog'; +import { + DropdownMenu, + DropdownMenuTrigger, + DropdownMenuContent, + DropdownMenuRadioGroup, + DropdownMenuRadioItem, +} from '@/components/ui/dropdown-menu'; +import { cn } from '@/lib/utils'; + +export function AccountSettings({ children }: { children?: React.ReactNode }) { + const { t } = useTranslation(); + const currentUser = useCurrentUser(); + const service = useService(); + + const handleSelectDateFormat = useCallback( + async (dateFormat: number) => { + if (!service || !currentUser?.metadata) return; + + await service?.updateUserProfile({ ...currentUser.metadata, [MetadataKey.DateFormat]: dateFormat }); + }, + [currentUser, service] + ); + + const handleSelectTimeFormat = useCallback( + async (timeFormat: number) => { + if (!service || !currentUser?.metadata) return; + + await service?.updateUserProfile({ ...currentUser.metadata, [MetadataKey.TimeFormat]: timeFormat }); + }, + [currentUser, service] + ); + + const handleSelectStartWeekOn = useCallback( + async (startWeekOn: number) => { + if (!service || !currentUser?.metadata) return; + + await service?.updateUserProfile({ ...currentUser.metadata, [MetadataKey.StartWeekOn]: startWeekOn }); + }, + [currentUser, service] + ); + + if (!currentUser || !service) { + return <>; + } + + const dateFormat = Number(currentUser?.metadata?.[MetadataKey.DateFormat] as DateFormat) || DateFormat.Local; + const timeFormat = Number(currentUser?.metadata?.[MetadataKey.TimeFormat] as TimeFormat) || TimeFormat.TwelveHour; + const startWeekOn = Number(currentUser?.metadata?.[MetadataKey.StartWeekOn] as number) || 0; + + return ( + + {children} + + {t('web.accountSettings')} +
+ + + +
+
+
+ ); +} + +function DateFormatDropdown({ dateFormat, onSelect }: { dateFormat: number; onSelect: (dateFormat: number) => void }) { + const { t } = useTranslation(); + + const [isOpen, setIsOpen] = useState(false); + + const dateFormats = useMemo( + () => [ + { + value: DateFormat.Local, + label: t('grid.field.dateFormatLocal'), + }, + { + label: t('grid.field.dateFormatUS'), + value: DateFormat.US, + }, + { + label: t('grid.field.dateFormatISO'), + value: DateFormat.ISO, + }, + { + label: t('grid.field.dateFormatFriendly'), + value: DateFormat.Friendly, + }, + { + label: t('grid.field.dateFormatDayMonthYear'), + value: DateFormat.DayMonthYear, + }, + ], + [t] + ); + + const value = dateFormats.find((format) => format.value === dateFormat); + + return ( +
+ {t('grid.field.dateFormat')} +
+ + { + e.preventDefault(); + e.stopPropagation(); + }} + onClick={() => setIsOpen((prev) => !prev)} + > +
+ e.preventDefault()}> + {value?.label || t('settings.workspacePage.dateTime.dateFormat.label')} + + +
+
+ + onSelect(Number(value))}> + {dateFormats.map((item) => ( + + {item.label} + + ))} + + +
+
+
+ ); +} + +function TimeFormatDropdown({ timeFormat, onSelect }: { timeFormat: number; onSelect: (timeFormat: number) => void }) { + const { t } = useTranslation(); + + const [isOpen, setIsOpen] = useState(false); + + const timeFormats = useMemo( + () => [ + { + value: TimeFormat.TwelveHour, + label: t('grid.field.timeFormatTwelveHour'), + }, + { + label: t('grid.field.timeFormatTwentyFourHour'), + value: TimeFormat.TwentyFourHour, + }, + ], + [t] + ); + + const value = timeFormats.find((format) => format.value === timeFormat); + + return ( +
+ {t('grid.field.timeFormat')} +
+ + { + e.preventDefault(); + e.stopPropagation(); + }} + onClick={() => setIsOpen((prev) => !prev)} + > +
+ e.preventDefault()}> + {value?.label || t('grid.field.timeFormatTwelveHour')} + + +
+
+ + onSelect(Number(value))}> + {timeFormats.map((item) => ( + + {item.label} + + ))} + + +
+
+
+ ); +} + +function StartWeekOnDropdown({ + startWeekOn, + onSelect, +}: { + startWeekOn: number; + onSelect: (startWeekOn: number) => void; +}) { + const { t } = useTranslation(); + + const [isOpen, setIsOpen] = useState(false); + + const daysOfWeek = [ + { + value: 0, + label: dayjs().day(0).format('dddd'), + }, + { + value: 1, + label: dayjs().day(1).format('dddd'), + }, + ] as const; + + const value = daysOfWeek.find((format) => format.value === startWeekOn); + + return ( +
+ {t('web.startWeekOn')} +
+ + { + e.preventDefault(); + e.stopPropagation(); + }} + onClick={() => setIsOpen((prev) => !prev)} + > +
+ e.preventDefault()}> + {value?.label || t('grid.field.timeFormatTwelveHour')} + + +
+
+ + onSelect(Number(value))}> + {daysOfWeek.map((item) => ( + + {item.label} + + ))} + + +
+
+
+ ); +} diff --git a/src/components/app/workspaces/Workspaces.tsx b/src/components/app/workspaces/Workspaces.tsx index 1f01b1dd..ec14853e 100644 --- a/src/components/app/workspaces/Workspaces.tsx +++ b/src/components/app/workspaces/Workspaces.tsx @@ -6,11 +6,13 @@ import { invalidToken } from '@/application/session/token'; import { Workspace } from '@/application/types'; import { ReactComponent as UpgradeAIMaxIcon } from '@/assets/icons/ai.svg'; import { ReactComponent as ChevronDownIcon } from '@/assets/icons/alt_arrow_down.svg'; +import { ReactComponent as ChevronRightIcon } from '@/assets/icons/alt_arrow_right.svg'; import { ReactComponent as TipIcon } from '@/assets/icons/help.svg'; import { ReactComponent as AddUserIcon } from '@/assets/icons/invite_user.svg'; import { ReactComponent as LogoutIcon } from '@/assets/icons/logout.svg'; import { ReactComponent as AddIcon } from '@/assets/icons/plus.svg'; import { ReactComponent as ImportIcon } from '@/assets/icons/save_as.svg'; +import { ReactComponent as SettingsIcon } from '@/assets/icons/settings.svg'; import { ReactComponent as UpgradeIcon } from '@/assets/icons/upgrade.svg'; import { useAppHandlers, useCurrentWorkspaceId, useUserWorkspaceInfo } from '@/components/app/app.hooks'; import CurrentWorkspace from '@/components/app/workspaces/CurrentWorkspace'; @@ -37,6 +39,8 @@ import Import from '@/components/_shared/more-actions/importer/Import'; import { notify } from '@/components/_shared/notify'; import { openUrl } from '@/utils/url'; +import { AccountSettings } from './AccountSettings'; + export function Workspaces() { const { t } = useTranslation(); const userWorkspaceInfo = useUserWorkspaceInfo(); @@ -211,6 +215,13 @@ export function Workspaces() { + + e.preventDefault()}> + +
{t('web.accountSettings')}
+ +
+
{t('button.logout')} diff --git a/src/components/database/components/cell/ai-text/AITextCellActions.tsx b/src/components/database/components/cell/ai-text/AITextCellActions.tsx index a6e3f030..78d7b844 100644 --- a/src/components/database/components/cell/ai-text/AITextCellActions.tsx +++ b/src/components/database/components/cell/ai-text/AITextCellActions.tsx @@ -17,6 +17,7 @@ import { languageTexts, parseAITranslateTypeOption } from '@/application/databas import { GenerateAITranslateRowPayload, YDatabaseCell, YDatabaseField, YjsDatabaseKey } from '@/application/types'; import { ReactComponent as AIIcon } from '@/assets/icons/ai_improve_writing.svg'; import { ReactComponent as CopyIcon } from '@/assets/icons/copy.svg'; +import { useCurrentUser } from '@/components/main/app.hooks'; import { Button } from '@/components/ui/button'; import { Progress } from '@/components/ui/progress'; import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'; @@ -56,18 +57,20 @@ function AITextCellActions({ const row = useRowData(rowId); const updateCell = useUpdateCellDispatch(rowId, fieldId); const { generateAITranslateForRow, generateAISummaryForRow } = useDatabaseContext(); + const currentUser = useCurrentUser(); const getCellData = useCallback( (cell: YDatabaseCell, field: YDatabaseField) => { + if (!currentUser) return ''; const type = Number(field?.get(YjsDatabaseKey.type)); if (type === FieldType.CreatedTime) { - return getRowTimeString(field, row.get(YjsDatabaseKey.created_at)) || ''; + return getRowTimeString(field, row.get(YjsDatabaseKey.created_at), currentUser) || ''; } else if (type === FieldType.LastEditedTime) { - return getRowTimeString(field, row.get(YjsDatabaseKey.last_modified)) || ''; + return getRowTimeString(field, row.get(YjsDatabaseKey.last_modified), currentUser) || ''; } else if (cell && ![FieldType.AISummaries, FieldType.AITranslations].includes(type)) { try { - return getCellDataText(cell, field); + return getCellDataText(cell, field, currentUser); } catch (e) { console.error(e); return ''; @@ -76,7 +79,7 @@ function AITextCellActions({ return ''; }, - [row] + [currentUser, row] ); const handleGenerateSummary = useCallback(async () => { diff --git a/src/components/database/components/cell/date/DateTimeCellPicker.tsx b/src/components/database/components/cell/date/DateTimeCellPicker.tsx index 443b1907..9b9d1d71 100644 --- a/src/components/database/components/cell/date/DateTimeCellPicker.tsx +++ b/src/components/database/components/cell/date/DateTimeCellPicker.tsx @@ -4,20 +4,19 @@ import { useCallback, useMemo, useRef, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { - DateFormat, - getDateFormat, - getTimeFormat, - getTypeOptions, TimeFormat, + getFieldDateTimeFormats, + getTypeOptions, useFieldSelector, } from '@/application/database-yjs'; import { DateTimeCell } from '@/application/database-yjs/cell.type'; import { useUpdateCellDispatch } from '@/application/database-yjs/dispatch'; -import { YjsDatabaseKey } from '@/application/types'; +import { MetadataKey } from '@/application/user-metadata'; import { ReactComponent as ChevronRight } from '@/assets/icons/alt_arrow_right.svg'; import { ReactComponent as DateSvg } from '@/assets/icons/date.svg'; import { ReactComponent as TimeIcon } from '@/assets/icons/time.svg'; import DateTimeFormatMenu from '@/components/database/components/cell/date/DateTimeFormatMenu'; import DateTimeInput from '@/components/database/components/cell/date/DateTimeInput'; +import { useCurrentUser } from '@/components/main/app.hooks'; import { Calendar } from '@/components/ui/calendar'; import { dropdownMenuItemVariants } from '@/components/ui/dropdown-menu'; import { @@ -28,6 +27,7 @@ import { import { Separator } from '@/components/ui/separator'; import { Switch } from '@/components/ui/switch'; import { cn } from '@/lib/utils'; +import { getDateFormat, getTimeFormat } from '@/utils/time'; function DateTimeCellPicker ({ open, onOpenChange, cell, fieldId, rowId }: { open: boolean; @@ -36,6 +36,7 @@ function DateTimeCellPicker ({ open, onOpenChange, cell, fieldId, rowId }: { fieldId: string; rowId: string; }) { + const currentUser = useCurrentUser(); const { t } = useTranslation(); const [isRange, setIsRange] = useState(() => { @@ -56,13 +57,20 @@ function DateTimeCellPicker ({ open, onOpenChange, cell, fieldId, rowId }: { const typeOptionValue = useMemo(() => { const typeOption = getTypeOptions(field); + const { dateFormat, timeFormat } = getFieldDateTimeFormats(typeOption, currentUser); return { - timeFormat: getTimeFormat(Number(typeOption.get(YjsDatabaseKey.time_format)) as TimeFormat), - dateFormat: getDateFormat(Number(typeOption.get(YjsDatabaseKey.date_format)) as DateFormat), + dateFormat: getDateFormat(dateFormat), + timeFormat: getTimeFormat(timeFormat), }; // eslint-disable-next-line react-hooks/exhaustive-deps - }, [field, clock]); + }, [field, clock, currentUser?.metadata]); + + const weekStartsOn = useMemo(() => { + const value = Number(currentUser?.metadata?.[MetadataKey.StartWeekOn]) || 0; + + return value >= 0 && value <= 6 ? (value as 0 | 1 | 2 | 3 | 4 | 5 | 6) : 0; + }, [currentUser?.metadata]); const updateCell = useUpdateCellDispatch(rowId, fieldId); @@ -193,6 +201,7 @@ function DateTimeCellPicker ({ open, onOpenChange, cell, fieldId, rowId }: { showOutsideDays month={month} onMonthChange={onMonthChange} + weekStartsOn={weekStartsOn} {...(isRange ? { mode: 'range', selected: dateRange, diff --git a/src/components/database/components/database-row/DatabaseRowSubDocument.tsx b/src/components/database/components/database-row/DatabaseRowSubDocument.tsx index 6af975fa..564dbdcf 100644 --- a/src/components/database/components/database-row/DatabaseRowSubDocument.tsx +++ b/src/components/database/components/database-row/DatabaseRowSubDocument.tsx @@ -23,8 +23,9 @@ import { YjsDatabaseKey, YjsEditorKey, } from '@/application/types'; -import { Editor } from '@/components/editor'; import { EditorSkeleton } from '@/components/_shared/skeleton/EditorSkeleton'; +import { Editor } from '@/components/editor'; +import { useCurrentUser } from '@/components/main/app.hooks'; export const DatabaseRowSubDocument = memo(({ rowId }: { rowId: string }) => { const meta = useRowMetaSelector(rowId); @@ -34,6 +35,8 @@ export const DatabaseRowSubDocument = memo(({ rowId }: { rowId: string }) => { const database = useDatabase(); const row = useRowData(rowId) as YDatabaseRow | undefined; const checkIfRowDocumentExists = context.checkIfRowDocumentExists; + const { createOrphanedView, loadView } = context; + const currentUser = useCurrentUser(); const getCellData = useCallback( (cell: YDatabaseCell, field: YDatabaseField) => { @@ -41,12 +44,12 @@ export const DatabaseRowSubDocument = memo(({ rowId }: { rowId: string }) => { const type = Number(field?.get(YjsDatabaseKey.type)); if (type === FieldType.CreatedTime) { - return getRowTimeString(field, row.get(YjsDatabaseKey.created_at)) || ''; + return getRowTimeString(field, row.get(YjsDatabaseKey.created_at), currentUser) || ''; } else if (type === FieldType.LastEditedTime) { - return getRowTimeString(field, row.get(YjsDatabaseKey.last_modified)) || ''; + return getRowTimeString(field, row.get(YjsDatabaseKey.last_modified), currentUser) || ''; } else if (cell) { try { - return getCellDataText(cell, field); + return getCellDataText(cell, field, currentUser); } catch (e) { console.error(e); return ''; @@ -55,7 +58,7 @@ export const DatabaseRowSubDocument = memo(({ rowId }: { rowId: string }) => { return ''; }, - [row] + [row, currentUser] ); const properties = useMemo(() => { @@ -82,7 +85,6 @@ export const DatabaseRowSubDocument = memo(({ rowId }: { rowId: string }) => { return obj; }, [database, getCellData, row]); - const { createOrphanedView, loadView } = context; const updateRowMeta = useUpdateRowMetaDispatch(rowId); const [loading, setLoading] = useState(true); diff --git a/src/components/database/components/filters/filter-menu/DateTimeFilterDatePicker.tsx b/src/components/database/components/filters/filter-menu/DateTimeFilterDatePicker.tsx index 0a5ebddd..42b0a515 100644 --- a/src/components/database/components/filters/filter-menu/DateTimeFilterDatePicker.tsx +++ b/src/components/database/components/filters/filter-menu/DateTimeFilterDatePicker.tsx @@ -1,22 +1,26 @@ import dayjs from 'dayjs'; import { useCallback, useMemo, useState } from 'react'; -import { - DateFilter, - DateFilterCondition, - DateFormat, - getDateFormat, - getTimeFormat, - TimeFormat, -} from '@/application/database-yjs'; +import { DateFilter, DateFilterCondition } from '@/application/database-yjs'; import { useUpdateFilter } from '@/application/database-yjs/dispatch'; +import { DateFormat, TimeFormat } from '@/application/types'; +import { MetadataKey } from '@/application/user-metadata'; import DateTimeInput from '@/components/database/components/cell/date/DateTimeInput'; +import { useCurrentUser } from '@/components/main/app.hooks'; import { Button } from '@/components/ui/button'; import { Calendar } from '@/components/ui/calendar'; import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'; -import { renderDate } from '@/utils/time'; +import { getDateFormat, getTimeFormat, renderDate } from '@/utils/time'; function DateTimeFilterDatePicker({ filter }: { filter: DateFilter }) { + const currentUser = useCurrentUser(); + + const weekStartsOn = useMemo(() => { + const value = Number(currentUser?.metadata?.[MetadataKey.StartWeekOn]) || 0; + + return value >= 0 && value <= 6 ? (value as 0 | 1 | 2 | 3 | 4 | 5 | 6) : 0; + }, [currentUser?.metadata]); + const isRange = useMemo(() => { return [DateFilterCondition.DateStartsBetween, DateFilterCondition.DateEndsBetween].includes(filter.condition); }, [filter.condition]); @@ -74,9 +78,11 @@ function DateTimeFilterDatePicker({ filter }: { filter: DateFilter }) { const { timestamp, end, start } = filter; if (isRange && start && end) { - return `${renderDate(start.toString(), getDateFormat(DateFormat.Local), true)} - ${renderDate( + const dateFormat = currentUser?.metadata?.[MetadataKey.DateFormat] as DateFormat | DateFormat.Local; + + return `${renderDate(start.toString(), getDateFormat(dateFormat), true)} - ${renderDate( end.toString(), - getDateFormat(DateFormat.Local), + getDateFormat(dateFormat), true )}`; } @@ -84,7 +90,7 @@ function DateTimeFilterDatePicker({ filter }: { filter: DateFilter }) { if (!timestamp) return ''; return renderDate(timestamp.toString(), getDateFormat(DateFormat.Local), true); - }, [filter, isRange]); + }, [filter, isRange, currentUser?.metadata]); const [month, setMonth] = useState(undefined); @@ -135,6 +141,7 @@ function DateTimeFilterDatePicker({ filter }: { filter: DateFilter }) { showOutsideDays month={month} onMonthChange={onMonthChange} + weekStartsOn={weekStartsOn} {...(isRange ? { mode: 'range', diff --git a/src/components/database/components/filters/overview/DateFilterContentOverview.tsx b/src/components/database/components/filters/overview/DateFilterContentOverview.tsx index c8fdcb98..dd47010a 100644 --- a/src/components/database/components/filters/overview/DateFilterContentOverview.tsx +++ b/src/components/database/components/filters/overview/DateFilterContentOverview.tsx @@ -1,10 +1,16 @@ -import { DateFilter, DateFilterCondition, DateFormat, getDateFormat } from '@/application/database-yjs'; import dayjs from 'dayjs'; import { useMemo } from 'react'; import { useTranslation } from 'react-i18next'; -function DateFilterContentOverview ({ filter }: { filter: DateFilter }) { +import { DateFilter, DateFilterCondition, } from '@/application/database-yjs'; +import { DateFormat } from '@/application/types'; +import { MetadataKey } from '@/application/user-metadata'; +import { getDateFormat } from '@/utils/time'; +import { useCurrentUser } from '@/components/main/app.hooks'; + +function DateFilterContentOverview({ filter }: { filter: DateFilter }) { const { t } = useTranslation(); + const currentUser = useCurrentUser(); const value = useMemo(() => { let startStr = ''; @@ -18,7 +24,8 @@ function DateFilterContentOverview ({ filter }: { filter: DateFilter }) { endStr = dayjs.unix(end).format(format); } - const timestamp = filter.timestamp ? dayjs.unix(filter.timestamp).format(getDateFormat(DateFormat.Local)) : ''; + const dateFormat = currentUser?.metadata?.[MetadataKey.DateFormat] as DateFormat | DateFormat.Local; + const timestamp = filter.timestamp ? dayjs.unix(filter.timestamp).format(getDateFormat(dateFormat)) : ''; switch (filter.condition) { case DateFilterCondition.DateStartsOn: @@ -48,7 +55,7 @@ function DateFilterContentOverview ({ filter }: { filter: DateFilter }) { default: return ''; } - }, [filter, t]); + }, [filter, t, currentUser?.metadata]); return <>{value}; } diff --git a/src/components/database/components/property/date/DateTimeFormatGroup.tsx b/src/components/database/components/property/date/DateTimeFormatGroup.tsx index fd1160c9..31c45f15 100644 --- a/src/components/database/components/property/date/DateTimeFormatGroup.tsx +++ b/src/components/database/components/property/date/DateTimeFormatGroup.tsx @@ -1,6 +1,5 @@ -import { DateFormat, TimeFormat } from '@/application/database-yjs'; import { useUpdateDateTimeFieldFormat } from '@/application/database-yjs/dispatch'; -import { YjsDatabaseKey } from '@/application/types'; +import { DateFormat, TimeFormat, YjsDatabaseKey } from '@/application/types'; import { useFieldTypeOption } from '@/components/database/components/cell/Cell.hooks'; import { DropdownMenuGroup, DropdownMenuItem, DropdownMenuItemTick, @@ -18,8 +17,11 @@ function DateTimeFormatGroup ({ }) { const { t } = useTranslation(); const typeOption = useFieldTypeOption(fieldId); - const selectedDateFormat = Number(typeOption.get(YjsDatabaseKey.date_format)); - const selectedTimeFormat = Number(typeOption.get(YjsDatabaseKey.time_format)); + const typeOptionDateFormat = typeOption.get(YjsDatabaseKey.date_format); + const typeOptionTimeFormat = typeOption.get(YjsDatabaseKey.time_format); + + const selectedDateFormat = typeOptionDateFormat !== undefined ? Number(typeOptionDateFormat) : undefined; + const selectedTimeFormat = typeOptionTimeFormat !== undefined ? Number(typeOptionTimeFormat) : undefined; const updateFormat = useUpdateDateTimeFieldFormat(fieldId); const dateFormats = useMemo(() => [{ diff --git a/src/components/editor/components/leaf/mention/MentionDate.tsx b/src/components/editor/components/leaf/mention/MentionDate.tsx index bec2ac50..49b3de32 100644 --- a/src/components/editor/components/leaf/mention/MentionDate.tsx +++ b/src/components/editor/components/leaf/mention/MentionDate.tsx @@ -1,12 +1,20 @@ -import { ReactComponent as DateSvg } from '@/assets/icons/date.svg'; -import { ReactComponent as ReminderSvg } from '@/assets/icons/reminder_clock.svg'; -import { renderDate } from '@/utils/time'; import { useMemo } from 'react'; +import { DateFormat } from '@/application/types'; +import { MetadataKey } from '@/application/user-metadata'; +import { ReactComponent as DateSvg } from '@/assets/icons/date.svg'; +import { ReactComponent as ReminderSvg } from '@/assets/icons/reminder_clock.svg'; +import { useCurrentUser } from '@/components/main/app.hooks'; +import { getDateFormat, renderDate } from '@/utils/time'; + function MentionDate({ date, reminder }: { date: string; reminder?: { id: string; option: string } }) { - const dateFormat = useMemo(() => { - return renderDate(date, 'MMM D, YYYY'); - }, [date]); + const currentUser = useCurrentUser(); + + const formattedDate = useMemo(() => { + const dateFormat = (currentUser?.metadata?.[MetadataKey.DateFormat] as DateFormat) ?? DateFormat.Local; + + return renderDate(date, getDateFormat(dateFormat)); + }, [currentUser?.metadata, date]); return ( @ - {dateFormat} + {formattedDate} {reminder ? : } diff --git a/src/components/ui/dropdown-menu.tsx b/src/components/ui/dropdown-menu.tsx index 437a842f..1dd20b88 100644 --- a/src/components/ui/dropdown-menu.tsx +++ b/src/components/ui/dropdown-menu.tsx @@ -7,6 +7,8 @@ import { ReactComponent as ChevronRightIcon } from '@/assets/icons/alt_arrow_rig import { ReactComponent as CheckIcon } from '@/assets/icons/tick.svg'; import { cn } from '@/lib/utils'; +const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup; + function DropdownMenu({ ...props }: React.ComponentProps) { return ; } @@ -112,6 +114,28 @@ const DropdownMenuItem = forwardRef< ); }); +const DropdownMenuRadioItem = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, ...props }, ref) => ( + + {children} + +)); + +DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName; + function DropdownMenuLabel({ className, inset, @@ -245,6 +269,7 @@ export { DropdownMenuGroup, DropdownMenuItem, DropdownMenuItemTick, + DropdownMenuRadioItem, dropdownMenuItemVariants, DropdownMenuLabel, DropdownMenuPortal, @@ -254,4 +279,5 @@ export { DropdownMenuSubContent, DropdownMenuSubTrigger, DropdownMenuTrigger, + DropdownMenuRadioGroup, }; diff --git a/src/utils/time.ts b/src/utils/time.ts index fa45f690..c6145436 100644 --- a/src/utils/time.ts +++ b/src/utils/time.ts @@ -1,3 +1,4 @@ +import { DateFormat, TimeFormat } from '@/application/types'; import dayjs from 'dayjs'; export function renderDate(date: string | number, format: string, isUnix?: boolean): string { @@ -61,3 +62,31 @@ export function isTimestampBetweenRange(timestamp: string, startTimestamp: strin return dateUnix >= startUnix && dateUnix <= endUnix; } + +export function getTimeFormat(timeFormat?: TimeFormat) { + switch (timeFormat) { + case TimeFormat.TwelveHour: + return 'h:mm A'; + case TimeFormat.TwentyFourHour: + return 'HH:mm'; + default: + return 'HH:mm'; + } +} + +export function getDateFormat(dateFormat?: DateFormat) { + switch (dateFormat) { + case DateFormat.Friendly: + return 'MMM DD, YYYY'; + case DateFormat.ISO: + return 'YYYY-MM-DD'; + case DateFormat.US: + return 'YYYY/MM/DD'; + case DateFormat.Local: + return 'MM/DD/YYYY'; + case DateFormat.DayMonthYear: + return 'DD/MM/YYYY'; + default: + return 'YYYY-MM-DD'; + } +}