Skip to content
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.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .changeset/skip-quarters-timestamp.md
Original file line number Diff line number Diff line change
@@ -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.
2 changes: 2 additions & 0 deletions packages/react-core/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,13 @@ export {
feedProviderKey,
type BadgeCountType,
formatBadgeCount,
type FormatTimestampOptions,
getBadgeAriaLabel,
formatTimestamp,
msTeamsProviderKey,
renderNodeOrFallback,
slackProviderKey,
type TimestampFormatter,
toSentenceCase,
type AuthCheckResult,
type ColorMode,
Expand Down
2 changes: 2 additions & 0 deletions packages/react-core/src/modules/core/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,8 @@ export {
feedProviderKey,
slackProviderKey,
msTeamsProviderKey,
type FormatTimestampOptions,
type TimestampFormatter,
} from "./utils";
export {
type BadgeCountType,
Expand Down
32 changes: 28 additions & 4 deletions packages/react-core/src/modules/core/utils.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -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;
Expand Down
85 changes: 85 additions & 0 deletions packages/react-core/test/core/utils.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
);
});
});

// ----------------------------------------------------------------------------------
Expand Down
2 changes: 2 additions & 0 deletions packages/react/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,7 @@ export {
export {
type BadgeCountType,
FilterStatus,
type FormatTimestampOptions,
KnockProvider,
feedProviderKey,
formatBadgeCount,
Expand All @@ -74,6 +75,7 @@ export {
msTeamsProviderKey,
renderNodeOrFallback,
slackProviderKey,
type TimestampFormatter,
toSentenceCase,
type ColorMode,
type KnockProviderProps,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,8 @@ import {
TextContentBlock,
} from "@knocklabs/client";
import {
formatTimestamp,
type TimestampFormatter,
formatTimestamp as defaultFormatTimestamp,
renderNodeOrFallback,
useKnockFeed,
useTranslations,
Expand All @@ -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 = {
Expand All @@ -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();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { FeedItem, NetworkStatus, isRequestInFlight } from "@knocklabs/client";
import {
ColorMode,
FilterStatus,
type TimestampFormatter,
useFeedSettings,
useKnockFeed,
useTranslations,
Expand Down Expand Up @@ -30,6 +31,7 @@ export type RenderItemProps<T = GenericData> = {
item: FeedItem<T>;
onItemClick?: NotificationCellProps["onItemClick"];
onButtonClick?: NotificationCellProps["onButtonClick"];
formatTimestamp?: TimestampFormatter;
};

export type RenderItem = (props: RenderItemProps) => ReactNode;
Expand All @@ -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) => (
Expand Down Expand Up @@ -86,6 +90,7 @@ export const NotificationFeed: React.FC<NotificationFeedProps> = ({
header,
renderHeader = defaultRenderHeader,
renderLoading = defaultRenderLoading,
formatTimestamp,
}) => {
const [status, setStatus] = useState(initialFilterStatus);
const { feedClient, useFeedStore, colorMode } = useKnockFeed();
Expand Down Expand Up @@ -144,6 +149,7 @@ export const NotificationFeed: React.FC<NotificationFeedProps> = ({
item,
onItemClick: onNotificationClick,
onButtonClick: onNotificationButtonClick,
formatTimestamp,
}),
)}
</div>
Expand Down
Loading