diff --git a/packages/frontend/core/src/components/affine/page-history-modal/data.ts b/packages/frontend/core/src/components/affine/page-history-modal/data.ts index 09ef07f0ff78..18948e9c5e2c 100644 --- a/packages/frontend/core/src/components/affine/page-history-modal/data.ts +++ b/packages/frontend/core/src/components/affine/page-history-modal/data.ts @@ -1,6 +1,9 @@ import { useDocMetaHelper } from '@affine/core/hooks/use-block-suite-page-meta'; import { useDocCollectionPage } from '@affine/core/hooks/use-block-suite-workspace-page'; -import { timestampToLocalDate } from '@affine/core/utils'; +import { + type CalendarTranslation, + timestampToCalendarDate, +} from '@affine/core/utils'; import { DebugLogger } from '@affine/debug'; import type { ListHistoryQuery } from '@affine/graphql'; import { listHistoryQuery, recoverDocMutation } from '@affine/graphql'; @@ -174,10 +177,13 @@ export const useSnapshotPage = ( return page; }; -export const historyListGroupByDay = (histories: DocHistory[]) => { +export const historyListGroupByDay = ( + histories: DocHistory[], + translation: CalendarTranslation +) => { const map = new Map(); for (const history of histories) { - const day = timestampToLocalDate(history.timestamp); + const day = timestampToCalendarDate(history.timestamp, translation); const list = map.get(day) ?? []; list.push(history); map.set(day, list); diff --git a/packages/frontend/core/src/components/affine/page-history-modal/history-modal.tsx b/packages/frontend/core/src/components/affine/page-history-modal/history-modal.tsx index 64d8ce3a5a7a..6995c844f5a6 100644 --- a/packages/frontend/core/src/components/affine/page-history-modal/history-modal.tsx +++ b/packages/frontend/core/src/components/affine/page-history-modal/history-modal.tsx @@ -33,7 +33,11 @@ import { import { encodeStateAsUpdate } from 'yjs'; import { pageHistoryModalAtom } from '../../../atoms/page-history'; -import { mixpanel, timestampToLocalTime } from '../../../utils'; +import { + type CalendarTranslation, + mixpanel, + timestampToLocalTime, +} from '../../../utils'; import { BlockSuiteEditor } from '../../blocksuite/block-suite-editor'; import { StyledEditorModeSwitch } from '../../blocksuite/block-suite-mode-switch/style'; import { @@ -311,14 +315,19 @@ const PageHistoryList = ({ onLoadMore: (() => void) | false; loadingMore: boolean; }) => { + const t = useAFFiNEI18N(); const historyListByDay = useMemo(() => { - return historyListGroupByDay(historyList); - }, [historyList]); + const translation: CalendarTranslation = { + yesterday: t['com.affine.yesterday'], + today: t['com.affine.today'], + tomorrow: t['com.affine.tomorrow'], + nextWeek: t['com.affine.nextWeek'], + }; + return historyListGroupByDay(historyList, translation); + }, [historyList, t]); const [collapsedMap, setCollapsedMap] = useState>({}); - const t = useAFFiNEI18N(); - useLayoutEffect(() => { if (historyList.length > 0 && !activeVersion) { onVersionChange(historyList[0].timestamp); diff --git a/packages/frontend/core/src/utils/__tests__/intl-formatter.spec.ts b/packages/frontend/core/src/utils/__tests__/intl-formatter.spec.ts new file mode 100644 index 000000000000..aea8a6f974f1 --- /dev/null +++ b/packages/frontend/core/src/utils/__tests__/intl-formatter.spec.ts @@ -0,0 +1,112 @@ +import { getI18n } from '@affine/i18n'; +import { describe, expect, test } from 'vitest'; + +import type { CalendarTranslation } from '../intl-formatter'; +import { timestampToCalendarDate } from '../intl-formatter'; + +const translation: CalendarTranslation = { + yesterday: () => 'Yesterday', + today: () => 'Today', + tomorrow: () => 'Tomorrow', + nextWeek: () => 'Next Week', +}; + +const ONE_DAY = 24 * 60 * 60 * 1000; + +describe('intl calendar date formatter', () => { + const week = new Intl.DateTimeFormat(getI18n()?.language, { + weekday: 'long', + }); + + test('someday before last week', async () => { + const timestamp = '2000-01-01 10:00'; + expect(timestampToCalendarDate(timestamp, translation)).toBe('Jan 1, 2000'); + }); + + test('someday in last week', async () => { + const timestamp = Date.now() - 6 * ONE_DAY; + expect(timestampToCalendarDate(timestamp, translation)).toBe( + week.format(timestamp) + ); + }); + + test('someday is yesterday', async () => { + const timestamp = Date.now() - ONE_DAY; + expect(timestampToCalendarDate(timestamp, translation)).toBe('Yesterday'); + }); + + test('someday is today', async () => { + const timestamp = Date.now(); + expect(timestampToCalendarDate(timestamp, translation)).toBe('Today'); + }); + + test('someday is tomorrow', async () => { + const timestamp = Date.now() + ONE_DAY; + expect(timestampToCalendarDate(timestamp, translation)).toBe('Tomorrow'); + }); + + test('someday in next week', async () => { + const timestamp = Date.now() + 6 * ONE_DAY; + expect(timestampToCalendarDate(timestamp, translation)).toBe( + `Next Week ${week.format(timestamp)}` + ); + }); + + test('someday after next week', async () => { + const timestamp = '3000-01-01 10:00'; + expect(timestampToCalendarDate(timestamp, translation)).toBe('Jan 1, 3000'); + }); +}); + +describe('intl calendar date formatter with specific reference time', () => { + const referenceTime = '2024-05-10 14:00'; + + test('someday before last week', async () => { + const timestamp = '2024-04-27 10:00'; + expect(timestampToCalendarDate(timestamp, translation, referenceTime)).toBe( + 'Apr 27, 2024' + ); + }); + + test('someday in last week', async () => { + const timestamp = '2024-05-6 10:00'; + expect(timestampToCalendarDate(timestamp, translation, referenceTime)).toBe( + 'Monday' + ); + }); + + test('someday is yesterday', async () => { + const timestamp = '2024-05-9 10:00'; + expect(timestampToCalendarDate(timestamp, translation, referenceTime)).toBe( + 'Yesterday' + ); + }); + + test('someday is today', async () => { + const timestamp = '2024-05-10 10:00'; + expect(timestampToCalendarDate(timestamp, translation, referenceTime)).toBe( + 'Today' + ); + }); + + test('someday is tomorrow', async () => { + const timestamp = '2024-05-11 10:00'; + expect(timestampToCalendarDate(timestamp, translation, referenceTime)).toBe( + 'Tomorrow' + ); + }); + + test('someday in next week', async () => { + const timestamp = '2024-05-15 10:00'; + expect(timestampToCalendarDate(timestamp, translation, referenceTime)).toBe( + 'Next Week Wednesday' + ); + }); + + test('someday after next week', async () => { + const timestamp = '2024-05-30 10:00'; + expect(timestampToCalendarDate(timestamp, translation, referenceTime)).toBe( + 'May 30, 2024' + ); + }); +}); diff --git a/packages/frontend/core/src/utils/intl-formatter.ts b/packages/frontend/core/src/utils/intl-formatter.ts index d25de98a784e..ef88b8d3995d 100644 --- a/packages/frontend/core/src/utils/intl-formatter.ts +++ b/packages/frontend/core/src/utils/intl-formatter.ts @@ -1,19 +1,66 @@ +import { getI18n } from '@affine/i18n'; import dayjs from 'dayjs'; -const timeFormatter = new Intl.DateTimeFormat(undefined, { - timeStyle: 'short', -}); +function createTimeFormatter() { + return new Intl.DateTimeFormat(getI18n()?.language, { + timeStyle: 'short', + }); +} -const dateFormatter = new Intl.DateTimeFormat(undefined, { - year: 'numeric', - month: 'short', - day: 'numeric', -}); +function createDateFormatter() { + return new Intl.DateTimeFormat(getI18n()?.language, { + year: 'numeric', + month: 'short', + day: 'numeric', + }); +} + +function createWeekFormatter() { + return new Intl.DateTimeFormat(getI18n()?.language, { + weekday: 'long', + }); +} export const timestampToLocalTime = (ts: string | number) => { - return timeFormatter.format(dayjs(ts).toDate()); + const formatter = createTimeFormatter(); + return formatter.format(dayjs(ts).toDate()); }; export const timestampToLocalDate = (ts: string | number) => { - return dateFormatter.format(dayjs(ts).toDate()); + const formatter = createDateFormatter(); + return formatter.format(dayjs(ts).toDate()); +}; + +export interface CalendarTranslation { + yesterday: () => string; + today: () => string; + tomorrow: () => string; + nextWeek: () => string; +} + +export const timestampToCalendarDate = ( + ts: string | number, + translation: CalendarTranslation, + referenceTime?: string | number +) => { + const startOfDay = dayjs(referenceTime).startOf('d'); + const diff = dayjs(ts).diff(startOfDay, 'd', true); + const sameElse = timestampToLocalDate(ts); + + const formatter = createWeekFormatter(); + const week = formatter.format(dayjs(ts).toDate()); + + return diff < -6 + ? sameElse + : diff < -1 + ? week + : diff < 0 + ? translation.yesterday() + : diff < 1 + ? translation.today() + : diff < 2 + ? translation.tomorrow() + : diff < 7 + ? `${translation.nextWeek()} ${week}` + : sameElse; }; diff --git a/packages/frontend/i18n/src/index.ts b/packages/frontend/i18n/src/index.ts index ddc76289a9dc..3866cbb81fd9 100644 --- a/packages/frontend/i18n/src/index.ts +++ b/packages/frontend/i18n/src/index.ts @@ -45,6 +45,8 @@ export function useI18N() { return i18n; } +export { getI18n } from 'react-i18next'; + const resources = LOCALES.reduce((acc, { tag, res }) => { return Object.assign(acc, { [tag]: { translation: res } }); }, {}); diff --git a/packages/frontend/i18n/src/resources/en.json b/packages/frontend/i18n/src/resources/en.json index ae0ec5d56491..7e8e8259e478 100644 --- a/packages/frontend/i18n/src/resources/en.json +++ b/packages/frontend/i18n/src/resources/en.json @@ -784,6 +784,7 @@ "com.affine.last7Days": "Last 7 Days", "com.affine.lastMonth": "Last month", "com.affine.lastWeek": "Last week", + "com.affine.nextWeek": "Next week", "com.affine.lastYear": "Last year", "com.affine.loading": "Loading...", "com.affine.moreThan30Days": "Older than a month", @@ -1239,6 +1240,7 @@ "com.affine.toastMessage.restored": "{{title}} restored", "com.affine.toastMessage.successfullyDeleted": "Successfully deleted", "com.affine.today": "Today", + "com.affine.tomorrow": "Tomorrow", "com.affine.trashOperation.delete": "Delete", "com.affine.trashOperation.delete.description": "Once deleted, you can't undo this action. Do you confirm?", "com.affine.trashOperation.delete.title": "Permanently delete",