mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-07-17 01:56:27 +08:00
refactor(i18n): i18n utils tools (#7251)
This commit is contained in:
@@ -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 ? (
|
||||
|
||||
+2
-2
@@ -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>
|
||||
) : (
|
||||
|
||||
+6
-8
@@ -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;
|
||||
|
||||
|
||||
+7
-3
@@ -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(
|
||||
{
|
||||
|
||||
+2
@@ -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);
|
||||
}
|
||||
};
|
||||
@@ -7,13 +7,6 @@
|
||||
".": "./src/index.ts",
|
||||
"./hooks": "./src/i18n-generated"
|
||||
},
|
||||
"publishConfig": {
|
||||
"main": "dist/src/index.js",
|
||||
"types": "dist/src/index.d.ts",
|
||||
"exports": {
|
||||
".": "./dist/src/index.js"
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
"build": "node build.mjs",
|
||||
"dev": "node dev.mjs",
|
||||
@@ -28,6 +21,7 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@magic-works/i18n-codegen": "^0.6.0",
|
||||
"dayjs": "^1.11.11",
|
||||
"i18next": "^23.11.1",
|
||||
"react-i18next": "^14.1.0",
|
||||
"undici": "^6.12.0"
|
||||
@@ -36,7 +30,8 @@
|
||||
"@types/prettier": "^3.0.0",
|
||||
"prettier": "^3.2.5",
|
||||
"ts-node": "^10.9.2",
|
||||
"typescript": "^5.4.5"
|
||||
"typescript": "^5.4.5",
|
||||
"vitest": "1.6.0"
|
||||
},
|
||||
"version": "0.14.0"
|
||||
}
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
import { getI18n } from 'react-i18next';
|
||||
|
||||
import type { useAFFiNEI18N } from './i18n-generated';
|
||||
|
||||
export type I18nFuncs = ReturnType<typeof useAFFiNEI18N>;
|
||||
|
||||
export type I18nInfos = {
|
||||
[K in keyof I18nFuncs]: I18nFuncs[K] extends (...a: infer Opt) => any
|
||||
? Opt[0]
|
||||
: never;
|
||||
};
|
||||
|
||||
export type I18nKeys = keyof I18nInfos;
|
||||
|
||||
export type I18nString =
|
||||
| {
|
||||
[K in I18nKeys]: {
|
||||
key: K;
|
||||
} & (I18nInfos[K] extends undefined
|
||||
? unknown
|
||||
: { options: I18nInfos[K] });
|
||||
}[I18nKeys]
|
||||
| string;
|
||||
|
||||
const I18nMethod = {
|
||||
t(i18nStr: I18nString) {
|
||||
const i18n = getI18n();
|
||||
if (typeof i18nStr === 'object') {
|
||||
return i18n.t(i18nStr.key, 'options' in i18nStr ? i18nStr.options : {});
|
||||
}
|
||||
return i18nStr;
|
||||
},
|
||||
get language() {
|
||||
const i18n = getI18n();
|
||||
return i18n.language;
|
||||
},
|
||||
};
|
||||
|
||||
const I18nProxy = new Proxy(I18nMethod, {
|
||||
get(self, key) {
|
||||
const i18n = getI18n();
|
||||
if (typeof key === 'string' && i18n.exists(key)) {
|
||||
return i18n.t.bind(i18n, key as string);
|
||||
} else {
|
||||
return (self as any)[key as string] as any;
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* I18n['com.affine.xxx']({ arg1: 'hello' }) -> '中文 hello'
|
||||
*/
|
||||
export const I18n = I18nProxy as I18nFuncs & typeof I18nMethod;
|
||||
export type I18n = typeof I18n;
|
||||
@@ -11,6 +11,10 @@ import {
|
||||
import { LOCALES } from './resources';
|
||||
import type en_US from './resources/en.json';
|
||||
|
||||
export * from './i18n';
|
||||
export * from './i18n-generated';
|
||||
export * from './utils';
|
||||
|
||||
declare module 'i18next' {
|
||||
// Refs: https://www.i18next.com/overview/typescript#argument-of-type-defaulttfuncreturn-is-not-assignable-to-parameter-of-type-xyz
|
||||
interface CustomTypeOptions {
|
||||
@@ -88,9 +92,11 @@ export const createI18n = (): I18nextProviderProps['i18n'] => {
|
||||
console.error('i18n init failed');
|
||||
});
|
||||
|
||||
i18n.on('languageChanged', lng => {
|
||||
localStorage.setItem(STORAGE_KEY, lng);
|
||||
});
|
||||
if (globalThis.localStorage) {
|
||||
i18n.on('languageChanged', lng => {
|
||||
localStorage.setItem(STORAGE_KEY, lng);
|
||||
});
|
||||
}
|
||||
return i18n;
|
||||
};
|
||||
export function setUpLanguage(i: i18n) {
|
||||
|
||||
@@ -1311,7 +1311,11 @@
|
||||
"com.affine.workspaceType.offline": "Available Offline",
|
||||
"com.affine.write_with_a_blank_page": "Write with a blank page",
|
||||
"com.affine.yesterday": "Yesterday",
|
||||
"com.affine.just-now": "Just now",
|
||||
"com.affine.time.now": "now",
|
||||
"com.affine.time.this-week": "this week",
|
||||
"com.affine.time.this-mouth": "this month",
|
||||
"com.affine.time.this-year": "this year",
|
||||
"com.affine.time.today": "today",
|
||||
"com.affine.syncing": "Syncing",
|
||||
"core": "core",
|
||||
"dark": "Dark",
|
||||
|
||||
@@ -1312,6 +1312,11 @@
|
||||
"com.affine.workspaceType.offline": "离线可用",
|
||||
"com.affine.write_with_a_blank_page": "在空白页面书写",
|
||||
"com.affine.yesterday": "昨天",
|
||||
"com.affine.time.now": "刚刚",
|
||||
"com.affine.time.this-week": "本周",
|
||||
"com.affine.time.this-mouth": "本月",
|
||||
"com.affine.time.this-year": "今年",
|
||||
"com.affine.time.today": "今天",
|
||||
"core": "核心",
|
||||
"dark": "深色",
|
||||
"emptyAllPages": "点击<1>$t(New Page)</1>按钮创建您的第一篇文档。",
|
||||
|
||||
@@ -0,0 +1,399 @@
|
||||
import { describe, expect, test } from 'vitest';
|
||||
|
||||
import { createI18n, getI18n } from '../../';
|
||||
import { i18nTime } from '../time';
|
||||
|
||||
// Intl api is not available in github action, skip the test
|
||||
describe.skip('humanTime', () => {
|
||||
test('absolute', async () => {
|
||||
createI18n();
|
||||
expect(i18nTime('2024-10-10 13:30:28')).toBe('Oct 10, 2024, 1:30:28 PM');
|
||||
expect(
|
||||
i18nTime('2024-10-10 13:30:28', {
|
||||
absolute: {
|
||||
accuracy: 'minute',
|
||||
},
|
||||
})
|
||||
).toBe('Oct 10, 2024, 1:30 PM');
|
||||
expect(
|
||||
i18nTime('2024-10-10 13:30:28', {
|
||||
absolute: {
|
||||
accuracy: 'day',
|
||||
},
|
||||
})
|
||||
).toBe('Oct 10, 2024');
|
||||
expect(
|
||||
i18nTime('2024-10-10 13:30:28', {
|
||||
absolute: {
|
||||
accuracy: 'day',
|
||||
noYear: true,
|
||||
},
|
||||
})
|
||||
).toBe('Oct 10');
|
||||
expect(
|
||||
i18nTime('2024-10-10 13:30:28', {
|
||||
absolute: {
|
||||
accuracy: 'year',
|
||||
},
|
||||
})
|
||||
).toBe('2024');
|
||||
expect(
|
||||
i18nTime('2024-10-10 13:30:28', {
|
||||
absolute: {
|
||||
noDate: true,
|
||||
accuracy: 'minute',
|
||||
},
|
||||
})
|
||||
).toBe('1:30 PM');
|
||||
});
|
||||
|
||||
test('relative', async () => {
|
||||
createI18n();
|
||||
expect(
|
||||
i18nTime('2024-10-10 13:30:28.005', {
|
||||
now: '2024-10-10 13:30:30',
|
||||
relative: true,
|
||||
})
|
||||
).toBe('1s ago');
|
||||
expect(
|
||||
i18nTime('2024-10-10 13:25:00', {
|
||||
now: '2024-10-10 13:30:30',
|
||||
relative: true,
|
||||
})
|
||||
).toBe('5m ago');
|
||||
expect(
|
||||
i18nTime('2024-10-10 12:59:00', {
|
||||
now: '2024-10-10 13:30:30',
|
||||
relative: true,
|
||||
})
|
||||
).toBe('31m ago');
|
||||
expect(
|
||||
i18nTime('2024-10-10 12:30:30', {
|
||||
now: '2024-10-10 13:30:30',
|
||||
relative: true,
|
||||
})
|
||||
).toBe('1h ago');
|
||||
expect(
|
||||
i18nTime('2024-10-9 13:30:00', {
|
||||
now: '2024-10-10 13:30:30',
|
||||
relative: true,
|
||||
})
|
||||
).toBe('yesterday');
|
||||
expect(
|
||||
i18nTime('2024-10-9 12:30:00', {
|
||||
now: '2024-10-10 13:30:30',
|
||||
relative: true,
|
||||
})
|
||||
).toBe('yesterday');
|
||||
expect(
|
||||
i18nTime('2024-10-8 23:59:00', {
|
||||
now: '2024-10-10 13:30:30',
|
||||
relative: true,
|
||||
})
|
||||
).toBe('2d ago');
|
||||
expect(
|
||||
i18nTime('2024-10-7 23:59:00', {
|
||||
now: '2024-10-10 13:30:30',
|
||||
relative: true,
|
||||
})
|
||||
).toBe('3d ago');
|
||||
expect(
|
||||
i18nTime('2024-10-4 00:00:00', {
|
||||
now: '2024-10-10 13:30:30',
|
||||
relative: true,
|
||||
})
|
||||
).toBe('6d ago');
|
||||
expect(
|
||||
i18nTime('2024-10-3 23:59:59', {
|
||||
now: '2024-10-10 13:30:30',
|
||||
relative: true,
|
||||
})
|
||||
).toBe('last wk.');
|
||||
expect(
|
||||
i18nTime('2024-9-29 23:59:59', {
|
||||
now: '2024-10-10 13:30:30',
|
||||
relative: true,
|
||||
})
|
||||
).toBe('last wk.');
|
||||
expect(
|
||||
i18nTime('2024-9-28 23:59:59', {
|
||||
now: '2024-10-10 13:30:30',
|
||||
relative: true,
|
||||
})
|
||||
).toBe('2w ago');
|
||||
expect(
|
||||
i18nTime('2024-9-15 00:00:00', {
|
||||
now: '2024-10-10 13:30:30',
|
||||
relative: true,
|
||||
})
|
||||
).toBe('last mo.');
|
||||
expect(
|
||||
i18nTime('2024-9-1 00:00:00', {
|
||||
now: '2024-9-30 13:30:30',
|
||||
relative: true,
|
||||
})
|
||||
).toBe('4w ago');
|
||||
expect(
|
||||
i18nTime('2024-9-10 13:30:30', {
|
||||
now: '2024-10-10 13:30:30',
|
||||
relative: true,
|
||||
})
|
||||
).toBe('last mo.');
|
||||
expect(
|
||||
i18nTime('2023-9-10 13:30:30', {
|
||||
now: '2024-10-10 13:30:30',
|
||||
relative: true,
|
||||
})
|
||||
).toBe('last yr.');
|
||||
});
|
||||
|
||||
test('relative - accuracy', async () => {
|
||||
createI18n();
|
||||
expect(
|
||||
i18nTime('2024-10-10 13:30:28.005', {
|
||||
now: '2024-10-10 13:30:30',
|
||||
relative: {
|
||||
accuracy: 'minute',
|
||||
},
|
||||
})
|
||||
).toBe('now');
|
||||
expect(
|
||||
i18nTime('2024-10-10 13:25:00', {
|
||||
now: '2024-10-10 13:30:30',
|
||||
relative: {
|
||||
accuracy: 'minute',
|
||||
},
|
||||
})
|
||||
).toBe('5m ago');
|
||||
expect(
|
||||
i18nTime('2024-10-10 12:59:00', {
|
||||
now: '2024-10-10 13:30:30',
|
||||
relative: {
|
||||
accuracy: 'hour',
|
||||
},
|
||||
})
|
||||
).toBe('now');
|
||||
expect(
|
||||
i18nTime('2024-10-10 12:30:30', {
|
||||
now: '2024-10-10 13:30:30',
|
||||
relative: {
|
||||
accuracy: 'day',
|
||||
},
|
||||
})
|
||||
).toBe('today');
|
||||
expect(
|
||||
i18nTime('2024-10-4 00:00:00', {
|
||||
now: '2024-10-10 13:30:30',
|
||||
relative: {
|
||||
accuracy: 'week',
|
||||
},
|
||||
})
|
||||
).toBe('last wk.');
|
||||
expect(
|
||||
i18nTime('2024-10-9 00:00:00', {
|
||||
now: '2024-10-10 13:30:30',
|
||||
relative: {
|
||||
accuracy: 'week',
|
||||
},
|
||||
})
|
||||
).toBe('this week');
|
||||
expect(
|
||||
i18nTime('2024-9-1 00:00:00', {
|
||||
now: '2024-9-30 13:30:30',
|
||||
relative: {
|
||||
accuracy: 'month',
|
||||
},
|
||||
})
|
||||
).toBe('this month');
|
||||
expect(
|
||||
i18nTime('2024-9-10 13:30:30', {
|
||||
now: '2024-10-10 13:30:30',
|
||||
relative: {
|
||||
accuracy: 'year',
|
||||
},
|
||||
})
|
||||
).toBe('this year');
|
||||
expect(
|
||||
i18nTime('2023-9-10 13:30:30', {
|
||||
now: '2024-10-10 13:30:30',
|
||||
relative: {
|
||||
accuracy: 'year',
|
||||
},
|
||||
})
|
||||
).toBe('last yr.');
|
||||
});
|
||||
|
||||
test('relative - disable yesterdayAndTomorrow', async () => {
|
||||
createI18n();
|
||||
expect(
|
||||
i18nTime('2024-10-9 13:30:30', {
|
||||
now: '2024-10-10 13:30:30',
|
||||
relative: {
|
||||
yesterdayAndTomorrow: false,
|
||||
},
|
||||
})
|
||||
).toBe('1d ago');
|
||||
expect(
|
||||
i18nTime('2024-10-11 13:30:30', {
|
||||
now: '2024-10-10 13:30:30',
|
||||
relative: {
|
||||
yesterdayAndTomorrow: false,
|
||||
},
|
||||
})
|
||||
).toBe('in 1d');
|
||||
});
|
||||
|
||||
test('relative - weekday', async () => {
|
||||
createI18n();
|
||||
expect(
|
||||
i18nTime('2024-10-9 13:30:30', {
|
||||
now: '2024-10-10 13:30:30',
|
||||
relative: {
|
||||
weekday: true,
|
||||
yesterdayAndTomorrow: false,
|
||||
},
|
||||
})
|
||||
).toBe('Wednesday');
|
||||
expect(
|
||||
i18nTime('2024-10-4 13:30:30', {
|
||||
now: '2024-10-10 13:30:30',
|
||||
relative: {
|
||||
weekday: true,
|
||||
yesterdayAndTomorrow: false,
|
||||
},
|
||||
})
|
||||
).toBe('Friday');
|
||||
expect(
|
||||
i18nTime('2024-10-3 13:30:30', {
|
||||
now: '2024-10-10 13:30:30',
|
||||
relative: {
|
||||
weekday: true,
|
||||
yesterdayAndTomorrow: false,
|
||||
},
|
||||
})
|
||||
).toBe('1w ago');
|
||||
expect(
|
||||
i18nTime('2024-10-11 13:30:30', {
|
||||
now: '2024-10-10 13:30:30',
|
||||
relative: {
|
||||
weekday: true,
|
||||
yesterdayAndTomorrow: false,
|
||||
},
|
||||
})
|
||||
).toBe('Friday');
|
||||
expect(
|
||||
i18nTime('2024-10-16 13:30:30', {
|
||||
now: '2024-10-10 13:30:30',
|
||||
relative: {
|
||||
weekday: true,
|
||||
yesterdayAndTomorrow: false,
|
||||
},
|
||||
})
|
||||
).toBe('Wednesday');
|
||||
expect(
|
||||
i18nTime('2024-10-17 13:30:30', {
|
||||
now: '2024-10-10 13:30:30',
|
||||
relative: {
|
||||
weekday: true,
|
||||
yesterdayAndTomorrow: false,
|
||||
},
|
||||
})
|
||||
).toBe('in 1w');
|
||||
});
|
||||
|
||||
test('mix relative and absolute', async () => {
|
||||
createI18n();
|
||||
expect(
|
||||
i18nTime('2024-10-9 14:30:30', {
|
||||
now: '2024-10-10 13:30:30',
|
||||
relative: {
|
||||
max: [1, 'day'],
|
||||
},
|
||||
})
|
||||
).toBe('23h ago');
|
||||
expect(
|
||||
i18nTime('2024-10-9 13:30:30', {
|
||||
now: '2024-10-10 13:30:30',
|
||||
relative: {
|
||||
max: [1, 'day'],
|
||||
},
|
||||
absolute: {
|
||||
accuracy: 'day',
|
||||
},
|
||||
})
|
||||
).toBe('Oct 9, 2024');
|
||||
|
||||
expect(
|
||||
i18nTime('2024-10-9 13:30:30', {
|
||||
now: '2024-10-10 13:30:30',
|
||||
relative: {
|
||||
max: [2, 'day'],
|
||||
},
|
||||
absolute: {
|
||||
accuracy: 'day',
|
||||
},
|
||||
})
|
||||
).toBe('yesterday');
|
||||
|
||||
expect(
|
||||
i18nTime('2024-10-8 13:30:30', {
|
||||
now: '2024-10-10 13:30:30',
|
||||
relative: {
|
||||
max: [2, 'day'],
|
||||
},
|
||||
absolute: {
|
||||
accuracy: 'day',
|
||||
},
|
||||
})
|
||||
).toBe('Oct 8, 2024');
|
||||
});
|
||||
|
||||
test('chinese', () => {
|
||||
createI18n();
|
||||
getI18n().changeLanguage('zh-Hans');
|
||||
expect(i18nTime('2024-10-10 13:30:28.005')).toBe('2024年10月10日 13:30:28');
|
||||
expect(
|
||||
i18nTime('2024-10-10 13:30:28.005', {
|
||||
absolute: {
|
||||
accuracy: 'day',
|
||||
},
|
||||
})
|
||||
).toBe('2024年10月10日');
|
||||
expect(
|
||||
i18nTime('2024-10-10 13:30:28.005', {
|
||||
now: '2024-10-10 13:30:30',
|
||||
relative: true,
|
||||
})
|
||||
).toBe('1秒前');
|
||||
expect(
|
||||
i18nTime('2024-10-9 13:30:30', {
|
||||
now: '2024-10-10 13:30:30',
|
||||
relative: true,
|
||||
})
|
||||
).toBe('昨天');
|
||||
expect(
|
||||
i18nTime('2024-10-8 13:30:30', {
|
||||
now: '2024-10-10 13:30:30',
|
||||
relative: {
|
||||
weekday: true,
|
||||
},
|
||||
})
|
||||
).toBe('星期二');
|
||||
expect(
|
||||
i18nTime('2024-10-8 13:30:30', {
|
||||
now: '2024-10-10 13:30:30',
|
||||
relative: {
|
||||
accuracy: 'week',
|
||||
},
|
||||
})
|
||||
).toBe('本周');
|
||||
expect(
|
||||
i18nTime('2024-10-8 13:30:30', {
|
||||
now: '2024-10-10 13:30:30',
|
||||
relative: {
|
||||
accuracy: 'month',
|
||||
},
|
||||
})
|
||||
).toBe('本月');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1 @@
|
||||
export { i18nTime } from './time';
|
||||
@@ -0,0 +1,281 @@
|
||||
import dayjs from 'dayjs';
|
||||
|
||||
import { I18n } from '../i18n';
|
||||
|
||||
export type TimeUnit =
|
||||
| 'second'
|
||||
| 'minute'
|
||||
| 'hour'
|
||||
| 'day'
|
||||
| 'week'
|
||||
| 'month'
|
||||
| 'year';
|
||||
|
||||
const timeUnitCode = {
|
||||
second: 1,
|
||||
minute: 2,
|
||||
hour: 3,
|
||||
day: 4,
|
||||
week: 5,
|
||||
month: 6,
|
||||
year: 7,
|
||||
} satisfies Record<TimeUnit, number>;
|
||||
|
||||
/**
|
||||
* ```ts
|
||||
* // timestamp to string
|
||||
* i18nTime(1728538228000) -> 'Oct 10, 2024, 1:30:28 PM'
|
||||
*
|
||||
* // absolute time string
|
||||
* i18nTime('2024-10-10 13:30:28', { absolute: { accuracy: 'minute' } }) -> '2024-10-10 13:30 PM'
|
||||
* i18nTime('2024-10-10 13:30:28', { absolute: { accuracy: 'minute', noDate: true } }) -> '13:30 PM'
|
||||
* i18nTime('2024-10-10 13:30:28', { absolute: { accuracy: 'minute', noYear: true } }) -> 'Oct 10, 13:30 PM'
|
||||
* i18nTime('2024-10-10 13:30:28', { absolute: { accuracy: 'day' } }) -> 'Oct 10, 2024'
|
||||
*
|
||||
* // relative time string
|
||||
* i18nTime('2024-10-10 13:30:30', { relative: true }) -> 'now'
|
||||
* i18nTime('2024-10-10 13:30:00', { relative: true }) -> '30s ago'
|
||||
* i18nTime('2024-10-10 13:30:30', { relative: { accuracy: 'minute' } }) -> '2m ago'
|
||||
*
|
||||
* // show absolute time string if time is pass 1 day
|
||||
* i18nTime('2024-10-9 14:30:30', { relative: { max: [1, 'day'] } }) -> '23h ago'
|
||||
* i18nTime('2024-10-9 13:30:30', { relative: { max: [1, 'day'] } }) -> 'Oct 9, 2024, 1:30:30 PM'
|
||||
* ```
|
||||
*/
|
||||
export function i18nTime(
|
||||
time: dayjs.ConfigType,
|
||||
options: {
|
||||
// override i18n instance, default is global I18n instance
|
||||
i18n?: I18n;
|
||||
// override now time, default is current time
|
||||
now?: dayjs.ConfigType;
|
||||
relative?:
|
||||
| {
|
||||
// max time to show relative time, if time is pass this time, show absolute time
|
||||
max?: [number, TimeUnit];
|
||||
// show time with this accuracy
|
||||
accuracy?: TimeUnit;
|
||||
// show weekday, e.g. 'Monday', 'Tuesday', etc.
|
||||
weekday?: boolean;
|
||||
// show 'yesterday' or 'tomorrow' if time is
|
||||
yesterdayAndTomorrow?: boolean;
|
||||
}
|
||||
| true; // use default relative option
|
||||
absolute?: {
|
||||
// show time with this accuracy
|
||||
accuracy?: TimeUnit;
|
||||
// hide year
|
||||
noYear?: boolean;
|
||||
// hide date (year, month, day)
|
||||
noDate?: boolean;
|
||||
};
|
||||
} = {}
|
||||
) {
|
||||
const i18n = options.i18n ?? I18n;
|
||||
time = dayjs(time);
|
||||
const now = dayjs(options.now);
|
||||
|
||||
const defaultRelativeOption = {
|
||||
max: [1000, 'year'],
|
||||
accuracy: 'second',
|
||||
weekday: false,
|
||||
yesterdayAndTomorrow: true,
|
||||
} satisfies typeof options.relative;
|
||||
|
||||
const relativeOption = options.relative
|
||||
? options.relative === true
|
||||
? defaultRelativeOption
|
||||
: {
|
||||
...defaultRelativeOption,
|
||||
...options.relative,
|
||||
}
|
||||
: null;
|
||||
|
||||
const defaultAbsoluteOption = {
|
||||
accuracy: 'second',
|
||||
noYear: false,
|
||||
noDate: false,
|
||||
} satisfies typeof options.absolute;
|
||||
|
||||
const absoluteOption = {
|
||||
...defaultAbsoluteOption,
|
||||
...options.absolute,
|
||||
};
|
||||
|
||||
if (relativeOption) {
|
||||
// show relative
|
||||
|
||||
const formatter = new Intl.RelativeTimeFormat(i18n.language, {
|
||||
style: 'narrow',
|
||||
numeric: relativeOption.yesterdayAndTomorrow ? 'auto' : 'always',
|
||||
});
|
||||
|
||||
const timeUnitProcessor = {
|
||||
second: () => {
|
||||
const diffSecond = time.diff(now) / 1000;
|
||||
if (Math.abs(diffSecond) < 1) {
|
||||
return i18n['com.affine.time.now']();
|
||||
}
|
||||
if (
|
||||
relativeOption.max[1] === 'second' &&
|
||||
Math.abs(diffSecond) >= relativeOption.max[0]
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
if (Math.abs(diffSecond) < 60) {
|
||||
return formatter.format(Math.trunc(diffSecond), 'second');
|
||||
}
|
||||
return null;
|
||||
},
|
||||
minute: () => {
|
||||
const diffMinute = time.diff(now) / 1000 / 60;
|
||||
if (Math.abs(diffMinute) < 1) {
|
||||
return i18n['com.affine.time.now']();
|
||||
}
|
||||
if (
|
||||
relativeOption.max[1] === 'minute' &&
|
||||
Math.abs(diffMinute) >= relativeOption.max[0]
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
if (Math.abs(diffMinute) < 60) {
|
||||
return formatter.format(Math.trunc(diffMinute), 'minute');
|
||||
}
|
||||
return null;
|
||||
},
|
||||
hour: () => {
|
||||
const diffHour = time.diff(now) / 1000 / 60 / 60;
|
||||
if (Math.abs(diffHour) < 1) {
|
||||
return i18n['com.affine.time.now']();
|
||||
}
|
||||
if (
|
||||
relativeOption.max[1] === 'hour' &&
|
||||
Math.abs(diffHour) >= relativeOption.max[0]
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
if (Math.abs(diffHour) < 24) {
|
||||
return formatter.format(Math.trunc(diffHour), 'hour');
|
||||
}
|
||||
return null;
|
||||
},
|
||||
day: () => {
|
||||
const diffDay = time.startOf('day').diff(now.startOf('day'), 'day');
|
||||
if (Math.abs(diffDay) < 1) {
|
||||
return i18n['com.affine.time.today']();
|
||||
}
|
||||
if (
|
||||
relativeOption.max[1] === 'day' &&
|
||||
Math.abs(diffDay) >= relativeOption.max[0]
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
if (relativeOption.yesterdayAndTomorrow && Math.abs(diffDay) < 2) {
|
||||
return formatter.format(Math.trunc(diffDay), 'day');
|
||||
} else if (relativeOption.weekday && Math.abs(diffDay) < 7) {
|
||||
return new Intl.DateTimeFormat(i18n.language, {
|
||||
weekday: 'long',
|
||||
}).format(time.startOf('day').toDate());
|
||||
} else if (Math.abs(diffDay) < 7) {
|
||||
return formatter.format(Math.trunc(diffDay), 'day');
|
||||
}
|
||||
return null;
|
||||
},
|
||||
week: () => {
|
||||
const inSameMonth = time.startOf('month').isSame(now.startOf('month'));
|
||||
const diffWeek = time.startOf('week').diff(now.startOf('week'), 'week');
|
||||
if (Math.abs(diffWeek) < 1) {
|
||||
return i18n['com.affine.time.this-week']();
|
||||
}
|
||||
if (
|
||||
relativeOption.max[1] === 'week' &&
|
||||
Math.abs(diffWeek) >= relativeOption.max[0]
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
if (inSameMonth || Math.abs(diffWeek) < 3) {
|
||||
return formatter.format(Math.trunc(diffWeek), 'week');
|
||||
}
|
||||
return null;
|
||||
},
|
||||
month: () => {
|
||||
const diffMonth = time
|
||||
.startOf('month')
|
||||
.diff(now.startOf('month'), 'month');
|
||||
if (Math.abs(diffMonth) < 1) {
|
||||
return i18n['com.affine.time.this-mouth']();
|
||||
}
|
||||
if (
|
||||
relativeOption.max[1] === 'month' &&
|
||||
Math.abs(diffMonth) >= relativeOption.max[0]
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
if (Math.abs(diffMonth) < 12) {
|
||||
return formatter.format(Math.trunc(diffMonth), 'month');
|
||||
}
|
||||
return null;
|
||||
},
|
||||
year: () => {
|
||||
const diffYear = time.startOf('year').diff(now.startOf('year'), 'year');
|
||||
if (Math.abs(diffYear) < 1) {
|
||||
return i18n['com.affine.time.this-year']();
|
||||
}
|
||||
if (
|
||||
relativeOption.max[1] === 'year' &&
|
||||
Math.abs(diffYear) >= relativeOption.max[0]
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
return formatter.format(Math.trunc(diffYear), 'year');
|
||||
},
|
||||
} as Record<TimeUnit, () => string | false | null>;
|
||||
|
||||
const processors = Object.entries(timeUnitProcessor).sort(
|
||||
(a, b) => timeUnitCode[a[0] as TimeUnit] - timeUnitCode[b[0] as TimeUnit]
|
||||
) as [TimeUnit, () => string | false | null][];
|
||||
|
||||
for (const [unit, processor] of processors) {
|
||||
if (timeUnitCode[relativeOption.accuracy] > timeUnitCode[unit]) {
|
||||
continue;
|
||||
}
|
||||
const result = processor();
|
||||
if (result) {
|
||||
return result;
|
||||
}
|
||||
if (result === false) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// show absolute
|
||||
const formatter = new Intl.DateTimeFormat(i18n.language, {
|
||||
year:
|
||||
!absoluteOption.noYear && !absoluteOption.noDate ? 'numeric' : undefined,
|
||||
month:
|
||||
!absoluteOption.noDate &&
|
||||
timeUnitCode[absoluteOption.accuracy] <= timeUnitCode['month']
|
||||
? 'short'
|
||||
: undefined,
|
||||
day:
|
||||
!absoluteOption.noDate &&
|
||||
timeUnitCode[absoluteOption.accuracy] <= timeUnitCode['day']
|
||||
? 'numeric'
|
||||
: undefined,
|
||||
hour:
|
||||
timeUnitCode[absoluteOption.accuracy] <= timeUnitCode['hour']
|
||||
? 'numeric'
|
||||
: undefined,
|
||||
minute:
|
||||
timeUnitCode[absoluteOption.accuracy] <= timeUnitCode['minute']
|
||||
? 'numeric'
|
||||
: undefined,
|
||||
second:
|
||||
timeUnitCode[absoluteOption.accuracy] <= timeUnitCode['second']
|
||||
? 'numeric'
|
||||
: undefined,
|
||||
});
|
||||
|
||||
return formatter.format(time.toDate());
|
||||
}
|
||||
@@ -611,12 +611,14 @@ __metadata:
|
||||
dependencies:
|
||||
"@magic-works/i18n-codegen": "npm:^0.6.0"
|
||||
"@types/prettier": "npm:^3.0.0"
|
||||
dayjs: "npm:^1.11.11"
|
||||
i18next: "npm:^23.11.1"
|
||||
prettier: "npm:^3.2.5"
|
||||
react-i18next: "npm:^14.1.0"
|
||||
ts-node: "npm:^10.9.2"
|
||||
typescript: "npm:^5.4.5"
|
||||
undici: "npm:^6.12.0"
|
||||
vitest: "npm:1.6.0"
|
||||
languageName: unknown
|
||||
linkType: soft
|
||||
|
||||
@@ -20240,7 +20242,7 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"dayjs@npm:^1.11.10":
|
||||
"dayjs@npm:^1.11.10, dayjs@npm:^1.11.11":
|
||||
version: 1.11.11
|
||||
resolution: "dayjs@npm:1.11.11"
|
||||
checksum: 10/f03948b172fbeed229837965988d1d5bac99c72a31c28731a457303259439f2f36289186489ae140adbeb10f591a926908c8de5d81eb449a2edbf5cbd6e9e30c
|
||||
|
||||
Reference in New Issue
Block a user