feat(chat): align activity timing UI with hover-only end timestamps (#557)
* feat(chat): add shared activity timestamp formatter * feat(chat): pass assistant created timestamp to message body * feat(chat): render footer timestamp beside elapsed time * feat(chat): pass timing metadata in reasoning text blocks * feat(chat): propagate justification timing to timeline block * feat(chat): show thinking duration with hover end time * feat(chat): reveal tool end time on activity hover
This commit is contained in:
@@ -389,6 +389,11 @@ const ChatMessage: React.FC<ChatMessageProps> = ({
|
|||||||
return typeof timeInfo?.completed === 'number' ? timeInfo.completed : null;
|
return typeof timeInfo?.completed === 'number' ? timeInfo.completed : null;
|
||||||
}, [message.info.time]);
|
}, [message.info.time]);
|
||||||
|
|
||||||
|
const messageCreatedAt = React.useMemo(() => {
|
||||||
|
const timeInfo = message.info.time as { created?: number } | undefined;
|
||||||
|
return typeof timeInfo?.created === 'number' ? timeInfo.created : null;
|
||||||
|
}, [message.info.time]);
|
||||||
|
|
||||||
const isMessageCompleted = React.useMemo(() => {
|
const isMessageCompleted = React.useMemo(() => {
|
||||||
if (isUser) return true;
|
if (isUser) return true;
|
||||||
return Boolean(messageCompletedAt && messageCompletedAt > 0);
|
return Boolean(messageCompletedAt && messageCompletedAt > 0);
|
||||||
@@ -1004,6 +1009,7 @@ const ChatMessage: React.FC<ChatMessageProps> = ({
|
|||||||
isMessageCompleted={isMessageCompleted}
|
isMessageCompleted={isMessageCompleted}
|
||||||
messageFinish={messageFinish}
|
messageFinish={messageFinish}
|
||||||
messageCompletedAt={messageCompletedAt ?? undefined}
|
messageCompletedAt={messageCompletedAt ?? undefined}
|
||||||
|
messageCreatedAt={messageCreatedAt ?? undefined}
|
||||||
syntaxTheme={syntaxTheme}
|
syntaxTheme={syntaxTheme}
|
||||||
isMobile={isMobile}
|
isMobile={isMobile}
|
||||||
hasTouchInput={hasTouchInput}
|
hasTouchInput={hasTouchInput}
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ import { useMessageTTS } from '@/hooks/useMessageTTS';
|
|||||||
import { useConfigStore } from '@/stores/useConfigStore';
|
import { useConfigStore } from '@/stores/useConfigStore';
|
||||||
import { TextSelectionMenu } from './TextSelectionMenu';
|
import { TextSelectionMenu } from './TextSelectionMenu';
|
||||||
import { copyTextToClipboard } from '@/lib/clipboard';
|
import { copyTextToClipboard } from '@/lib/clipboard';
|
||||||
|
import { formatTimestampForDisplay } from './timeFormat';
|
||||||
|
|
||||||
type SubtaskPartLike = Part & {
|
type SubtaskPartLike = Part & {
|
||||||
type: 'subtask';
|
type: 'subtask';
|
||||||
@@ -257,6 +258,7 @@ interface MessageBodyProps {
|
|||||||
isMessageCompleted: boolean;
|
isMessageCompleted: boolean;
|
||||||
messageFinish?: string;
|
messageFinish?: string;
|
||||||
messageCompletedAt?: number;
|
messageCompletedAt?: number;
|
||||||
|
messageCreatedAt?: number;
|
||||||
|
|
||||||
syntaxTheme: { [key: string]: React.CSSProperties };
|
syntaxTheme: { [key: string]: React.CSSProperties };
|
||||||
|
|
||||||
@@ -508,6 +510,7 @@ const AssistantMessageBody: React.FC<Omit<MessageBodyProps, 'isUser'>> = ({
|
|||||||
isMessageCompleted,
|
isMessageCompleted,
|
||||||
messageFinish,
|
messageFinish,
|
||||||
messageCompletedAt,
|
messageCompletedAt,
|
||||||
|
messageCreatedAt,
|
||||||
|
|
||||||
syntaxTheme,
|
syntaxTheme,
|
||||||
isMobile,
|
isMobile,
|
||||||
@@ -1123,6 +1126,16 @@ const AssistantMessageBody: React.FC<Omit<MessageBodyProps, 'isUser'>> = ({
|
|||||||
return formatTurnDuration(messageCompletedAt - userCreatedAt);
|
return formatTurnDuration(messageCompletedAt - userCreatedAt);
|
||||||
}, [isLastAssistantInTurn, hasStopFinish, turnGroupingContext?.userMessageCreatedAt, messageCompletedAt]);
|
}, [isLastAssistantInTurn, hasStopFinish, turnGroupingContext?.userMessageCreatedAt, messageCompletedAt]);
|
||||||
|
|
||||||
|
const footerTimestamp = React.useMemo(() => {
|
||||||
|
const timestamp = typeof messageCompletedAt === 'number' && messageCompletedAt > 0
|
||||||
|
? messageCompletedAt
|
||||||
|
: (typeof messageCreatedAt === 'number' && messageCreatedAt > 0 ? messageCreatedAt : null);
|
||||||
|
if (timestamp === null) return null;
|
||||||
|
|
||||||
|
const formatted = formatTimestampForDisplay(timestamp);
|
||||||
|
return formatted.length > 0 ? formatted : null;
|
||||||
|
}, [messageCompletedAt, messageCreatedAt]);
|
||||||
|
|
||||||
const footerButtons = (
|
const footerButtons = (
|
||||||
<>
|
<>
|
||||||
{onCopyMessage && (
|
{onCopyMessage && (
|
||||||
@@ -1259,12 +1272,17 @@ const AssistantMessageBody: React.FC<Omit<MessageBodyProps, 'isUser'>> = ({
|
|||||||
<div className="flex items-center gap-1.5">
|
<div className="flex items-center gap-1.5">
|
||||||
{footerButtons}
|
{footerButtons}
|
||||||
</div>
|
</div>
|
||||||
{turnDurationText ? (
|
<div className="flex items-center gap-1.5">
|
||||||
<span className="text-sm text-muted-foreground/60 tabular-nums flex items-center gap-1">
|
{turnDurationText ? (
|
||||||
<RiHourglassLine className="h-3.5 w-3.5" />
|
<span className="text-sm text-muted-foreground/60 tabular-nums flex items-center gap-1">
|
||||||
{turnDurationText}
|
<RiHourglassLine className="h-3.5 w-3.5" />
|
||||||
</span>
|
{turnDurationText}
|
||||||
) : null}
|
</span>
|
||||||
|
) : null}
|
||||||
|
{footerTimestamp ? (
|
||||||
|
<span className="text-sm text-muted-foreground/60 tabular-nums">{footerTimestamp}</span>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -1277,12 +1295,17 @@ const AssistantMessageBody: React.FC<Omit<MessageBodyProps, 'isUser'>> = ({
|
|||||||
<div className="flex items-center gap-1.5">
|
<div className="flex items-center gap-1.5">
|
||||||
{footerButtons}
|
{footerButtons}
|
||||||
</div>
|
</div>
|
||||||
{turnDurationText ? (
|
<div className="flex items-center gap-1.5">
|
||||||
<span className="text-sm text-muted-foreground/60 tabular-nums flex items-center gap-1">
|
{turnDurationText ? (
|
||||||
<RiHourglassLine className="h-3.5 w-3.5" />
|
<span className="text-sm text-muted-foreground/60 tabular-nums flex items-center gap-1">
|
||||||
{turnDurationText}
|
<RiHourglassLine className="h-3.5 w-3.5" />
|
||||||
</span>
|
{turnDurationText}
|
||||||
) : null}
|
</span>
|
||||||
|
) : null}
|
||||||
|
{footerTimestamp ? (
|
||||||
|
<span className="text-sm text-muted-foreground/60 tabular-nums">{footerTimestamp}</span>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -61,6 +61,7 @@ const AssistantTextPart: React.FC<AssistantTextPartProps> = ({
|
|||||||
variant="justification"
|
variant="justification"
|
||||||
onContentChange={onContentChange}
|
onContentChange={onContentChange}
|
||||||
blockId={part.id || `${messageId}-reasoning-text`}
|
blockId={part.id || `${messageId}-reasoning-text`}
|
||||||
|
time={time}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import type { Part } from '@opencode-ai/sdk/v2';
|
|||||||
import type { ContentChangeReason } from '@/hooks/useChatScrollManager';
|
import type { ContentChangeReason } from '@/hooks/useChatScrollManager';
|
||||||
import { ReasoningTimelineBlock } from './ReasoningPart';
|
import { ReasoningTimelineBlock } from './ReasoningPart';
|
||||||
|
|
||||||
type PartWithText = Part & { text?: string; content?: string };
|
type PartWithText = Part & { text?: string; content?: string; time?: { start?: number; end?: number } };
|
||||||
|
|
||||||
const cleanJustificationText = (text: string): string => {
|
const cleanJustificationText = (text: string): string => {
|
||||||
if (typeof text !== 'string' || text.trim().length === 0) {
|
if (typeof text !== 'string' || text.trim().length === 0) {
|
||||||
@@ -32,6 +32,7 @@ const JustificationBlock: React.FC<JustificationBlockProps> = ({
|
|||||||
const partWithText = part as PartWithText;
|
const partWithText = part as PartWithText;
|
||||||
const rawText = partWithText.text || partWithText.content || '';
|
const rawText = partWithText.text || partWithText.content || '';
|
||||||
const textContent = React.useMemo(() => cleanJustificationText(rawText), [rawText]);
|
const textContent = React.useMemo(() => cleanJustificationText(rawText), [rawText]);
|
||||||
|
const time = partWithText.time;
|
||||||
|
|
||||||
// Don't render if there's no text content
|
// Don't render if there's no text content
|
||||||
if (!textContent || textContent.trim().length === 0) {
|
if (!textContent || textContent.trim().length === 0) {
|
||||||
@@ -44,6 +45,7 @@ const JustificationBlock: React.FC<JustificationBlockProps> = ({
|
|||||||
variant="justification"
|
variant="justification"
|
||||||
onContentChange={onContentChange}
|
onContentChange={onContentChange}
|
||||||
blockId={part.id || `${messageId}-justification`}
|
blockId={part.id || `${messageId}-justification`}
|
||||||
|
time={time}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -3,10 +3,11 @@ import type { ComponentType } from 'react';
|
|||||||
import type { Part } from '@opencode-ai/sdk/v2';
|
import type { Part } from '@opencode-ai/sdk/v2';
|
||||||
import { RiArrowDownSLine, RiArrowRightSLine, RiBrainAi3Line, RiChatAi3Line } from '@remixicon/react';
|
import { RiArrowDownSLine, RiArrowRightSLine, RiBrainAi3Line, RiChatAi3Line } from '@remixicon/react';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
|
import { formatTimestampForDisplay } from '../timeFormat';
|
||||||
import type { ContentChangeReason } from '@/hooks/useChatScrollManager';
|
import type { ContentChangeReason } from '@/hooks/useChatScrollManager';
|
||||||
import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay';
|
import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay';
|
||||||
|
|
||||||
type PartWithText = Part & { text?: string; content?: string };
|
type PartWithText = Part & { text?: string; content?: string; time?: { start?: number; end?: number } };
|
||||||
|
|
||||||
export type ReasoningVariant = 'thinking' | 'justification';
|
export type ReasoningVariant = 'thinking' | 'justification';
|
||||||
|
|
||||||
@@ -56,11 +57,37 @@ const getReasoningSummary = (text: string): string => {
|
|||||||
return trimmed.substring(0, cutoff).trim();
|
return trimmed.substring(0, cutoff).trim();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const formatDuration = (start: number, end?: number, now: number = Date.now()): string => {
|
||||||
|
const duration = end ? end - start : now - start;
|
||||||
|
const seconds = duration / 1000;
|
||||||
|
const displaySeconds = seconds < 0.05 && end !== undefined ? 0.1 : seconds;
|
||||||
|
return `${displaySeconds.toFixed(1)}s`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const LiveDuration: React.FC<{ start: number; end?: number; active: boolean }> = ({ start, end, active }) => {
|
||||||
|
const [now, setNow] = React.useState(() => Date.now());
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (!active) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const timer = window.setInterval(() => {
|
||||||
|
setNow(Date.now());
|
||||||
|
}, 100);
|
||||||
|
|
||||||
|
return () => window.clearInterval(timer);
|
||||||
|
}, [active]);
|
||||||
|
|
||||||
|
return <>{formatDuration(start, end, now)}</>;
|
||||||
|
};
|
||||||
|
|
||||||
type ReasoningTimelineBlockProps = {
|
type ReasoningTimelineBlockProps = {
|
||||||
text: string;
|
text: string;
|
||||||
variant: ReasoningVariant;
|
variant: ReasoningVariant;
|
||||||
onContentChange?: (reason?: ContentChangeReason) => void;
|
onContentChange?: (reason?: ContentChangeReason) => void;
|
||||||
blockId: string;
|
blockId: string;
|
||||||
|
time?: { start?: number; end?: number };
|
||||||
};
|
};
|
||||||
|
|
||||||
export const ReasoningTimelineBlock: React.FC<ReasoningTimelineBlockProps> = ({
|
export const ReasoningTimelineBlock: React.FC<ReasoningTimelineBlockProps> = ({
|
||||||
@@ -68,11 +95,22 @@ export const ReasoningTimelineBlock: React.FC<ReasoningTimelineBlockProps> = ({
|
|||||||
variant,
|
variant,
|
||||||
onContentChange,
|
onContentChange,
|
||||||
blockId,
|
blockId,
|
||||||
|
time,
|
||||||
}) => {
|
}) => {
|
||||||
const [isExpanded, setIsExpanded] = React.useState(false);
|
const [isExpanded, setIsExpanded] = React.useState(false);
|
||||||
|
|
||||||
const summary = React.useMemo(() => getReasoningSummary(text), [text]);
|
const summary = React.useMemo(() => getReasoningSummary(text), [text]);
|
||||||
const { label, Icon } = variantConfig[variant];
|
const { label, Icon } = variantConfig[variant];
|
||||||
|
const timeStart = typeof time?.start === 'number' && Number.isFinite(time.start) ? time.start : undefined;
|
||||||
|
const timeEnd = typeof time?.end === 'number' && Number.isFinite(time.end) ? time.end : undefined;
|
||||||
|
const endedTimestampText = React.useMemo(() => {
|
||||||
|
if (typeof timeEnd !== 'number') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const formatted = formatTimestampForDisplay(timeEnd);
|
||||||
|
return formatted.length > 0 ? formatted : null;
|
||||||
|
}, [timeEnd]);
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
if (text.trim().length === 0) {
|
if (text.trim().length === 0) {
|
||||||
@@ -117,11 +155,30 @@ export const ReasoningTimelineBlock: React.FC<ReasoningTimelineBlockProps> = ({
|
|||||||
<span className="typography-meta font-medium">{label}</span>
|
<span className="typography-meta font-medium">{label}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{summary && (
|
{(summary || typeof timeStart === 'number' || endedTimestampText) ? (
|
||||||
<div className="flex-1 min-w-0 typography-meta text-muted-foreground/70">
|
<div className="flex items-center gap-1 flex-1 min-w-0 typography-meta text-muted-foreground/70">
|
||||||
<span className="truncate block italic">{summary}</span>
|
{summary ? <span className="truncate italic">{summary}</span> : null}
|
||||||
|
{typeof timeStart === 'number' ? (
|
||||||
|
<span className="text-muted-foreground/80 flex-shrink-0 tabular-nums">
|
||||||
|
<LiveDuration
|
||||||
|
start={timeStart}
|
||||||
|
end={timeEnd}
|
||||||
|
active={typeof timeEnd !== 'number'}
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
{endedTimestampText ? (
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
'text-muted-foreground/70 flex-shrink-0 tabular-nums transition-opacity duration-150',
|
||||||
|
'opacity-0 group-hover/tool:opacity-100'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{endedTimestampText}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
)}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{isExpanded && (
|
{isExpanded && (
|
||||||
@@ -159,6 +216,7 @@ const ReasoningPart: React.FC<ReasoningPartProps> = ({
|
|||||||
const partWithText = part as PartWithText;
|
const partWithText = part as PartWithText;
|
||||||
const rawText = partWithText.text || partWithText.content || '';
|
const rawText = partWithText.text || partWithText.content || '';
|
||||||
const textContent = React.useMemo(() => cleanReasoningText(rawText), [rawText]);
|
const textContent = React.useMemo(() => cleanReasoningText(rawText), [rawText]);
|
||||||
|
const time = partWithText.time;
|
||||||
|
|
||||||
// Show reasoning even if time.end isn't set yet (during streaming)
|
// Show reasoning even if time.end isn't set yet (during streaming)
|
||||||
// Only hide if there's no text content
|
// Only hide if there's no text content
|
||||||
@@ -172,6 +230,7 @@ const ReasoningPart: React.FC<ReasoningPartProps> = ({
|
|||||||
variant="thinking"
|
variant="thinking"
|
||||||
onContentChange={onContentChange}
|
onContentChange={onContentChange}
|
||||||
blockId={part.id || `${messageId}-reasoning`}
|
blockId={part.id || `${messageId}-reasoning`}
|
||||||
|
time={time}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { RuntimeAPIContext } from '@/contexts/runtimeAPIContext';
|
|||||||
import { RiAiAgentLine, RiArrowDownSLine, RiArrowRightSLine, RiBookLine, RiExternalLinkLine, RiFileEditLine, RiFileList2Line, RiFileSearchLine, RiFileTextLine, RiFolder6Line, RiGitBranchLine, RiGlobalLine, RiListCheck2, RiListCheck3, RiMenuSearchLine, RiPencilLine, RiSurveyLine, RiTaskLine, RiTerminalBoxLine, RiToolsLine } from '@remixicon/react';
|
import { RiAiAgentLine, RiArrowDownSLine, RiArrowRightSLine, RiBookLine, RiExternalLinkLine, RiFileEditLine, RiFileList2Line, RiFileSearchLine, RiFileTextLine, RiFolder6Line, RiGitBranchLine, RiGlobalLine, RiListCheck2, RiListCheck3, RiMenuSearchLine, RiPencilLine, RiSurveyLine, RiTaskLine, RiTerminalBoxLine, RiToolsLine } from '@remixicon/react';
|
||||||
import { File as PierreFile, PatchDiff } from '@pierre/diffs/react';
|
import { File as PierreFile, PatchDiff } from '@pierre/diffs/react';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
|
import { formatTimestampForDisplay } from '../timeFormat';
|
||||||
import { SimpleMarkdownRenderer } from '../../MarkdownRenderer';
|
import { SimpleMarkdownRenderer } from '../../MarkdownRenderer';
|
||||||
import { getToolMetadata, getLanguageFromExtension, isImageFile, getImageMimeType } from '@/lib/toolHelpers';
|
import { getToolMetadata, getLanguageFromExtension, isImageFile, getImageMimeType } from '@/lib/toolHelpers';
|
||||||
import type { ToolPart as ToolPartType, ToolState as ToolStateUnion } from '@opencode-ai/sdk/v2';
|
import type { ToolPart as ToolPartType, ToolState as ToolStateUnion } from '@opencode-ai/sdk/v2';
|
||||||
@@ -1419,6 +1420,15 @@ const ToolPart: React.FC<ToolPartProps> = ({
|
|||||||
const effectiveTimeStart = isTaskTool ? (pinnedTaskTime.start ?? time?.start) : time?.start;
|
const effectiveTimeStart = isTaskTool ? (pinnedTaskTime.start ?? time?.start) : time?.start;
|
||||||
const effectiveTimeEnd = isTaskTool ? (pinnedTaskTime.end ?? time?.end) : time?.end;
|
const effectiveTimeEnd = isTaskTool ? (pinnedTaskTime.end ?? time?.end) : time?.end;
|
||||||
|
|
||||||
|
const endedTimestampText = React.useMemo(() => {
|
||||||
|
if (typeof effectiveTimeEnd !== 'number' || !Number.isFinite(effectiveTimeEnd)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const formatted = formatTimestampForDisplay(effectiveTimeEnd);
|
||||||
|
return formatted.length > 0 ? formatted : null;
|
||||||
|
}, [effectiveTimeEnd]);
|
||||||
|
|
||||||
const taskOutputString = React.useMemo(() => {
|
const taskOutputString = React.useMemo(() => {
|
||||||
return typeof stateWithData.output === 'string' ? stateWithData.output : undefined;
|
return typeof stateWithData.output === 'string' ? stateWithData.output : undefined;
|
||||||
}, [stateWithData.output]);
|
}, [stateWithData.output]);
|
||||||
@@ -1667,7 +1677,7 @@ const ToolPart: React.FC<ToolPartProps> = ({
|
|||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
{typeof effectiveTimeStart === 'number' && (
|
{typeof effectiveTimeStart === 'number' && (
|
||||||
<span className="text-muted-foreground/80 flex-shrink-0">
|
<span className="text-muted-foreground/80 flex-shrink-0 tabular-nums">
|
||||||
<LiveDuration
|
<LiveDuration
|
||||||
start={effectiveTimeStart}
|
start={effectiveTimeStart}
|
||||||
end={typeof effectiveTimeEnd === 'number' ? effectiveTimeEnd : undefined}
|
end={typeof effectiveTimeEnd === 'number' ? effectiveTimeEnd : undefined}
|
||||||
@@ -1675,6 +1685,16 @@ const ToolPart: React.FC<ToolPartProps> = ({
|
|||||||
/>
|
/>
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
|
{endedTimestampText ? (
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
'text-muted-foreground/70 flex-shrink-0 tabular-nums transition-opacity duration-150',
|
||||||
|
'opacity-0 group-hover/tool:opacity-100'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{endedTimestampText}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,34 @@
|
|||||||
|
const pad2 = (value: number): string => String(value).padStart(2, '0');
|
||||||
|
|
||||||
|
const isSameDay = (left: Date, right: Date): boolean => {
|
||||||
|
return (
|
||||||
|
left.getFullYear() === right.getFullYear() &&
|
||||||
|
left.getMonth() === right.getMonth() &&
|
||||||
|
left.getDate() === right.getDate()
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const isValidTimestamp = (timestamp: number): boolean => {
|
||||||
|
return Number.isFinite(timestamp) && !Number.isNaN(new Date(timestamp).getTime());
|
||||||
|
};
|
||||||
|
|
||||||
|
export const formatTimestampForDisplay = (timestamp: number): string => {
|
||||||
|
if (!isValidTimestamp(timestamp)) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
const date = new Date(timestamp);
|
||||||
|
const now = new Date();
|
||||||
|
|
||||||
|
const timePart = `${pad2(date.getHours())}:${pad2(date.getMinutes())}:${pad2(date.getSeconds())}`;
|
||||||
|
|
||||||
|
if (isSameDay(date, now)) {
|
||||||
|
return timePart;
|
||||||
|
}
|
||||||
|
|
||||||
|
const yearPart = String(date.getFullYear()).slice(-2);
|
||||||
|
const monthPart = pad2(date.getMonth() + 1);
|
||||||
|
const dayPart = pad2(date.getDate());
|
||||||
|
|
||||||
|
return `${yearPart}-${monthPart}-${dayPart} ${timePart}`;
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user