refactor(i18n): i18n utils tools (#7251)

This commit is contained in:
EYHN
2024-06-19 07:38:20 +00:00
parent b379aa0a91
commit bcc66422fd
23 changed files with 848 additions and 363 deletions
@@ -1,12 +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 {
type CalendarTranslation,
timestampToCalendarDate,
} from '@affine/core/utils';
import { DebugLogger } from '@affine/debug';
import type { ListHistoryQuery } from '@affine/graphql';
import { listHistoryQuery, recoverDocMutation } from '@affine/graphql';
import { i18nTime } from '@affine/i18n';
import { assertEquals } from '@blocksuite/global/utils';
import { DocCollection } from '@blocksuite/store';
import { globalBlockSuiteSchema } from '@toeverything/infra';
@@ -175,13 +172,20 @@ export const useSnapshotPage = (
return page;
};
export const historyListGroupByDay = (
histories: DocHistory[],
translation: CalendarTranslation
) => {
export const historyListGroupByDay = (histories: DocHistory[]) => {
const map = new Map<string, DocHistory[]>();
for (const history of histories) {
const day = timestampToCalendarDate(history.timestamp, translation);
const day = i18nTime(history.timestamp, {
relative: {
max: [1, 'week'],
accuracy: 'day',
weekday: true,
},
absolute: {
accuracy: 'day',
noYear: true,
},
});
const list = map.get(day) ?? [];
list.push(history);
map.set(day, list);
@@ -6,7 +6,7 @@ import { openSettingModalAtom } from '@affine/core/atoms';
import { useDocCollectionPageTitle } from '@affine/core/hooks/use-block-suite-workspace-page-title';
import { WorkspacePermissionService } from '@affine/core/modules/permissions';
import { WorkspaceQuotaService } from '@affine/core/modules/quota';
import { Trans } from '@affine/i18n';
import { i18nTime, Trans } from '@affine/i18n';
import { useAFFiNEI18N } from '@affine/i18n/hooks';
import { CloseIcon, ToggleCollapseIcon } from '@blocksuite/icons/rc';
import type { Doc as BlockSuiteDoc, DocCollection } from '@blocksuite/store';
@@ -33,11 +33,7 @@ import {
import { encodeStateAsUpdate } from 'yjs';
import { pageHistoryModalAtom } from '../../../atoms/page-history';
import {
type CalendarTranslation,
mixpanel,
timestampToLocalTime,
} from '../../../utils';
import { mixpanel } from '../../../utils';
import { BlockSuiteEditor } from '../../blocksuite/block-suite-editor';
import { StyledEditorModeSwitch } from '../../blocksuite/block-suite-mode-switch/style';
import {
@@ -144,7 +140,11 @@ const HistoryEditorPreview = ({
</StyledEditorModeSwitch>
<div className={styles.previewHeaderTitle}>{title}</div>
<div className={styles.previewHeaderTimestamp}>
{ts ? timestampToLocalTime(ts) : null}
{ts
? i18nTime(ts, {
absolute: { accuracy: 'minute', noDate: true },
})
: null}
</div>
</div>
@@ -317,14 +317,8 @@ const PageHistoryList = ({
}) => {
const t = useAFFiNEI18N();
const historyListByDay = useMemo(() => {
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]);
return historyListGroupByDay(historyList);
}, [historyList]);
const [collapsedMap, setCollapsedMap] = useState<Record<number, boolean>>({});
@@ -382,7 +376,9 @@ const PageHistoryList = ({
data-active={activeVersion === history.timestamp}
>
<button>
{timestampToLocalTime(history.timestamp)}
{i18nTime(history.timestamp, {
absolute: { noDate: true, accuracy: 'minute' },
})}
</button>
</div>
{idx > list.length - 1 ? (
@@ -4,7 +4,7 @@ import type {
PageInfoCustomPropertyMeta,
PagePropertyType,
} from '@affine/core/modules/properties/services/schema';
import { timestampToLocalDate } from '@affine/core/utils';
import { i18nTime } from '@affine/i18n';
import { useAFFiNEI18N } from '@affine/i18n/hooks';
import { DocService, useService } from '@toeverything/infra';
import { noop } from 'lodash-es';
@@ -22,7 +22,7 @@ interface PropertyRowValueProps {
export const DateValue = ({ property }: PropertyRowValueProps) => {
const displayValue = property.value
? timestampToLocalDate(property.value)
? i18nTime(property.value, { absolute: { accuracy: 'day' } })
: undefined;
const manager = useContext(managerContext);
@@ -14,11 +14,7 @@ import type {
PageInfoCustomPropertyMeta,
PagePropertyType,
} from '@affine/core/modules/properties/services/schema';
import {
timestampToHumanTime,
timestampToLocalDate,
timestampToLocalDateTime,
} from '@affine/core/utils';
import { i18nTime } from '@affine/i18n';
import { useAFFiNEI18N } from '@affine/i18n/hooks';
import { assertExists } from '@blocksuite/global/utils';
import {
@@ -617,7 +613,7 @@ export const PagePropertiesTableHeader = ({
const timestampElement = useMemo(() => {
const localizedCreateTime = manager.createDate
? timestampToLocalDate(manager.createDate)
? i18nTime(manager.createDate)
: null;
const createTimeElement = (
@@ -632,11 +628,11 @@ export const PagePropertiesTableHeader = ({
content={
<>
<div className={styles.tableHeaderTimestamp}>
{t['Updated']()} {timestampToLocalDateTime(serverClock)}
{t['Updated']()} {i18nTime(serverClock)}
</div>
{manager.createDate && (
<div className={styles.tableHeaderTimestamp}>
{t['Created']()} {timestampToLocalDateTime(manager.createDate)}
{t['Created']()} {i18nTime(manager.createDate)}
</div>
)}
</>
@@ -645,7 +641,16 @@ export const PagePropertiesTableHeader = ({
<div className={styles.tableHeaderTimestamp}>
{!syncing && !retrying ? (
<>
{t['Updated']()} {timestampToHumanTime(serverClock)}
{t['Updated']()}{' '}
{i18nTime(serverClock, {
relative: {
max: [1, 'day'],
accuracy: 'minute',
},
absolute: {
accuracy: 'day',
},
})}
</>
) : (
<>{t['com.affine.syncing']()}</>
@@ -655,7 +660,7 @@ export const PagePropertiesTableHeader = ({
) : manager.updatedDate ? (
<Tooltip side="right" content={createTimeElement}>
<div className={styles.tableHeaderTimestamp}>
{t['Updated']()} {timestampToLocalDate(manager.updatedDate)}
{t['Updated']()} {i18nTime(manager.updatedDate)}
</div>
</Tooltip>
) : (
@@ -18,7 +18,7 @@ import {
SubscriptionRecurring,
SubscriptionStatus,
} from '@affine/graphql';
import { Trans } from '@affine/i18n';
import { i18nTime, Trans } from '@affine/i18n';
import { useAFFiNEI18N } from '@affine/i18n/hooks';
import { ArrowRightSmallIcon } from '@blocksuite/icons/rc';
import { useLiveData, useService } from '@toeverything/infra';
@@ -29,11 +29,7 @@ import { openSettingModalAtom } from '../../../../../atoms';
import { useMutation } from '../../../../../hooks/use-mutation';
import { useQuery } from '../../../../../hooks/use-query';
import { SubscriptionService } from '../../../../../modules/cloud';
import {
mixpanel,
popupWindow,
timestampToLocalDate,
} from '../../../../../utils';
import { mixpanel, popupWindow } from '../../../../../utils';
import { SWRErrorBoundary } from '../../../../pure/swr-error-bundary';
import { CancelAction, ResumeAction } from '../plans/actions';
import { AICancel, AIResume, AISubscribe } from '../plans/ai/actions';
@@ -282,11 +278,13 @@ const AIPlanCard = ({ onClick }: { onClick: () => void }) => {
/>
) : subscription?.nextBillAt ? (
t['com.affine.payment.ai.billing-tip.next-bill-at']({
due: timestampToLocalDate(subscription.nextBillAt),
due: i18nTime(subscription.nextBillAt, {
absolute: { accuracy: 'day' },
}),
})
) : subscription?.canceledAt && subscription.end ? (
t['com.affine.payment.ai.billing-tip.end-at']({
end: timestampToLocalDate(subscription.end),
end: i18nTime(subscription.end, { absolute: { accuracy: 'day' } }),
})
) : null;
@@ -1,6 +1,6 @@
import { Button } from '@affine/component';
import { AuthService, SubscriptionService } from '@affine/core/modules/cloud';
import { timestampToLocalDate } from '@affine/core/utils';
import { i18nTime } from '@affine/i18n';
import { useAFFiNEI18N } from '@affine/i18n/hooks';
import { useLiveData, useService } from '@toeverything/infra';
import { useEffect } from 'react';
@@ -32,11 +32,15 @@ export const AIPlan = () => {
const billingTip = subscription?.nextBillAt
? t['com.affine.payment.ai.billing-tip.next-bill-at']({
due: timestampToLocalDate(subscription.nextBillAt),
due: i18nTime(subscription.nextBillAt, {
absolute: { accuracy: 'day' },
}),
})
: subscription?.canceledAt && subscription.end
? t['com.affine.payment.ai.billing-tip.end-at']({
end: timestampToLocalDate(subscription.end),
end: i18nTime(subscription.end, {
absolute: { accuracy: 'day' },
}),
})
: null;
@@ -1,6 +1,7 @@
import { Checkbox } from '@affine/component';
import { Checkbox, Tooltip } from '@affine/component';
import { getDNDId } from '@affine/core/hooks/affine/use-global-dnd-helper';
import { TagService } from '@affine/core/modules/tag';
import { i18nTime } from '@affine/i18n';
import { useDraggable } from '@dnd-kit/core';
import { useLiveData, useService } from '@toeverything/infra';
import type { PropsWithChildren } from 'react';
@@ -15,7 +16,7 @@ import {
} from '../scoped-atoms';
import type { DraggableTitleCellData, PageListItemProps } from '../types';
import { useAllDocDisplayProperties } from '../use-all-doc-display-properties';
import { ColWrapper, formatDate, stopPropagation } from '../utils';
import { ColWrapper, stopPropagation } from '../utils';
import * as styles from './page-list-item.css';
import { PageTags } from './page-tags';
@@ -97,13 +98,17 @@ const PageCreateDateCell = ({
createDate,
}: Pick<PageListItemProps, 'createDate'>) => {
return (
<div
data-testid="page-list-item-date"
data-date-raw={createDate}
className={styles.dateCell}
>
{formatDate(createDate)}
</div>
<Tooltip content={i18nTime(createDate)}>
<div
data-testid="page-list-item-date"
data-date-raw={createDate}
className={styles.dateCell}
>
{i18nTime(createDate, {
relative: true,
})}
</div>
</Tooltip>
);
};
@@ -111,13 +116,19 @@ const PageUpdatedDateCell = ({
updatedDate,
}: Pick<PageListItemProps, 'updatedDate'>) => {
return (
<div
data-testid="page-list-item-date"
data-date-raw={updatedDate}
className={styles.dateCell}
>
{updatedDate ? formatDate(updatedDate) : '-'}
</div>
<Tooltip content={updatedDate ? i18nTime(updatedDate) : undefined}>
<div
data-testid="page-list-item-date"
data-date-raw={updatedDate}
className={styles.dateCell}
>
{updatedDate
? i18nTime(updatedDate, {
relative: true,
})
: '-'}
</div>
</Tooltip>
);
};
@@ -5,70 +5,6 @@ import { forwardRef } from 'react';
import * as styles from './list.css';
import type { ColWrapperProps } from './types';
export function isToday(date: Date): boolean {
const today = new Date();
return (
date.getDate() === today.getDate() &&
date.getMonth() === today.getMonth() &&
date.getFullYear() === today.getFullYear()
);
}
export function isYesterday(date: Date): boolean {
const yesterday = new Date();
yesterday.setDate(yesterday.getDate() - 1);
return (
date.getFullYear() === yesterday.getFullYear() &&
date.getMonth() === yesterday.getMonth() &&
date.getDate() === yesterday.getDate()
);
}
export function isLastWeek(date: Date): boolean {
const today = new Date();
const lastWeek = new Date(
today.getFullYear(),
today.getMonth(),
today.getDate() - 7
);
return date >= lastWeek && date < today;
}
export function isLastMonth(date: Date): boolean {
const today = new Date();
const lastMonth = new Date(
today.getFullYear(),
today.getMonth() - 1,
today.getDate()
);
return date >= lastMonth && date < today;
}
export function isLastYear(date: Date): boolean {
const today = new Date();
const lastYear = new Date(
today.getFullYear() - 1,
today.getMonth(),
today.getDate()
);
return date >= lastYear && date < today;
}
export const formatDate = (date: Date): string => {
// yyyy-mm-dd MM-DD HH:mm
// const year = date.getFullYear();
const month = (date.getMonth() + 1).toString().padStart(2, '0');
const day = date.getDate().toString().padStart(2, '0');
const hours = date.getHours().toString().padStart(2, '0');
const minutes = date.getMinutes().toString().padStart(2, '0');
if (isToday(date)) {
// HH:mm
return `${hours}:${minutes}`;
}
// MM-DD HH:mm
return `${month}-${day} ${hours}:${minutes}`;
};
export const ColWrapper = forwardRef<HTMLDivElement, ColWrapperProps>(
function ColWrapper(
{
@@ -4,6 +4,7 @@
import 'fake-indexeddb/auto';
import { WorkspacePropertiesAdapter } from '@affine/core/modules/properties';
import { createI18n } from '@affine/i18n';
import { render } from '@testing-library/react';
import {
FrameworkRoot,
@@ -35,6 +36,7 @@ beforeEach(async () => {
describe('useDocCollectionPageTitle', () => {
test('basic', async () => {
createI18n();
const { framework, workspace, doc } = await configureTestingEnvironment();
const { findByText, rerender } = render(
<FrameworkRoot framework={framework}>
@@ -1,9 +1,9 @@
import { i18nTime } from '@affine/i18n';
import { initEmptyPage } from '@toeverything/infra';
import dayjs from 'dayjs';
import { useCallback, useMemo } from 'react';
import type { DocCollection } from '../shared';
import { timestampToLocalDate } from '../utils';
import { useCurrentWorkspacePropertiesAdapter } from './use-affine-adapter';
import { useDocCollectionHelper } from './use-block-suite-workspace-helper';
import { useNavigateHelper } from './use-navigate-helper';
@@ -109,7 +109,7 @@ export const useJournalHelper = (docCollection: DocCollection) => {
(pageId: string) => {
const journalDateString = getJournalDateString(pageId);
if (!journalDateString) return null;
return timestampToLocalDate(journalDateString);
return i18nTime(journalDateString, { absolute: { accuracy: 'day' } });
},
[getJournalDateString]
);
@@ -1,9 +1,9 @@
import { Loading } from '@affine/component/ui/loading';
import type { CommandCategory } from '@affine/core/commands';
import { formatDate } from '@affine/core/components/page-list';
import { useDocEngineStatus } from '@affine/core/hooks/affine/use-doc-engine-status';
import { useAsyncCallback } from '@affine/core/hooks/affine-async-hooks';
import { QuickSearchService } from '@affine/core/modules/cmdk';
import { i18nTime } from '@affine/i18n';
import { useAFFiNEI18N } from '@affine/i18n/hooks';
import type { DocMeta } from '@blocksuite/store';
import { useLiveData, useService } from '@toeverything/infra';
@@ -111,7 +111,7 @@ const QuickSearchGroup = ({
</div>
{command.timestamp ? (
<div className={styles.timestamp}>
{formatDate(new Date(command.timestamp))}
{i18nTime(command.timestamp, { relative: true })}
</div>
) : null}
{command.keyBinding ? (
@@ -1,112 +0,0 @@
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'
);
});
});
@@ -1,6 +1,5 @@
export * from './create-emotion-cache';
export * from './fractional-indexing';
export * from './intl-formatter';
export * from './mixpanel';
export * from './popup';
export * from './string2color';
@@ -1,105 +0,0 @@
import { getI18n } from '@affine/i18n';
import dayjs from 'dayjs';
function createTimeFormatter() {
return new Intl.DateTimeFormat(getI18n()?.language, {
timeStyle: 'short',
});
}
function createDateTimeFormatter() {
return new Intl.DateTimeFormat(getI18n()?.language, {
timeStyle: 'medium',
dateStyle: 'medium',
});
}
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) => {
const formatter = createTimeFormatter();
return formatter.format(dayjs(ts).toDate());
};
export const timestampToLocalDate = (ts: string | number) => {
const formatter = createDateFormatter();
return formatter.format(dayjs(ts).toDate());
};
export const timestampToLocalDateTime = (ts: string | number) => {
const formatter = createDateTimeFormatter();
return formatter.format(dayjs(ts).toDate());
};
export const createRelativeTimeFormatter = () => {
return new Intl.RelativeTimeFormat(getI18n()?.language, {
style: 'narrow',
});
};
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;
};
// TODO: refactor this to @affine/i18n
export const timestampToHumanTime = (ts: number) => {
const diff = Math.abs(dayjs(ts).diff(dayjs()));
if (diff < 1000 * 60) {
return getI18n().t('com.affine.just-now');
} else if (diff < 1000 * 60 * 60) {
return createRelativeTimeFormatter().format(
-Math.floor(diff / 1000 / 60),
'minutes'
);
} else if (diff < 1000 * 60 * 60 * 24) {
return createRelativeTimeFormatter().format(
-Math.floor(diff / 1000 / 60 / 60),
'hours'
);
} else {
return timestampToLocalDate(ts);
}
};