Make commit history easier to scan and review (#2146)
* feat(app): improve commit history and diff review * fix(app): keep commit timestamps current * fix(app): pause hidden commit clocks * fix(app): reopen commit diff in persistence test
This commit is contained in:
@@ -0,0 +1,72 @@
|
|||||||
|
import { execFileSync } from "node:child_process";
|
||||||
|
import { writeFile } from "node:fs/promises";
|
||||||
|
import path from "node:path";
|
||||||
|
import { test, expect } from "./fixtures";
|
||||||
|
|
||||||
|
const COMMIT_SUBJECT = "Show commit timestamps";
|
||||||
|
|
||||||
|
test("commit history shows dates and shares diff layout preferences", async ({
|
||||||
|
page,
|
||||||
|
withWorkspace,
|
||||||
|
}) => {
|
||||||
|
const workspace = await withWorkspace({ prefix: "commit-diff-panel-" });
|
||||||
|
await createFeatureCommit(workspace.repoPath);
|
||||||
|
await page.setViewportSize({ width: 1400, height: 900 });
|
||||||
|
await workspace.navigateTo();
|
||||||
|
|
||||||
|
await page.getByRole("button", { name: "Open explorer" }).click();
|
||||||
|
const commitsSection = page.getByRole("button", { name: /Commits/i });
|
||||||
|
await expect(commitsSection).toBeVisible({ timeout: 30_000 });
|
||||||
|
await commitsSection.click();
|
||||||
|
|
||||||
|
const commitRow = page.locator('[data-testid^="commit-row-"]').filter({
|
||||||
|
hasText: COMMIT_SUBJECT,
|
||||||
|
});
|
||||||
|
await expect(commitRow).toContainText(COMMIT_SUBJECT, { timeout: 30_000 });
|
||||||
|
await expect(commitRow).toContainText("Jan 15");
|
||||||
|
await commitRow.click();
|
||||||
|
|
||||||
|
const panel = page.getByTestId("commit-diff-panel").filter({ visible: true });
|
||||||
|
await expect(panel.getByTestId("commit-diff-toolbar")).toBeVisible({ timeout: 30_000 });
|
||||||
|
await expect(panel.getByTestId("commit-diff-layout-unified")).toHaveAttribute(
|
||||||
|
"aria-selected",
|
||||||
|
"true",
|
||||||
|
);
|
||||||
|
await expect(panel.getByTestId("diff-code-row-0")).toBeVisible({ timeout: 30_000 });
|
||||||
|
|
||||||
|
await panel.getByTestId("commit-diff-layout-split").click();
|
||||||
|
await expect(panel.getByTestId("commit-diff-layout-split")).toHaveAttribute(
|
||||||
|
"aria-selected",
|
||||||
|
"true",
|
||||||
|
);
|
||||||
|
await expect(panel.getByTestId("diff-code-row-0")).toHaveCount(0);
|
||||||
|
await expect(panel.getByTestId("diff-file-0-body")).toBeVisible();
|
||||||
|
|
||||||
|
await page.getByTestId(/^workspace-commit-diff-close-/).click();
|
||||||
|
await expect(panel).toHaveCount(0);
|
||||||
|
await commitRow.click();
|
||||||
|
await expect(panel.getByTestId("commit-diff-layout-split")).toHaveAttribute(
|
||||||
|
"aria-selected",
|
||||||
|
"true",
|
||||||
|
{ timeout: 30_000 },
|
||||||
|
);
|
||||||
|
|
||||||
|
await page.setViewportSize({ width: 480, height: 900 });
|
||||||
|
await expect(panel.getByTestId("commit-diff-toolbar")).toHaveCount(0);
|
||||||
|
await expect(panel.getByTestId("diff-code-row-0")).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
async function createFeatureCommit(repoPath: string): Promise<void> {
|
||||||
|
execFileSync("git", ["checkout", "-b", "feature"], { cwd: repoPath, stdio: "ignore" });
|
||||||
|
await writeFile(path.join(repoPath, "feature.txt"), "before\nafter\n");
|
||||||
|
execFileSync("git", ["add", "feature.txt"], { cwd: repoPath, stdio: "ignore" });
|
||||||
|
execFileSync("git", ["commit", "-m", COMMIT_SUBJECT], {
|
||||||
|
cwd: repoPath,
|
||||||
|
stdio: "ignore",
|
||||||
|
env: {
|
||||||
|
...process.env,
|
||||||
|
GIT_AUTHOR_DATE: "2020-01-15T12:00:00Z",
|
||||||
|
GIT_COMMITTER_DATE: "2020-01-15T12:00:00Z",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -3,14 +3,16 @@ import { Pressable, Text, View } from "react-native";
|
|||||||
import { StyleSheet } from "react-native-unistyles";
|
import { StyleSheet } from "react-native-unistyles";
|
||||||
import type { CheckoutCommit } from "@getpaseo/protocol/messages";
|
import type { CheckoutCommit } from "@getpaseo/protocol/messages";
|
||||||
import { ThemedChevron, chevronColorMapping } from "@/git/themed-chevron";
|
import { ThemedChevron, chevronColorMapping } from "@/git/themed-chevron";
|
||||||
|
import { formatTimeAgo } from "@/utils/time";
|
||||||
import { dotStyles } from "./shared";
|
import { dotStyles } from "./shared";
|
||||||
|
|
||||||
interface CommitRowProps {
|
interface CommitRowProps {
|
||||||
commit: CheckoutCommit;
|
commit: CheckoutCommit;
|
||||||
|
now: Date;
|
||||||
onCommitPress: (sha: string) => void;
|
onCommitPress: (sha: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const CommitRow = memo(function CommitRow({ commit, onCommitPress }: CommitRowProps) {
|
export const CommitRow = memo(function CommitRow({ commit, now, onCommitPress }: CommitRowProps) {
|
||||||
const handlePress = useCallback(() => {
|
const handlePress = useCallback(() => {
|
||||||
onCommitPress(commit.sha);
|
onCommitPress(commit.sha);
|
||||||
}, [commit.sha, onCommitPress]);
|
}, [commit.sha, onCommitPress]);
|
||||||
@@ -30,6 +32,7 @@ export const CommitRow = memo(function CommitRow({ commit, onCommitPress }: Comm
|
|||||||
<Text style={styles.subject} numberOfLines={1}>
|
<Text style={styles.subject} numberOfLines={1}>
|
||||||
{commit.subject}
|
{commit.subject}
|
||||||
</Text>
|
</Text>
|
||||||
|
<Text style={styles.timestamp}>{formatTimeAgo(new Date(commit.authorDate), now)}</Text>
|
||||||
<View style={styles.caret}>
|
<View style={styles.caret}>
|
||||||
<ThemedChevron size={14} uniProps={chevronColorMapping} />
|
<ThemedChevron size={14} uniProps={chevronColorMapping} />
|
||||||
</View>
|
</View>
|
||||||
@@ -58,6 +61,11 @@ const styles = StyleSheet.create((theme) => ({
|
|||||||
fontSize: theme.fontSize.sm,
|
fontSize: theme.fontSize.sm,
|
||||||
color: theme.colors.foreground,
|
color: theme.colors.foreground,
|
||||||
},
|
},
|
||||||
|
timestamp: {
|
||||||
|
flexShrink: 0,
|
||||||
|
fontSize: theme.fontSize.xs,
|
||||||
|
color: theme.colors.foregroundMuted,
|
||||||
|
},
|
||||||
caret: {
|
caret: {
|
||||||
width: 16,
|
width: 16,
|
||||||
height: 16,
|
height: 16,
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import { useCallback, useMemo } from "react";
|
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||||
import { Pressable, Text, View } from "react-native";
|
import { Pressable, Text, View } from "react-native";
|
||||||
import { StyleSheet } from "react-native-unistyles";
|
import { StyleSheet } from "react-native-unistyles";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { useRetainedPanelActive } from "@/components/retained-panel";
|
||||||
import { useChangesPreferences } from "@/hooks/use-changes-preferences";
|
import { useChangesPreferences } from "@/hooks/use-changes-preferences";
|
||||||
import { useCheckoutCommitsQuery, type CheckoutCommitsQueryResult } from "@/git/use-commits-query";
|
import { useCheckoutCommitsQuery, type CheckoutCommitsQueryResult } from "@/git/use-commits-query";
|
||||||
import { ThemedChevron, chevronColorMapping } from "@/git/themed-chevron";
|
import { ThemedChevron, chevronColorMapping } from "@/git/themed-chevron";
|
||||||
@@ -30,6 +31,8 @@ function CommitsSectionSkeleton() {
|
|||||||
<View style={styles.skeletonDot} />
|
<View style={styles.skeletonDot} />
|
||||||
<View style={styles.skeletonSha} />
|
<View style={styles.skeletonSha} />
|
||||||
<View style={styles.skeletonSubject} />
|
<View style={styles.skeletonSubject} />
|
||||||
|
<View style={styles.skeletonTimestamp} />
|
||||||
|
<View style={styles.skeletonCaret} />
|
||||||
</View>
|
</View>
|
||||||
))}
|
))}
|
||||||
</View>
|
</View>
|
||||||
@@ -38,9 +41,11 @@ function CommitsSectionSkeleton() {
|
|||||||
|
|
||||||
function CommitsSectionContent({
|
function CommitsSectionContent({
|
||||||
query,
|
query,
|
||||||
|
now,
|
||||||
onCommitPress,
|
onCommitPress,
|
||||||
}: {
|
}: {
|
||||||
query: Exclude<CheckoutCommitsQueryResult, { status: "unsupported" }>;
|
query: Exclude<CheckoutCommitsQueryResult, { status: "unsupported" }>;
|
||||||
|
now: Date;
|
||||||
onCommitPress: (sha: string) => void;
|
onCommitPress: (sha: string) => void;
|
||||||
}) {
|
}) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
@@ -64,7 +69,7 @@ function CommitsSectionContent({
|
|||||||
return (
|
return (
|
||||||
<View style={styles.list}>
|
<View style={styles.list}>
|
||||||
{query.data.commits.map((commit) => (
|
{query.data.commits.map((commit) => (
|
||||||
<CommitRow key={commit.sha} commit={commit} onCommitPress={onCommitPress} />
|
<CommitRow key={commit.sha} commit={commit} now={now} onCommitPress={onCommitPress} />
|
||||||
))}
|
))}
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
@@ -73,7 +78,10 @@ function CommitsSectionContent({
|
|||||||
export function CommitsSection({ serverId, cwd, onCommitPress }: CommitsSectionProps) {
|
export function CommitsSection({ serverId, cwd, onCommitPress }: CommitsSectionProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { preferences, updatePreferences } = useChangesPreferences();
|
const { preferences, updatePreferences } = useChangesPreferences();
|
||||||
|
const isPanelActive = useRetainedPanelActive();
|
||||||
const collapsed = preferences.commitsCollapsed;
|
const collapsed = preferences.commitsCollapsed;
|
||||||
|
const [now, setNow] = useState(() => new Date());
|
||||||
|
const displayNow = useMemo(() => (isPanelActive ? new Date() : now), [isPanelActive, now]);
|
||||||
const query = useCheckoutCommitsQuery({
|
const query = useCheckoutCommitsQuery({
|
||||||
serverId,
|
serverId,
|
||||||
cwd,
|
cwd,
|
||||||
@@ -81,9 +89,20 @@ export function CommitsSection({ serverId, cwd, onCommitPress }: CommitsSectionP
|
|||||||
});
|
});
|
||||||
|
|
||||||
const handleToggleSection = useCallback(() => {
|
const handleToggleSection = useCallback(() => {
|
||||||
|
if (collapsed) {
|
||||||
|
setNow(new Date());
|
||||||
|
}
|
||||||
void updatePreferences({ commitsCollapsed: !collapsed });
|
void updatePreferences({ commitsCollapsed: !collapsed });
|
||||||
}, [collapsed, updatePreferences]);
|
}, [collapsed, updatePreferences]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (collapsed || !isPanelActive) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const interval = setInterval(() => setNow(new Date()), 10_000);
|
||||||
|
return () => clearInterval(interval);
|
||||||
|
}, [collapsed, isPanelActive]);
|
||||||
|
|
||||||
const headerChevronStyle = useMemo(
|
const headerChevronStyle = useMemo(
|
||||||
() => [styles.headerChevron, !collapsed && styles.headerChevronExpanded],
|
() => [styles.headerChevron, !collapsed && styles.headerChevronExpanded],
|
||||||
[collapsed],
|
[collapsed],
|
||||||
@@ -125,7 +144,9 @@ export function CommitsSection({ serverId, cwd, onCommitPress }: CommitsSectionP
|
|||||||
<Text style={styles.legendText}>{t("workspace.git.diff.commits.legendRemote")}</Text>
|
<Text style={styles.legendText}>{t("workspace.git.diff.commits.legendRemote")}</Text>
|
||||||
</View>
|
</View>
|
||||||
</Pressable>
|
</Pressable>
|
||||||
{collapsed ? null : <CommitsSectionContent query={query} onCommitPress={onCommitPress} />}
|
{collapsed ? null : (
|
||||||
|
<CommitsSectionContent query={query} now={displayNow} onCommitPress={onCommitPress} />
|
||||||
|
)}
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -195,14 +216,15 @@ const styles = StyleSheet.create((theme) => ({
|
|||||||
paddingVertical: theme.spacing[2],
|
paddingVertical: theme.spacing[2],
|
||||||
},
|
},
|
||||||
skeleton: {
|
skeleton: {
|
||||||
paddingHorizontal: theme.spacing[2],
|
paddingBottom: theme.spacing[1],
|
||||||
paddingBottom: theme.spacing[2],
|
|
||||||
gap: theme.spacing[2],
|
gap: theme.spacing[2],
|
||||||
},
|
},
|
||||||
skeletonRow: {
|
skeletonRow: {
|
||||||
flexDirection: "row",
|
flexDirection: "row",
|
||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
gap: theme.spacing[2],
|
gap: theme.spacing[2],
|
||||||
|
paddingHorizontal: theme.spacing[2],
|
||||||
|
paddingVertical: theme.spacing[1],
|
||||||
minHeight: 20,
|
minHeight: 20,
|
||||||
},
|
},
|
||||||
skeletonDot: {
|
skeletonDot: {
|
||||||
@@ -218,9 +240,22 @@ const styles = StyleSheet.create((theme) => ({
|
|||||||
backgroundColor: theme.colors.surface2,
|
backgroundColor: theme.colors.surface2,
|
||||||
},
|
},
|
||||||
skeletonSubject: {
|
skeletonSubject: {
|
||||||
width: "55%",
|
flex: 1,
|
||||||
|
minWidth: 0,
|
||||||
height: 12,
|
height: 12,
|
||||||
borderRadius: theme.borderRadius.sm,
|
borderRadius: theme.borderRadius.sm,
|
||||||
backgroundColor: theme.colors.surface2,
|
backgroundColor: theme.colors.surface2,
|
||||||
},
|
},
|
||||||
|
skeletonTimestamp: {
|
||||||
|
width: 40,
|
||||||
|
height: 10,
|
||||||
|
borderRadius: theme.borderRadius.sm,
|
||||||
|
backgroundColor: theme.colors.surface2,
|
||||||
|
flexShrink: 0,
|
||||||
|
},
|
||||||
|
skeletonCaret: {
|
||||||
|
width: 16,
|
||||||
|
height: 16,
|
||||||
|
flexShrink: 0,
|
||||||
|
},
|
||||||
}));
|
}));
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
import { useMemo } from "react";
|
import { useCallback, useMemo, type ReactNode } from "react";
|
||||||
import { Text, View } from "react-native";
|
import { Text, View } from "react-native";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { GitCommitHorizontal } from "lucide-react-native";
|
import { GitCommitHorizontal } from "lucide-react-native";
|
||||||
import { StyleSheet, withUnistyles } from "react-native-unistyles";
|
import { StyleSheet, withUnistyles } from "react-native-unistyles";
|
||||||
import invariant from "tiny-invariant";
|
import invariant from "tiny-invariant";
|
||||||
import { useIsCompactFormFactor } from "@/constants/layout";
|
import { SegmentedControl } from "@/components/ui/segmented-control";
|
||||||
|
import { useIsCompactFormFactor, WORKSPACE_SECONDARY_HEADER_HEIGHT } from "@/constants/layout";
|
||||||
import { isWeb } from "@/constants/platform";
|
import { isWeb } from "@/constants/platform";
|
||||||
import { useChangesPreferences } from "@/hooks/use-changes-preferences";
|
import { useChangesPreferences } from "@/hooks/use-changes-preferences";
|
||||||
import { useAppSettings } from "@/hooks/use-settings";
|
import { useAppSettings } from "@/hooks/use-settings";
|
||||||
@@ -22,7 +23,7 @@ function CommitDiffPanel() {
|
|||||||
const { serverId, workspaceId, target } = usePaneContext();
|
const { serverId, workspaceId, target } = usePaneContext();
|
||||||
const cwd = useWorkspaceDirectory(serverId, workspaceId);
|
const cwd = useWorkspaceDirectory(serverId, workspaceId);
|
||||||
const { settings } = useAppSettings();
|
const { settings } = useAppSettings();
|
||||||
const { preferences } = useChangesPreferences();
|
const { preferences, updatePreferences } = useChangesPreferences();
|
||||||
const isCompact = useIsCompactFormFactor();
|
const isCompact = useIsCompactFormFactor();
|
||||||
invariant(target.kind === "commit_diff", "CommitDiffPanel requires commit_diff target");
|
invariant(target.kind === "commit_diff", "CommitDiffPanel requires commit_diff target");
|
||||||
|
|
||||||
@@ -32,7 +33,29 @@ function CommitDiffPanel() {
|
|||||||
sha: target.sha,
|
sha: target.sha,
|
||||||
enabled: Boolean(cwd),
|
enabled: Boolean(cwd),
|
||||||
});
|
});
|
||||||
const effectiveLayout = isWeb && !isCompact ? preferences.layout : "unified";
|
const canUseSplitLayout = isWeb && !isCompact;
|
||||||
|
const effectiveLayout = canUseSplitLayout ? preferences.layout : "unified";
|
||||||
|
const layoutOptions = useMemo(
|
||||||
|
() => [
|
||||||
|
{
|
||||||
|
value: "unified" as const,
|
||||||
|
label: t("workspace.git.diff.unified"),
|
||||||
|
testID: "commit-diff-layout-unified",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: "split" as const,
|
||||||
|
label: t("workspace.git.diff.split"),
|
||||||
|
testID: "commit-diff-layout-split",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
[t],
|
||||||
|
);
|
||||||
|
const handleLayoutChange = useCallback(
|
||||||
|
(layout: "unified" | "split") => {
|
||||||
|
void updatePreferences({ layout });
|
||||||
|
},
|
||||||
|
[updatePreferences],
|
||||||
|
);
|
||||||
const displayPreferences = useMemo(
|
const displayPreferences = useMemo(
|
||||||
() => ({
|
() => ({
|
||||||
layout: effectiveLayout,
|
layout: effectiveLayout,
|
||||||
@@ -59,29 +82,47 @@ function CommitDiffPanel() {
|
|||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
let bodyContent: ReactNode;
|
||||||
if (error) {
|
if (error) {
|
||||||
return (
|
bodyContent = (
|
||||||
<View style={styles.centerState} testID="commit-diff-error">
|
<View style={styles.centerState} testID="commit-diff-error">
|
||||||
<Text style={styles.errorText}>{t("panels.diff.loadError")}</Text>
|
<Text style={styles.errorText}>{t("panels.diff.loadError")}</Text>
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
}
|
} else if (isLoading && files.length === 0) {
|
||||||
if (isLoading && files.length === 0) {
|
bodyContent = (
|
||||||
return (
|
|
||||||
<View style={styles.centerState} testID="commit-diff-loading">
|
<View style={styles.centerState} testID="commit-diff-loading">
|
||||||
<Text style={styles.mutedText}>{t("workspace.tabs.loading")}</Text>
|
<Text style={styles.mutedText}>{t("workspace.tabs.loading")}</Text>
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
}
|
} else if (files.length === 0) {
|
||||||
if (files.length === 0) {
|
bodyContent = (
|
||||||
return (
|
|
||||||
<View style={styles.centerState} testID="commit-diff-empty">
|
<View style={styles.centerState} testID="commit-diff-empty">
|
||||||
<Text style={styles.mutedText}>{t("panels.diff.empty")}</Text>
|
<Text style={styles.mutedText}>{t("panels.diff.empty")}</Text>
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
|
} else {
|
||||||
|
bodyContent = (
|
||||||
|
<SharedDiffView files={files} displayPreferences={displayPreferences} mode={commitMode} />
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return <SharedDiffView files={files} displayPreferences={displayPreferences} mode={commitMode} />;
|
return (
|
||||||
|
<View style={styles.container} testID="commit-diff-panel">
|
||||||
|
{canUseSplitLayout ? (
|
||||||
|
<View style={styles.toolbar} testID="commit-diff-toolbar">
|
||||||
|
<SegmentedControl
|
||||||
|
options={layoutOptions}
|
||||||
|
value={preferences.layout}
|
||||||
|
onValueChange={handleLayoutChange}
|
||||||
|
size="sm"
|
||||||
|
testID="commit-diff-layout-control"
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
) : null}
|
||||||
|
<View style={styles.body}>{bodyContent}</View>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function useCommitDiffPanelDescriptor(
|
function useCommitDiffPanelDescriptor(
|
||||||
@@ -104,6 +145,24 @@ export const commitDiffPanelRegistration: PanelRegistration<"commit_diff"> = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const styles = StyleSheet.create((theme) => ({
|
const styles = StyleSheet.create((theme) => ({
|
||||||
|
container: {
|
||||||
|
flex: 1,
|
||||||
|
minHeight: 0,
|
||||||
|
},
|
||||||
|
toolbar: {
|
||||||
|
height: WORKSPACE_SECONDARY_HEADER_HEIGHT,
|
||||||
|
flexDirection: "row",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "flex-end",
|
||||||
|
paddingHorizontal: theme.spacing[3],
|
||||||
|
borderBottomWidth: theme.borderWidth[1],
|
||||||
|
borderBottomColor: theme.colors.border,
|
||||||
|
flexShrink: 0,
|
||||||
|
},
|
||||||
|
body: {
|
||||||
|
flex: 1,
|
||||||
|
minHeight: 0,
|
||||||
|
},
|
||||||
centerState: {
|
centerState: {
|
||||||
flex: 1,
|
flex: 1,
|
||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
|
|||||||
@@ -1,5 +1,20 @@
|
|||||||
import { describe, it, expect } from "vitest";
|
import { describe, it, expect } from "vitest";
|
||||||
import { formatDuration, formatMessageTimestamp } from "./time";
|
import { formatDuration, formatMessageTimestamp, formatTimeAgo } from "./time";
|
||||||
|
|
||||||
|
describe("formatTimeAgo", () => {
|
||||||
|
const now = new Date("2026-07-16T12:00:00.000Z");
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
["2026-07-16T11:59:55.000Z", "just now"],
|
||||||
|
["2026-07-16T11:59:30.000Z", "30s ago"],
|
||||||
|
["2026-07-16T11:55:00.000Z", "5m ago"],
|
||||||
|
["2026-07-16T10:00:00.000Z", "2h ago"],
|
||||||
|
["2026-07-13T12:00:00.000Z", "3d ago"],
|
||||||
|
["2026-01-15T12:00:00.000Z", "Jan 15"],
|
||||||
|
])("formats %s as %s", (date, expected) => {
|
||||||
|
expect(formatTimeAgo(new Date(date), now)).toBe(expected);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe("formatDuration", () => {
|
describe("formatDuration", () => {
|
||||||
it("renders sub-minute durations as whole seconds", () => {
|
it("renders sub-minute durations as whole seconds", () => {
|
||||||
|
|||||||
@@ -2,8 +2,7 @@
|
|||||||
* Format a date as a human-friendly relative time string
|
* Format a date as a human-friendly relative time string
|
||||||
* Examples: "just now", "5m ago", "2h ago", "3d ago", "Jan 15"
|
* Examples: "just now", "5m ago", "2h ago", "3d ago", "Jan 15"
|
||||||
*/
|
*/
|
||||||
export function formatTimeAgo(date: Date): string {
|
export function formatTimeAgo(date: Date, now: Date = new Date()): string {
|
||||||
const now = new Date();
|
|
||||||
const diffMs = now.getTime() - date.getTime();
|
const diffMs = now.getTime() - date.getTime();
|
||||||
const diffSec = Math.floor(diffMs / 1000);
|
const diffSec = Math.floor(diffMs / 1000);
|
||||||
const diffMin = Math.floor(diffSec / 60);
|
const diffMin = Math.floor(diffSec / 60);
|
||||||
|
|||||||
Reference in New Issue
Block a user