Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: history timeline shows relative time, such as today and yesterday #6864

Merged
merged 1 commit into from
May 13, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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<string, DocHistory[]>();
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);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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<Record<number, boolean>>({});

const t = useAFFiNEI18N();

useLayoutEffect(() => {
if (historyList.length > 0 && !activeVersion) {
onVersionChange(historyList[0].timestamp);
Expand Down
112 changes: 112 additions & 0 deletions packages/frontend/core/src/utils/__tests__/intl-formatter.spec.ts
Original file line number Diff line number Diff line change
@@ -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'
);
});
});
67 changes: 57 additions & 10 deletions packages/frontend/core/src/utils/intl-formatter.ts
Original file line number Diff line number Diff line change
@@ -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;
};
2 changes: 2 additions & 0 deletions packages/frontend/i18n/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,8 @@ export function useI18N() {
return i18n;
}

export { getI18n } from 'react-i18next';

const resources = LOCALES.reduce<Resource>((acc, { tag, res }) => {
return Object.assign(acc, { [tag]: { translation: res } });
}, {});
Expand Down
2 changes: 2 additions & 0 deletions packages/frontend/i18n/src/resources/en.json
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
{

Check notice on line 1 in packages/frontend/i18n/src/resources/en.json

View workflow job for this annotation

GitHub Actions / main

Unused keys

com.affine.ai-onboarding.local.action-dismiss

Check warning on line 1 in packages/frontend/i18n/src/resources/en.json

View workflow job for this annotation

GitHub Actions / main

Inconsistent keys

com.affine.cmdk.affine.create-new-page-as, com.affine.payment.ai.pricing-plan.caption-free, com.affine.payment.billing-setting.ai.free-desc

Check notice on line 1 in packages/frontend/i18n/src/resources/en.json

View workflow job for this annotation

GitHub Actions / main

New keys

Get in touch! Join our communities., It takes up little space on your device., It takes up more space on your device., com.affine.auth.open.affine.download-app, com.affine.auth.open.affine.prompt, com.affine.auth.open.affine.try-again, com.affine.auth.reset.password.message, com.affine.auth.reset.password.page.success, com.affine.auth.sent.change.email.hint, com.affine.auth.sent.reset.password.success.message, com.affine.auth.sent.set.password.hint, com.affine.auth.sent.set.password.success.message, com.affine.auth.set.password.save, com.affine.auth.sign.up.sent.email.subtitle, com.affine.auth.sign.up.success.title, com.affine.editCollection.rules.tips.highlight, com.affine.page-properties.add-property.menu.create, com.affine.page-properties.add-property.menu.header, com.affine.pageMode.all, com.affine.pageMode.edgeless, com.affine.pageMode.page, com.affine.setting.account.delete.message, com.affine.settings.appearance.border-style-description, com.affine.settings.appearance.start-week-description, com.affine.settings.email.action, com.affine.settings.password.action.change, com.affine.settings.password.action.set, com.affine.settings.password.message, com.affine.settings.profile.message, com.affine.settings.profile.placeholder, com.affine.settings.workspace.experimental-features, com.affine.settings.workspace.experimental-features.get-started, com.affine.settings.workspace.experimental-features.header.plugins, com.affine.settings.workspace.experimental-features.prompt-disclaimer, com.affine.settings.workspace.experimental-features.prompt-header, com.affine.settings.workspace.experimental-features.prompt-warning, com.affine.settings.workspace.experimental-features.prompt-warning-title, com.affine.settings.workspace.preferences, com.affine.settings.workspace.properties, com.affine.settings.workspace.properties.add_property, com.affine.settings.workspace.properties.all, com.affine.settings.workspace.properties.delete-property, com.affine.settings.workspace.properties.doc, com.affine.settings.workspace.properties.doc_others, com.affine.settings.workspace.properties.edit-property, com.affine.settings.workspace.properties.header.subtitle, com.affine.settings.workspace.properties.header.title, com.affine.settings.workspace.properties.in-use, com.affine.settings.workspace.properties.set-as-required, com.affine.settings.workspace.properties.unused, com.affine.settings.workspace.storage.tip, com.affine.storage.maximum-tips.pro, com.affine.workspace.cloud.description

Check notice on line 1 in packages/frontend/i18n/src/resources/en.json

View workflow job for this annotation

GitHub Actions / main

Unused keys

com.affine.ai-onboarding.local.action-dismiss

Check warning on line 1 in packages/frontend/i18n/src/resources/en.json

View workflow job for this annotation

GitHub Actions / main

Inconsistent keys

com.affine.cmdk.affine.create-new-page-as, com.affine.payment.ai.pricing-plan.caption-free, com.affine.payment.billing-setting.ai.free-desc

Check notice on line 1 in packages/frontend/i18n/src/resources/en.json

View workflow job for this annotation

GitHub Actions / main

New keys

Get in touch! Join our communities., It takes up little space on your device., It takes up more space on your device., com.affine.auth.open.affine.download-app, com.affine.auth.open.affine.prompt, com.affine.auth.open.affine.try-again, com.affine.auth.reset.password.message, com.affine.auth.reset.password.page.success, com.affine.auth.sent.change.email.hint, com.affine.auth.sent.reset.password.success.message, com.affine.auth.sent.set.password.hint, com.affine.auth.sent.set.password.success.message, com.affine.auth.set.password.save, com.affine.auth.sign.up.sent.email.subtitle, com.affine.auth.sign.up.success.title, com.affine.editCollection.rules.tips.highlight, com.affine.nextWeek, com.affine.page-properties.add-property.menu.create, com.affine.page-properties.add-property.menu.header, com.affine.pageMode.all, com.affine.pageMode.edgeless, com.affine.pageMode.page, com.affine.setting.account.delete.message, com.affine.settings.appearance.border-style-description, com.affine.settings.appearance.start-week-description, com.affine.settings.email.action, com.affine.settings.password.action.change, com.affine.settings.password.action.set, com.affine.settings.password.message, com.affine.settings.profile.message, com.affine.settings.profile.placeholder, com.affine.settings.workspace.experimental-features, com.affine.settings.workspace.experimental-features.get-started, com.affine.settings.workspace.experimental-features.header.plugins, com.affine.settings.workspace.experimental-features.prompt-disclaimer, com.affine.settings.workspace.experimental-features.prompt-header, com.affine.settings.workspace.experimental-features.prompt-warning, com.affine.settings.workspace.experimental-features.prompt-warning-title, com.affine.settings.workspace.preferences, com.affine.settings.workspace.properties, com.affine.settings.workspace.properties.add_property, com.affine.settings.workspace.properties.all, com.affine.settings.workspace.properties.delete-property, com.affine.settings.workspace.properties.doc, com.affine.settings.workspace.properties.doc_others, com.affine.settings.workspace.properties.edit-property, com.affine.settings.workspace.properties.header.subtitle, com.affine.settings.workspace.properties.header.title, com.affine.settings.workspace.properties.in-use, com.affine.settings.workspace.properties.set-as-required, com.affine.settings.workspace.properties.unused, com.affine.settings.workspace.storage.tip, com.affine.storage.maximum-tips.pro, com.affine.tomorrow, com.affine.workspace.cloud.description
"404 - Page Not Found": "404 - Page Not Found",
"404.back": "Back to My Content",
"404.hint": "Sorry, you do not have access or this content does not exist...",
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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",
Expand Down