diff --git a/.changeset/skip-quarters-timestamp.md b/.changeset/skip-quarters-timestamp.md new file mode 100644 index 000000000..6c85f442d --- /dev/null +++ b/.changeset/skip-quarters-timestamp.md @@ -0,0 +1,6 @@ +--- +"@knocklabs/react-core": minor +"@knocklabs/react": minor +--- + +Skip quarters in feed timestamp formatting: timestamps for dates 3-12 months old now show "X months ago" instead of "X quarters ago". Added optional `formatTimestamp` prop to `NotificationCell` and `NotificationFeed` for custom timestamp formatting. diff --git a/packages/react-core/src/index.ts b/packages/react-core/src/index.ts index a7ca7a19b..de99a377a 100644 --- a/packages/react-core/src/index.ts +++ b/packages/react-core/src/index.ts @@ -4,11 +4,13 @@ export { feedProviderKey, type BadgeCountType, formatBadgeCount, + type FormatTimestampOptions, getBadgeAriaLabel, formatTimestamp, msTeamsProviderKey, renderNodeOrFallback, slackProviderKey, + type TimestampFormatter, toSentenceCase, type AuthCheckResult, type ColorMode, diff --git a/packages/react-core/src/modules/core/index.ts b/packages/react-core/src/modules/core/index.ts index b7e04a257..9233cacbd 100644 --- a/packages/react-core/src/modules/core/index.ts +++ b/packages/react-core/src/modules/core/index.ts @@ -21,6 +21,8 @@ export { feedProviderKey, slackProviderKey, msTeamsProviderKey, + type FormatTimestampOptions, + type TimestampFormatter, } from "./utils"; export { type BadgeCountType, diff --git a/packages/react-core/src/modules/core/utils.ts b/packages/react-core/src/modules/core/utils.ts index 4c5eb9920..a3bb9b398 100644 --- a/packages/react-core/src/modules/core/utils.ts +++ b/packages/react-core/src/modules/core/utils.ts @@ -1,5 +1,6 @@ import Knock, { FeedClientOptions } from "@knocklabs/client"; -import { intlFormatDistance, parseISO } from "date-fns"; +import { differenceInSeconds, intlFormatDistance, parseISO } from "date-fns"; +import { secondsInQuarter, secondsInYear } from "date-fns/constants"; import { ReactNode } from "react"; import { BadgeCountType } from "./types"; @@ -24,18 +25,41 @@ export function getBadgeAriaLabel( return `${count} ${qualifier}${noun}`; } -type FormatTimestampOptions = { +export type FormatTimestampOptions = { locale?: string | string[]; }; +export type TimestampFormatter = ( + ts: string, + options?: FormatTimestampOptions, +) => string; + +/** + * Formats a timestamp as a human-readable relative time string. + * + * By default, this function skips the "quarter" unit for dates between 3 and 12 + * months old, using "X months ago" instead of "X quarters ago" for better + * readability. For all other date ranges, the unit is chosen automatically. + */ export function formatTimestamp( ts: string, options: FormatTimestampOptions = {}, -) { +): string { try { const parsedTs = parseISO(ts); - const formatted = intlFormatDistance(parsedTs, new Date(), { + const now = new Date(); + const elapsedSeconds = Math.abs(differenceInSeconds(parsedTs, now)); + + // Mirrors the elapsed-seconds range in which date-fns would pick + // "quarter", so every would-be quarter result is shown in months. + const unit = + elapsedSeconds >= secondsInQuarter && elapsedSeconds < secondsInYear + ? "month" + : undefined; + + const formatted = intlFormatDistance(parsedTs, now, { locale: options.locale, + unit, }); return formatted; diff --git a/packages/react-core/test/core/utils.test.ts b/packages/react-core/test/core/utils.test.ts index 857fa1244..38a316d04 100644 --- a/packages/react-core/test/core/utils.test.ts +++ b/packages/react-core/test/core/utils.test.ts @@ -107,6 +107,91 @@ describe("formatTimestamp", () => { }), ).toBe("hace 2 semanas"); }); + + test("it uses months instead of quarters for dates 3-12 months old", () => { + // Set a fixed date: Oct 8, 2026 at noon + const now = new Date(2026, 9, 8, 12, 0, 0); + vi.setSystemTime(now); + + // 3 months ago: should show "3 months ago" instead of "last quarter" + const threeMonthsAgo = new Date(2026, 6, 8, 12, 0, 0); + expect(formatTimestamp(threeMonthsAgo.toISOString())).toBe("3 months ago"); + + // 5 months ago: should show "5 months ago" instead of "2 quarters ago" + const fiveMonthsAgo = new Date(2026, 4, 8, 12, 0, 0); + expect(formatTimestamp(fiveMonthsAgo.toISOString())).toBe("5 months ago"); + + // 9 months ago: should show "9 months ago" instead of "3 quarters ago" + const nineMonthsAgo = new Date(2026, 0, 8, 12, 0, 0); + expect(formatTimestamp(nineMonthsAgo.toISOString())).toBe("9 months ago"); + + // 11 months ago: should show "11 months ago" instead of "4 quarters ago" + const elevenMonthsAgo = new Date(2025, 10, 8, 12, 0, 0); + expect(formatTimestamp(elevenMonthsAgo.toISOString())).toBe("11 months ago"); + }); + + test("it uses months when under 3 calendar months but past date-fns's quarter threshold", () => { + vi.setSystemTime(new Date("2026-10-31T11:00:00Z")); + + expect(formatTimestamp("2026-07-31T12:00:00Z")).toBe("3 months ago"); + }); + + test("it uses months when over 12 calendar months but under date-fns's year threshold", () => { + vi.setSystemTime(new Date("2026-10-08T12:00:00Z")); + + // 365 days and a few hours is still under date-fns's secondsInYear + // (365.2425 days), where it would otherwise pick "4 quarters ago". + expect(formatTimestamp("2025-10-08T08:00:00Z")).toBe("12 months ago"); + }); + + test("it allows automatic unit selection for dates less than 3 months old", () => { + // Set a fixed date: Oct 8, 2026 at noon + const now = new Date(2026, 9, 8, 12, 0, 0); + vi.setSystemTime(now); + + // 2 months ago: should use automatic unit selection ("2 months ago") + const twoMonthsAgo = new Date(2026, 7, 8, 12, 0, 0); + expect(formatTimestamp(twoMonthsAgo.toISOString())).toBe("2 months ago"); + }); + + test("it uses months for dates exactly 12 months old to avoid quarter bug", () => { + // Set a fixed date: Oct 8, 2026 at noon + const now = new Date(2026, 9, 8, 12, 0, 0); + vi.setSystemTime(now); + + // 12 months ago: should show "12 months ago" (not "4 quarters ago") + // Note: date-fns has a bug where this would show "4 quarters ago" + // if we didn't force the "month" unit. + const twelveMonthsAgo = new Date(2025, 9, 8, 12, 0, 0); + expect(formatTimestamp(twelveMonthsAgo.toISOString())).toBe("12 months ago"); + }); + + test("it allows automatic unit selection for dates 13+ months old", () => { + // Set a fixed date: Oct 8, 2026 at noon + const now = new Date(2026, 9, 8, 12, 0, 0); + vi.setSystemTime(now); + + // 13 months ago: should use automatic unit selection ("last year") + const thirteenMonthsAgo = new Date(2025, 8, 8, 12, 0, 0); + expect(formatTimestamp(thirteenMonthsAgo.toISOString())).toBe("last year"); + + // 2 years ago: should show "2 years ago" + const twoYearsAgo = new Date(2024, 9, 8, 12, 0, 0); + expect(formatTimestamp(twoYearsAgo.toISOString())).toBe("2 years ago"); + }); + + test("it uses months instead of quarters in spanish", () => { + const locale = "es"; + // Set a fixed date: Oct 8, 2026 at noon + const now = new Date(2026, 9, 8, 12, 0, 0); + vi.setSystemTime(now); + + // 5 months ago in Spanish + const fiveMonthsAgo = new Date(2026, 4, 8, 12, 0, 0); + expect(formatTimestamp(fiveMonthsAgo.toISOString(), { locale })).toBe( + "hace 5 meses", + ); + }); }); // ---------------------------------------------------------------------------------- diff --git a/packages/react/src/index.ts b/packages/react/src/index.ts index 325170aae..8a731c897 100644 --- a/packages/react/src/index.ts +++ b/packages/react/src/index.ts @@ -66,6 +66,7 @@ export { export { type BadgeCountType, FilterStatus, + type FormatTimestampOptions, KnockProvider, feedProviderKey, formatBadgeCount, @@ -74,6 +75,7 @@ export { msTeamsProviderKey, renderNodeOrFallback, slackProviderKey, + type TimestampFormatter, toSentenceCase, type ColorMode, type KnockProviderProps, diff --git a/packages/react/src/modules/feed/components/NotificationCell/NotificationCell.tsx b/packages/react/src/modules/feed/components/NotificationCell/NotificationCell.tsx index 9f8f11e17..66c5f2b02 100644 --- a/packages/react/src/modules/feed/components/NotificationCell/NotificationCell.tsx +++ b/packages/react/src/modules/feed/components/NotificationCell/NotificationCell.tsx @@ -7,7 +7,8 @@ import { TextContentBlock, } from "@knocklabs/client"; import { - formatTimestamp, + type TimestampFormatter, + formatTimestamp as defaultFormatTimestamp, renderNodeOrFallback, useKnockFeed, useTranslations, @@ -29,6 +30,8 @@ export interface NotificationCellProps { avatar?: ReactNode; children?: ReactNode; archiveButton?: ReactNode; + // Custom timestamp formatter. If not provided, uses the default formatTimestamp. + formatTimestamp?: TimestampFormatter; } type BlockByName = { @@ -46,7 +49,15 @@ export const NotificationCell = React.forwardRef< NotificationCellProps >( ( - { item, onItemClick, onButtonClick, avatar, children, archiveButton }, + { + item, + onItemClick, + onButtonClick, + avatar, + children, + archiveButton, + formatTimestamp = defaultFormatTimestamp, + }, ref, ) => { const { feedClient, colorMode } = useKnockFeed(); diff --git a/packages/react/src/modules/feed/components/NotificationFeed/NotificationFeed.tsx b/packages/react/src/modules/feed/components/NotificationFeed/NotificationFeed.tsx index e9a4af704..af75bd692 100644 --- a/packages/react/src/modules/feed/components/NotificationFeed/NotificationFeed.tsx +++ b/packages/react/src/modules/feed/components/NotificationFeed/NotificationFeed.tsx @@ -2,6 +2,7 @@ import { FeedItem, NetworkStatus, isRequestInFlight } from "@knocklabs/client"; import { ColorMode, FilterStatus, + type TimestampFormatter, useFeedSettings, useKnockFeed, useTranslations, @@ -30,6 +31,7 @@ export type RenderItemProps = { item: FeedItem; onItemClick?: NotificationCellProps["onItemClick"]; onButtonClick?: NotificationCellProps["onButtonClick"]; + formatTimestamp?: TimestampFormatter; }; export type RenderItem = (props: RenderItemProps) => ReactNode; @@ -53,6 +55,8 @@ export interface NotificationFeedProps { onMarkAllAsReadClick?: (e: React.MouseEvent, unreadItems: FeedItem[]) => void; initialFilterStatus?: FilterStatus; renderLoading?: RenderLoading; + // Custom timestamp formatter. If not provided, uses the default formatTimestamp. + formatTimestamp?: TimestampFormatter; } const defaultRenderItem = (props: RenderItemProps) => ( @@ -86,6 +90,7 @@ export const NotificationFeed: React.FC = ({ header, renderHeader = defaultRenderHeader, renderLoading = defaultRenderLoading, + formatTimestamp, }) => { const [status, setStatus] = useState(initialFilterStatus); const { feedClient, useFeedStore, colorMode } = useKnockFeed(); @@ -144,6 +149,7 @@ export const NotificationFeed: React.FC = ({ item, onItemClick: onNotificationClick, onButtonClick: onNotificationButtonClick, + formatTimestamp, }), )}